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

29 KiB
Raw Blame History

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:

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:

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

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):

from .auth import verify_webai_key

And add the new endpoint right after the existing get_portfolio() function (after line 384):

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

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:

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

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
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):

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

# /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):

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

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:

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:

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):

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
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":[....

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
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"}
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
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.

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.