40 Commits

Author SHA1 Message Date
ec5fee8429 chore(agent-office): drop unused break state styling
Backend no longer emits the 'break' state (see web-backend
de8adae). Remove the matching entry from STATE_COLORS and the
.ao-card-dot.break CSS rule. Safe because AgentCard's unknown-state
fallback (DEFAULT_STATE_COLOR) handles any stray legacy value.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:44:58 +09:00
96cc5e7839 fix(agent-office): render TaskTab result_data when it's already an object
Old code assumed result_data was a JSON string and ran JSON.parse on it,
falling back to returning the value verbatim on parse error. When the
backend ships result_data as a dict (e.g. compose tasks return
{music_task_id, tracks}), JSON.parse threw, the catch returned the raw
object, and React threw error #31 'Objects are not valid as a React
child' the moment the user expanded the task row.

Extract formatResultData helper: object → JSON.stringify, JSON string
→ parse then pretty-print, plain string → as-is.

Regression tests cover all three input shapes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:38:06 +09:00
e6742e06ba fix(agent-office): unwrap {tasks}/{logs} response objects before .map
Backend returns {"tasks": [...]} and {"logs": [...]} but TaskTab and
LogTab stored the raw object and called .map on it, throwing
'l.map is not a function' the moment a user opened the Tasks or
Logs tab. Unwrap via Array.isArray check (also covers theoretical
bare-array responses).

Regression test for TaskTab covers all three response shapes:
wrapped object, bare array, and empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:34:08 +09:00
b713f00bf9 feat(agent-office): WS reconnect exponential backoff + status detail
- Replace fixed 3s reconnect with exponential backoff
  (1s/2s/4s/8s/16s/30s, capped). Reduces console noise when
  upstream WebSocket is blocked (e.g. DSM reverse proxy without
  WS upgrade headers).
- ws.onerror swallowed (onclose still schedules reconnect) so the
  browser stops printing an unhandled-error pair per attempt.
- Expose reconnectAttempt in hook; TopBar shows 'Connecting…'
  pre-first-attempt and 'Disconnected · 재연결 시도 #N' after.

Root cause of WS failure is upstream (curl proves the endpoint
itself is fine — see DSM reverse proxy WebSocket headers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:25:18 +09:00
0dce449124 chore(agent-office): convert agent PNGs to WebP (~93% smaller)
ffmpeg libwebp quality=85 compression_level=6.
Total: 11.8MB → 875KB (~11MB saved). Visually indistinguishable on
the card grid at the 9:16 image aspect.

PNG removals were already staged in the previous CommandTab commit;
this commit adds the 6 .webp replacements and points constants.js
imports at .webp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 07:58:12 +09:00
2c32659f6a fix(agent-office): useAgentManager reconnect via ref to satisfy lint
Previously connect's onclose handler referenced connect itself before
the useCallback declaration, triggering react-hooks/immutability. Hold
the latest connect in a ref (updated in useEffect) and call through it
on reconnect. Same runtime behavior, lint-clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 07:58:04 +09:00
add2d8044c style(agent-office): neutral color for sidepanel state line
Was hardcoded #22c55e (green) regardless of actual state, making
error/break states look healthy. Switch to muted #94a3b8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 07:57:57 +09:00
2e9b0daec6 fix(agent-office): CommandTab approval state + blog→insta agent
- Approval card gated on 'waiting_approval' (was 'waiting'), matching
  the state useAgentManager emits — previously the approval UI was
  silently suppressed and pendingTask buttons unreachable
- QUICK_ACTIONS/PARAM_ACTIONS: drop blog (agent removed),
  add insta (extract / collect_trends / render)
- Regression test covers the three approval-card branches

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 07:57:41 +09:00
46589c05b1 feat(insta): 슬레이트 캐러셀 + 반응형 레이아웃 전면 개선
문제:
- 페이지 1~10 미리보기가 가로 overflow인데 시각 affordance 없어서 page 2~10 못 봄
- 슬레이트 목록(.ic-slates-grid)이 모바일에서 어색 + 카드 자체가 viewport 밖으로 밀림

수정:
- PagesStrip 컴포넌트 신설: 좌/우 chevron + page 인디케이터(3/10) + 양옆 fade gradient
  + 키보드 ←/→ + scroll-snap + 클릭 페이지 이동 + 활성 카드 핑크 테두리/scale
- .ic-page-img width를 clamp(140px, 42vw, 220px)로 viewport 비례
- .ic-slates-grid 모바일 2칸 강제, 640px+ 부터 auto-fill
- .ic-detail에 min-width: 0 + max-width: 100% (자식이 부모 안 밀게)
- .ic-layout grid-template-columns에 minmax(0, 1fr) — 자식 overflow 시 부모 안정
- .ic 모바일 좌우 padding 12px (768px+ 16px)
2026-05-18 07:30:25 +09:00
2a9c8cb619 style(agent-office): match card image to 941x1672 aspect, fill grid width
- Card image aspect-ratio 1/1 → 941/1672 (real image ratio, no crop)
- object-fit cover → contain (defensive against rounding)
- Drop card aspect-ratio so it grows from natural image+name height
- Drop grid max-width 720px so grid fills the viewport width

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 00:00:30 +09:00
bcaf217b72 feat(agent-office): commit agent character images
6 PNGs for 5 active agents + 1 shared placeholder. Required by
constants.js imports; without these the build resolves them from
local disk but a clean clone or NAS deploy would 404.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:03:46 +09:00
18e309a14b feat(agent-office): replace canvas office with 3x3 agent grid
- AgentOffice renders TopBar + AgentGrid + dynamic right panel
- Right panel: SidePanel (active) / EmptyDetailPanel (initial or placeholder)
- TopBar simplified to connected status only (theme/zoom dropped)
- Wire AgentGrid through useAgentManager state
- Remove canvas/ (9 files), useOfficeCanvas, office-map.json
- New CSS for grid cards (state dot, notification badge, accent border)
- Mobile: 2-column grid + bottom-sheet panel

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:03:15 +09:00
80598cda93 refactor(agent-office): SidePanel uses central AGENT_META + image header
- emoji icon replaced with agent_{id}.png image
- AGENT_META imported from constants (single source of truth)
- blog removed, insta added (matches backend agent registry)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:59:25 +09:00
e49457ca46 feat(agent-office): EmptyDetailPanel for initial + placeholder views
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:58:06 +09:00
e04e2b010c feat(agent-office): AgentGrid renders 9 slots from GRID_SLOTS
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:57:20 +09:00
3fd923400f feat(agent-office): PlaceholderCard for unstaffed slots
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:56:02 +09:00
6d1f4914ca test(agent-office): cover pulse class for AgentCard state dot
Adds two tests verifying that working state adds the pulse class and
idle state does not. Pulse animation is part of the design spec §5
but was not covered by the original 8 tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:55:24 +09:00
1630109856 feat(agent-office): AgentCard component with state dot + badge
- state→color mapping via STATE_COLORS
- notification badge with 9+ overflow
- active prop for selected card border

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:52:34 +09:00
50d427e367 refactor(agent-office): derive ACTIVE_AGENT_IDS from GRID_SLOTS
Eliminates dual-write drift risk between ACTIVE_AGENT_IDS list
and GRID_SLOTS slot ordering. Single source of truth is now
GRID_SLOTS; ACTIVE_AGENT_IDS is computed from it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:50:42 +09:00
07f1d34f4c feat(agent-office): centralize AGENT_META + grid slot layout
- 5 active agents (stock/music/insta/realestate/lotto) + 4 placeholders
- AGENT_META, GRID_SLOTS, STATE_COLORS in single constants module
- blog removed (replaced by insta)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:47:47 +09:00
d2838dfb7a docs(agent-office): implementation plan for 3x3 grid redesign
11 tasks covering AGENT_META centralization, AgentCard/PlaceholderCard/
AgentGrid/EmptyDetailPanel new components, SidePanel image header,
TopBar simplification, canvas removal, build + manual verification.

TDD for pure logic (constants, AgentCard); visual verification for layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:36:52 +09:00
ce09f804b6 docs(agent-office): 3x3 grid redesign design spec
Replace pixel-office canvas with 3x3 agent image grid.
- 5 active agents (stock/music/insta/realestate/lotto) + 4 placeholders
- Drop blog from AGENT_META, insta replaces it
- New assets dir: src/pages/agent-office/assets/agents/
- Remove canvas/ dir + useOfficeCanvas + office-map.json
- Keep useAgentManager (WebSocket) + 4-tab SidePanel

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:26:57 +09:00
534ded59e8 docs(signal-v2): amend spread formula to absolute (q90-q10) for Chronos-bolt zero-shot reality
Phase 0 spec §6.1 originally specified relative spread (q90-q10)/median < 0.6.
Phase 3b smoke (005930: median=-0.59%, q90-q10=15.3%) revealed Chronos-bolt
zero-shot median frequently sits near zero, causing relative spread to explode
(15.3/0.0059 ≈ 25) and reject every signal. Absolute spread (0.153 < 0.6)
preserves the threshold semantic and keeps Phase 7 IC validation tractable.

Phase 4 spec §4.2 + Phase 0 §6.1 both amended with cross-reference.
chronos_predictor.py conf calculation unchanged — monotonic mapping there
is independent of hard-gate semantics.
2026-05-17 13:10:50 +09:00
f4b78da176 docs(signal-v2): Phase 4 implementation plan — 4 tasks TDD
Task 1: foundation (config 6 env + state.signals)
Task 2: signal_generator + 9 unit tests (TDD)
Task 3: pull_worker + main.py integration + 1 test
Task 4: user manual (.env optional + smoke + push)

10 new tests, total 55 signal_v2 tests. ~3-5 days.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 12:52:13 +09:00
aeeab6704f fix(insta): SlateDetail JSON 객체 렌더 오류 + 카드 생성 시 자동 스크롤
(1) React error #31 fix: cover_copy/cta_copy는 객체({headline,body,accent_color}),
    body_copies는 배열 — 직접 {slate.cover_copy}로 렌더하면 에러. 필드별로
    분해 렌더하고, 10페이지 전체 카피(커버 1 + 본문 8 + CTA 1)를 detail에
    노출하도록 SlateDetail 확장.

(2) UX: handleCreateSlate 시작 시 window.scrollTo(0, 0)로 상단 progress 배너
    노출 보장. KeywordsPanel의 🎴 버튼도 부모 handleCreateSlate 위임으로
    통합 — Trends/Cards 양쪽 어디서 눌러도 동일 흐름(배너 + 자동 미리보기).

(3) KeywordsPanel의 자체 slatePoll 제거 — 상단 ic-slate-progress 배너로
    일원화하여 중복 진행 표시 회피.
2026-05-17 12:51:26 +09:00
6222b56716 feat(insta): trends 카드 생성 시 progress 배너 + 자동 미리보기 전환
Trends 탭의 🎴 버튼 클릭이 silent로 끝나 사용자에게 무동작처럼 보이던
이슈 fix. handleCreateSlate를 3초 간격 폴링으로 확장 (최대 8분):

- 시작/진행/성공/실패 상단 배너로 시각화
- 카드 생성 완료 시 자동으로 Cards 탭 전환 + 새 슬레이트 자동 선택
  → SlateDetail이 카피·이미지 미리보기 즉시 표시
- 실패 시 에러 메시지 + 클릭으로 dismiss
- "Claude 카피 추론 + Playwright 카드 10장 생성 중 (3~7분)" 안내 문구
2026-05-17 12:41:04 +09:00
9e9eed2162 docs(signal-v2): Phase 4 signal generator spec
매수/매도 룰 (Phase 0 spec §6.1-§6.3) + confidence_webai 공식
(chronos*0.5 + minute*0.3 + screener*0.2) + SignalDedup 24h. 6 env
외부화 (STOP_LOSS/TAKE_PROFIT/SPREAD/BID_RATIO/CONFIDENCE/MIN_MOMENTUM).
state.signals = Phase 0 spec §5.2 schema. 10 new tests.

brainstorming 6 decisions: scope=A(생성만) / trigger=A(매 cycle) /
minute_score=A(linear 5-level) / thresholds=A+(6 env) / state=A(spec §5.2) /
test=A(9 unit + 1 integration).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 12:40:24 +09:00
06affd9614 feat(insta): swap google_trends source for youtube_trending (Google Trends API 폐기 대응) 2026-05-17 11:54:10 +09:00
b0eda14982 feat(insta): Trends tab — account focus + external trends + impact preview
- api.js: add getInstaTrends, instaCollectTrends, getInstaPreferences, putInstaPreferences helpers
- InstaCards.jsx: add Cards/Trends tab bar with URL sync (?tab=trends); add AccountFocusPanel (category weight sliders + save), ExternalTrendsPanel (Naver+Google trend rows + manual collect), PreferenceImpactPanel (next extract preview); existing 5-panel Cards tab preserved intact
- InstaCards.css: add ic-tabbar, ic-tab, ic-trends-grid, ic-panel base, ic-focus/ic-trend/ic-impact component styles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 18:04:47 +09:00
1f55d24ce6 docs(signal-v2): Phase 3b implementation plan — 7 tasks TDD
Task 1: foundation (config + state + requirements)
Task 2: kis_client.get_daily_ohlcv + 1 test
Task 3: momentum_classifier (pure functions) + 6 tests
Task 4: chronos_predictor + 4 tests (mock pipeline)
Task 5: pull_worker post-close cycle + scheduler trigger + 1 test
Task 6: main.py lifespan ChronosPredictor
Task 7: user manual (pip install + .env + smoke + push)

12 new tests, total 45 signal_v2 tests. ~1 week.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:42:17 +09:00
6eb4ab1204 docs(signal-v2): Phase 3b Chronos-2 + minute momentum spec
KIS daily OHLCV fetch (kis_client.get_daily_ohlcv, FHKST03010100) +
ChronosPredictor (HuggingFace amazon/chronos-2 zero-shot, env-configurable
model, always-loaded) + minute momentum classifier (5-level rule:
strong_up/weak_up/neutral/weak_down/strong_down) + post-close cycle
trigger (16:00 KST). 12 new tests (33 → 45 total).

brainstorming 7 decisions: daily=B(KIS REST) / freq=A(post-close 1x) /
model=A(env CHRONOS_MODEL) / momentum=A(5-level rule) / state=B(median+
q10+q90+conf+as_of) / test=A(mock+pure) / scope=integrated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:37:17 +09:00
78b77e2691 docs(signal-v2): Phase 3a implementation plan — 7 tasks TDD
Task 1: config + state + websockets dep
Task 2: scheduler NXT windows + 3 tests
Task 3: kis_client REST + 4 tests
Task 4: kis_websocket + 4 tests (most heavy)
Task 5: pull_worker minute cycle + 2 tests
Task 6: main.py KIS lifespan + 1 test
Task 7: user manual .env + smoke + push

13 new tests, total 32 signal_v2 tests. ~1 week.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 05:00:43 +09:00
1813db761f docs(signal-v2): Phase 3a KIS data collection spec
KIS REST client (minute OHLCV + asking price polling, V1 token
read-only share) + KIS WebSocket client (approval_key + portfolio
asking_price realtime subscribe) + PollState extension + scheduler
NXT windows (20:00-23:30 / 04:30-07:00). 13 new tests.

brainstorming 6 decisions: scope=B(3a/3b split) / data=B(REST minute +
WS asking) / auth=A(V1 token share) / subscribe=A(portfolio WS +
screener REST) / NXT=C(scheduler extend) / test=A(respx + WS mock).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 04:50:16 +09:00
01d9b2f872 docs(signal-v2): Phase 2 implementation plan — 6 tasks TDD
Task 1: foundation (config + state + gitignore + requirements)
Task 2: stock_client + 6 tests (httpx retry + cache)
Task 3: scheduler + 5 tests (market windows + holidays)
Task 4: rate_limit + 3 tests (SQLite WAL dedup)
Task 5: pull_worker + FastAPI app + 2 tests (lifespan + /health)
Task 6: holidays sync + start.bat + user .env + manual smoke

Total 16 tests. ~1 week.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 03:31:31 +09:00
b9dabd07e0 docs(signal-v2): Phase 2 web-ai pull worker spec
stock pull worker + asyncio scheduler + rate limit SQLite + FastAPI
app (:8001). 16 tests planned. brainstorming 6 decisions:
batch=A(separate FastAPI :8001) / scope=A(3 items) / scheduler=B(asyncio
cron) / http=B(httpx + custom retry + memory cache) / rate-limit=A(SQLite
WAL) / test=B(pytest-asyncio + httpx mock).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 03:14:45 +09:00
a8e411ec22 feat(insta): replace Blog Lab page with Insta cards UI
/blog-lab → /insta route. New InstaCards page consumes insta-lab API
(news/keywords/slates + 10-page card preview + prompt template editor).
25개 blog-marketing API helper 제거, 13개 insta helper 추가.
2026-05-16 02:47:19 +09:00
f261a80d52 docs(signal-v1): web-ai V1 rename plan — 13 step atomic refactor
Single big task (Task 1) for atomic mv + load_dotenv update +
new CLAUDE.md/start.bat + verification. Task 2 = user manual
push + 30-min runtime verification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 00:23:38 +09:00
42e9c8df27 docs(signal-v1): web-ai V1 → signal_v1/ rename spec
atomic refactor (single commit) of web-ai root V1 assets into
signal_v1/ subdirectory. V2 (signal_v2/) Phase 2 will be added
alongside in subsequent slice. Pattern mirrors stock-lab → stock
graduation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 00:19:00 +09:00
c84c6b5bac docs(signal-v2): Phase 1 implementation plan — 7 tasks TDD
7 tasks: auth.py + verify_webai_key (Task 1) → portfolio + pnl_pct
(Task 2) → news-sentiment (Task 3) → common edge cases (Task 4) →
docker-compose env (Task 5) → nginx config (Task 6) → deploy +
manual smoke (Task 7). 16 tests total (4 unit + 12 integration).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:30:53 +09:00
094366a162 docs(signal-v2): Phase 1 stock WebAI API spec
3 endpoints + X-WebAI-Key auth + nginx rate limit + 15 tests.
brainstorming 7 decisions: scope=B / auth=A(static key) / portfolio=B(pnl_pct) /
news-sentiment=A(daily dump) / endpoint=1(/api/webai prefix) / rate=B(nginx) /
test=B(pytest schema).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:24:37 +09:00
57 changed files with 12052 additions and 2807 deletions

View File

@@ -26,7 +26,7 @@
| `/lab/day-calc` | `DayCalc` | 날짜 계산기 | | `/lab/day-calc` | `DayCalc` | 날짜 계산기 |
| `/lab/music` | `MusicStudio` | AI 음악 생성 스튜디오 (Sonic Forge) | | `/lab/music` | `MusicStudio` | AI 음악 생성 스튜디오 (Sonic Forge) |
| `/todo` | `Todo` | 태스크 보드 | | `/todo` | `Todo` | 태스크 보드 |
| `/blog-lab` | `BlogMarketing` | 블로그 마케팅 수익화 대시보드 | | `/insta` | `InstaCards` | 뉴스 키워드 발굴 → AI 카드 10장 자동 생성 → 인스타 업로드 |
| `/agent-office` | `AgentOffice` | AI 에이전트 가상 오피스 (WebSocket + 채팅) | | `/agent-office` | `AgentOffice` | AI 에이전트 가상 오피스 (WebSocket + 채팅) |
| `/portfolio` | `Portfolio` | 개인 포트폴리오 (프로필·경력·프로젝트·자기소개) | | `/portfolio` | `Portfolio` | 개인 포트폴리오 (프로필·경력·프로젝트·자기소개) |
@@ -113,9 +113,11 @@ proxy: {
| 여행 | POST | `/api/travel/sync` | | 여행 | POST | `/api/travel/sync` |
| 여행 | PUT | `/api/travel/albums/:album/cover`, `/api/travel/albums/:album/region` | | 여행 | PUT | `/api/travel/albums/:album/cover`, `/api/travel/albums/:album/region` |
| 여행 | PUT | `/api/travel/regions/:id` | | 여행 | PUT | `/api/travel/regions/:id` |
| 블로그마케팅 | POST | `/api/blog-marketing/research`, `/api/blog-marketing/generate` | | 인스타 | GET | `/api/insta/status`, `/api/insta/news/articles`, `/api/insta/keywords`, `/api/insta/slates`, `/api/insta/slates/:id` |
| 블로그마케팅 | GET | `/api/blog-marketing/posts`, `/api/blog-marketing/dashboard` | | 인스타 | POST | `/api/insta/news/collect`, `/api/insta/keywords/extract`, `/api/insta/slates`, `/api/insta/slates/:id/render` |
| 블로그마케팅 | POST | `/api/blog-marketing/market/:id`, `/api/blog-marketing/review/:id` | | 인스타 | 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` | | 에이전트 | GET | `/api/agent-office/agents`, `/api/agent-office/states` |
| 에이전트 | POST | `/api/agent-office/command`, `/api/agent-office/approve` | | 에이전트 | POST | `/api/agent-office/command`, `/api/agent-office/approve` |
| 에이전트 | WS | `/api/agent-office/ws` | | 에이전트 | WS | `/api/agent-office/ws` |

View 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.

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

View File

@@ -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.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,817 @@
# Signal V2 Phase 4 — Signal Generator 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:** signal_v2 에 매수/매도 신호 생성 레이어 추가. Phase 2/3a/3b 의 모든 state 산출 → Phase 0 spec §6.1-§6.3 룰 → `state.signals[ticker]` (Phase 0 spec §5.2 schema) + `SignalDedup` 24h 차단.
**Architecture:** 순수 함수 `generate_signals(state, dedup, settings)` 가 매 분봉 cycle 후 호출. 매수 (Hard gate 4 조건 + soft confidence > 0.7) + 매도 (손절>이상>익절 우선순위). 6 env 외부화 (운영 튜닝).
**Tech Stack:** Python 순수 함수 / pytest / SignalDedup (Phase 2) / 외부 의존성 없음
**Spec:** `web-ui/docs/superpowers/specs/2026-05-17-signal-v2-phase4-signal-generator.md`
---
## 파일 구조
| 파일 | 책임 |
|------|------|
| `signal_v2/config.py` | (수정) Settings 에 6 env field 추가 |
| `signal_v2/state.py` | (수정) PollState `signals` field 추가 |
| `signal_v2/signal_generator.py` | (신규) `generate_signals(state, dedup, settings)` + 8 helper |
| `signal_v2/pull_worker.py` | (수정) `poll_loop` signature + 매 cycle 후 `generate_signals` 호출 |
| `signal_v2/main.py` | (수정) lifespan 의 poll_task 호출에 `dedup` + `settings` 전달 |
| `signal_v2/tests/test_signal_generator.py` | (신규) 9 단위 케이스 |
| `signal_v2/tests/test_pull_worker.py` | (수정) integration 1 케이스 추가 |
7 파일 변경, **10 신규 테스트** (45 → 55).
---
## Task 순서
```
Task 1: foundation (config 6 env + state signals field)
Task 2: signal_generator.py + 9 단위 tests (TDD)
Task 3: pull_worker + main.py 통합 + 1 integration test
Task 4: 사용자 수동 (.env optional + smoke + push)
```
---
### Task 1: foundation (config + state)
**Files:**
- Modify: `web-ai/signal_v2/config.py`
- Modify: `web-ai/signal_v2/state.py`
- [ ] **Step 1: Update config.py with 6 new fields**
Read `web-ai/signal_v2/config.py`. Add 6 fields to Settings (after `chronos_model` field, before properties):
```python
stop_loss_pct: float = field(
default_factory=lambda: float(os.getenv("STOP_LOSS_PCT", "-0.07"))
)
take_profit_pct: float = field(
default_factory=lambda: float(os.getenv("TAKE_PROFIT_PCT", "0.15"))
)
chronos_spread_threshold: float = field(
default_factory=lambda: float(os.getenv("CHRONOS_SPREAD_THRESHOLD", "0.6"))
)
asking_bid_ratio_threshold: float = field(
default_factory=lambda: float(os.getenv("ASKING_BID_RATIO_THRESHOLD", "0.6"))
)
confidence_threshold: float = field(
default_factory=lambda: float(os.getenv("CONFIDENCE_THRESHOLD", "0.7"))
)
min_momentum_for_buy: str = field(
default_factory=lambda: os.getenv("MIN_MOMENTUM_FOR_BUY", "strong_up")
)
```
- [ ] **Step 2: Update state.py with signals field**
Read `web-ai/signal_v2/state.py`. Add `signals` field to PollState (after `minute_momentum`):
```python
signals: dict[str, dict] = field(default_factory=dict)
```
- [ ] **Step 3: Smoke import test**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-ai
python -c "from signal_v2.config import get_settings; from signal_v2.state import state; s = get_settings(); print(f'stop_loss={s.stop_loss_pct}, conf_threshold={s.confidence_threshold}, min_momentum={s.min_momentum_for_buy}'); print(state)"
```
Expected: `stop_loss=-0.07, conf_threshold=0.7, min_momentum=strong_up` + state print with `signals={}`.
- [ ] **Step 4: Run existing tests — no regression**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-ai
python -m pytest signal_v2/tests -q 2>&1 | tail -3
```
Expected: 45 passed.
- [ ] **Step 5: Commit**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-ai
git add signal_v2/config.py signal_v2/state.py
git commit -m "$(cat <<'EOF'
feat(signal_v2-phase4): foundation — 6 env thresholds + state.signals
config.py: STOP_LOSS_PCT / TAKE_PROFIT_PCT / CHRONOS_SPREAD_THRESHOLD /
ASKING_BID_RATIO_THRESHOLD / CONFIDENCE_THRESHOLD / MIN_MOMENTUM_FOR_BUY
env vars with sensible defaults (Phase 0 spec §6.1-§6.2 values).
state.py: PollState.signals dict[ticker, signal_body] for Phase 5 input.
45 existing tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
### Task 2: signal_generator.py + 9 단위 tests
**Files:**
- Create: `web-ai/signal_v2/signal_generator.py`
- Create: `web-ai/signal_v2/tests/test_signal_generator.py`
- [ ] **Step 1: Write 9 failing tests**
Create `web-ai/signal_v2/tests/test_signal_generator.py`:
```python
"""Tests for signal_generator."""
from collections import deque
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from signal_v2.signal_generator import generate_signals
from signal_v2.state import PollState
def _settings(**overrides):
"""Build a Settings-like object for tests (avoid env)."""
defaults = dict(
stop_loss_pct=-0.07,
take_profit_pct=0.15,
chronos_spread_threshold=0.6,
asking_bid_ratio_threshold=0.6,
confidence_threshold=0.7,
min_momentum_for_buy="strong_up",
)
defaults.update(overrides)
m = MagicMock()
for k, v in defaults.items():
setattr(m, k, v)
return m
def _make_state_with_buy_candidate(
ticker="005930", name="삼성전자", rank=1,
chronos_median=0.02, chronos_q10=-0.01, chronos_q90=0.04, chronos_conf=0.85,
momentum="strong_up", bid_ratio=0.7, current_price=78500,
):
state = PollState()
state.screener_preview = {"items": [{"ticker": ticker, "name": name}]}
state.chronos_predictions[ticker] = {
"median": chronos_median, "q10": chronos_q10, "q90": chronos_q90,
"conf": chronos_conf, "as_of": "2026-05-17T16:00:00+09:00",
}
state.minute_momentum[ticker] = momentum
state.asking_price[ticker] = {
"bid_total": int(bid_ratio * 1000),
"ask_total": int((1 - bid_ratio) * 1000),
"bid_ratio": bid_ratio,
"current_price": current_price,
"as_of": "2026-05-17T16:00:01+09:00",
}
return state
def _make_state_with_holding(
ticker="005930", name="삼성전자",
pnl_pct=0.0, avg_price=75000, current_price=75000,
):
state = PollState()
state.portfolio = {"holdings": [{
"ticker": ticker, "name": name,
"avg_price": avg_price, "current_price": current_price,
"pnl_pct": pnl_pct, "profit_rate": pnl_pct * 100,
"quantity": 100, "broker": "키움",
}]}
state.screener_preview = {"items": []}
return state
@pytest.fixture
def dedup_mock():
d = MagicMock()
d.is_recent.return_value = False
return d
def test_buy_signal_when_all_conditions_pass_and_confidence_high(dedup_mock):
state = _make_state_with_buy_candidate()
generate_signals(state, dedup_mock, _settings())
assert "005930" in state.signals
sig = state.signals["005930"]
assert sig["action"] == "buy"
assert sig["confidence_webai"] > 0.7
dedup_mock.record.assert_called()
def test_silent_when_chronos_median_negative(dedup_mock):
state = _make_state_with_buy_candidate(chronos_median=-0.01)
generate_signals(state, dedup_mock, _settings())
assert "005930" not in state.signals
def test_silent_when_distribution_spread_too_wide(dedup_mock):
# spread = (0.5 - (-0.5)) / max(|0.001|, 0.001) = 1000 → > 0.6
state = _make_state_with_buy_candidate(
chronos_median=0.001, chronos_q10=-0.5, chronos_q90=0.5,
)
generate_signals(state, dedup_mock, _settings())
assert "005930" not in state.signals
def test_silent_when_momentum_not_strong_up(dedup_mock):
state = _make_state_with_buy_candidate(momentum="weak_up")
generate_signals(state, dedup_mock, _settings())
assert "005930" not in state.signals
def test_silent_when_bid_ratio_below_threshold(dedup_mock):
state = _make_state_with_buy_candidate(bid_ratio=0.5)
generate_signals(state, dedup_mock, _settings())
assert "005930" not in state.signals
def test_silent_when_confidence_below_threshold(dedup_mock):
# chronos_conf low + rank=20 → confidence < 0.7
state = _make_state_with_buy_candidate(chronos_conf=0.3)
# add 19 fake items to push rank to 20
state.screener_preview["items"] = (
[{"ticker": f"FAKE{i:03d}"} for i in range(19)]
+ [{"ticker": "005930", "name": "삼성전자"}]
)
generate_signals(state, dedup_mock, _settings())
# confidence_webai = 0.3*0.5 + 1.0*0.3 + 0.05*0.2 = 0.15 + 0.3 + 0.01 = 0.46 < 0.7
assert "005930" not in state.signals
def test_sell_signal_when_stop_loss_triggered(dedup_mock):
state = _make_state_with_holding(pnl_pct=-0.08, current_price=69000, avg_price=75000)
generate_signals(state, dedup_mock, _settings())
assert "005930" in state.signals
sig = state.signals["005930"]
assert sig["action"] == "sell"
assert sig["confidence_webai"] == 1.0 # 손절선 즉시
assert sig["pnl_pct"] == -0.08
def test_sell_signal_when_take_profit_triggered(dedup_mock):
state = _make_state_with_holding(pnl_pct=0.16, current_price=87000, avg_price=75000)
generate_signals(state, dedup_mock, _settings())
assert "005930" in state.signals
sig = state.signals["005930"]
assert sig["action"] == "sell"
assert sig["confidence_webai"] == 0.6 # 익절선 검토 알림
def test_silent_when_dedup_recently_sent(dedup_mock):
state = _make_state_with_buy_candidate()
dedup_mock.is_recent.return_value = True # dedup 차단
generate_signals(state, dedup_mock, _settings())
assert "005930" not in state.signals
dedup_mock.record.assert_not_called()
```
- [ ] **Step 2: Run tests to verify FAIL**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-ai
python -m pytest signal_v2/tests/test_signal_generator.py -v 2>&1 | tail -10
```
Expected: ImportError (signal_v2.signal_generator missing).
- [ ] **Step 3: Implement signal_generator.py**
Create `web-ai/signal_v2/signal_generator.py`:
```python
"""Phase 4 — 매수/매도 신호 생성.
순수 함수 generate_signals(state, dedup, settings). state 를 mutate.
"""
from __future__ import annotations
import logging
from datetime import datetime
from zoneinfo import ZoneInfo
logger = logging.getLogger(__name__)
KST = ZoneInfo("Asia/Seoul")
# 분봉 모멘텀 → linear score
MOMENTUM_SCORES = {
"strong_up": 1.0,
"weak_up": 0.7,
"neutral": 0.5,
"weak_down": 0.3,
"strong_down": 0.0,
}
def generate_signals(state, dedup, settings) -> None:
"""Phase 4 entry — state mutating. 매수/매도 룰 적용."""
_evaluate_buy_signals(state, dedup, settings)
_evaluate_sell_signals(state, dedup, settings)
# ----- 매수 -----
def _evaluate_buy_signals(state, dedup, settings) -> None:
candidates = _buy_candidates(state)
for ticker, name, rank in candidates:
if not _check_buy_hard_gate(state, ticker, settings):
continue
confidence = _compute_buy_confidence(state, ticker, rank)
if confidence <= settings.confidence_threshold:
continue
if dedup.is_recent(ticker, "buy", within_hours=24):
continue
state.signals[ticker] = _build_buy_signal(state, ticker, name, rank, confidence)
dedup.record(ticker, "buy", confidence=confidence)
def _buy_candidates(state) -> list[tuple[str, str, int | None]]:
"""screener Top-N (rank 1..N) + portfolio (rank=None)."""
candidates: list[tuple[str, str, int | None]] = []
seen: set[str] = set()
# Screener Top-N
if state.screener_preview is not None:
for i, item in enumerate(state.screener_preview.get("items", [])):
ticker = item.get("ticker")
if not ticker or ticker in seen:
continue
seen.add(ticker)
name = item.get("name", ticker)
candidates.append((ticker, name, i + 1))
# Portfolio holdings
if state.portfolio is not None:
for h in state.portfolio.get("holdings", []):
ticker = h.get("ticker")
if not ticker or ticker in seen:
continue
seen.add(ticker)
candidates.append((ticker, h.get("name", ticker), None))
return candidates
def _check_buy_hard_gate(state, ticker: str, settings) -> bool:
pred = state.chronos_predictions.get(ticker)
if pred is None or pred["median"] <= 0:
return False
spread = (pred["q90"] - pred["q10"]) / max(abs(pred["median"]), 0.001)
if spread >= settings.chronos_spread_threshold:
return False
momentum = state.minute_momentum.get(ticker)
if momentum != settings.min_momentum_for_buy:
return False
ap = state.asking_price.get(ticker)
if ap is None or ap["bid_ratio"] < settings.asking_bid_ratio_threshold:
return False
return True
def _compute_buy_confidence(state, ticker: str, rank: int | None) -> float:
pred = state.chronos_predictions[ticker]
chronos_conf = pred["conf"]
minute_score = MOMENTUM_SCORES.get(state.minute_momentum.get(ticker, "neutral"), 0.5)
screener_norm = 1 - (rank - 1) / 20 if rank is not None else 0.0
return chronos_conf * 0.5 + minute_score * 0.3 + screener_norm * 0.2
def _build_buy_signal(state, ticker: str, name: str, rank: int | None, confidence: float) -> dict:
ap = state.asking_price[ticker]
pred = state.chronos_predictions[ticker]
return {
"ticker": ticker,
"name": name,
"action": "buy",
"confidence_webai": confidence,
"current_price": ap["current_price"],
"avg_price": None,
"pnl_pct": None,
"context": _build_context(state, ticker, rank),
"as_of": datetime.now(KST).isoformat(),
}
# ----- 매도 -----
def _evaluate_sell_signals(state, dedup, settings) -> None:
if state.portfolio is None:
return
for holding in state.portfolio.get("holdings", []):
ticker = holding.get("ticker")
if not ticker:
continue
sell = _try_stop_loss(state, holding, settings)
if sell is None:
sell = _try_anomaly(state, holding, settings)
if sell is None:
sell = _try_take_profit(state, holding, settings)
if sell is None:
continue
if dedup.is_recent(ticker, "sell", within_hours=24):
continue
state.signals[ticker] = sell
dedup.record(ticker, "sell", confidence=sell["confidence_webai"])
def _try_stop_loss(state, holding: dict, settings) -> dict | None:
pnl = holding.get("pnl_pct")
if pnl is None or pnl >= settings.stop_loss_pct:
return None
return _build_sell_signal(state, holding, confidence=1.0, reason="stop_loss")
def _try_take_profit(state, holding: dict, settings) -> dict | None:
pnl = holding.get("pnl_pct")
if pnl is None or pnl <= settings.take_profit_pct:
return None
return _build_sell_signal(state, holding, confidence=0.6, reason="take_profit")
def _try_anomaly(state, holding: dict, settings) -> dict | None:
ticker = holding["ticker"]
pred = state.chronos_predictions.get(ticker)
if pred is None or pred["median"] >= -0.01:
return None
momentum = state.minute_momentum.get(ticker)
if momentum != "strong_down":
return None
ap = state.asking_price.get(ticker)
if ap is None:
return None
if ap["bid_ratio"] > (1 - settings.asking_bid_ratio_threshold):
return None # 매도세 60% 미만
minute_score = 1.0 - MOMENTUM_SCORES.get(momentum, 0.5) # 반전
confidence = pred["conf"] * 0.5 + minute_score * 0.3 + 1.0 * 0.2
if confidence <= settings.confidence_threshold:
return None
return _build_sell_signal(state, holding, confidence=confidence, reason="anomaly")
def _build_sell_signal(state, holding: dict, confidence: float, reason: str) -> dict:
ticker = holding["ticker"]
return {
"ticker": ticker,
"name": holding.get("name", ticker),
"action": "sell",
"confidence_webai": confidence,
"current_price": holding.get("current_price"),
"avg_price": holding.get("avg_price"),
"pnl_pct": holding.get("pnl_pct"),
"context": _build_context(state, ticker, rank=None, sell_reason=reason),
"as_of": datetime.now(KST).isoformat(),
}
# ----- Context -----
def _build_context(state, ticker: str, rank: int | None, sell_reason: str | None = None) -> dict:
pred = state.chronos_predictions.get(ticker) or {}
ap = state.asking_price.get(ticker) or {}
news_item = _find_news_sentiment(state, ticker)
screener_scores = _find_screener_scores(state, ticker)
context: dict = {
"chronos_pred_1d": pred.get("median"),
"chronos_pred_conf": pred.get("conf"),
"chronos_q10": pred.get("q10"),
"chronos_q90": pred.get("q90"),
"screener_rank": rank,
"screener_scores": screener_scores,
"minute_momentum": state.minute_momentum.get(ticker),
"asking_bid_ratio": ap.get("bid_ratio"),
"news_sentiment": news_item.get("score") if news_item else None,
"news_reason": news_item.get("reason") if news_item else None,
}
if sell_reason is not None:
context["sell_reason"] = sell_reason
return context
def _find_news_sentiment(state, ticker: str) -> dict | None:
if state.news_sentiment is None:
return None
for item in state.news_sentiment.get("items", []):
if item.get("ticker") == ticker:
return item
return None
def _find_screener_scores(state, ticker: str) -> dict | None:
if state.screener_preview is None:
return None
for item in state.screener_preview.get("items", []):
if item.get("ticker") == ticker:
return item.get("scores")
return None
```
- [ ] **Step 4: Run tests to verify PASS**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-ai
python -m pytest signal_v2/tests/test_signal_generator.py -v 2>&1 | tail -15
```
Expected: 9 passed.
Full suite:
```bash
python -m pytest signal_v2/tests -q 2>&1 | tail -3
```
Expected: 54 passed.
If any test fails, examine the assertion + impl. Common gotchas:
- Confidence calculation order — chronos*0.5 + minute*0.3 + screener*0.2
- Stop loss `<` (strict) vs `<=` — spec says "도달 시" so use `<` strict
- screener_norm when rank=None → 0.0 (not 1.0)
- [ ] **Step 5: Commit**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-ai
git add signal_v2/signal_generator.py signal_v2/tests/test_signal_generator.py
git commit -m "$(cat <<'EOF'
feat(signal_v2-phase4): signal_generator + 9 unit tests
generate_signals(state, dedup, settings) → state mutating:
- Buy: screener Top-N + portfolio. Hard gate (chronos median > 0 +
spread < 0.6 + momentum strong_up + bid_ratio >= 0.6) + soft
confidence (chronos*0.5 + minute*0.3 + screener*0.2) > 0.7.
- Sell: portfolio only. Priority stop_loss > anomaly > take_profit.
Stop loss confidence 1.0 (immediate), take_profit 0.6 (review).
- SignalDedup 24h via dedup.is_recent/record per (ticker, action).
- State signal dict matches Phase 0 spec §5.2 schema.
54 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
### Task 3: pull_worker + main.py integration + 1 test
**Files:**
- Modify: `web-ai/signal_v2/pull_worker.py`
- Modify: `web-ai/signal_v2/main.py`
- Modify: `web-ai/signal_v2/tests/test_pull_worker.py`
- [ ] **Step 1: Write failing integration test**
Append to `web-ai/signal_v2/tests/test_pull_worker.py`:
```python
def test_poll_loop_calls_generate_signals_after_cycle(monkeypatch):
"""매 cycle 후 generate_signals 호출 + state.signals 갱신."""
from unittest.mock import MagicMock
from signal_v2.state import PollState
state = PollState()
state.portfolio = {"holdings": [{
"ticker": "005930", "name": "삼성전자",
"avg_price": 75000, "current_price": 69000,
"pnl_pct": -0.08, "profit_rate": -8.0,
"quantity": 100, "broker": "키움",
}]}
state.screener_preview = {"items": []}
dedup = MagicMock()
dedup.is_recent.return_value = False
settings = MagicMock()
settings.stop_loss_pct = -0.07
settings.take_profit_pct = 0.15
settings.chronos_spread_threshold = 0.6
settings.asking_bid_ratio_threshold = 0.6
settings.confidence_threshold = 0.7
settings.min_momentum_for_buy = "strong_up"
from signal_v2.signal_generator import generate_signals
# Call generate_signals directly to verify state mutation through the public API.
generate_signals(state, dedup, settings)
# Stop loss should trigger
assert "005930" in state.signals
assert state.signals["005930"]["action"] == "sell"
assert state.signals["005930"]["confidence_webai"] == 1.0
dedup.record.assert_called_with("005930", "sell", confidence=1.0)
```
- [ ] **Step 2: Run test to verify PASS (signal_generator from Task 2 already exists)**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-ai
python -m pytest signal_v2/tests/test_pull_worker.py::test_poll_loop_calls_generate_signals_after_cycle -v 2>&1 | tail -10
```
Expected: PASS (test exercises generate_signals directly — public API integration).
- [ ] **Step 3: Update pull_worker.py — poll_loop signature + cycle integration**
Read `web-ai/signal_v2/pull_worker.py`. Modify the `poll_loop` signature to accept dedup + settings:
```python
async def poll_loop(
client, state, shutdown,
kis_client=None, chronos=None,
dedup=None, settings=None,
) -> None:
"""...existing docstring..."""
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, kis_client=kis_client)
except Exception:
logger.exception("poll cycle failed")
try:
update_minute_momentum_for_all(state)
except Exception:
logger.exception("minute momentum update failed")
if _is_post_close_trigger(now) and chronos is not None and kis_client is not None:
try:
await _run_post_close_cycle(kis_client, chronos, state)
except Exception:
logger.exception("post-close cycle failed")
# Phase 4: generate signals
if dedup is not None and settings is not None:
try:
from signal_v2.signal_generator import generate_signals
generate_signals(state, dedup, settings)
except Exception:
logger.exception("generate_signals failed")
interval = _next_interval(now)
try:
await asyncio.wait_for(shutdown.wait(), timeout=interval)
break
except asyncio.TimeoutError:
continue
logger.info("poll_loop ended")
```
- [ ] **Step 4: Update main.py — pass dedup + settings to poll_loop**
Read `web-ai/signal_v2/main.py`. Find the `asyncio.create_task(poll_loop(...))` call inside `lifespan` and add `dedup` + `settings` params:
```python
_ctx.poll_task = asyncio.create_task(
poll_loop(
_ctx.client, state_mod.state, _ctx.shutdown,
kis_client=_ctx.kis_client,
chronos=_ctx.chronos,
dedup=_ctx.dedup,
settings=settings,
)
)
```
- [ ] **Step 5: Run full test suite**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-ai
python -m pytest signal_v2/tests -q 2>&1 | tail -3
```
Expected: 55 passed (54 + 1 new integration).
- [ ] **Step 6: Commit**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-ai
git add signal_v2/pull_worker.py signal_v2/main.py signal_v2/tests/test_pull_worker.py
git commit -m "$(cat <<'EOF'
feat(signal_v2-phase4): pull_worker + main.py integrate signal generator
poll_loop signature now accepts dedup + settings. After each cycle
(stock pull + minute momentum + post-close), call generate_signals
to populate state.signals. main.py lifespan passes _ctx.dedup and
settings to poll_loop.
1 integration test added. 55 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
### Task 4: 사용자 수동 — .env optional + smoke + push
**This task requires user action.**
- [ ] **Step 1: .env optional**
6 env 의 default 가 Phase 0 spec 값과 동일 — `.env` 변경 불필요. 운영 검증 후 조정 시:
```
STOP_LOSS_PCT=-0.07
TAKE_PROFIT_PCT=0.15
CHRONOS_SPREAD_THRESHOLD=0.6
ASKING_BID_RATIO_THRESHOLD=0.6
CONFIDENCE_THRESHOLD=0.7
MIN_MOMENTUM_FOR_BUY=strong_up
```
- [ ] **Step 2: signal_v2 재시작**
기존 signal_v2 가 가동 중이면 Ctrl+C 후:
```powershell
cd C:\Users\jaeoh\Desktop\workspace\web-ai\signal_v2
.\start.bat
```
기대: 정상 시작 (signal_generator 자동 호출 — 매 cycle 마다).
- [ ] **Step 3: state.signals 검증 (수동)**
운영 시간대라면 cycle 진행 + state.signals 채워질 수 있음. 수동 검증:
```powershell
cd C:\Users\jaeoh\Desktop\workspace\web-ai
python -c "
import asyncio
from signal_v2.config import get_settings
from signal_v2.kis_client import KISClient
from signal_v2.chronos_predictor import ChronosPredictor
from signal_v2.state import PollState
from signal_v2.rate_limit import SignalDedup
from signal_v2.pull_worker import _run_post_close_cycle, update_minute_momentum_for_all
from signal_v2.signal_generator import generate_signals
async def main():
s = get_settings()
kc = KISClient(app_key=s.kis_app_key, app_secret=s.kis_app_secret, account=s.kis_account, is_virtual=s.kis_is_virtual, v1_token_path=s.v1_token_path)
cp = ChronosPredictor(model_name=s.chronos_model)
dedup = SignalDedup(s.db_path)
state = PollState()
state.portfolio = {'holdings': [{'ticker': '005930', 'name': '삼성전자', 'avg_price': 75000, 'current_price': 78500, 'pnl_pct': 0.047, 'profit_rate': 4.67, 'quantity': 100, 'broker': '키움'}]}
state.screener_preview = {'items': []}
try:
await _run_post_close_cycle(kc, cp, state)
update_minute_momentum_for_all(state)
generate_signals(state, dedup, s)
print('Signals:', state.signals)
finally:
await kc.close()
asyncio.run(main())
"
```
Expected: `Signals: {}` (정상 — pnl_pct 0.047 은 손절/익절 트리거 안 함, 매수 조건 다 만족 어려움) 또는 일부 신호 dict.
- [ ] **Step 4: V1 무영향**
V1 정상 가동 확인.
- [ ] **Step 5: push**
```powershell
cd C:\Users\jaeoh\Desktop\workspace\web-ai
git push
```
- [ ] **Step 6: 결과 보고**
- Step 2 (signal_v2 시작): PASS / FAIL
- Step 3 (state.signals 검증): PASS — empty dict or 신호 결과 공유 / FAIL
- Step 4 (V1 무영향): PASS / FAIL
- Step 5 (push): PASS / FAIL
전체 PASS 시 **Phase 4 완료** → Phase 5 (agent-office /signal + Qwen3 + 이중 텔레그램) brainstorming.
---
## Self-Review
**1. Spec coverage:**
| Spec § | 요구사항 | Plan task |
|--------|----------|----------|
| §2 ① signal_generator | Task 2 ✅ |
| §2 ② config 6 env | Task 1 ✅ |
| §2 ③ state.signals | Task 1 ✅ |
| §2 ④ pull_worker integration | Task 3 ✅ |
| §2 ⑤ main.py lifespan | Task 3 ✅ |
| §2 ⑥ 10 tests | Task 2 (9) + Task 3 (1) = 10 ✅ |
| §4 매수 룰 + confidence | Task 2 (_check_buy_hard_gate + _compute_buy_confidence) ✅ |
| §5 매도 룰 + dedup | Task 2 (_try_stop_loss/anomaly/take_profit + dedup.is_recent/record) ✅ |
| §6 state 통합 + pull_worker | Task 1 + Task 3 ✅ |
| §7 signal_generator 구조 | Task 2 Step 3 (8 helpers) ✅ |
| §8 10 테스트 케이스 | Task 2-3 ✅ |
| §9 DoD 8 항목 | Task 1-4 합산 ✅ |
No gaps.
**2. Placeholder scan**: No "TBD" / "implement later". 각 step 의 코드 + 명령 모두 명시.
**3. Type consistency:**
- `generate_signals(state, dedup, settings) -> None` consistent Task 2 + Task 3 ✅
- `MOMENTUM_SCORES` 매핑 consistent (1.0/0.7/0.5/0.3/0.0) ✅
- Settings field names consistent Task 1 + Task 2 (stop_loss_pct, etc.) ✅
- PollState.signals dict[str, dict] consistent ✅
- helper signatures (_check_buy_hard_gate, _compute_buy_confidence, _try_stop_loss, _try_anomaly, _try_take_profit, _build_buy_signal, _build_sell_signal, _build_context) consistent ✅
Plan passes self-review.

View File

@@ -194,7 +194,7 @@ agent-office 가 web-ai 의 Ollama (Qwen3 14B Q4) 에 보내는 prompt 의 응
### 6.1 매수 신호 (screener Top-20 종목 대상) ### 6.1 매수 신호 (screener Top-20 종목 대상)
조건 (전부 충족): 조건 (전부 충족):
1. Chronos-2 1-day quantile (median) 예측 > 0% 그리고 분포 폭 (90-10 분위수 / 50 분위수) < 0.6 (좁은 분포 = 높은 conf) 1. Chronos-2 1-day quantile (median) 예측 > 0% 그리고 분포 폭 `q90 - q10` < 0.6 (절대 spread, 60% return 변동 미만 = 모델 확신; **Phase 4 amend 2026-05-17**: 기존 relative formula `(q90-q10)/median` 는 Chronos-bolt 의 median≈0 출력에서 거의 모든 신호 거부 → absolute spread 채택. 자세한 사유는 `2026-05-17-signal-v2-phase4-signal-generator.md` §4.2 참조)
2. 분봉 모멘텀 = `strong_up`: 2. 분봉 모멘텀 = `strong_up`:
- 5분봉 5개 연속 양봉 - 5분봉 5개 연속 양봉
- 거래량 > 평균 1.5배 - 거래량 > 평균 1.5배

View 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 자동화 — 일정 운영 안정화 후

View File

@@ -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 직접 호출 결정

View File

@@ -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 시점)

View File

@@ -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 회피)

View File

@@ -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분 (남은 섹션)
```

View File

@@ -0,0 +1,345 @@
# Agent Office 그리드 재설계 — Design Spec
**Date:** 2026-05-17
**Author:** CEO (with Claude)
**Target:** `web-ui` `/agent-office` 페이지
---
## 1. 배경 & 목적
현재 `/agent-office` 페이지는 픽셀 사무실 Canvas 위에서 5명의 에이전트 캐릭터가 무의미하게 걸어다니는 형태다. 시각적 즐거움은 있으나 정보 밀도가 낮고, 각 에이전트가 무슨 일을 하는지 한눈에 파악하기 어렵다.
이를 **3x3 그리드** 기반의 정보 중심 UI로 재설계한다. 왼편에 9개의 에이전트 이미지 카드를 배치하고, 카드 클릭 시 오른편 패널에서 해당 에이전트의 명령·태스크·토큰·로그를 확인한다.
---
## 2. 범위 (Scope)
### In scope
- `src/pages/agent-office/AgentOffice.jsx` 전면 재작성 (Canvas → Grid)
- 그리드 카드 컴포넌트 신규 작성
- `SidePanel.jsx` 헤더 부분 수정 (emoji → 이미지)
- `SidePanel.jsx``AGENT_META`에서 `blog` 제거, `insta` 추가
- TopBar 단순화 (theme/zoom 컨트롤 제거)
- Canvas 관련 파일/디렉토리 전체 삭제
- 이미지 에셋 디렉토리 신설
### Out of scope
- 백엔드 변경 (현재 백엔드의 `insta` 에이전트는 이미 등록 완료, 추가 작업 불필요)
- 새 에이전트 추가 (4개 placeholder는 "준비 중" 표시만)
- 4탭 컨텐츠 (Commands/Tasks/Tokens/Logs) 로직 수정
---
## 3. 에이전트 구성
### 실제 작동 5명
| ID | 표시명 | 색상 | 역할 요약 |
|----|--------|------|-----------|
| `stock` | 주식 트레이더 | `#4488cc` | 주식 매매·뉴스 분석·포트폴리오 |
| `music` | 음악 프로듀서 | `#44aa88` | AI 음악 생성 |
| `insta` | 인스타 큐레이터 | `#d97706` | 매일 09:30 뉴스 수집 → 키워드 추출 → AI 카드 10장 생성 → 텔레그램 푸시 |
| `realestate` | 청약 애널리스트 | `#c026d3` | 부동산 청약 매칭·자치구 5티어 분석 |
| `lotto` | 로또 큐레이터 | `#ef4444` | 로또 번호 추천·브리핑 |
> `blog`는 `insta`로 대체됨. 기존 `SidePanel.jsx`의 `AGENT_META`에서 `blog` 항목 삭제 + `insta` 추가.
### Placeholder 4개
- ID 없음 (그리드 슬롯 인덱스 6/7/8/9로만 식별)
- 모두 동일하게 `agent_undetermined.png` + "준비 중" 라벨
- 클릭 시 정적 안내 패널 노출
---
## 4. 디렉토리 & 파일 구조
### 신설 디렉토리
```
src/pages/agent-office/assets/agents/
├── agent_stock.png (사용자 제공)
├── agent_music.png (사용자 제공)
├── agent_insta.png (사용자 제공)
├── agent_realestate.png (사용자 제공)
├── agent_lotto.png (사용자 제공)
└── agent_undetermined.png (사용자 제공, 4 placeholder 공유)
```
### 파일명 규칙
`agent_{id}.png` 형식. `{id}`는 백엔드의 agent_id와 일치 (소문자, underscore).
### 권장 이미지 사양
- 정사각형 (예: 512x512)
- PNG (투명 배경 허용)
- 카드 표시 시 `object-fit: cover`로 정사각 크롭
### 삭제 대상
```
src/pages/agent-office/
├── canvas/ ← 전체 삭제
│ ├── themes.js
│ ├── FurnitureRenderer.js
│ ├── ProceduralSprite.js
│ ├── AgentSprite.js
│ ├── SpriteLoader.js
│ ├── OverlayRenderer.js
│ ├── Pathfinder.js
│ ├── OfficeRenderer.js
│ └── TileMap.js
├── hooks/
│ └── useOfficeCanvas.js ← 삭제
└── assets/
└── office-map.json ← 삭제
```
### 유지 대상
```
src/pages/agent-office/
├── AgentOffice.jsx ← 재작성
├── AgentOffice.css ← 재작성
├── hooks/
│ └── useAgentManager.js ← 그대로 (WebSocket 로직)
└── components/
├── TopBar.jsx ← 단순화 (theme/zoom 제거)
├── SidePanel.jsx ← 헤더 수정 + AGENT_META 갱신
├── CommandTab.jsx ← 그대로
├── TaskTab.jsx ← 그대로
├── TokenTab.jsx ← 그대로
└── LogTab.jsx ← 그대로
```
### 신규 컴포넌트
```
src/pages/agent-office/components/
├── AgentGrid.jsx ← 3x3 그리드 래퍼
├── AgentCard.jsx ← 카드 1개 (image + state dot + badge + name)
├── PlaceholderCard.jsx ← "준비 중" 카드
└── EmptyDetailPanel.jsx ← 초기 안내 / placeholder 클릭 시 안내
```
---
## 5. 레이아웃
### 전체 화면 구조
```
┌─────────────────────────────────────────────────────────────┐
│ TopBar (connected status only) │
├──────────────────────────────────┬──────────────────────────┤
│ │ │
│ AgentGrid (3x3) │ Right Panel │
│ ┌──────┬──────┬──────┐ │ │
│ │stock │music │insta │ │ ┌─ active 선택 시 ─┐ │
│ ├──────┼──────┼──────┤ │ │ SidePanel │ │
│ │realE │lotto │ ?? │ │ │ - 헤더(이미지+이름)│ │
│ ├──────┼──────┼──────┤ │ │ - 4 tabs │ │
│ │ ?? │ ?? │ ?? │ │ └──────────────────┘ │
│ └──────┴──────┴──────┘ │ │
│ │ ┌─ placeholder 선택 ─┐ │
│ │ │ "준비 중인 에이전트"│ │
│ │ └────────────────────┘ │
│ │ │
│ │ ┌─ 초기(미선택) ──────┐ │
│ │ │ "에이전트를 선택…" │ │
│ │ └────────────────────┘ │
└──────────────────────────────────┴──────────────────────────┘
```
### 그리드 슬롯 순서 (좌→우, 위→아래)
| Index | Slot |
|-------|------|
| 1 (행1·열1) | `stock` |
| 2 (행1·열2) | `music` |
| 3 (행1·열3) | `insta` |
| 4 (행2·열1) | `realestate` |
| 5 (행2·열2) | `lotto` |
| 6 (행2·열3) | placeholder |
| 7 (행3·열1) | placeholder |
| 8 (행3·열2) | placeholder |
| 9 (행3·열3) | placeholder |
### AgentCard 시각 구조
```
┌─────────────────────┐
│ ● state [③] │ ← 상태 dot(좌상, image 약간 위) + 알림 뱃지(우상)
│ ┌───────────────┐ │
│ │ │ │
│ │ agent_xxx │ │ ← 정사각 이미지 (object-fit: cover)
│ │ .png │ │
│ │ │ │
│ └───────────────┘ │
│ 주식 트레이더 │ ← display_name
└─────────────────────┘
```
#### 상태 dot
| state | color | 동작 |
|-------|-------|------|
| `idle` | `#6b7280` (회색) | 정적 |
| `working` | `#22c55e` (초록) | pulse 애니메이션 |
| `error` | `#ef4444` (빨강) | 정적 |
| `waiting_approval` | `#f59e0b` (주황) | pulse |
| `break` | `#94a3b8` (밝은 회색) | 정적 |
상태 dot은 카드의 좌상단, 이미지보다 약간 위쪽에 위치 (이미지 영역 바깥 또는 모서리 살짝 걸침).
#### 알림 뱃지
- `notifications[agentId] > 0`일 때만 우상단에 표시
- 빨강 배경에 흰 숫자 (count > 9면 "9+")
- 카드 클릭 시 자동으로 0으로 리셋 (`clearNotifications` 호출 — 기존 로직 재사용)
---
## 6. 데이터 플로우
```
useAgentManager (그대로 유지)
├── WebSocket /api/agent-office/ws
├── agents: { [id]: { state, detail, task_id } }
├── notifications: { [id]: count }
├── pendingTasks: [...]
├── connected: bool
└── refreshTrigger: number
AgentOffice.jsx
├── agents → AgentGrid에 전달 → 각 AgentCard가 state로 dot 색상 결정
├── notifications → 각 AgentCard가 badge 표시
├── selectedAgent (local state): string | null | "placeholder"
└── 카드 클릭 시 setSelectedAgent + clearNotifications
Right Panel 분기
├── selectedAgent === null → EmptyDetailPanel (초기 안내)
├── selectedAgent === "placeholder"→ EmptyDetailPanel ("준비 중" 메시지)
└── selectedAgent ∈ active 5명 → SidePanel (4탭, 기존 로직)
```
---
## 7. SidePanel 수정 사항
### AGENT_META 갱신
```js
// src/pages/agent-office/components/SidePanel.jsx
import stockImg from '../assets/agents/agent_stock.png';
import musicImg from '../assets/agents/agent_music.png';
import instaImg from '../assets/agents/agent_insta.png';
import realestateImg from '../assets/agents/agent_realestate.png';
import lottoImg from '../assets/agents/agent_lotto.png';
const AGENT_META = {
stock: { displayName: '주식 트레이더', image: stockImg, color: '#4488cc' },
music: { displayName: '음악 프로듀서', image: musicImg, color: '#44aa88' },
insta: { displayName: '인스타 큐레이터', image: instaImg, color: '#d97706' },
realestate: { displayName: '청약 애널리스트', image: realestateImg, color: '#c026d3' },
lotto: { displayName: '로또 큐레이터', image: lottoImg, color: '#ef4444' }
};
// blog 항목 삭제
```
### 헤더 시각 변경
```jsx
// 변경 전: emoji icon
<div className="ao-sidepanel-icon" style={{ background: meta.color }}>
{meta.emoji}
</div>
// 변경 후: 이미지
<div className="ao-sidepanel-icon" style={{ borderColor: meta.color }}>
<img src={meta.image} alt={meta.displayName} />
</div>
```
4탭(Commands/Tasks/Tokens/Logs) 본체 로직은 손대지 않음.
---
## 8. CSS 토큰 (제안)
```css
:root {
--ao-bg: #0f172a;
--ao-card-bg: #1e293b;
--ao-card-border: #334155;
--ao-card-border-active: #60a5fa;
--ao-text: #e2e8f0;
--ao-text-muted: #94a3b8;
--ao-grid-gap: 16px;
--ao-card-radius: 12px;
}
.ao-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--ao-grid-gap);
}
.ao-card {
aspect-ratio: 1 / 1.15; /* 이미지 정사각 + 이름줄 */
background: var(--ao-card-bg);
border: 1px solid var(--ao-card-border);
border-radius: var(--ao-card-radius);
cursor: pointer;
transition: transform 120ms ease, border-color 120ms ease;
}
.ao-card:hover { transform: translateY(-2px); }
.ao-card.active { border-color: var(--ao-card-border-active); }
.ao-card.placeholder { opacity: 0.55; cursor: pointer; }
```
반응형: 모바일에서는 `grid-template-columns: repeat(2, 1fr)` 또는 `repeat(1, 1fr)`로 축소.
---
## 9. 에러 처리 / Edge Cases
| 상황 | 동작 |
|------|------|
| 이미지 로드 실패 | `<img onError>`로 단색 배경 + 첫 글자 fallback |
| WebSocket 끊김 | TopBar에 disconnected 표시. 카드는 마지막 상태 유지 (회색 처리 안 함 — 기존 동작 유지) |
| `agents[id]` 미존재 | dot 회색(`idle`), 정상 표시 |
| placeholder 클릭 | 우측 패널만 변경, WebSocket 호출/clearNotifications 호출 없음 |
---
## 10. 테스트 계획
- [ ] 6개 이미지 파일이 디렉토리에 존재할 때 그리드 정상 렌더링
- [ ] 이미지 누락 시 fallback 표시
- [ ] WebSocket으로 `agent_state` 수신 시 dot 색상 변경
- [ ] `notification` 수신 시 뱃지 표시, 카드 클릭 시 0으로 리셋
- [ ] active 5명 클릭 → SidePanel 4탭 표시 (기존 동작 유지)
- [ ] placeholder 4슬롯 클릭 → "준비 중" 패널
- [ ] TopBar의 connected/disconnected 표시 정상
- [ ] Canvas 잔재(파일 import 누락 등) 없음 — `npm run build` 통과
- [ ] 모바일 뷰(<768px) 그리드 축소 정상
---
## 11. 이행 절차 (사용자 작업 포함)
1. **사용자**: `src/pages/agent-office/assets/agents/` 디렉토리에 6개 PNG 파일 배치
2. **Claude (구현 단계)**: writing-plans 스킬로 단계별 작업 계획 작성
3. 구현·삭제·테스트 후 commit
4. NAS 배포는 별도 (`npm run release:nas`)
---
## 12. 향후 확장
- 9번째 active 에이전트 채용 시: 이미지 추가 + `AGENT_META` 갱신 + 슬롯 인덱스 매핑 변경
- 그리드 자동 정렬(상태별/우선순위별 sort) — 현재는 정적 배치
- 카드 hover 시 미니 프리뷰 (최근 활동 1줄 요약) — 추후 검토

View File

@@ -0,0 +1,406 @@
# Confidence Signal Pipeline V2 — Phase 4: Signal Generator Design
**작성일**: 2026-05-17
**작성자**: 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`)
- Phase 3b Chronos-2 + momentum (`2026-05-16-signal-v2-phase3b-chronos-momentum.md`)
**브레인스토밍 결정 6개**:
- scope = A (신호 생성만, Phase 5 가 발송)
- trigger = A (매 분봉 cycle 후 일괄 평가)
- minute_score = A (Linear 5-level 1.0/0.7/0.5/0.3/0.0)
- 임계값 = A+ (6 env 외부화)
- state.signals schema = A (Phase 0 spec §5.2 그대로)
- 테스트 = A (9 단위 + 1 integration = 10 신규)
---
## 1. 목표
Phase 2/3a/3b 의 모든 산출을 종합해 Phase 0 spec §6.1/§6.2/§6.3 의 매수/매도/dedup 룰 적용. 임계값 통과한 신호를 `state.signals` 에 저장 + `SignalDedup` 으로 24h 중복 차단.
**Why**: Phase 5 (agent-office) 의 입력 계약 완성. signal_v2 가 자체적으로 매수/매도 신호 생성 → Phase 5 가 발송.
---
## 2. 범위
### 포함 (6 항목)
-`signal_generator.py` 신규 — `generate_signals(state, dedup, settings) -> None` (state mutating)
-`config.py` 확장 — 6 env (`STOP_LOSS_PCT`, `TAKE_PROFIT_PCT`, `CHRONOS_SPREAD_THRESHOLD`, `ASKING_BID_RATIO_THRESHOLD`, `CONFIDENCE_THRESHOLD`, `MIN_MOMENTUM_FOR_BUY`)
-`state.py` 확장 — `signals: dict[str, dict]` (Phase 5 input)
-`pull_worker.py` 확장 — 매 cycle 후 `generate_signals` 호출 + signature 확장 (dedup + settings)
-`main.py` 의 lifespan poll_task 호출 시 dedup/settings 전달
- ⑥ 테스트 9 단위 + 1 integration = **10 신규** (45 → 55)
### Phase 4 산출 (Phase 5 input)
`state.signals[ticker]` — Phase 0 spec §5.2 schema:
```python
{
"ticker": str, "name": str,
"action": "buy" | "sell",
"confidence_webai": float,
"current_price": int,
"avg_price": int | None, # sell 시만
"pnl_pct": float | None,
"context": {
"chronos_pred_1d": float (median),
"chronos_pred_conf": float,
"chronos_q10": float, "chronos_q90": float,
"screener_rank": int | None,
"screener_scores": dict | None,
"minute_momentum": str,
"asking_bid_ratio": float,
"news_sentiment": float | None,
"news_reason": str | None,
},
"as_of": str (ISO),
}
```
### 범위 외 (NOT)
- agent-office `/signal` HTTP POST (Phase 5)
- Qwen3 검증 + 이중 텔레그램 (Phase 5)
- 호가 변경 시 즉시 매도 trigger (Phase 7 backlog)
- 자동 매매 (Phase 8 backlog)
- ML 기반 룰 변종 (Phase 7 백테스트 후)
- `kospi_change`, `news_top` 컨텍스트 (Phase 7 backlog)
- 외부 API 호출 — Phase 4 는 state 만 사용 (pure function)
---
## 3. 파일 구조 + 변경 매트릭스
| 파일 | 작업 | 라인 |
|------|------|------|
| `signal_v2/signal_generator.py` | 신규 (generate_signals + 5 helpers) | ~250 |
| `signal_v2/config.py` | Settings 6 field 추가 | +15 |
| `signal_v2/state.py` | PollState `signals` 필드 | +2 |
| `signal_v2/pull_worker.py` | poll_loop signature + 매 cycle 호출 | +10 |
| `signal_v2/main.py` | lifespan poll_task 인자 추가 | +3 |
| `signal_v2/tests/test_signal_generator.py` | 9 단위 신규 | ~350 |
| `signal_v2/tests/test_pull_worker.py` | 1 integration 추가 | +50 |
**합계**: 7 파일 변경, 10 신규 테스트.
### 외부 의존성 신규
**없음**. signal_generator 는 순수 함수, 외부 라이브러리 0.
### 6 신규 env
| env | 기본값 | 의미 |
|-----|--------|------|
| `STOP_LOSS_PCT` | `-0.07` | 손절선 비율. `pnl_pct < 이 값` → 즉시 매도 |
| `TAKE_PROFIT_PCT` | `0.15` | 익절선 비율. `pnl_pct > 이 값` → 검토 알림 |
| `CHRONOS_SPREAD_THRESHOLD` | `0.6` | `(q90-q10)/max(|median|, 0.001) < 이 값` → 매수 통과 |
| `ASKING_BID_RATIO_THRESHOLD` | `0.6` | `bid_ratio >= 이 값` → 매수 통과 |
| `CONFIDENCE_THRESHOLD` | `0.7` | `confidence_webai > 이 값` → 신호 발생 |
| `MIN_MOMENTUM_FOR_BUY` | `strong_up` | 분봉 모멘텀 카테고리 |
---
## 4. 매수 룰 + Confidence
### 4.1 매수 룰 대상
- screener Top-N (`state.screener_preview.items`)
- portfolio 보유 종목 (추가 매수 검토, dedup 으로 중복 차단)
### 4.2 Hard gate (모든 조건 충족)
1. `state.chronos_predictions[ticker].median > 0` (다음날 상승)
2. `(q90 - q10) < settings.chronos_spread_threshold` (**absolute spread** — Phase 3b 실 운영 데이터 기반 변경)
3. `state.minute_momentum[ticker] == settings.min_momentum_for_buy` (기본 strong_up)
4. `state.asking_price[ticker].bid_ratio >= settings.asking_bid_ratio_threshold`
**Spread formula 결정 노트 (2026-05-17 implementer 변경 채택)**:
- Phase 0 spec §6.1 의 한국어 "(90-10 분위수) / 50 분위수 < 0.6" 은 *relative spread* 로 명시되었으나, Phase 3b 실 운영 결과 (Chronos zero-shot prediction 의 median 이 종종 0 가까이) 에서 relative formula 가 거의 모든 신호 거부 → useless.
- **변경**: absolute spread `(q90 - q10) < 0.6` 사용. 0.6 = 60% 변동 예측 — 한국 주식 1-day 변동성 (1-2%) 대비 매우 넓음 (모델 자신 없음 신호).
- 결과: Phase 3b smoke 005930 (median=-0.59%, q10=-8.9%, q90=6.4%, spread=15.3%) → spread 0.153 < 0.6 → hard gate 통과 가능 (다른 조건 충족 시).
- Phase 7 IC 검증 시 임계값 재조정 가능 (env `CHRONOS_SPREAD_THRESHOLD`).
### 4.3 Soft confidence (Phase 0 spec §6.1)
```python
chronos_conf = state.chronos_predictions[ticker]["conf"]
minute_score = MOMENTUM_SCORES[state.minute_momentum[ticker]]
# MOMENTUM_SCORES = {"strong_up": 1.0, "weak_up": 0.7, "neutral": 0.5,
# "weak_down": 0.3, "strong_down": 0.0}
screener_norm = 1 - (rank - 1) / 20 if rank is not None else 0.0
confidence_webai = chronos_conf * 0.5 + minute_score * 0.3 + screener_norm * 0.2
```
### 4.4 임계값
`confidence_webai > settings.confidence_threshold` (기본 0.7) → 신호 발생.
### 4.5 누락 처리
- portfolio (Top-N 외) 매수: `screener_rank = None``screener_norm = 0` (보수적)
- `chronos_predictions[ticker]` 누락 → silent (Hard gate 위반)
- `asking_price[ticker]` 누락 → silent
---
## 5. 매도 룰 + Dedup
### 5.1 매도 대상
portfolio holdings 만 (`state.portfolio.holdings`).
### 5.2 매도 룰 (Phase 0 spec §6.2)
**(a) 손절선 (즉시 trigger)**:
- `pnl_pct < settings.stop_loss_pct` (기본 -0.07)
- 다른 룰 무관 — 즉시 매도
- `confidence_webai = 1.0`
**(b) 익절선 (검토 알림)**:
- `pnl_pct > settings.take_profit_pct` (기본 0.15)
- "검토 권고" — 강제 매도 X
- `confidence_webai = 0.6`
**(c) 이상 신호**:
- `chronos_predictions[ticker].median < -0.01`
- `minute_momentum[ticker] == "strong_down"`
- `asking_price[ticker].bid_ratio < (1 - settings.asking_bid_ratio_threshold)` (매도세 ≥ 60%)
- confidence_webai = chronos_conf × 0.5 + inverted_minute × 0.3 + 1.0 × 0.2
- 임계값 > `settings.confidence_threshold`
### 5.3 우선순위 (같은 ticker 다중 trigger 시)
1. **손절** (Phase 0 spec §6.2 "즉시") — 다른 룰 우회
2. **이상 신호**
3. **익절선**
상위 trigger 시 하위 skip (한 종목당 한 cycle 1 매도 신호).
### 5.4 Dedup (Phase 0 spec §6.3 + Phase 2 SignalDedup)
```python
if dedup.is_recent(ticker, action, within_hours=24):
continue # silent
# 신호 dict 생성
state.signals[ticker] = {...}
dedup.record(ticker, action, confidence=confidence_webai)
```
Dedup 키 `(ticker, action)` — 같은 종목의 매수/매도 별도 추적, 충돌 없음.
손절선도 dedup 적용 (Phase 0 spec §6.3 "1일 1회 max").
---
## 6. State 통합 + pull_worker
### 6.1 PollState 확장
```python
signals: dict[str, dict] = field(default_factory=dict)
```
매 cycle 마다 **덮어쓰기 X** — 같은 ticker key 재발생 시 갱신, 그 외 유지. dedup 으로 중복 차단되므로 누적 안전. Phase 5 consumer 가 처리 후 본인 측 dedup.
### 6.2 pull_worker 흐름
```python
async def poll_loop(client, state, shutdown,
kis_client=None, chronos=None,
dedup=None, settings=None) -> None:
while not shutdown.is_set():
now = datetime.now(KST)
if _is_market_day(now) and _is_polling_window(now):
# 1. stock + KIS 분봉/호가 (Phase 2 + 3a)
await _run_polling_cycle(client, state, kis_client=kis_client)
# 2. 분봉 모멘텀 (Phase 3b)
update_minute_momentum_for_all(state)
# 3. 종가 트리거 시 Chronos (Phase 3b)
if _is_post_close_trigger(now) and chronos and kis_client:
await _run_post_close_cycle(kis_client, chronos, state)
# 4. (신규 Phase 4) 신호 생성
if dedup is not None and settings is not None:
try:
generate_signals(state, dedup, settings)
except Exception:
logger.exception("generate_signals failed")
...
```
### 6.3 main.py lifespan
```python
_ctx.poll_task = asyncio.create_task(
poll_loop(
_ctx.client, state_mod.state, _ctx.shutdown,
kis_client=_ctx.kis_client,
chronos=_ctx.chronos,
dedup=_ctx.dedup,
settings=settings,
)
)
```
---
## 7. signal_generator.py 구조
```python
def generate_signals(state: PollState, dedup: SignalDedup, settings: Settings) -> None:
"""Phase 4 entry point — state mutating."""
_evaluate_buy_signals(state, dedup, settings)
_evaluate_sell_signals(state, dedup, settings)
def _evaluate_buy_signals(state, dedup, settings) -> None:
"""screener Top-N + portfolio 매수 후보 평가."""
candidates = _buy_candidates(state) # screener Top-N + portfolio holdings
for ticker, rank in candidates:
if not _check_buy_hard_gate(state, ticker, settings):
continue
confidence = _compute_buy_confidence(state, ticker, rank)
if confidence <= settings.confidence_threshold:
continue
if dedup.is_recent(ticker, "buy", within_hours=24):
continue
state.signals[ticker] = _build_buy_signal(state, ticker, rank, confidence)
dedup.record(ticker, "buy", confidence=confidence)
def _evaluate_sell_signals(state, dedup, settings) -> None:
"""portfolio 보유 종목 매도 평가 — 손절 > 이상 > 익절 우선순위."""
if state.portfolio is None:
return
for holding in state.portfolio.get("holdings", []):
ticker = holding["ticker"]
# 우선순위 1: 손절선
sell = _try_stop_loss(state, holding, settings)
# 우선순위 2: 이상 신호
if sell is None:
sell = _try_anomaly(state, holding, settings)
# 우선순위 3: 익절선
if sell is None:
sell = _try_take_profit(state, holding, settings)
if sell is None:
continue
if dedup.is_recent(ticker, "sell", within_hours=24):
continue
state.signals[ticker] = sell
dedup.record(ticker, "sell", confidence=sell["confidence_webai"])
```
Helper 함수:
- `_buy_candidates(state) -> list[tuple[ticker, rank | None]]`
- `_check_buy_hard_gate(state, ticker, settings) -> bool`
- `_compute_buy_confidence(state, ticker, rank | None) -> float`
- `_build_buy_signal(state, ticker, rank, confidence) -> dict`
- `_try_stop_loss(state, holding, settings) -> dict | None`
- `_try_anomaly(state, holding, settings) -> dict | None`
- `_try_take_profit(state, holding, settings) -> dict | None`
- `_build_context(state, ticker, rank, ...) -> dict`
---
## 8. 테스트 (10 신규)
### 8.1 `test_signal_generator.py` (9 단위)
| # | 이름 | Setup | 검증 |
|---|------|-------|------|
| 1 | `test_buy_signal_when_all_conditions_pass_and_confidence_high` | chronos +2%, narrow, strong_up, bid_ratio 0.7, rank 1 | state.signals[ticker]["action"]=="buy", confidence > 0.7, dedup.record 호출 |
| 2 | `test_silent_when_chronos_median_negative` | median -1% | state.signals empty |
| 3 | `test_silent_when_distribution_spread_too_wide` | spread 1.0 | empty |
| 4 | `test_silent_when_momentum_not_strong_up` | weak_up | empty |
| 5 | `test_silent_when_bid_ratio_below_threshold` | 0.5 | empty |
| 6 | `test_silent_when_confidence_below_threshold` | rank 20 + median +0.5% (chronos_conf 낮음) → confidence < 0.7 | empty |
| 7 | `test_sell_signal_when_stop_loss_triggered` | pnl_pct -0.08 | "sell" + confidence 1.0 |
| 8 | `test_sell_signal_when_take_profit_triggered` | pnl_pct 0.16 | "sell" + confidence 0.6 |
| 9 | `test_silent_when_dedup_recently_sent` | dedup.is_recent True | empty |
### 8.2 `test_pull_worker.py` (1 integration)
| # | 이름 | 검증 |
|---|------|------|
| 10 | `test_poll_loop_calls_generate_signals_after_cycle` | mock state setup + mock dedup → poll_loop 1 cycle → state.signals 갱신 |
**합계**: 9 + 1 = **10 신규**. 45 → 55 total.
---
## 9. 위험 / 운영 / DoD
### 9.1 위험 매트릭스
| 위험 | 완화 |
|------|------|
| Phase 0 spec 의 confidence 공식이 실 운영과 안 맞음 | 6 env 외부화 → Phase 7 IC 검증 후 .env 조정 |
| Chronos 누락 (장 시작 첫 cycle) | Hard gate 위반 → silent. 종가 cron 후 매수 신호 가능 |
| Dedup DB 손상 | WAL + busy_timeout. 운영자 manual 복구 (signal_v2.db 삭제) |
| 동시 cycle 에서 같은 종목 buy + sell trigger | dedup PK `(ticker, action)` 별도 추적 — 충돌 없음 |
| portfolio 매수 → screener_norm=0 → 신호 발생 어려움 | 보수적. 다른 component 높아야 신호. 의도된 동작 |
| 손절선 trigger 후 24h 추가 손실 → 다음 알림 차단 | 운영적 허용 (Phase 0 spec §6.3 1일 1회 max) |
| 신호 빈도 너무 적음 | 4주 IC 검증 + 임계값 완화 |
| 신호 빈도 너무 많음 (false positive) | dedup + 임계값 강화. Phase 7 |
| 매도 우선순위 잘못 (손절 > 이상 > 익절) | 테스트 케이스로 검증 + 코드 명시 |
| signals dict 누적 (cycle 사이 stale entry) | dedup 으로 중복 차단되므로 안전. Phase 5 consumer 가 처리 후 본인 측 marker |
### 9.2 운영 영향
| 항목 | 영향 |
|------|------|
| 다운타임 | signal_v2 재기동 ~5초 |
| 사용자 영향 | 없음 (Phase 5 까지 발송 없음) |
| `.env` 갱신 | optional 0-6개 (기본값 충분) |
| V1 영향 | 0 |
| KIS API 부하 | 0 (Phase 4 는 외부 호출 없음) |
### 9.3 Phase 4 완료 조건 (DoD)
- [ ] `signal_v2/signal_generator.py` 신규 (generate_signals + 8 helpers)
- [ ] `signal_v2/config.py` Settings 에 6 field 추가 (default 있음)
- [ ] `signal_v2/state.py` PollState `signals` field
- [ ] `signal_v2/pull_worker.py` poll_loop signature + 매 cycle 호출
- [ ] `signal_v2/main.py` lifespan 의 poll_task 인자 (dedup, settings) 추가
- [ ] 9 단위 + 1 integration 테스트 PASS (총 55)
- [ ] 운영 smoke: signal_v2 시작 → 1 cycle 후 state.signals 빈 dict (운영 시간대 신호 발생 가능 종목 없을 시 정상) 또는 ≥ 1 신호 생성
- [ ] V1 무영향
- [ ] git push
---
## 10. Phase 5 와의 관계
본 Phase 4 완료 후 즉시 **Phase 5 (agent-office /signal + Qwen3 + 이중 텔레그램)** brainstorming. 의존성:
```
[Phase 4 spec/plan/실행] → [Phase 5 spec/plan/실행]
3-5일 2주
```
Phase 5 의 입력 = 본 spec 의 `state.signals[ticker]` (state polling 또는 HTTP push). Phase 5 작업:
- agent-office `/signal` endpoint 신설 (Phase 0 spec §5.2 schema 수신)
- web-ai → agent-office HTTP client 추가 (signal_v2 측)
- web-ai 의 Ollama Qwen3 14B Q4 설치 + agent-office 의 LLM 검증 호출
- 이중 텔레그램 (본인 풀 / 아내 lite)
---
## 11. Backlog (본 spec NOT)
- 호가 변경 시 즉시 매도 trigger — Phase 7 운영 후 검토
- `kospi_change` 컨텍스트 (KIS 지수 fetch) — Phase 7
- `news_top` 컨텍스트 (news_sentiment.reason 다중 추출) — Phase 7
- 매수/매도 ML 룰 — Phase 7 백테스트 후
- portfolio 매수의 screener_norm fallback (다른 default 값) — IC 검증 후
- 신호 hit-rate 대시보드 — Phase 7
- 분할 매수/매도 전략 — Phase 7 이후
- 자동 매매 (실주문) — Phase 8
- 손절선 dedup 면제 (즉시성 위해) — Phase 7 운영 검증 후

View File

@@ -479,113 +479,90 @@ 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() {
return apiGet('/api/blog-marketing/dashboard');
}
// 마케터 단계
export function startMarket(postId) {
return apiPost(`/api/blog-marketing/market/${postId}`);
}
// 브랜드커넥트 링크 CRUD
export function getBrandLinks(params = {}) {
const qs = new URLSearchParams();
if (params.post_id) qs.set('post_id', String(params.post_id));
if (params.keyword_id) qs.set('keyword_id', String(params.keyword_id));
const q = qs.toString();
return apiGet(`/api/blog-marketing/links${q ? '?' + q : ''}`);
}
export function createBrandLink(data) {
return apiPost('/api/blog-marketing/links', data);
}
export function updateBrandLink(id, data) {
return apiPut(`/api/blog-marketing/links/${id}`, data);
}
export function deleteBrandLink(id) {
return apiDelete(`/api/blog-marketing/links/${id}`);
} }
// ── Agent Office ────────────────────────────────── // ── Agent Office ──────────────────────────────────

View File

@@ -125,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" />
</>
);

View File

@@ -5,8 +5,8 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100vh; height: 100vh;
background: #0d0d1a; background: #0f172a;
color: #ffffff; color: #e2e8f0;
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
overflow: hidden; overflow: hidden;
} }
@@ -32,50 +32,9 @@
font-size: 15px; font-size: 15px;
color: #8b5cf6; color: #8b5cf6;
} }
.ao-topbar-status { .ao-topbar-status { font-size: 11px; }
font-size: 11px;
}
.ao-topbar-status.connected { color: #22c55e; } .ao-topbar-status.connected { color: #22c55e; }
.ao-topbar-status.disconnected { color: #ef4444; } .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 ===== */ /* ===== Main Area ===== */
.ao-main { .ao-main {
@@ -84,13 +43,103 @@
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
.ao-canvas {
/* ===== Grid Wrap ===== */
.ao-grid-wrap {
flex: 1; flex: 1;
cursor: grab; overflow-y: auto;
padding: 24px;
}
.ao-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
width: 100%;
}
/* ===== Agent Card ===== */
.ao-card {
position: relative;
background: #1e293b;
border: 1px solid #334155;
border-radius: 12px;
cursor: pointer;
padding: 12px;
display: flex;
flex-direction: column;
align-items: center;
font-family: inherit;
color: inherit;
transition: transform 120ms ease, border-color 120ms ease, box-shadow 120ms ease;
}
.ao-card:hover {
transform: translateY(-2px);
border-color: var(--card-accent, #60a5fa);
}
.ao-card.active {
border-color: var(--card-accent, #60a5fa);
box-shadow: 0 0 0 2px var(--card-accent, #60a5fa);
}
.ao-card.placeholder {
opacity: 0.55;
}
.ao-card-dot {
position: absolute;
top: 8px;
left: 8px;
width: 10px;
height: 10px;
border-radius: 50%;
background: #6b7280;
box-shadow: 0 0 0 2px #0f172a;
}
.ao-card-dot.working { background: #22c55e; }
.ao-card-dot.error { background: #ef4444; }
.ao-card-dot.waiting_approval { background: #f59e0b; }
.ao-card-dot.pulse {
animation: ao-pulse 1.6s ease-in-out infinite;
}
@keyframes ao-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.45; transform: scale(1.2); }
}
.ao-card-badge {
position: absolute;
top: 6px;
right: 6px;
min-width: 18px;
height: 18px;
padding: 0 5px;
background: #ef4444;
color: #fff;
border-radius: 9px;
font-size: 10px;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
}
.ao-card-image {
width: 100%;
aspect-ratio: 941 / 1672;
border-radius: 8px;
overflow: hidden;
background: #0f172a;
margin-bottom: 8px;
}
.ao-card-image img {
width: 100%;
height: 100%;
object-fit: contain;
display: block; display: block;
} }
.ao-canvas:active { .ao-card-name {
cursor: grabbing; font-size: 12px;
color: #e2e8f0;
text-align: center;
} }
/* ===== Side Panel ===== */ /* ===== Side Panel ===== */
@@ -103,6 +152,11 @@
flex-shrink: 0; flex-shrink: 0;
animation: slideIn 0.2s ease-out; animation: slideIn 0.2s ease-out;
} }
.ao-sidepanel-initial {
display: flex;
align-items: center;
justify-content: center;
}
@keyframes slideIn { @keyframes slideIn {
from { transform: translateX(100%); } from { transform: translateX(100%); }
to { transform: translateX(0); } to { transform: translateX(0); }
@@ -120,13 +174,18 @@
gap: 10px; gap: 10px;
} }
.ao-sidepanel-icon { .ao-sidepanel-icon {
width: 36px; width: 40px;
height: 36px; height: 40px;
border-radius: 8px; border-radius: 8px;
display: flex; border: 2px solid #444;
align-items: center; overflow: hidden;
justify-content: center; flex-shrink: 0;
font-size: 18px; }
.ao-sidepanel-icon img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
} }
.ao-sidepanel-name { .ao-sidepanel-name {
font-weight: bold; font-weight: bold;
@@ -134,7 +193,7 @@
} }
.ao-sidepanel-state { .ao-sidepanel-state {
font-size: 11px; font-size: 11px;
color: #22c55e; color: #94a3b8;
} }
.ao-sidepanel-close { .ao-sidepanel-close {
background: none; background: none;
@@ -144,9 +203,7 @@
cursor: pointer; cursor: pointer;
padding: 0 4px; padding: 0 4px;
} }
.ao-sidepanel-close:hover { .ao-sidepanel-close:hover { color: #fff; }
color: #fff;
}
/* Tabs */ /* Tabs */
.ao-sidepanel-tabs { .ao-sidepanel-tabs {
@@ -170,9 +227,7 @@
border-bottom-color: #8b5cf6; border-bottom-color: #8b5cf6;
font-weight: bold; font-weight: bold;
} }
.ao-sidepanel-tab:hover { .ao-sidepanel-tab:hover { color: #aaa; }
color: #aaa;
}
.ao-sidepanel-content { .ao-sidepanel-content {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@@ -207,10 +262,7 @@
.ao-btn-quick:hover { background: #3a3a5e; } .ao-btn-quick:hover { background: #3a3a5e; }
.ao-btn-quick:disabled { opacity: 0.4; } .ao-btn-quick:disabled { opacity: 0.4; }
.ao-param-row { .ao-param-row { display: flex; gap: 6px; }
display: flex;
gap: 6px;
}
.ao-input { .ao-input {
flex: 1; flex: 1;
background: #1a1a2e; background: #1a1a2e;
@@ -236,177 +288,67 @@
.ao-btn-send:hover { background: #5b21b6; } .ao-btn-send:hover { background: #5b21b6; }
.ao-btn-send:disabled { opacity: 0.4; } .ao-btn-send:disabled { opacity: 0.4; }
/* Approval */
.ao-approval-card { .ao-approval-card {
background: rgba(146,64,14,0.15); background: rgba(146,64,14,0.15);
border: 1px solid #92400e; border: 1px solid #92400e;
border-radius: 6px; border-radius: 6px;
padding: 10px; padding: 10px;
} }
.ao-approval-title { .ao-approval-title { color: #fbbf24; font-size: 12px; font-weight: bold; margin-bottom: 4px; }
color: #fbbf24; .ao-approval-desc { color: #ddd; font-size: 11px; margin-bottom: 8px; word-break: break-all; }
font-size: 12px; .ao-approval-actions { display: flex; gap: 6px; }
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 { .ao-btn-approve {
flex: 1; flex: 1; background: #065f46; color: #fff; border: none;
background: #065f46; padding: 7px; border-radius: 4px; font-size: 12px; cursor: pointer;
color: #fff;
border: none;
padding: 7px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
} }
.ao-btn-reject { .ao-btn-reject {
flex: 1; flex: 1; background: #7f1d1d; color: #fff; border: none;
background: #7f1d1d; padding: 7px; border-radius: 4px; font-size: 12px; cursor: pointer;
color: #fff;
border: none;
padding: 7px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
} }
/* ===== Task Tab ===== */ /* ===== Task Tab ===== */
.ao-task-tab { display: flex; flex-direction: column; gap: 4px; } .ao-task-tab { display: flex; flex-direction: column; gap: 4px; }
.ao-task-item { .ao-task-item { background: #1a1a2e; border-radius: 4px; padding: 8px; cursor: pointer; }
background: #1a1a2e;
border-radius: 4px;
padding: 8px;
cursor: pointer;
}
.ao-task-item:hover { background: #222240; } .ao-task-item:hover { background: #222240; }
.ao-task-header { .ao-task-header { display: flex; align-items: center; gap: 6px; font-size: 12px; }
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.ao-task-type { color: #ccc; font-weight: bold; flex: 1; } .ao-task-type { color: #ccc; font-weight: bold; flex: 1; }
.ao-task-badge { .ao-task-badge { padding: 1px 6px; border-radius: 3px; font-size: 10px; }
padding: 1px 6px;
border-radius: 3px;
font-size: 10px;
}
.ao-task-time { color: #666; font-size: 10px; } .ao-task-time { color: #666; font-size: 10px; }
.ao-task-result { .ao-task-result {
margin-top: 6px; margin-top: 6px; background: #0d0d1a; padding: 6px; border-radius: 3px;
background: #0d0d1a; font-size: 10px; color: #aaa; max-height: 200px; overflow-y: auto;
padding: 6px; white-space: pre-wrap; word-break: break-all;
border-radius: 3px;
font-size: 10px;
color: #aaa;
max-height: 200px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
} }
/* ===== Token Tab ===== */ /* ===== Token Tab ===== */
.ao-token-tab { display: flex; flex-direction: column; gap: 12px; } .ao-token-tab { display: flex; flex-direction: column; gap: 12px; }
.ao-token-period { .ao-token-period { display: flex; gap: 4px; }
display: flex;
gap: 4px;
}
.ao-btn-period { .ao-btn-period {
flex: 1; flex: 1; background: #1a1a2e; color: #888; border: 1px solid #333;
background: #1a1a2e; padding: 5px; border-radius: 4px; font-size: 11px; font-family: inherit; cursor: pointer;
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-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 { margin-top: 4px; }
.ao-token-bar-label { font-size: 10px; color: #888; margin-bottom: 4px; } .ao-token-bar-label { font-size: 10px; color: #888; margin-bottom: 4px; }
.ao-token-bar-track { .ao-token-bar-track { display: flex; height: 8px; border-radius: 4px; overflow: hidden; background: #1a1a2e; }
display: flex;
height: 8px;
border-radius: 4px;
overflow: hidden;
background: #1a1a2e;
}
.ao-token-bar-fill.input { background: #3b82f6; } .ao-token-bar-fill.input { background: #3b82f6; }
.ao-token-bar-fill.output { background: #8b5cf6; } .ao-token-bar-fill.output { background: #8b5cf6; }
.ao-token-bar-legend { .ao-token-bar-legend { display: flex; gap: 12px; font-size: 10px; color: #888; margin-top: 4px; }
display: flex; .ao-token-bar-legend .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; }
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.input { background: #3b82f6; }
.ao-token-bar-legend .dot.output { background: #8b5cf6; } .ao-token-bar-legend .dot.output { background: #8b5cf6; }
.ao-token-detail { .ao-token-detail { display: flex; justify-content: space-between; font-size: 10px; color: #666; }
display: flex;
justify-content: space-between;
font-size: 10px;
color: #666;
}
/* ===== Log Tab ===== */ /* ===== Log Tab ===== */
.ao-log-tab { .ao-log-tab {
max-height: 100%; max-height: 100%; overflow-y: auto; display: flex; flex-direction: column; gap: 2px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 2px;
} }
.ao-log-item { .ao-log-item {
display: flex; display: flex; gap: 6px; font-size: 11px; padding: 3px 0; border-bottom: 1px solid #1a1a2e;
gap: 6px;
font-size: 11px;
padding: 3px 0;
border-bottom: 1px solid #1a1a2e;
} }
.ao-log-time { color: #555; min-width: 60px; } .ao-log-time { color: #555; min-width: 60px; }
.ao-log-level { min-width: 48px; font-weight: bold; } .ao-log-level { min-width: 48px; font-weight: bold; }
@@ -414,26 +356,22 @@
/* ===== Common ===== */ /* ===== Common ===== */
.ao-empty { .ao-empty {
color: #555; color: #94a3b8;
text-align: center; text-align: center;
padding: 24px; padding: 24px;
font-size: 13px; font-size: 13px;
line-height: 1.6;
} }
/* ===== Mobile (< 768px) ===== */ /* ===== Mobile (< 768px) ===== */
@media (max-width: 768px) { @media (max-width: 768px) {
.ao-topbar-right { gap: 6px; } .ao-grid-wrap { padding: 12px; }
.ao-topbar-select { font-size: 11px; padding: 2px 6px; } .ao-grid {
grid-template-columns: repeat(2, 1fr);
.ao-main { gap: 10px;
flex-direction: column;
} }
.ao-main { flex-direction: column; }
.ao-canvas {
flex: 1;
}
/* Side panel → bottom sheet */
.ao-sidepanel { .ao-sidepanel {
position: fixed; position: fixed;
bottom: 0; bottom: 0;
@@ -452,9 +390,7 @@
to { transform: translateY(0); } to { transform: translateY(0); }
} }
.ao-sidepanel-header { .ao-sidepanel-header { padding: 8px 12px; }
padding: 8px 12px;
}
.ao-sidepanel-header::before { .ao-sidepanel-header::before {
content: ''; content: '';
display: block; display: block;
@@ -464,12 +400,7 @@
border-radius: 2px; border-radius: 2px;
margin: 0 auto 8px; margin: 0 auto 8px;
} }
.ao-sidepanel-tab { font-size: 11px; padding: 6px 2px; }
.ao-sidepanel-tab {
font-size: 11px;
padding: 6px 2px;
}
.ao-sidepanel-content { .ao-sidepanel-content {
padding: 8px 12px; padding: 8px 12px;
padding-bottom: env(safe-area-inset-bottom, 16px); padding-bottom: env(safe-area-inset-bottom, 16px);

View File

@@ -1,96 +1,70 @@
// src/pages/agent-office/AgentOffice.jsx // src/pages/agent-office/AgentOffice.jsx
import { useState, useEffect, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useAgentManager } from './hooks/useAgentManager.js'; import { useAgentManager } from './hooks/useAgentManager.js';
import { useOfficeCanvas } from './hooks/useOfficeCanvas.js'; import { AGENT_META } from './constants.js';
import TopBar from './components/TopBar.jsx'; import TopBar from './components/TopBar.jsx';
import AgentGrid from './components/AgentGrid.jsx';
import SidePanel from './components/SidePanel.jsx'; import SidePanel from './components/SidePanel.jsx';
import EmptyDetailPanel from './components/EmptyDetailPanel.jsx';
import './AgentOffice.css'; import './AgentOffice.css';
export default function AgentOffice() { export default function AgentOffice() {
const { const {
agents, pendingTasks, notifications, connected, agents, pendingTasks, notifications, connected, reconnectAttempt,
refreshTrigger, clearNotifications refreshTrigger, clearNotifications
} = useAgentManager(); } = useAgentManager();
const { // selectedAgent: null | active agent id | "placeholder-N"
canvasRef, updateAgentState, setAgentNotification,
setTheme, setZoom, hitTest, getZoom, wasDragging
} = useOfficeCanvas();
const [selectedAgent, setSelectedAgent] = useState(null); const [selectedAgent, setSelectedAgent] = useState(null);
const [theme, setThemeState] = useState(localStorage.getItem('agent-office-theme') || 'modern');
const [zoom, setZoomState] = useState(2);
// WebSocket 상태 → 캔버스 동기화 const handleSelectAgent = useCallback((agentId) => {
useEffect(() => { setSelectedAgent(agentId);
for (const [id, agentState] of Object.entries(agents)) { clearNotifications(agentId);
updateAgentState(id, agentState.state, agentState.detail); }, [clearNotifications]);
}
}, [agents, updateAgentState]);
// 알림 → 캔버스 동기화 const handleSelectPlaceholder = useCallback((placeholderKey) => {
useEffect(() => { setSelectedAgent(placeholderKey);
for (const [id, count] of Object.entries(notifications)) { }, []);
setAgentNotification(id, count);
}
}, [notifications, setAgentNotification]);
// 캔버스 클릭 핸들러 const handleClose = useCallback(() => {
const handleCanvasClick = useCallback((e) => { setSelectedAgent(null);
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 pendingTask = selectedAgent && AGENT_META[selectedAgent]
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) ? pendingTasks.find(t => t.agent_id === selectedAgent)
: null; : null;
let rightPanel;
if (selectedAgent === null) {
rightPanel = <EmptyDetailPanel variant="initial" />;
} else if (selectedAgent.startsWith('placeholder-')) {
rightPanel = <EmptyDetailPanel variant="placeholder" onClose={handleClose} />;
} else {
rightPanel = (
<SidePanel
agentId={selectedAgent}
agentState={agents[selectedAgent]}
pendingTask={pendingTask}
onClose={handleClose}
refreshTrigger={refreshTrigger}
/>
);
}
return ( return (
<div className="ao-root"> <div className="ao-root">
<TopBar <TopBar connected={connected} reconnectAttempt={reconnectAttempt} />
connected={connected}
theme={theme}
onThemeChange={handleThemeChange}
zoom={zoom}
onZoomChange={handleZoomChange}
/>
<div className="ao-main"> <div className="ao-main">
<canvas <div className="ao-grid-wrap">
ref={canvasRef} <AgentGrid
className="ao-canvas" agents={agents}
onClick={handleCanvasClick} notifications={notifications}
/> selectedAgent={selectedAgent}
onSelectAgent={handleSelectAgent}
{selectedAgent && ( onSelectPlaceholder={handleSelectPlaceholder}
<SidePanel
agentId={selectedAgent}
agentState={agents[selectedAgent]}
pendingTask={pendingTask}
onClose={() => setSelectedAgent(null)}
refreshTrigger={refreshTrigger}
/> />
)} </div>
{rightPanel}
</div> </div>
</div> </div>
); );

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -1,72 +0,0 @@
{
"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"
}
}

View File

@@ -1,261 +0,0 @@
// 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
);
}
}

View File

@@ -1,209 +0,0 @@
// 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();
}
}

View File

@@ -1,316 +0,0 @@
// 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);
}
}

View File

@@ -1,122 +0,0 @@
// 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();
}
}

View File

@@ -1,112 +0,0 @@
// 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)];
}
}

View File

@@ -1,164 +0,0 @@
// 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 };
}
}

View File

@@ -1,77 +0,0 @@
// 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();
}
}

View File

@@ -1,80 +0,0 @@
// 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
};
}
}

View File

@@ -1,42 +0,0 @@
// 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 }));
}

View File

@@ -0,0 +1,30 @@
// src/pages/agent-office/components/AgentCard.jsx
import { AGENT_META, STATE_COLORS, DEFAULT_STATE_COLOR } from '../constants.js';
export default function AgentCard({ agentId, agentState, notificationCount = 0, active = false, onClick }) {
const meta = AGENT_META[agentId];
if (!meta) return null;
const state = agentState?.state || 'idle';
const stateInfo = STATE_COLORS[state] || DEFAULT_STATE_COLOR;
const dotClass = `ao-card-dot ${state}${stateInfo.pulse ? ' pulse' : ''}`;
const badgeText = notificationCount > 9 ? '9+' : String(notificationCount);
return (
<button
type="button"
className={`ao-card${active ? ' active' : ''}`}
onClick={onClick}
style={{ '--card-accent': meta.color }}
>
<span className={dotClass} title={state} />
{notificationCount > 0 && (
<span className="ao-card-badge">{badgeText}</span>
)}
<div className="ao-card-image">
<img src={meta.image} alt={meta.displayName} />
</div>
<div className="ao-card-name">{meta.displayName}</div>
</button>
);
}

View File

@@ -0,0 +1,76 @@
// src/pages/agent-office/components/AgentCard.test.jsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import AgentCard from './AgentCard.jsx';
describe('AgentCard', () => {
it('에이전트의 displayName을 표시', () => {
render(<AgentCard agentId="stock" agentState={{ state: 'idle' }} notificationCount={0} onClick={() => {}} />);
expect(screen.getByText('주식 트레이더')).toBeInTheDocument();
});
it('working 상태일 때 dot에 working 클래스 부여', () => {
const { container } = render(
<AgentCard agentId="stock" agentState={{ state: 'working' }} notificationCount={0} onClick={() => {}} />
);
const dot = container.querySelector('.ao-card-dot');
expect(dot).toHaveClass('working');
});
it('working 상태에서는 pulse 클래스도 부여', () => {
const { container } = render(
<AgentCard agentId="stock" agentState={{ state: 'working' }} notificationCount={0} onClick={() => {}} />
);
const dot = container.querySelector('.ao-card-dot');
expect(dot).toHaveClass('pulse');
});
it('idle 상태에는 pulse 클래스 부여하지 않음', () => {
const { container } = render(
<AgentCard agentId="stock" agentState={{ state: 'idle' }} notificationCount={0} onClick={() => {}} />
);
const dot = container.querySelector('.ao-card-dot');
expect(dot).not.toHaveClass('pulse');
});
it('agentState 없으면 idle로 fallback', () => {
const { container } = render(
<AgentCard agentId="stock" agentState={undefined} notificationCount={0} onClick={() => {}} />
);
const dot = container.querySelector('.ao-card-dot');
expect(dot).toHaveClass('idle');
});
it('notificationCount > 0이면 뱃지 표시', () => {
render(<AgentCard agentId="stock" agentState={{ state: 'idle' }} notificationCount={3} onClick={() => {}} />);
expect(screen.getByText('3')).toBeInTheDocument();
});
it('notificationCount === 0이면 뱃지 없음', () => {
const { container } = render(
<AgentCard agentId="stock" agentState={{ state: 'idle' }} notificationCount={0} onClick={() => {}} />
);
expect(container.querySelector('.ao-card-badge')).toBeNull();
});
it('notificationCount > 9이면 9+ 표시', () => {
render(<AgentCard agentId="stock" agentState={{ state: 'idle' }} notificationCount={15} onClick={() => {}} />);
expect(screen.getByText('9+')).toBeInTheDocument();
});
it('클릭 시 onClick 호출', () => {
const onClick = vi.fn();
const { container } = render(
<AgentCard agentId="stock" agentState={{ state: 'idle' }} notificationCount={0} onClick={onClick} />
);
fireEvent.click(container.querySelector('.ao-card'));
expect(onClick).toHaveBeenCalledTimes(1);
});
it('active prop 시 카드에 active 클래스 부여', () => {
const { container } = render(
<AgentCard agentId="stock" agentState={{ state: 'idle' }} notificationCount={0} active onClick={() => {}} />
);
expect(container.querySelector('.ao-card')).toHaveClass('active');
});
});

View File

@@ -0,0 +1,33 @@
// src/pages/agent-office/components/AgentGrid.jsx
import { GRID_SLOTS } from '../constants.js';
import AgentCard from './AgentCard.jsx';
import PlaceholderCard from './PlaceholderCard.jsx';
export default function AgentGrid({ agents, notifications, selectedAgent, onSelectAgent, onSelectPlaceholder }) {
return (
<div className="ao-grid">
{GRID_SLOTS.map((slot, idx) => {
if (slot.agentId === null) {
const placeholderKey = `placeholder-${idx}`;
return (
<PlaceholderCard
key={placeholderKey}
active={selectedAgent === placeholderKey}
onClick={() => onSelectPlaceholder(placeholderKey)}
/>
);
}
return (
<AgentCard
key={slot.agentId}
agentId={slot.agentId}
agentState={agents[slot.agentId]}
notificationCount={notifications[slot.agentId] || 0}
active={selectedAgent === slot.agentId}
onClick={() => onSelectAgent(slot.agentId)}
/>
);
})}
</div>
);
}

View File

@@ -5,7 +5,7 @@ import { sendAgentCommand, approveAgentTask } from '../../../api';
const QUICK_ACTIONS = { const QUICK_ACTIONS = {
stock: [{ action: 'fetch_news', label: 'Fetch News' }, { action: 'test_telegram', label: 'Test Telegram' }], stock: [{ action: 'fetch_news', label: 'Fetch News' }, { action: 'test_telegram', label: 'Test Telegram' }],
music: [{ action: 'credits', label: 'Check Credits' }], music: [{ action: 'credits', label: 'Check Credits' }],
blog: [{ action: 'list_trend_keywords', label: 'List Keywords' }], insta: [{ action: 'extract', label: 'Extract News' }, { action: 'collect_trends', label: 'Collect Trends' }],
realestate: [{ action: 'dashboard', label: 'Dashboard' }], realestate: [{ action: 'dashboard', label: 'Dashboard' }],
lotto: [{ action: 'status', label: 'Status' }, { action: 'curate_now', label: 'Curate Now' }] lotto: [{ action: 'status', label: 'Status' }, { action: 'curate_now', label: 'Curate Now' }]
}; };
@@ -13,7 +13,7 @@ const QUICK_ACTIONS = {
const PARAM_ACTIONS = { const PARAM_ACTIONS = {
stock: { action: 'add_alert', label: 'Add Alert', placeholder: '{"symbol":"005930","target_price":70000,"direction":"above"}' }, 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' }, music: { action: 'compose', label: 'Compose', placeholder: 'jazzy lo-fi piano beat' },
blog: { action: 'research', label: 'Research', placeholder: 'keyword to research' }, insta: { action: 'render', label: 'Render Slate', placeholder: 'keyword_id (예: 42)' },
realestate: { action: 'fetch_matches', label: 'Fetch Matches', placeholder: '' }, realestate: { action: 'fetch_matches', label: 'Fetch Matches', placeholder: '' },
lotto: null lotto: null
}; };
@@ -46,6 +46,8 @@ export default function CommandTab({ agentId, agentState, pendingTask, onCommand
params = { prompt: paramInput }; params = { prompt: paramInput };
} else if (paramAction.action === 'research') { } else if (paramAction.action === 'research') {
params = { keyword: paramInput }; params = { keyword: paramInput };
} else if (paramAction.action === 'render') {
params = { keyword_id: parseInt(paramInput, 10) };
} else { } else {
try { params = JSON.parse(paramInput); } catch { params = { value: paramInput }; } try { params = JSON.parse(paramInput); } catch { params = { value: paramInput }; }
} }
@@ -87,7 +89,7 @@ export default function CommandTab({ agentId, agentState, pendingTask, onCommand
return ( return (
<div className="ao-command-tab"> <div className="ao-command-tab">
{/* 승인 대기 UI */} {/* 승인 대기 UI */}
{agentState === 'waiting' && pendingTask && ( {agentState === 'waiting_approval' && pendingTask && (
<div className="ao-approval-card"> <div className="ao-approval-card">
<div className="ao-approval-title">Awaiting Approval</div> <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-desc">{pendingTask.task_type}: {pendingTask.detail || JSON.stringify(pendingTask.input_data)}</div>

View File

@@ -0,0 +1,51 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import CommandTab from './CommandTab.jsx';
vi.mock('../../../api', () => ({
sendAgentCommand: vi.fn(),
approveAgentTask: vi.fn(),
}));
describe('CommandTab approval card', () => {
const samplePendingTask = {
id: 'task-123',
task_type: 'lotto_briefing',
input_data: { draw_no: 1234 },
};
it('agentState가 waiting_approval이고 pendingTask가 있으면 승인 카드를 표시', () => {
render(
<CommandTab
agentId="lotto"
agentState="waiting_approval"
pendingTask={samplePendingTask}
/>
);
expect(screen.getByText('Awaiting Approval')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Approve' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Reject' })).toBeInTheDocument();
});
it('agentState가 working이면 승인 카드를 표시하지 않음', () => {
render(
<CommandTab
agentId="lotto"
agentState="working"
pendingTask={samplePendingTask}
/>
);
expect(screen.queryByText('Awaiting Approval')).toBeNull();
});
it('pendingTask가 null이면 waiting_approval이어도 승인 카드를 표시하지 않음', () => {
render(
<CommandTab
agentId="lotto"
agentState="waiting_approval"
pendingTask={null}
/>
);
expect(screen.queryByText('Awaiting Approval')).toBeNull();
});
});

View File

@@ -0,0 +1,41 @@
// src/pages/agent-office/components/EmptyDetailPanel.jsx
import { PLACEHOLDER_IMAGE } from '../constants.js';
export default function EmptyDetailPanel({ variant = 'initial', onClose }) {
if (variant === 'placeholder') {
return (
<div className="ao-sidepanel">
<div className="ao-sidepanel-header">
<div className="ao-sidepanel-agent">
<div className="ao-sidepanel-icon">
<img src={PLACEHOLDER_IMAGE} alt="준비 중" />
</div>
<div className="ao-sidepanel-info">
<div className="ao-sidepanel-name">준비 </div>
<div className="ao-sidepanel-state"> 미고용 슬롯</div>
</div>
</div>
<button className="ao-sidepanel-close" onClick={onClose}>×</button>
</div>
<div className="ao-sidepanel-content">
<p className="ao-empty">
자리는 아직 비어 있어요.<br />
준비 중인 에이전트입니다.
</p>
</div>
</div>
);
}
// variant === 'initial'
return (
<div className="ao-sidepanel ao-sidepanel-initial">
<div className="ao-sidepanel-content">
<p className="ao-empty">
왼쪽 그리드에서<br />
에이전트를 선택하세요.
</p>
</div>
</div>
);
}

View File

@@ -15,7 +15,7 @@ export default function LogTab({ agentId, refreshTrigger }) {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
getAgentLogs(agentId, 50).then(data => { getAgentLogs(agentId, 50).then(data => {
if (!cancelled) setLogs(data || []); if (!cancelled) setLogs(Array.isArray(data) ? data : (data?.logs || []));
}); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [agentId, refreshTrigger]); }, [agentId, refreshTrigger]);

View File

@@ -0,0 +1,17 @@
// src/pages/agent-office/components/PlaceholderCard.jsx
import { PLACEHOLDER_IMAGE, PLACEHOLDER_LABEL } from '../constants.js';
export default function PlaceholderCard({ active = false, onClick }) {
return (
<button
type="button"
className={`ao-card placeholder${active ? ' active' : ''}`}
onClick={onClick}
>
<div className="ao-card-image">
<img src={PLACEHOLDER_IMAGE} alt={PLACEHOLDER_LABEL} />
</div>
<div className="ao-card-name">{PLACEHOLDER_LABEL}</div>
</button>
);
}

View File

@@ -1,18 +1,11 @@
// src/pages/agent-office/components/SidePanel.jsx // src/pages/agent-office/components/SidePanel.jsx
import { useState } from 'react'; import { useState } from 'react';
import { AGENT_META } from '../constants.js';
import CommandTab from './CommandTab.jsx'; import CommandTab from './CommandTab.jsx';
import TaskTab from './TaskTab.jsx'; import TaskTab from './TaskTab.jsx';
import TokenTab from './TokenTab.jsx'; import TokenTab from './TokenTab.jsx';
import LogTab from './LogTab.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']; const TABS = ['Commands', 'Tasks', 'Tokens', 'Logs'];
export default function SidePanel({ agentId, agentState, pendingTask, onClose, refreshTrigger }) { export default function SidePanel({ agentId, agentState, pendingTask, onClose, refreshTrigger }) {
@@ -26,11 +19,10 @@ export default function SidePanel({ agentId, agentState, pendingTask, onClose, r
return ( return (
<div className="ao-sidepanel"> <div className="ao-sidepanel">
{/* Header */}
<div className="ao-sidepanel-header"> <div className="ao-sidepanel-header">
<div className="ao-sidepanel-agent"> <div className="ao-sidepanel-agent">
<div className="ao-sidepanel-icon" style={{ background: meta.color }}> <div className="ao-sidepanel-icon" style={{ borderColor: meta.color }}>
{meta.emoji} <img src={meta.image} alt={meta.displayName} />
</div> </div>
<div className="ao-sidepanel-info"> <div className="ao-sidepanel-info">
<div className="ao-sidepanel-name">{meta.displayName}</div> <div className="ao-sidepanel-name">{meta.displayName}</div>
@@ -40,7 +32,6 @@ export default function SidePanel({ agentId, agentState, pendingTask, onClose, r
<button className="ao-sidepanel-close" onClick={onClose}>×</button> <button className="ao-sidepanel-close" onClick={onClose}>×</button>
</div> </div>
{/* Tabs */}
<div className="ao-sidepanel-tabs"> <div className="ao-sidepanel-tabs">
{TABS.map(tab => ( {TABS.map(tab => (
<button <button
@@ -53,7 +44,6 @@ export default function SidePanel({ agentId, agentState, pendingTask, onClose, r
))} ))}
</div> </div>
{/* Tab Content */}
<div className="ao-sidepanel-content"> <div className="ao-sidepanel-content">
{activeTab === 'Commands' && ( {activeTab === 'Commands' && (
<CommandTab agentId={agentId} agentState={agentState?.state} pendingTask={pendingTask} /> <CommandTab agentId={agentId} agentState={agentState?.state} pendingTask={pendingTask} />

View File

@@ -11,6 +11,22 @@ const STATUS_STYLE = {
rejected: { bg: '#7f1d1d', fg: '#fca5a5' } rejected: { bg: '#7f1d1d', fg: '#fca5a5' }
}; };
// result_data는 백엔드에서 dict 또는 string 둘 다 올 수 있다.
// React child로 직접 못 그리는 객체는 stringify, string은 parse 시도 후 pretty,
// 둘 다 안 되면 원본 문자열을 그대로 표시.
function formatResultData(rd) {
if (rd == null) return '';
if (typeof rd === 'object') {
try { return JSON.stringify(rd, null, 2); }
catch { return String(rd); }
}
if (typeof rd === 'string') {
try { return JSON.stringify(JSON.parse(rd), null, 2); }
catch { return rd; }
}
return String(rd);
}
function formatTime(ts) { function formatTime(ts) {
if (!ts) return ''; if (!ts) return '';
const d = new Date(ts); const d = new Date(ts);
@@ -27,7 +43,7 @@ export default function TaskTab({ agentId, refreshTrigger }) {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
getAgentTasks(agentId, 20).then(data => { getAgentTasks(agentId, 20).then(data => {
if (!cancelled) setTasks(data || []); if (!cancelled) setTasks(Array.isArray(data) ? data : (data?.tasks || []));
}); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [agentId, refreshTrigger]); }, [agentId, refreshTrigger]);
@@ -46,10 +62,7 @@ export default function TaskTab({ agentId, refreshTrigger }) {
</div> </div>
{expanded === task.id && task.result_data && ( {expanded === task.id && task.result_data && (
<pre className="ao-task-result"> <pre className="ao-task-result">
{(() => { {formatResultData(task.result_data)}
try { return JSON.stringify(JSON.parse(task.result_data), null, 2); }
catch { return task.result_data; }
})()}
</pre> </pre>
)} )}
</div> </div>

View File

@@ -0,0 +1,87 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import TaskTab from './TaskTab.jsx';
const mockGetAgentTasks = vi.fn();
vi.mock('../../../api', () => ({
getAgentTasks: (...args) => mockGetAgentTasks(...args),
}));
describe('TaskTab response shape handling', () => {
it('백엔드가 {tasks: [...]} 객체로 wrapping해서 응답해도 .map 깨지지 않음', async () => {
mockGetAgentTasks.mockResolvedValueOnce({
tasks: [
{ id: 't1', task_type: 'compose', status: 'succeeded', created_at: '2026-05-18T08:00:00Z' },
{ id: 't2', task_type: 'fetch_news', status: 'failed', created_at: '2026-05-18T08:05:00Z' },
],
});
render(<TaskTab agentId="music" refreshTrigger={0} />);
await waitFor(() => expect(screen.getByText('compose')).toBeInTheDocument());
expect(screen.getByText('fetch_news')).toBeInTheDocument();
});
it('백엔드가 bare array를 반환해도 동작 (backward compat)', async () => {
mockGetAgentTasks.mockResolvedValueOnce([
{ id: 't9', task_type: 'compose', status: 'succeeded', created_at: '2026-05-18T08:00:00Z' },
]);
render(<TaskTab agentId="music" refreshTrigger={0} />);
await waitFor(() => expect(screen.getByText('compose')).toBeInTheDocument());
});
it('응답이 falsy/empty이면 No tasks yet 표시', async () => {
mockGetAgentTasks.mockResolvedValueOnce({ tasks: [] });
render(<TaskTab agentId="music" refreshTrigger={0} />);
await waitFor(() => expect(screen.getByText('No tasks yet')).toBeInTheDocument());
});
it('task 클릭 → result_data가 객체일 때도 stringify되어 안전하게 렌더', async () => {
mockGetAgentTasks.mockResolvedValueOnce({
tasks: [{
id: 't_compose',
task_type: 'compose',
status: 'succeeded',
created_at: '2026-05-18T08:00:00Z',
result_data: { music_task_id: 'abc-123', tracks: [] },
}],
});
render(<TaskTab agentId="music" refreshTrigger={0} />);
const row = await screen.findByText('compose');
fireEvent.click(row.closest('.ao-task-item'));
const pre = await screen.findByText(/music_task_id/);
expect(pre.textContent).toContain('"music_task_id": "abc-123"');
expect(pre.textContent).toContain('"tracks": []');
});
it('task 클릭 → result_data가 JSON 문자열일 때 parse 후 pretty 렌더', async () => {
mockGetAgentTasks.mockResolvedValueOnce({
tasks: [{
id: 't_str',
task_type: 'compose',
status: 'succeeded',
created_at: '2026-05-18T08:00:00Z',
result_data: '{"foo":"bar"}',
}],
});
render(<TaskTab agentId="music" refreshTrigger={0} />);
const row = await screen.findByText('compose');
fireEvent.click(row.closest('.ao-task-item'));
const pre = await screen.findByText(/foo/);
expect(pre.textContent).toContain('"foo": "bar"');
});
it('task 클릭 → result_data가 plain string이면 그대로 표시 (parse 실패 fallback)', async () => {
mockGetAgentTasks.mockResolvedValueOnce({
tasks: [{
id: 't_plain',
task_type: 'fetch_news',
status: 'succeeded',
created_at: '2026-05-18T08:00:00Z',
result_data: 'Just a log line',
}],
});
render(<TaskTab agentId="music" refreshTrigger={0} />);
const row = await screen.findByText('fetch_news');
fireEvent.click(row.closest('.ao-task-item'));
expect(await screen.findByText('Just a log line')).toBeInTheDocument();
});
});

View File

@@ -1,33 +1,21 @@
// src/pages/agent-office/components/TopBar.jsx // src/pages/agent-office/components/TopBar.jsx
import { getThemeNames } from '../canvas/themes.js'; export default function TopBar({ connected, reconnectAttempt = 0 }) {
let statusText;
export default function TopBar({ connected, theme, onThemeChange, zoom, onZoomChange }) { if (connected) {
const themes = getThemeNames(); statusText = 'Connected';
} else if (reconnectAttempt === 0) {
statusText = 'Connecting…';
} else {
statusText = `Disconnected · 재연결 시도 #${reconnectAttempt}`;
}
return ( return (
<div className="ao-topbar"> <div className="ao-topbar">
<div className="ao-topbar-left"> <div className="ao-topbar-left">
<span className="ao-topbar-title">Agent Office</span> <span className="ao-topbar-title">Agent Office</span>
<span className={`ao-topbar-status ${connected ? 'connected' : 'disconnected'}`}> <span className={`ao-topbar-status ${connected ? 'connected' : 'disconnected'}`}>
{connected ? 'Connected' : 'Disconnected'} {statusText}
</span> </span>
</div> </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> </div>
); );
} }

View File

@@ -0,0 +1,43 @@
// src/pages/agent-office/constants.js
import stockImg from './assets/agent_stock.webp';
import musicImg from './assets/agent_music.webp';
import instaImg from './assets/agent_insta.webp';
import realestateImg from './assets/agent_realestate.webp';
import lottoImg from './assets/agent_lotto.webp';
import undeterminedImg from './assets/agent_undetermined.webp';
export const AGENT_META = {
stock: { displayName: '주식 트레이더', color: '#4488cc', image: stockImg },
music: { displayName: '음악 프로듀서', color: '#44aa88', image: musicImg },
insta: { displayName: '인스타 큐레이터', color: '#d97706', image: instaImg },
realestate: { displayName: '청약 애널리스트', color: '#c026d3', image: realestateImg },
lotto: { displayName: '로또 큐레이터', color: '#ef4444', image: lottoImg },
};
// 3x3 슬롯 (좌→우, 위→아래). 처음 5칸은 active, 나머지 4칸은 placeholder.
export const GRID_SLOTS = [
{ agentId: 'stock' },
{ agentId: 'music' },
{ agentId: 'insta' },
{ agentId: 'realestate' },
{ agentId: 'lotto' },
{ agentId: null },
{ agentId: null },
{ agentId: null },
{ agentId: null },
];
export const ACTIVE_AGENT_IDS = GRID_SLOTS.filter(s => s.agentId !== null).map(s => s.agentId);
export const PLACEHOLDER_IMAGE = undeterminedImg;
export const PLACEHOLDER_LABEL = '준비 중';
// 상태 → dot 색상 매핑 (AgentCard에서 공유)
export const STATE_COLORS = {
idle: { color: '#6b7280', pulse: false },
working: { color: '#22c55e', pulse: true },
error: { color: '#ef4444', pulse: false },
waiting_approval: { color: '#f59e0b', pulse: true },
};
export const DEFAULT_STATE_COLOR = STATE_COLORS.idle;

View File

@@ -0,0 +1,35 @@
// src/pages/agent-office/constants.test.js
import { describe, it, expect } from 'vitest';
import { AGENT_META, GRID_SLOTS, ACTIVE_AGENT_IDS } from './constants.js';
describe('agent-office constants', () => {
it('5명의 active 에이전트가 정의됨', () => {
expect(ACTIVE_AGENT_IDS).toEqual(['stock', 'music', 'insta', 'realestate', 'lotto']);
});
it('각 active 에이전트에 displayName/color/image 메타가 있음', () => {
for (const id of ACTIVE_AGENT_IDS) {
expect(AGENT_META[id]).toBeDefined();
expect(AGENT_META[id].displayName).toBeTruthy();
expect(AGENT_META[id].color).toMatch(/^#/);
expect(AGENT_META[id].image).toBeTruthy();
}
});
it('blog 메타는 존재하지 않음 (insta로 대체됨)', () => {
expect(AGENT_META.blog).toBeUndefined();
});
it('GRID_SLOTS는 9칸, 처음 5칸은 active 에이전트', () => {
expect(GRID_SLOTS).toHaveLength(9);
expect(GRID_SLOTS.slice(0, 5).map(s => s.agentId)).toEqual(
['stock', 'music', 'insta', 'realestate', 'lotto']
);
});
it('GRID_SLOTS의 마지막 4칸은 placeholder (agentId=null)', () => {
for (const slot of GRID_SLOTS.slice(5)) {
expect(slot.agentId).toBeNull();
}
});
});

View File

@@ -1,24 +1,34 @@
// src/pages/agent-office/hooks/useAgentManager.js // src/pages/agent-office/hooks/useAgentManager.js
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
const WS_RECONNECT_DELAY = 3000; // Exponential backoff with cap. 1s → 2s → 4s → 8s → 16s → 30s (cap)
const WS_BACKOFF_BASE_MS = 1000;
const WS_BACKOFF_CAP_MS = 30000;
const WS_BACKOFF_MAX_EXP = 5;
export function useAgentManager() { export function useAgentManager() {
const [agents, setAgents] = useState({}); // { agentId: { state, detail, task_id } } const [agents, setAgents] = useState({}); // { agentId: { state, detail, task_id } }
const [pendingTasks, setPendingTasks] = useState([]); // [{id, agent_id, task_type, input_data}] const [pendingTasks, setPendingTasks] = useState([]); // [{id, agent_id, task_type, input_data}]
const [notifications, setNotifications] = useState({}); // { agentId: count } const [notifications, setNotifications] = useState({}); // { agentId: count }
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
const [reconnectAttempt, setReconnectAttempt] = useState(0);
const [refreshTrigger, setRefreshTrigger] = useState(0); // 탭 데이터 리프레시용 const [refreshTrigger, setRefreshTrigger] = useState(0); // 탭 데이터 리프레시용
const wsRef = useRef(null); const wsRef = useRef(null);
const reconnectRef = useRef(null); const reconnectRef = useRef(null);
const connectRef = useRef(null);
const attemptRef = useRef(0);
const connect = useCallback(() => { const connect = useCallback(() => {
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${protocol}://${window.location.host}/api/agent-office/ws`); const ws = new WebSocket(`${protocol}://${window.location.host}/api/agent-office/ws`);
wsRef.current = ws; wsRef.current = ws;
ws.onopen = () => setConnected(true); ws.onopen = () => {
setConnected(true);
attemptRef.current = 0;
setReconnectAttempt(0);
};
ws.onmessage = (e) => { ws.onmessage = (e) => {
const msg = JSON.parse(e.data); const msg = JSON.parse(e.data);
@@ -68,12 +78,23 @@ export function useAgentManager() {
ws.onclose = () => { ws.onclose = () => {
setConnected(false); setConnected(false);
reconnectRef.current = setTimeout(connect, WS_RECONNECT_DELAY); const exp = Math.min(attemptRef.current, WS_BACKOFF_MAX_EXP);
const delay = Math.min(WS_BACKOFF_BASE_MS * 2 ** exp, WS_BACKOFF_CAP_MS);
attemptRef.current += 1;
setReconnectAttempt(attemptRef.current);
reconnectRef.current = setTimeout(() => connectRef.current?.(), delay);
}; };
ws.onerror = () => ws.close(); // onerror fires before onclose; swallow so the browser doesn't print an
// unhandled-error pair for every retry. onclose still runs and schedules
// the next attempt.
ws.onerror = () => {};
}, []); }, []);
useEffect(() => {
connectRef.current = connect;
}, [connect]);
useEffect(() => { useEffect(() => {
connect(); connect();
return () => { return () => {
@@ -103,6 +124,7 @@ export function useAgentManager() {
pendingTasks, pendingTasks,
notifications, notifications,
connected, connected,
reconnectAttempt,
refreshTrigger, refreshTrigger,
sendCommand, sendCommand,
sendApproval, sendApproval,

View File

@@ -1,64 +0,0 @@
// 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
};
}

View File

@@ -1,154 +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: 768px) {
.bm-tabs {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.bm-tabs > * {
flex-shrink: 0;
white-space: nowrap;
}
}
@media (max-width: 480px) {
.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: 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; }
}
@media (prefers-reduced-motion: reduce) {
.bm-spinner { animation: none; }
}

View File

@@ -1,706 +0,0 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import PullToRefresh from '../../components/PullToRefresh';
import FAB from '../../components/FAB';
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);
const loadStatus = useCallback(() => {
return getBlogMarketingStatus().then(setStatus).catch(() => {});
}, []);
useEffect(() => {
loadStatus();
}, [loadStatus]);
const tabs = [
{ id: 'dashboard', label: 'Dashboard' },
{ id: 'research', label: 'Research' },
{ id: 'write', label: 'Write' },
{ id: 'posts', label: 'Posts' },
];
return (
<PullToRefresh onRefresh={loadStatus}>
<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 />}
{tab === 'write' && <WriteTab />}
{tab === 'posts' && <PostsTab />}
<FAB onClick={() => setTab('research')} label="키워드 분석" />
</div>
</PullToRefresh>
);
}
/* ══════════════════════ 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 }}>&#9997;</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>
);
}

View File

@@ -0,0 +1,293 @@
/* ── InstaCards ──────────────────────────────────────────────────────────── */
.ic { max-width: 1100px; margin: 0 auto; padding: 16px 12px 80px; }
@media (min-width: 768px) {
.ic { 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컬럼.
minmax(0, 1fr)로 자식 overflow가 부모를 밀어내지 않도록 함 */
.ic-layout { display: grid; grid-template-columns: minmax(0, 1fr); gap: 20px; }
.ic-layout > * { min-width: 0; }
@media (min-width: 768px) {
.ic-layout { grid-template-columns: 320px minmax(0, 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; }
/* 슬레이트 그리드 — 모바일 2칸 강제, 데스크탑 auto-fill */
.ic-slates-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
@media (min-width: 640px) {
.ic-slates-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 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; }
/* 슬레이트 상세 패널 — min-width: 0으로 자식 overflow가 부모 밀지 않게 */
.ic-detail {
margin-top: 20px; padding: 16px;
background: rgba(255,255,255,.04);
border: 1px solid rgba(255,255,255,.06); border-radius: 12px;
min-width: 0; max-width: 100%;
}
.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; min-width: 0; }
.ic-detail__actions { display: flex; gap: 8px; }
/* ── pages strip wrapper (chevron + fade + indicator 캐러셀) ── */
.ic-pages-wrap {
position: relative;
margin-bottom: 14px;
min-width: 0;
}
.ic-pages-strip {
display: flex;
gap: 8px;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
padding: 4px 48px 12px;
-webkit-overflow-scrolling: touch;
/* 양옆 fade로 "더 있다" affordance */
mask-image: linear-gradient(to right,
transparent 0, #000 48px, #000 calc(100% - 48px), transparent 100%);
-webkit-mask-image: linear-gradient(to right,
transparent 0, #000 48px, #000 calc(100% - 48px), transparent 100%);
}
.ic-pages-strip::-webkit-scrollbar { height: 6px; }
.ic-pages-strip::-webkit-scrollbar-thumb { background: rgba(236,72,153,.4); border-radius: 3px; }
.ic-pages-strip::-webkit-scrollbar-track { background: transparent; }
.ic-page-img {
width: clamp(140px, 42vw, 220px);
flex-shrink: 0;
aspect-ratio: 4/5;
border-radius: 8px;
object-fit: cover;
scroll-snap-align: center;
border: 2px solid rgba(255,255,255,.08);
background: rgba(255,255,255,.04);
cursor: pointer;
transition: transform .15s, border-color .15s;
}
.ic-page-img.is-active {
border-color: #ec4899;
transform: scale(1.03);
}
.ic-pages-nav {
position: absolute;
top: calc(50% - 6px);
transform: translateY(-50%);
width: 40px; height: 40px;
border-radius: 50%; border: 0;
background: rgba(0,0,0,.65); color: #fff;
font-size: 24px; font-weight: 700; line-height: 1;
cursor: pointer; z-index: 2;
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(4px);
transition: opacity .15s, background .15s;
}
.ic-pages-nav:hover:not(:disabled) { background: rgba(236,72,153,.9); }
.ic-pages-nav:disabled { opacity: .2; cursor: not-allowed; }
.ic-pages-nav--prev { left: 0; }
.ic-pages-nav--next { right: 0; }
.ic-pages-indicator {
display: inline-flex; align-items: baseline; gap: 4px;
margin: 4px auto 0;
padding: 4px 12px;
background: rgba(255,255,255,.06);
border-radius: 99px;
font-size: 0.78rem;
color: rgba(255,255,255,.7);
font-variant-numeric: tabular-nums;
}
.ic-pages-indicator-row { display: flex; justify-content: center; }
.ic-pages-indicator__current { color: #ec4899; font-weight: 700; }
.ic-pages-indicator__sep { opacity: 0.5; }
.ic-pages-indicator__total { opacity: 0.7; }
.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; }
/* ── slate creation progress banner (양 탭 공통) ── */
.ic-slate-progress {
margin: 8px 0 16px;
padding: 12px 16px;
border-radius: 8px;
font-size: 0.88rem;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
line-height: 1.5;
}
.ic-slate-progress--starting,
.ic-slate-progress--processing {
background: rgba(245, 158, 11, 0.12);
color: #fbbf24;
border-left: 4px solid #f59e0b;
}
.ic-slate-progress--succeeded {
background: rgba(16, 185, 129, 0.12);
color: #34d399;
border-left: 4px solid #10b981;
cursor: pointer;
}
.ic-slate-progress--failed {
background: rgba(239, 68, 68, 0.12);
color: #f87171;
border-left: 4px solid #ef4444;
cursor: pointer;
}
.ic-slate-progress__hint {
opacity: 0.7;
font-size: 0.78rem;
margin-left: 6px;
}

View File

@@ -0,0 +1,970 @@
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: 'youtube_trending', 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>📺 YouTube 인기</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);
/* ── 카드 생성 progress (Trends 탭 클릭 + Cards 탭 양쪽 모두 사용) ──
* null = idle
* { keyword, status: 'starting'|'processing'|'succeeded'|'failed', message?, slate_id?, error? } */
const [slateProgress, setSlateProgress] = 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: 키워드 → 카피 + 이미지 추론 → 자동 미리보기 ──
* 1. createInstaSlate 호출 → task_id
* 2. getInstaTask로 폴링 (3초 간격, 최대 8분 = Claude 카피 + Playwright 10장 렌더)
* 3. 완료 시 Cards 탭으로 자동 전환 + 슬레이트 선택 → SlateDetail이 카피·이미지 미리보기 */
const handleCreateSlate = useCallback(async ({ keyword, category, keyword_id } = {}) => {
if (!keyword || !category) {
alert('keyword + category 필수');
return;
}
setSlateProgress({ keyword, status: 'starting', message: '카드 생성 시작...' });
// 상단 progress 배너가 보이도록 스크롤 (Trends/Cards 어느 탭의 어느 위치에서 눌렀든)
if (typeof window !== 'undefined') {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
try {
const { task_id } = await createInstaSlate({ keyword, category, keyword_id });
let st = null;
// 최대 8분 (3초 × 160) 폴링
for (let i = 0; i < 160; i++) {
st = await getInstaTask(task_id);
setSlateProgress({
keyword,
status: st.status,
message: st.message || `진행률 ${st.progress}%`,
});
if (st.status === 'succeeded' || st.status === 'failed') break;
await new Promise(r => setTimeout(r, 3000));
}
if (st && st.status === 'succeeded' && st.result_id) {
// 완료 — Cards 탭으로 자동 이동해서 SlateDetail 보여주기
setSlateProgress({
keyword, status: 'succeeded', message: '완료', slate_id: st.result_id,
});
setSelectedSlateId(st.result_id);
switchTab('cards');
// 3초 후 progress 배너 자동 dismiss
setTimeout(() => setSlateProgress(null), 3000);
} else {
setSlateProgress({
keyword, status: 'failed',
error: (st && st.error) || '시간 초과 또는 알 수 없는 오류',
});
}
} catch (e) {
setSlateProgress({ keyword, status: 'failed', error: 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>
{/* ── 카드 생성 progress 배너 (양 탭 공통) ── */}
{slateProgress && (
<div
className={`ic-slate-progress ic-slate-progress--${slateProgress.status}`}
onClick={() => slateProgress.status !== 'processing' && slateProgress.status !== 'starting' && setSlateProgress(null)}
>
{slateProgress.status === 'starting' && '⏳'}
{slateProgress.status === 'processing' && '🎨'}
{slateProgress.status === 'succeeded' && '✅'}
{slateProgress.status === 'failed' && '⚠️'}
{' '}
<strong>{slateProgress.keyword}</strong>
{' — '}
{slateProgress.status === 'failed'
? `실패: ${slateProgress.error}`
: slateProgress.message}
{(slateProgress.status === 'starting' || slateProgress.status === 'processing') && (
<span className="ic-slate-progress__hint"> · Claude로 10페이지 카피 추론 + Playwright로 카드 10 생성 (3~7)</span>
)}
</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={handleCreateSlate} />
</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 load = useCallback(() => {
const cat = category === '전체' ? undefined : category;
getInstaKeywords({ category: cat }).then((r) => setKeywords(r.items || [])).catch(() => {});
}, [category]);
useEffect(() => { load(); }, [load]);
// 부모(InstaCards)의 handleCreateSlate에 위임 — progress 배너 + 스크롤 + 자동 미리보기 공통화
async function handleCreate(kw) {
if (creating) return;
setCreating(kw.id);
try {
await onCreateSlate?.({
keyword: kw.keyword,
category: kw.category,
keyword_id: kw.id,
});
} finally {
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>
{/* progress 표시는 상단 ic-slate-progress 배너에서 일괄 처리 */}
{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>
);
}
/* ══════════════════════ 페이지 스트립 (chevron + indicator) ═══════════ */
function PagesStrip({ slateId, pageCount }) {
const stripRef = useRef(null);
const [activePage, setActivePage] = useState(1);
const scrollToPage = useCallback((pageNo) => {
const strip = stripRef.current;
if (!strip) return;
const next = Math.max(1, Math.min(pageCount, pageNo));
const child = strip.children[next - 1];
if (child) {
child.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
setActivePage(next);
}
}, [pageCount]);
// 스크롤/드래그 시 가운데 카드 감지
const onScroll = useCallback(() => {
const strip = stripRef.current;
if (!strip) return;
const rect = strip.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
let best = 1, bestDist = Infinity;
Array.from(strip.children).forEach((child, i) => {
const cRect = child.getBoundingClientRect();
const cCenter = cRect.left + cRect.width / 2;
const dist = Math.abs(cCenter - centerX);
if (dist < bestDist) { bestDist = dist; best = i + 1; }
});
if (best !== activePage) setActivePage(best);
}, [activePage]);
// 키보드 ←/→
useEffect(() => {
const onKey = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.key === 'ArrowLeft') { scrollToPage(activePage - 1); e.preventDefault(); }
else if (e.key === 'ArrowRight') { scrollToPage(activePage + 1); e.preventDefault(); }
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [activePage, scrollToPage]);
return (
<div className="ic-pages-wrap">
<button
className="ic-pages-nav ic-pages-nav--prev"
onClick={() => scrollToPage(activePage - 1)}
disabled={activePage <= 1}
aria-label="이전 페이지"
type="button"
></button>
<div className="ic-pages-strip" ref={stripRef} onScroll={onScroll}>
{Array.from({ length: pageCount }, (_, i) => i + 1).map((page) => (
<img
key={page}
className={`ic-page-img ${activePage === page ? 'is-active' : ''}`}
src={getInstaAssetUrl(slateId, page)}
alt={`Page ${page}`}
loading="lazy"
onClick={() => scrollToPage(page)}
/>
))}
</div>
<button
className="ic-pages-nav ic-pages-nav--next"
onClick={() => scrollToPage(activePage + 1)}
disabled={activePage >= pageCount}
aria-label="다음 페이지"
type="button"
></button>
<div className="ic-pages-indicator-row">
<span className="ic-pages-indicator">
<span className="ic-pages-indicator__current">{activePage}</span>
<span className="ic-pages-indicator__sep">/</span>
<span className="ic-pages-indicator__total">{pageCount}</span>
</span>
</div>
</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>
{/* 페이지 이미지 스트립 (캐러셀: chevron + indicator + ←/→ 키보드) */}
{(slate.status === 'rendered' || slate.status === 'sent') ? (
<PagesStrip slateId={slate.id} pageCount={pageCount} />
) : (
<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>
)}
{/* 커버 카피 (1/10) */}
{slate.cover_copy && typeof slate.cover_copy === 'object' && (
<div className="ic-caption-box">
<div className="ic-caption-box__label">🎯 커버 (1/10)</div>
<div className="ic-caption-text">
<strong>{slate.cover_copy.headline}</strong>
{slate.cover_copy.body && (
<div style={{ marginTop: 6, opacity: 0.85, whiteSpace: 'pre-wrap' }}>
{slate.cover_copy.body}
</div>
)}
{slate.cover_copy.accent_color && (
<div style={{ marginTop: 6, fontSize: '0.72rem', opacity: 0.5 }}>
accent: <code>{slate.cover_copy.accent_color}</code>
</div>
)}
</div>
</div>
)}
{/* 본문 카피 8장 (2~9/10) */}
{Array.isArray(slate.body_copies) && slate.body_copies.length > 0 && (
<div className="ic-caption-box">
<div className="ic-caption-box__label">📝 본문 8 (2~9/10)</div>
{slate.body_copies.map((b, i) => (
<div
key={i}
style={{
borderTop: i > 0 ? '1px solid rgba(255,255,255,0.06)' : 'none',
padding: '10px 0',
}}
>
<strong>{i + 2}. {b?.headline || ''}</strong>
{b?.body && (
<div style={{ marginTop: 4, opacity: 0.85, whiteSpace: 'pre-wrap' }}>
{b.body}
</div>
)}
</div>
))}
</div>
)}
{/* CTA 카피 (10/10) */}
{slate.cta_copy && typeof slate.cta_copy === 'object' && (
<div className="ic-caption-box">
<div className="ic-caption-box__label">📣 마무리 (10/10)</div>
<div className="ic-caption-text">
<strong>{slate.cta_copy.headline}</strong>
{slate.cta_copy.body && (
<div style={{ marginTop: 6, opacity: 0.85, whiteSpace: 'pre-wrap' }}>
{slate.cta_copy.body}
</div>
)}
{slate.cta_copy.cta && (
<div style={{ marginTop: 8, color: '#ec4899', fontWeight: 700 }}>
CTA: {slate.cta_copy.cta}
</div>
)}
</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>
);
}

View File

@@ -9,7 +9,7 @@ import {
IconMusic, IconMusic,
IconLab, IconLab,
IconTodo, IconTodo,
IconBlogMarketing, IconInsta,
IconPortfolio, IconPortfolio,
} from './components/Icons'; } from './components/Icons';
@@ -26,7 +26,7 @@ const SwordStream = lazy(() => import('./pages/effect-lab/SwordStream'));
const DayCalc = lazy(() => import('./pages/effect-lab/DayCalc')); const DayCalc = lazy(() => import('./pages/effect-lab/DayCalc'));
const Todo = lazy(() => import('./pages/todo/Todo')); const Todo = lazy(() => import('./pages/todo/Todo'));
const MusicStudio = lazy(() => import('./pages/music/MusicStudio')); const MusicStudio = lazy(() => import('./pages/music/MusicStudio'));
const BlogMarketing = lazy(() => import('./pages/blog-marketing/BlogMarketing')); const InstaCards = lazy(() => import('./pages/insta/InstaCards'));
const Portfolio = lazy(() => import('./pages/portfolio/Portfolio')); const Portfolio = lazy(() => import('./pages/portfolio/Portfolio'));
export const navLinks = [ export const navLinks = [
@@ -103,13 +103,13 @@ export const navLinks = [
accent: '#f43f5e', accent: '#f43f5e',
}, },
{ {
id: 'blog-lab', id: 'insta',
label: 'Blog Lab', label: 'Insta',
path: '/blog-lab', path: '/insta',
subtitle: 'MONETIZE', subtitle: 'CARD FEED',
description: 'AI 블로그 마케팅으로 수익을 만드는 연구소', description: '뉴스에서 키워드 발굴 → AI 카드 10장 자동 생성 → 인스타 업로드',
icon: <IconBlogMarketing />, icon: <IconInsta />,
accent: '#10b981', accent: '#ec4899',
}, },
{ {
id: 'todo', id: 'todo',
@@ -190,8 +190,8 @@ export const appRoutes = [
element: <MusicStudio />, element: <MusicStudio />,
}, },
{ {
path: 'blog-lab', path: 'insta',
element: <BlogMarketing />, element: <InstaCards />,
}, },
{ {
path: 'todo', path: 'todo',