Files
web-page/docs/superpowers/plans/2026-05-15-signal-v2-phase1-webai-api.md
gahusb c84c6b5bac docs(signal-v2): Phase 1 implementation plan — 7 tasks TDD
7 tasks: auth.py + verify_webai_key (Task 1) → portfolio + pnl_pct
(Task 2) → news-sentiment (Task 3) → common edge cases (Task 4) →
docker-compose env (Task 5) → nginx config (Task 6) → deploy +
manual smoke (Task 7). 16 tests total (4 unit + 12 integration).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:30:53 +09:00

859 lines
29 KiB
Markdown
Raw Blame History

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