27 Commits

Author SHA1 Message Date
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
47 changed files with 9796 additions and 2791 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` |

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

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,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,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,104 @@
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.break { background: #94a3b8; }
.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 +153,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 +175,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;
@@ -144,9 +204,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 +228,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 +263,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 +289,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 +357,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 +391,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 +401,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,9 +1,11 @@
// 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() {
@@ -12,85 +14,57 @@ export default function AgentOffice() {
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) => {
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); 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;
return ( let rightPanel;
<div className="ao-root"> if (selectedAgent === null) {
<TopBar rightPanel = <EmptyDetailPanel variant="initial" />;
connected={connected} } else if (selectedAgent.startsWith('placeholder-')) {
theme={theme} rightPanel = <EmptyDetailPanel variant="placeholder" onClose={handleClose} />;
onThemeChange={handleThemeChange} } else {
zoom={zoom} rightPanel = (
onZoomChange={handleZoomChange}
/>
<div className="ao-main">
<canvas
ref={canvasRef}
className="ao-canvas"
onClick={handleCanvasClick}
/>
{selectedAgent && (
<SidePanel <SidePanel
agentId={selectedAgent} agentId={selectedAgent}
agentState={agents[selectedAgent]} agentState={agents[selectedAgent]}
pendingTask={pendingTask} pendingTask={pendingTask}
onClose={() => setSelectedAgent(null)} onClose={handleClose}
refreshTrigger={refreshTrigger} refreshTrigger={refreshTrigger}
/> />
)} );
}
return (
<div className="ao-root">
<TopBar connected={connected} />
<div className="ao-main">
<div className="ao-grid-wrap">
<AgentGrid
agents={agents}
notifications={notifications}
selectedAgent={selectedAgent}
onSelectAgent={handleSelectAgent}
onSelectPlaceholder={handleSelectPlaceholder}
/>
</div>
{rightPanel}
</div> </div>
</div> </div>
); );

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

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

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

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

@@ -1,9 +1,5 @@
// 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 }) {
export default function TopBar({ connected, theme, onThemeChange, zoom, onZoomChange }) {
const themes = getThemeNames();
return ( return (
<div className="ao-topbar"> <div className="ao-topbar">
<div className="ao-topbar-left"> <div className="ao-topbar-left">
@@ -12,22 +8,6 @@ export default function TopBar({ connected, theme, onThemeChange, zoom, onZoomCh
{connected ? 'Connected' : 'Disconnected'} {connected ? 'Connected' : 'Disconnected'}
</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,44 @@
// src/pages/agent-office/constants.js
import stockImg from './assets/agent_stock.png';
import musicImg from './assets/agent_music.png';
import instaImg from './assets/agent_insta.png';
import realestateImg from './assets/agent_realestate.png';
import lottoImg from './assets/agent_lotto.png';
import undeterminedImg from './assets/agent_undetermined.png';
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 },
break: { color: '#94a3b8', pulse: false },
};
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,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,205 @@
/* ── InstaCards ──────────────────────────────────────────────────────────── */
.ic { max-width: 1100px; margin: 0 auto; padding: 24px 16px 80px; }
/* 헤더 */
.ic-header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
.ic-header h1 { font-size: 1.5rem; font-weight: 700; color: var(--text-primary, #e4e4e7); margin: 0; }
.ic-status-badges { display: flex; gap: 8px; margin-left: auto; }
.ic-badge { font-size: 0.7rem; padding: 2px 8px; border-radius: 99px; background: rgba(236,72,153,.15); color: #ec4899; }
.ic-badge--on { background: rgba(16,185,129,.15); color: #10b981; }
.ic-badge--off { background: rgba(239,68,68,.12); color: #ef4444; }
/* 버튼 공통 */
.ic-btn { padding: 8px 18px; border-radius: 8px; border: none; font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: all .15s; display: inline-flex; align-items: center; gap: 6px; }
.ic-btn--primary { background: #ec4899; color: #fff; }
.ic-btn--primary:hover { background: #db2777; }
.ic-btn--primary:disabled { opacity: .5; cursor: not-allowed; }
.ic-btn--secondary { background: rgba(255,255,255,.08); color: rgba(255,255,255,.7); }
.ic-btn--secondary:hover { background: rgba(255,255,255,.12); }
.ic-btn--secondary:disabled { opacity: .5; cursor: not-allowed; }
.ic-btn--danger { background: rgba(239,68,68,.15); color: #ef4444; }
.ic-btn--danger:hover { background: rgba(239,68,68,.25); }
.ic-btn--sm { padding: 4px 10px; font-size: 0.75rem; }
.ic-spinner { width: 14px; height: 14px; border: 2px solid rgba(255,255,255,.3); border-top-color: #fff; border-radius: 50%; animation: ic-spin .6s linear infinite; display: inline-block; }
@keyframes ic-spin { to { transform: rotate(360deg); } }
/* 레이아웃: 모바일 1컬럼, 데스크탑 2컬럼 */
.ic-layout { display: grid; grid-template-columns: 1fr; gap: 20px; }
@media (min-width: 768px) {
.ic-layout { grid-template-columns: 320px 1fr; }
}
/* 섹션 카드 */
.ic-section { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 16px; }
.ic-section__title { font-size: 0.85rem; font-weight: 700; color: rgba(255,255,255,.6); text-transform: uppercase; letter-spacing: .05em; margin: 0 0 14px; }
/* 트리거 패널 */
.ic-trigger-buttons { display: flex; flex-direction: column; gap: 10px; }
.ic-task-status { margin-top: 10px; padding: 10px 12px; background: rgba(255,255,255,.03); border-radius: 8px; font-size: 0.8rem; }
.ic-task-status__label { color: rgba(255,255,255,.4); margin-bottom: 4px; }
.ic-task-status__msg { color: var(--text-primary, #e4e4e7); }
.ic-task-status__progress { margin-top: 6px; height: 3px; background: rgba(255,255,255,.08); border-radius: 2px; }
.ic-task-status__fill { height: 100%; background: #ec4899; border-radius: 2px; transition: width .3s; }
/* 카테고리 필터 */
.ic-filter { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
.ic-filter-btn { padding: 4px 12px; border-radius: 99px; border: 1px solid rgba(255,255,255,.1); background: rgba(255,255,255,.04); color: rgba(255,255,255,.5); font-size: 0.75rem; cursor: pointer; transition: all .15s; }
.ic-filter-btn--active { background: rgba(236,72,153,.18); border-color: #ec4899; color: #ec4899; }
/* 키워드 목록 */
.ic-keywords { display: flex; flex-direction: column; gap: 8px; }
.ic-keyword-row { display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: rgba(255,255,255,.03); border-radius: 8px; }
.ic-keyword-row__kw { font-size: 0.9rem; font-weight: 600; color: var(--text-primary, #e4e4e7); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ic-keyword-row__meta { font-size: 0.72rem; color: rgba(255,255,255,.35); white-space: nowrap; }
.ic-keyword-row__score { font-size: 0.75rem; font-weight: 700; color: #ec4899; min-width: 36px; text-align: right; }
/* 슬레이트 그리드 */
.ic-slates-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; }
.ic-slate-card { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 10px; overflow: hidden; cursor: pointer; transition: border-color .15s; }
.ic-slate-card:hover { border-color: rgba(236,72,153,.4); }
.ic-slate-card--active { border-color: #ec4899; }
.ic-slate-thumb { width: 100%; aspect-ratio: 4/5; object-fit: cover; background: rgba(255,255,255,.06); display: block; }
.ic-slate-thumb--placeholder { width: 100%; aspect-ratio: 4/5; background: rgba(255,255,255,.04); display: flex; align-items: center; justify-content: center; font-size: 1.8rem; }
.ic-slate-card__info { padding: 8px; }
.ic-slate-card__kw { font-size: 0.78rem; font-weight: 600; color: var(--text-primary, #e4e4e7); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ic-slate-card__meta { display: flex; align-items: center; justify-content: space-between; margin-top: 4px; }
.ic-slate-card__date { font-size: 0.65rem; color: rgba(255,255,255,.3); }
/* 상태 뱃지 */
.ic-status-badge { font-size: 0.65rem; padding: 1px 6px; border-radius: 99px; font-weight: 600; }
.ic-status-badge--draft { background: rgba(161,161,170,.15); color: #a1a1aa; }
.ic-status-badge--rendered { background: rgba(96,165,250,.15); color: #60a5fa; }
.ic-status-badge--sent { background: rgba(16,185,129,.15); color: #10b981; }
.ic-status-badge--failed { background: rgba(239,68,68,.12); color: #ef4444; }
/* 슬레이트 상세 패널 */
.ic-detail { margin-top: 20px; padding: 16px; background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; }
.ic-detail__header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
.ic-detail__title { font-size: 1rem; font-weight: 700; color: var(--text-primary, #e4e4e7); flex: 1; }
.ic-detail__actions { display: flex; gap: 8px; }
.ic-pages-strip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 8px; margin-bottom: 14px; scroll-snap-type: x mandatory; }
.ic-page-img { width: 120px; flex-shrink: 0; aspect-ratio: 4/5; border-radius: 6px; object-fit: cover; scroll-snap-align: start; border: 1px solid rgba(255,255,255,.08); background: rgba(255,255,255,.04); }
.ic-caption-box { background: rgba(255,255,255,.03); border-radius: 8px; padding: 12px; margin-bottom: 10px; }
.ic-caption-box__label { font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,.4); text-transform: uppercase; margin-bottom: 6px; }
.ic-caption-text { font-size: 0.85rem; color: var(--text-primary, #e4e4e7); line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
.ic-hashtags { font-size: 0.8rem; color: #60a5fa; line-height: 1.8; word-break: break-all; }
/* 프롬프트 에디터 */
.ic-prompt-editor { margin-top: 20px; }
.ic-prompt-editor__title { font-size: 0.85rem; font-weight: 700; color: rgba(255,255,255,.5); margin-bottom: 12px; text-transform: uppercase; }
.ic-prompt-block { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 10px; padding: 14px; margin-bottom: 12px; }
.ic-prompt-block__head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.ic-prompt-block__name { font-size: 0.8rem; font-weight: 700; color: rgba(255,255,255,.7); flex: 1; }
.ic-prompt-block__date { font-size: 0.68rem; color: rgba(255,255,255,.3); }
.ic-prompt-textarea { width: 100%; min-height: 140px; background: rgba(0,0,0,.3); border: 1px solid rgba(255,255,255,.1); border-radius: 6px; color: var(--text-primary, #e4e4e7); font-size: 0.8rem; font-family: monospace; line-height: 1.5; padding: 10px; resize: vertical; box-sizing: border-box; outline: none; }
.ic-prompt-textarea:focus { border-color: #ec4899; }
.ic-prompt-save-row { display: flex; justify-content: flex-end; margin-top: 8px; }
/* 빈 상태 */
.ic-empty { text-align: center; padding: 40px 20px; color: rgba(255,255,255,.3); font-size: 0.85rem; }
/* ── tabs ── */
.ic-tabbar { display: flex; gap: 8px; border-bottom: 1px solid #e2e8f0; margin-bottom: 16px; }
.ic-tab {
background: transparent; border: 0; padding: 10px 16px;
cursor: pointer; font-size: 14px; font-weight: 600;
color: #64748b; border-bottom: 2px solid transparent;
}
.ic-tab.is-active { color: #ec4899; border-bottom-color: #ec4899; }
/* ── trends grid ── */
.ic-trends-grid { display: grid; gap: 16px; grid-template-columns: 1fr; }
@media (min-width: 1024px) { .ic-trends-grid { grid-template-columns: 320px 1fr; } }
/* ── ic-panel base ── */
.ic-panel { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 16px; }
.ic-panel__title { font-size: 0.95rem; font-weight: 700; color: var(--text-primary, #e4e4e7); margin: 0 0 8px; }
.ic-panel__hint { font-size: 0.78rem; color: rgba(255,255,255,.4); margin: 0 0 10px; }
/* ── focus panel ── */
.ic-panel--focus .ic-focus__list { display: flex; flex-direction: column; gap: 10px; margin: 12px 0; }
.ic-focus__row { display: grid; grid-template-columns: 110px 1fr 50px; align-items: center; gap: 8px; }
.ic-focus__label { font-weight: 600; color: #475569; text-transform: capitalize; }
.ic-focus__slider { width: 100%; accent-color: #ec4899; }
.ic-focus__num { text-align: right; font-variant-numeric: tabular-nums; color: #475569; }
.ic-focus__add { display: flex; gap: 8px; margin-top: 12px; }
.ic-focus__add input { flex: 1; padding: 8px; border: 1px solid #cbd5e1; border-radius: 6px; background: rgba(255,255,255,.06); color: var(--text-primary, #e4e4e7); }
.ic-focus__add button { padding: 8px 14px; background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.12); border-radius: 6px; color: rgba(255,255,255,.7); cursor: pointer; font-size: 0.85rem; }
.ic-focus__save {
width: 100%; padding: 10px; margin-top: 12px;
background: #ec4899; color: #fff; border: 0; border-radius: 6px; cursor: pointer;
font-weight: 700;
}
.ic-focus__save:disabled { opacity: .5; cursor: not-allowed; }
.ic-focus__hint { margin-top: 12px; padding: 10px; background: rgba(245,158,11,.1); border-left: 3px solid #f59e0b; font-size: 12px; color: rgba(255,255,255,.6); line-height: 1.5; }
.ic-focus__hint code { background: rgba(0,0,0,.2); padding: 1px 4px; border-radius: 3px; }
/* ── trends panel ── */
.ic-trends__cols { display: grid; grid-template-columns: 1fr; gap: 16px; }
@media (min-width: 768px) { .ic-trends__cols { grid-template-columns: 1fr 1fr; } }
.ic-trends__col h4 { margin: 0 0 8px; font-size: 14px; color: rgba(255,255,255,.5); }
.ic-trend__group { margin-bottom: 14px; }
.ic-trend__group-head { font-size: 12px; font-weight: 700; text-transform: uppercase; margin-bottom: 4px; letter-spacing: 0.5px; }
.ic-trend__row {
display: grid; grid-template-columns: 10px 1fr 50px 36px;
align-items: center; gap: 8px; padding: 6px 4px;
border-bottom: 1px solid rgba(255,255,255,.06);
}
.ic-trend__cat-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.ic-trend__kw { font-weight: 500; color: var(--text-primary, #e4e4e7); font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ic-trend__score { text-align: right; color: rgba(255,255,255,.4); font-variant-numeric: tabular-nums; font-size: 12px; }
.ic-trend__make { background: #ec4899; border: 0; color: #fff; border-radius: 4px; cursor: pointer; padding: 4px; font-size: 14px; }
.ic-trend__make:hover { background: #db2777; }
.ic-panel__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.ic-panel__actions { display: flex; gap: 8px; align-items: center; }
.ic-panel__actions button { padding: 6px 12px; background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.1); border-radius: 6px; color: rgba(255,255,255,.7); cursor: pointer; font-size: 0.8rem; }
.ic-panel__actions button:disabled { opacity: .5; cursor: not-allowed; }
/* ── impact panel ── */
.ic-impact__row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.ic-impact__chip {
display: flex; align-items: baseline; gap: 6px;
padding: 6px 12px; background: rgba(255,255,255,.06); border-radius: 999px;
}
.ic-impact__cat { font-weight: 600; text-transform: capitalize; color: rgba(255,255,255,.6); font-size: 0.82rem; }
.ic-impact__count { color: #ec4899; font-weight: 700; font-size: 0.82rem; }
/* ── 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,895 @@
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>
);
}
/* ══════════════════════ 슬레이트 상세 ══════════════════════════════════ */
function SlateDetail({ slate, onDelete, onRender }) {
const pages = slate.assets || [];
const pageCount = pages.length > 0 ? pages.length : 10;
function copyCaption() {
const text = [slate.suggested_caption, slate.hashtags?.join(' ')].filter(Boolean).join('\n\n');
navigator.clipboard.writeText(text).then(() => alert('클립보드에 복사되었습니다!'));
}
return (
<div className="ic-detail">
<div className="ic-detail__header">
<div className="ic-detail__title">
{slate.keyword}
<span style={{ marginLeft: 8 }}><StatusBadge status={slate.status} /></span>
</div>
<div className="ic-detail__actions">
<button className="ic-btn ic-btn--secondary ic-btn--sm" onClick={onRender}>재렌더</button>
<button className="ic-btn ic-btn--danger ic-btn--sm" onClick={onDelete}>삭제</button>
</div>
</div>
{/* 페이지 이미지 스트립 */}
{(slate.status === 'rendered' || slate.status === 'sent') ? (
<div className="ic-pages-strip">
{Array.from({ length: pageCount }, (_, i) => i + 1).map((page) => (
<img
key={page}
className="ic-page-img"
src={getInstaAssetUrl(slate.id, page)}
alt={`Page ${page}`}
loading="lazy"
/>
))}
</div>
) : (
<div className="ic-empty" style={{ padding: '20px 0' }}>
{slate.status === 'failed' ? '렌더 실패 — 재렌더를 시도하세요.' : '렌더링 전입니다.'}
</div>
)}
{/* 캡션 */}
{slate.suggested_caption && (
<div className="ic-caption-box">
<div className="ic-caption-box__label">
캡션
<button
className="ic-btn ic-btn--secondary ic-btn--sm"
style={{ marginLeft: 8 }}
onClick={copyCaption}
>
복사
</button>
</div>
<div className="ic-caption-text">{slate.suggested_caption}</div>
{slate.hashtags?.length > 0 && (
<div className="ic-hashtags" style={{ marginTop: 8 }}>
{slate.hashtags.join(' ')}
</div>
)}
</div>
)}
{/* 커버 카피 (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',