diff --git a/docs/superpowers/plans/2026-05-15-signal-v2-phase1-webai-api.md b/docs/superpowers/plans/2026-05-15-signal-v2-phase1-webai-api.md new file mode 100644 index 0000000..2e556d6 --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-signal-v2-phase1-webai-api.md @@ -0,0 +1,858 @@ +# Signal V2 Phase 1 — stock WebAI API Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Confidence Signal Pipeline V2 의 Phase 2 (web-ai pull worker) 가 polling 할 stock 의 인증된 입력 계약 3종 (`/api/webai/portfolio`, `/api/webai/news-sentiment`, X-WebAI-Key 인증 인프라) 을 신설. + +**Architecture:** stock FastAPI app 에 `/api/webai/*` prefix 의 신규 endpoint 2개 추가. 인증은 `verify_webai_key` FastAPI dependency (단일 정적 키 `WEBAI_API_KEY` 환경변수 비교). nginx 에 `/api/webai/` location + `limit_req` rate limit. 기존 `/api/portfolio` 무변경, web-ui 영향 0. + +**Tech Stack:** FastAPI / pytest + TestClient / sqlite3 / nginx (limit_req_zone) + +**Spec:** `web-ui/docs/superpowers/specs/2026-05-15-signal-v2-phase1-webai-api.md` + +--- + +## 파일 구조 + +| 파일 | 책임 | +|------|------| +| `web-backend/stock/app/auth.py` (신규) | `verify_webai_key` FastAPI dependency — X-WebAI-Key 헤더 검증, env 미설정 503, 인증 실패 401 + logger.warning | +| `web-backend/stock/app/main.py` (수정) | 2 신규 endpoint: `GET /api/webai/portfolio`, `GET /api/webai/news-sentiment`. 기존 `get_portfolio()` 응답 위에 pnl_pct augment mapper | +| `web-backend/stock/app/test_webai_auth.py` (신규) | `verify_webai_key` 단위 3 케이스 | +| `web-backend/stock/app/test_webai_endpoints.py` (신규) | 두 endpoint × 4 + 공통 4 = 12 통합 케이스 | +| `web-backend/nginx/default.conf` (수정) | `limit_req_zone webai` + `/api/webai/` location | +| `web-backend/docker-compose.yml` (수정) | stock 컨테이너 env 에 `WEBAI_API_KEY` 추가 | + +--- + +## Task 순서 + +``` +Task 1: auth.py + verify_webai_key 단위 테스트 (TDD) +Task 2: /api/webai/portfolio + 응답 보강 + 통합 4 케이스 (TDD) +Task 3: /api/webai/news-sentiment + DB SELECT + 통합 4 케이스 (TDD) +Task 4: 공통 통합 4 케이스 (401 leak / 503 / wrong key / unknown date) +Task 5: docker-compose env 추가 +Task 6: nginx config (rate limit + location + 헤더 forward) +Task 7: 배포 + 사용자 .env 갱신 + manual smoke 검증 +``` + +각 Task 는 TDD 패턴 (test 먼저 → fail 확인 → 구현 → pass → commit). + +--- + +### Task 1: auth.py + verify_webai_key 단위 테스트 + +**Files:** +- Create: `web-backend/stock/app/auth.py` +- Create: `web-backend/stock/app/test_webai_auth.py` + +- [ ] **Step 1: Write the failing test** + +Create `web-backend/stock/app/test_webai_auth.py`: + +```python +import pytest +from fastapi import HTTPException +from starlette.requests import Request + + +def _make_request() -> Request: + """Minimal Request stub for verify_webai_key (only request.url.path + request.client used).""" + scope = { + "type": "http", + "path": "/api/webai/test", + "headers": [], + "client": ("1.2.3.4", 12345), + } + return Request(scope=scope) + + +def test_verify_with_valid_key_passes(monkeypatch): + monkeypatch.setenv("WEBAI_API_KEY", "secret-key-abc") + from app.auth import verify_webai_key + verify_webai_key(_make_request(), x_webai_key="secret-key-abc") + + +def test_verify_without_key_raises_401(monkeypatch): + monkeypatch.setenv("WEBAI_API_KEY", "secret-key-abc") + from app.auth import verify_webai_key + with pytest.raises(HTTPException) as exc: + verify_webai_key(_make_request(), x_webai_key=None) + assert exc.value.status_code == 401 + assert "X-WebAI-Key" in exc.value.detail + + +def test_verify_with_wrong_key_raises_401(monkeypatch): + monkeypatch.setenv("WEBAI_API_KEY", "secret-key-abc") + from app.auth import verify_webai_key + with pytest.raises(HTTPException) as exc: + verify_webai_key(_make_request(), x_webai_key="wrong-key") + assert exc.value.status_code == 401 + + +def test_verify_returns_503_when_env_missing(monkeypatch): + monkeypatch.delenv("WEBAI_API_KEY", raising=False) + from app.auth import verify_webai_key + with pytest.raises(HTTPException) as exc: + verify_webai_key(_make_request(), x_webai_key="anything") + assert exc.value.status_code == 503 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd web-backend/stock && python -m pytest app/test_webai_auth.py -v` +Expected: ImportError: cannot import name 'verify_webai_key' from 'app.auth' (또는 ModuleNotFoundError: No module named 'app.auth') + +- [ ] **Step 3: Write minimal implementation** + +Create `web-backend/stock/app/auth.py`: + +```python +import os +import logging + +from fastapi import Header, HTTPException +from starlette.requests import Request + +logger = logging.getLogger("stock") + + +def verify_webai_key( + request: Request, + x_webai_key: str | None = Header(default=None, alias="X-WebAI-Key"), +) -> None: + """ + /api/webai/* 보호용 FastAPI dependency. + + - WEBAI_API_KEY env 미설정 → 503 (다른 endpoint 무영향) + - 헤더 누락 또는 키 불일치 → 401 + logger.warning(ip) + """ + configured = os.getenv("WEBAI_API_KEY", "").strip() + if not configured: + logger.error("WEBAI_API_KEY not configured — refusing /api/webai/* request") + raise HTTPException(status_code=503, detail="webai auth not configured") + + if not x_webai_key or x_webai_key != configured: + remote = request.client.host if request.client else "?" + logger.warning("auth_fail path=%s remote=%s", request.url.path, remote) + raise HTTPException(status_code=401, detail="invalid or missing X-WebAI-Key") +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd web-backend/stock && python -m pytest app/test_webai_auth.py -v` +Expected: 4 passed + +- [ ] **Step 5: Commit** + +```bash +git add web-backend/stock/app/auth.py web-backend/stock/app/test_webai_auth.py +git commit -m "$(cat <<'EOF' +feat(stock-webai): add X-WebAI-Key auth dependency + tests + +verify_webai_key FastAPI dependency: 401 on missing/wrong key, +503 when WEBAI_API_KEY env unset. 4 unit tests pass. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 2: /api/webai/portfolio + 응답 보강 + 통합 4 케이스 + +**Files:** +- Modify: `web-backend/stock/app/main.py` (신규 endpoint + helper) +- Create: `web-backend/stock/app/test_webai_endpoints.py` (portfolio 4 케이스) + +- [ ] **Step 1: Write the failing tests (portfolio 4 케이스)** + +Create `web-backend/stock/app/test_webai_endpoints.py`: + +```python +import os +import sqlite3 +import pytest +from fastapi.testclient import TestClient + +from app.screener.schema import ensure_screener_schema +from app.db import init_db + + +@pytest.fixture(autouse=True) +def isolated_db_and_auth(tmp_path, monkeypatch): + db_path = tmp_path / "stock.db" + # 기본 stock DB 스키마 + monkeypatch.setenv("STOCK_DB_PATH", str(db_path)) + init_db() + # screener 스키마 (news_sentiment, krx_master 등) + c = sqlite3.connect(db_path) + ensure_screener_schema(c) + c.close() + # WEBAI_API_KEY 활성화 + monkeypatch.setenv("WEBAI_API_KEY", "test-secret") + + +@pytest.fixture +def client(): + from app.main import app + return TestClient(app) + + +HEADERS_OK = {"X-WebAI-Key": "test-secret"} + + +def _seed_portfolio(broker="키움", ticker="005930", name="삼성전자", + quantity=100, avg_price=75000.0, purchase_price=75500.0): + from app.db import add_portfolio_item + return add_portfolio_item(broker, ticker, name, quantity, avg_price, + purchase_price=purchase_price) + + +def test_webai_portfolio_normal_response_includes_pnl_pct(client, monkeypatch): + _seed_portfolio() + + # current_price 모킹 — profit_rate 4.67% 만들기 + from app import main + monkeypatch.setattr( + main, "get_current_prices_detail", + lambda tickers: {"005930": {"price": 78500.0, "session": "REGULAR", "as_of": "2026-05-15T15:30:00"}} + ) + + r = client.get("/api/webai/portfolio", headers=HEADERS_OK) + assert r.status_code == 200 + body = r.json() + assert len(body["holdings"]) == 1 + h = body["holdings"][0] + assert h["pnl_pct"] is not None + assert abs(h["pnl_pct"] - 0.0467) < 0.0005 # 0.0467 ± rounding + + +def test_webai_portfolio_summary_has_total_pnl_pct(client, monkeypatch): + _seed_portfolio() + from app import main + monkeypatch.setattr( + main, "get_current_prices_detail", + lambda tickers: {"005930": {"price": 78500.0, "session": "REGULAR", "as_of": "x"}} + ) + + r = client.get("/api/webai/portfolio", headers=HEADERS_OK) + body = r.json() + assert "total_pnl_pct" in body["summary"] + assert abs(body["summary"]["total_pnl_pct"] - 0.0467) < 0.0005 + + +def test_webai_portfolio_pnl_pct_matches_profit_rate_divided_100(client, monkeypatch): + _seed_portfolio() + from app import main + monkeypatch.setattr( + main, "get_current_prices_detail", + lambda tickers: {"005930": {"price": 78500.0, "session": "REGULAR", "as_of": "x"}} + ) + + r = client.get("/api/webai/portfolio", headers=HEADERS_OK) + h = r.json()["holdings"][0] + assert h["pnl_pct"] == round(h["profit_rate"] / 100, 6) + + +def test_webai_portfolio_missing_key_returns_401(client): + r = client.get("/api/webai/portfolio") + assert r.status_code == 401 + assert "X-WebAI-Key" in r.json()["detail"] +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd web-backend/stock && python -m pytest app/test_webai_endpoints.py -v` +Expected: 4 failed with 404 (endpoint not defined yet) — except `missing_key_returns_401` 도 404 (endpoint 자체가 없으므로) + +- [ ] **Step 3: Write minimal implementation** + +Modify `web-backend/stock/app/main.py` — add right after the imports block (around line 27): + +```python +from .auth import verify_webai_key +``` + +And add the new endpoint right after the existing `get_portfolio()` function (after line 384): + +```python +def _augment_portfolio_with_pnl_pct(raw: dict) -> dict: + """Add pnl_pct (ratio) to each holding and total_pnl_pct to summary.""" + holdings = [] + for h in raw["holdings"]: + pnl_pct = round(h["profit_rate"] / 100, 6) if h.get("profit_rate") is not None else None + holdings.append({**h, "pnl_pct": pnl_pct}) + + summary = dict(raw["summary"]) + rate = summary.get("total_profit_rate") + summary["total_pnl_pct"] = round(rate / 100, 6) if rate is not None else 0.0 + + return {"holdings": holdings, "cash": raw["cash"], "summary": summary} + + +@app.get("/api/webai/portfolio", dependencies=[Depends(verify_webai_key)]) +def get_webai_portfolio(): + """web-ai 전용 portfolio (인증 필수, pnl_pct 비율 필드 추가).""" + return _augment_portfolio_with_pnl_pct(get_portfolio()) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd web-backend/stock && python -m pytest app/test_webai_endpoints.py -v` +Expected: 4 passed + +Also run full stock suite to verify no regression: + +Run: `cd web-backend/stock && python -m pytest --ignore=app/test_scraper.py -q` +Expected: 86 + 4 = 90 passed + +- [ ] **Step 5: Commit** + +```bash +git add web-backend/stock/app/main.py web-backend/stock/app/test_webai_endpoints.py +git commit -m "$(cat <<'EOF' +feat(stock-webai): /api/webai/portfolio + pnl_pct augment + +Reuses get_portfolio() and adds pnl_pct (ratio, profit_rate/100) to +each holding plus total_pnl_pct to summary. 4 integration tests pass. +verify_webai_key dependency enforced. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 3: /api/webai/news-sentiment + DB SELECT + 통합 4 케이스 + +**Files:** +- Modify: `web-backend/stock/app/main.py` (신규 endpoint + helper) +- Modify: `web-backend/stock/app/test_webai_endpoints.py` (news-sentiment 4 케이스 추가) + +- [ ] **Step 1: Write the failing tests (news-sentiment 4 케이스)** + +Append to `web-backend/stock/app/test_webai_endpoints.py`: + +```python +def _seed_news_sentiment(date_str: str, rows: list[tuple]): + """rows: list of (ticker, score_raw, reason, news_count).""" + db_path = os.environ["STOCK_DB_PATH"] + c = sqlite3.connect(db_path) + for ticker, score, reason, news_count in rows: + c.execute( + "INSERT OR REPLACE INTO news_sentiment " + "(ticker, date, score_raw, reason, news_count, source) " + "VALUES (?, ?, ?, ?, ?, 'articles')", + (ticker, date_str, score, reason, news_count) + ) + c.commit() + c.close() + + +def _seed_krx_master(rows: list[tuple]): + """rows: list of (ticker, name).""" + db_path = os.environ["STOCK_DB_PATH"] + c = sqlite3.connect(db_path) + import datetime as dt + now = dt.datetime.utcnow().isoformat() + for ticker, name in rows: + c.execute( + "INSERT OR REPLACE INTO krx_master " + "(ticker, name, market, market_cap, updated_at) VALUES (?, ?, 'KOSPI', 0, ?)", + (ticker, name, now) + ) + c.commit() + c.close() + + +def test_webai_news_sentiment_returns_latest_date_when_no_param(client): + _seed_krx_master([("005930", "삼성전자"), ("000660", "SK하이닉스")]) + _seed_news_sentiment("2026-05-14", [("005930", 5.0, "old", 5)]) + _seed_news_sentiment("2026-05-15", [ + ("005930", 6.2, "HBM 양산 가시화", 12), + ("000660", 5.5, "PPI 우려에도 강세", 8), + ]) + + r = client.get("/api/webai/news-sentiment", headers=HEADERS_OK) + assert r.status_code == 200 + body = r.json() + assert body["date"] == "2026-05-15" + assert body["count"] == 2 + # sorted by score DESC + assert body["items"][0]["ticker"] == "005930" + assert body["items"][0]["score"] == 6.2 + assert body["items"][0]["name"] == "삼성전자" + assert body["items"][0]["reason"] == "HBM 양산 가시화" + + +def test_webai_news_sentiment_filters_by_date_param(client): + _seed_krx_master([("005930", "삼성전자")]) + _seed_news_sentiment("2026-05-14", [("005930", 5.0, "yesterday", 5)]) + _seed_news_sentiment("2026-05-15", [("005930", 6.2, "today", 12)]) + + r = client.get("/api/webai/news-sentiment?date=2026-05-14", headers=HEADERS_OK) + body = r.json() + assert body["date"] == "2026-05-14" + assert body["count"] == 1 + assert body["items"][0]["reason"] == "yesterday" + + +def test_webai_news_sentiment_empty_table_returns_count_zero(client): + r = client.get("/api/webai/news-sentiment", headers=HEADERS_OK) + body = r.json() + assert body["date"] is None + assert body["count"] == 0 + assert body["items"] == [] + + +def test_webai_news_sentiment_items_sorted_by_score_desc(client): + _seed_krx_master([("A", "A주"), ("B", "B주"), ("C", "C주")]) + _seed_news_sentiment("2026-05-15", [ + ("A", 1.0, "low", 1), + ("B", 9.0, "high", 1), + ("C", 5.0, "mid", 1), + ]) + + r = client.get("/api/webai/news-sentiment", headers=HEADERS_OK) + items = r.json()["items"] + assert [i["score"] for i in items] == [9.0, 5.0, 1.0] +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd web-backend/stock && python -m pytest app/test_webai_endpoints.py::test_webai_news_sentiment_returns_latest_date_when_no_param app/test_webai_endpoints.py::test_webai_news_sentiment_filters_by_date_param app/test_webai_endpoints.py::test_webai_news_sentiment_empty_table_returns_count_zero app/test_webai_endpoints.py::test_webai_news_sentiment_items_sorted_by_score_desc -v` +Expected: 4 failed with 404 (endpoint not defined) + +- [ ] **Step 3: Write minimal implementation** + +Modify `web-backend/stock/app/main.py` — add right after the portfolio endpoint added in Task 2: + +```python +def _fetch_news_sentiment_dump(date: str | None) -> dict: + """news_sentiment 일별 dump (krx_master JOIN, score DESC).""" + from .db import _conn # _conn() is the shared connection helper + conn = _conn() + try: + # 1) date resolve — None 이면 최신 date + if date is None: + row = conn.execute( + "SELECT MAX(date) FROM news_sentiment" + ).fetchone() + date = row[0] if row and row[0] else None + + if date is None: + return {"date": None, "count": 0, "items": []} + + # 2) JOIN krx_master.name (없으면 ticker 그대로) + rows = conn.execute( + """ + SELECT ns.ticker, + COALESCE(km.name, ns.ticker) AS name, + ns.score_raw, + ns.reason, + ns.news_count, + ns.source + FROM news_sentiment ns + LEFT JOIN krx_master km ON km.ticker = ns.ticker + WHERE ns.date = ? + ORDER BY ns.score_raw DESC + """, + (date,) + ).fetchall() + finally: + conn.close() + + items = [ + {"ticker": r[0], "name": r[1], "score": r[2], + "reason": r[3], "news_count": r[4], "source": r[5]} + for r in rows + ] + return {"date": date, "count": len(items), "items": items} + + +@app.get("/api/webai/news-sentiment", dependencies=[Depends(verify_webai_key)]) +def get_webai_news_sentiment(date: str | None = None): + """web-ai 전용 news sentiment 일별 dump.""" + return _fetch_news_sentiment_dump(date) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd web-backend/stock && python -m pytest app/test_webai_endpoints.py -v` +Expected: 8 passed (4 portfolio + 4 news-sentiment) + +Run full suite: +Run: `cd web-backend/stock && python -m pytest --ignore=app/test_scraper.py -q` +Expected: 86 + 8 = 94 passed + +- [ ] **Step 5: Commit** + +```bash +git add web-backend/stock/app/main.py web-backend/stock/app/test_webai_endpoints.py +git commit -m "$(cat <<'EOF' +feat(stock-webai): /api/webai/news-sentiment daily dump + +JOINs news_sentiment with krx_master for name fallback. Sorted by +score DESC. Date param defaults to latest. Empty table returns +{date: null, count: 0, items: []}. 4 integration tests pass. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 4: 공통 통합 4 케이스 (401 leak / 503 / wrong key / unknown date) + +**Files:** +- Modify: `web-backend/stock/app/test_webai_endpoints.py` (공통 4 케이스 추가) + +- [ ] **Step 1: Write the tests** + +Append to `web-backend/stock/app/test_webai_endpoints.py`: + +```python +def test_webai_401_response_has_no_payload_leak(client): + """인증 실패 응답에는 portfolio/sentiment 데이터가 없어야 한다.""" + _seed_portfolio() + r = client.get("/api/webai/portfolio") # 헤더 없음 + assert r.status_code == 401 + body = r.json() + assert "holdings" not in body + assert "cash" not in body + assert "summary" not in body + + +def test_webai_503_when_env_missing(client, monkeypatch): + """WEBAI_API_KEY env 미설정 시 503, 다른 endpoint 영향 없음.""" + monkeypatch.delenv("WEBAI_API_KEY", raising=False) + + r1 = client.get("/api/webai/portfolio", headers={"X-WebAI-Key": "anything"}) + assert r1.status_code == 503 + + # 기존 endpoint 무영향 — /api/portfolio 는 200 (빈 portfolio) + r2 = client.get("/api/portfolio") + assert r2.status_code == 200 + + +def test_webai_wrong_key_returns_401(client): + r = client.get("/api/webai/portfolio", headers={"X-WebAI-Key": "wrong"}) + assert r.status_code == 401 + + +def test_webai_news_sentiment_unknown_date_returns_empty(client): + r = client.get("/api/webai/news-sentiment?date=1999-01-01", headers=HEADERS_OK) + assert r.status_code == 200 + body = r.json() + assert body["count"] == 0 + assert body["items"] == [] +``` + +- [ ] **Step 2: Run tests to verify they pass** + +Run: `cd web-backend/stock && python -m pytest app/test_webai_endpoints.py -v` +Expected: 12 passed (4 + 4 + 4) + +Also run full stock suite: +Run: `cd web-backend/stock && python -m pytest --ignore=app/test_scraper.py -q` +Expected: 86 + 12 = 98 passed (note: spec said 101, but 86 stock + 4 auth + 12 endpoint = 102; the count in the spec was approximate, actual = current_baseline + 4 + 12) + +- [ ] **Step 3: Commit** + +```bash +git add web-backend/stock/app/test_webai_endpoints.py +git commit -m "$(cat <<'EOF' +test(stock-webai): edge cases — 401 no leak, 503 env missing, unknown date + +Verifies auth failure responses contain no portfolio/sentiment data, +503 when WEBAI_API_KEY env unset (existing endpoints unaffected), +news-sentiment unknown date returns empty result. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 5: docker-compose env 추가 + +**Files:** +- Modify: `web-backend/docker-compose.yml` (stock 서비스 env) + +- [ ] **Step 1: Locate the stock environment block** + +Run: `grep -n -A 20 "^ stock:" web-backend/docker-compose.yml | head -30` +Expected: stock 서비스 블록 출력. environment 또는 env_file 항목 확인. + +- [ ] **Step 2: Add WEBAI_API_KEY to stock env** + +Edit `web-backend/docker-compose.yml` — find the `stock:` service block and add `WEBAI_API_KEY=${WEBAI_API_KEY}` line to the `environment:` list. + +Example final state (excerpt): +```yaml + stock: + container_name: stock + build: + context: ./stock + environment: + - TZ=Asia/Seoul + - STOCK_DB_PATH=/app/data/stock.db + - WEBAI_API_KEY=${WEBAI_API_KEY} + # ... other vars +``` + +- [ ] **Step 3: Verify compose config** + +Run: `cd web-backend && docker compose config | grep -A 30 "stock:" | grep WEBAI_API_KEY` +Expected: `WEBAI_API_KEY: ""` (env 미설정 시 빈 문자열) 또는 실제 값 + +If the line is missing, re-check the edit. + +- [ ] **Step 4: Commit** + +```bash +cd web-backend +git add docker-compose.yml +git commit -m "$(cat <<'EOF' +chore(stock-webai): pass WEBAI_API_KEY env to stock container + +Required by /api/webai/* endpoints. Operator must set WEBAI_API_KEY +in NAS /volume1/docker/webpage/.env before deploy. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 6: nginx config (rate limit + location + 헤더 forward) + +**Files:** +- Modify: `web-backend/nginx/default.conf` + +- [ ] **Step 1: Add limit_req_zone to http {} block** + +Edit `web-backend/nginx/default.conf` — find the existing `limit_req_zone` directive (or the top of `http {}` block / top of `server {}` context) and add: + +```nginx +# /api/webai/* rate limit — web-ai pull worker (default 60/min, burst 20) +limit_req_zone $binary_remote_addr zone=webai:5m rate=60r/m; +``` + +Place it at the top of the http context (before any server blocks) or alongside existing limit_req_zone directives. + +- [ ] **Step 2: Add /api/webai/ location block** + +In the same file, find the existing `location /api/stock/` (or similar) block inside the relevant `server {}` and add the new location BEFORE it (to ensure prefix matching priority is explicit): + +```nginx +location /api/webai/ { + limit_req zone=webai burst=20 nodelay; + limit_req_status 429; + + proxy_pass http://stock:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-WebAI-Key $http_x_webai_key; +} +``` + +- [ ] **Step 3: Validate nginx config syntax** + +Run: `cd web-backend && docker compose run --rm --no-deps frontend nginx -t -c /etc/nginx/conf.d/default.conf 2>&1 | tail -5` + +If frontend image isn't built locally, use: +Run: `docker run --rm -v "$(pwd)/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro" nginx:alpine nginx -t 2>&1` +Expected: `nginx: configuration file /etc/nginx/nginx.conf test is successful` + +If the test fails due to missing upstream resolution (`host not found in upstream "stock"`), that's expected outside the compose network — the syntax check is what matters here. Ignore upstream resolution errors. + +- [ ] **Step 4: Commit** + +```bash +cd web-backend +git add nginx/default.conf +git commit -m "$(cat <<'EOF' +feat(nginx-webai): /api/webai/ location with rate limit + X-WebAI-Key forward + +limit_req_zone webai:5m rate=60r/m, burst=20 nodelay, return 429 on +limit hit. Proxies to stock:8000 with X-Real-IP, X-Forwarded-For, +and X-WebAI-Key headers preserved. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 7: 배포 + 사용자 .env 갱신 + manual smoke 검증 + +**Files:** +- 운영 `.env` (NAS `/volume1/docker/webpage/.env`) — 사용자 수동 +- web-ai `.env` (Windows PC) — 사용자 수동 (Phase 2 진입 시 사용, 본 Phase 에서 미사용 OK) + +**This task requires user action (NAS SSH + push). The implementer should pause and request the user to perform these steps. Do NOT mark the task complete until the user reports smoke test results.** + +- [ ] **Step 1: Generate WEBAI_API_KEY (사용자)** + +Sample command for the user to run locally: +```bash +python -c "import secrets; print(secrets.token_urlsafe(48))" +``` + +Save the output. This is the `WEBAI_API_KEY` value. + +- [ ] **Step 2: Update NAS .env (사용자)** + +SSH to NAS: +```bash +ssh user@gahusb.synology.me +sudo vi /volume1/docker/webpage/.env +``` + +Add line: +``` +WEBAI_API_KEY= +``` + +- [ ] **Step 3: Push web-backend (사용자)** + +Locally: +```bash +cd /c/Users/jaeoh/Desktop/workspace/web-backend && git push +``` +Wait for Gitea webhook → deployer rsync + docker compose up. + +If deployer DEPLOY_FAIL false alarm (known issue, see graduation experience): +```bash +ssh user@gahusb.synology.me +cd /volume1/docker/webpage +docker compose up -d --build stock frontend +docker ps --format "{{.Names}}: {{.Status}}" | grep -E "stock|frontend" +``` +Expected: both `healthy`. + +- [ ] **Step 4: Manual smoke — auth success** + +```bash +export WEBAI_API_KEY= +curl -s -H "X-WebAI-Key: $WEBAI_API_KEY" https://gahusb.synology.me/api/webai/portfolio | head -c 200 +``` +Expected: 200 JSON beginning with `{"holdings":[`. If portfolio empty, `{"holdings":[],"cash":[...`. + +```bash +curl -s -H "X-WebAI-Key: $WEBAI_API_KEY" "https://gahusb.synology.me/api/webai/news-sentiment" | head -c 300 +``` +Expected: 200 JSON with `"date":` and `"items":` keys. + +- [ ] **Step 5: Manual smoke — auth failure** + +```bash +curl -i -s https://gahusb.synology.me/api/webai/portfolio | head -5 +``` +Expected: +``` +HTTP/1.1 401 Unauthorized +... +{"detail":"invalid or missing X-WebAI-Key"} +``` + +```bash +curl -i -s -H "X-WebAI-Key: wrong" https://gahusb.synology.me/api/webai/portfolio | head -5 +``` +Expected: 401 with same detail. + +- [ ] **Step 6: Manual smoke — rate limit** + +```bash +for i in $(seq 1 120); do + curl -s -o /dev/null -w "%{http_code}\n" \ + -H "X-WebAI-Key: $WEBAI_API_KEY" \ + https://gahusb.synology.me/api/webai/portfolio +done | sort | uniq -c +``` +Expected: significant `200` count plus some `429` (rate limit triggered). Example: +``` + 85 200 + 35 429 +``` + +If you see all 200 (no 429), rate limit may not be applied. Check nginx logs and config. + +- [ ] **Step 7: Verify web-ui unchanged** + +Open https://gahusb.synology.me/ in browser. Navigate to `/stock` page. Verify the portfolio list still loads correctly (no errors). This confirms `/api/portfolio` (legacy, no auth) is unaffected. + +- [ ] **Step 8: Verify 503 fallback (optional, requires env removal + redeploy)** + +This is optional and disruptive — only run if you want to verify the 503 fallback explicitly. Skip in normal deploys. + +```bash +ssh user@gahusb.synology.me +cd /volume1/docker/webpage +# Comment out WEBAI_API_KEY in .env temporarily +sed -i 's/^WEBAI_API_KEY=/#WEBAI_API_KEY=/' .env +docker compose up -d stock +sleep 5 +curl -s -o /dev/null -w "%{http_code}\n" -H "X-WebAI-Key: anything" https://gahusb.synology.me/api/webai/portfolio +# Expected: 503 +# Restore: +sed -i 's/^#WEBAI_API_KEY=/WEBAI_API_KEY=/' .env +docker compose up -d stock +``` + +- [ ] **Step 9: Report results to user (운영 검증 게이트)** + +Report to the user: +- Step 4 (auth success): PASS / FAIL with details +- Step 5 (auth failure): PASS / FAIL +- Step 6 (rate limit): PASS (some 429 observed) / FAIL (all 200) +- Step 7 (web-ui unchanged): PASS / FAIL + +Only after the user confirms all PASS, mark Task 7 complete. If any FAIL, investigate before proceeding to Phase 2. + +--- + +## Self-Review (plan author runs this) + +**1. Spec coverage:** + +| Spec § | 요구사항 | Plan task | +|--------|----------|----------| +| §2 포함 ① portfolio + pnl_pct | Task 2 ✅ | +| §2 포함 ② news-sentiment | Task 3 ✅ | +| §2 포함 ③ X-WebAI-Key 인증 | Task 1 ✅ | +| §2 포함 ④ nginx rate limit | Task 6 ✅ | +| §2 포함 ⑤ 인증 실패 logger | Task 1 (logger.warning 호출 포함) ✅ | +| §2 포함 ⑥ 15 테스트 (4 unit + 12 integration) | Task 1 (4) + Task 2 (4) + Task 3 (4) + Task 4 (4) = 16. Note: spec said 15, plan delivers 16 (4 auth + 4 portfolio + 4 sentiment + 4 common). Counted higher, no gap. ✅ | +| §4.1 portfolio shape with pnl_pct | Task 2 Step 3 ✅ | +| §4.2 news-sentiment shape | Task 3 Step 3 ✅ | +| §4.3 401 leak free | Task 4 Step 1 (`test_webai_401_response_has_no_payload_leak`) ✅ | +| §4.4 503 when env missing | Task 1 (unit) + Task 4 (integration) ✅ | +| §5 auth.py implementation | Task 1 Step 3 ✅ | +| §6 nginx config | Task 6 ✅ | +| §10 DoD | Task 7 covers manual smoke + web-ui verification ✅ | + +No gaps. + +**2. Placeholder scan:** No "TBD" / "implement later" / vague descriptions found. Every step has executable code or commands. + +**3. Type consistency:** +- `verify_webai_key(request, x_webai_key)` signature consistent across Tasks 1, 2, 3 ✅ +- `_augment_portfolio_with_pnl_pct(raw)` defined in Task 2, no later reference (helper internal to main.py) ✅ +- `_fetch_news_sentiment_dump(date)` defined in Task 3, signature consistent ✅ +- `HEADERS_OK = {"X-WebAI-Key": "test-secret"}` defined in Task 2, reused in Tasks 3 and 4 ✅ +- `_seed_portfolio()` defined in Task 2, reused in Task 4 ✅ +- `_seed_news_sentiment()` / `_seed_krx_master()` defined in Task 3, consistent ✅ +- `WEBAI_API_KEY` env var name consistent across all tasks ✅ + +Plan passes self-review.