# AI News Sentiment Node 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:** 시총 상위 100종목의 네이버 뉴스를 Claude Haiku로 호재/악재 분석하여 8번째 점수 노드 `ai_news` 를 스크리너 가중합에 통합한다. 평일 08:00 KST 자동 잡 + 호재/악재 Top 5 텔레그램 알림.

**Architecture:** stock-lab 내부에 `screener/ai_news/` 모듈 신설 (scraper + analyzer + pipeline + telegram). 새 `nodes/ai_news.py` 가 `news_sentiment` 테이블을 percentile_rank로 변환해 가중합 기여. agent-office 는 cron 트리거와 텔레그램 발송만 담당. 프론트 변경은 canvasLayout.js 한 곳.

**Tech Stack:** Python 3.11 / FastAPI / SQLite (WAL+busy_timeout) / `anthropic` SDK (async) / `httpx` (async) / `BeautifulSoup4` / APScheduler / React 18.

**선행 spec**: `web-ui/docs/superpowers/specs/2026-05-13-ai-news-sentiment-node-design.md`

---

## 파일 구조

신규 파일 (backend):
```
web-backend/stock-lab/app/screener/
  ai_news/
    __init__.py
    scraper.py             — 네이버 종목 뉴스 스크래핑
    analyzer.py            — Claude Haiku 호재/악재 분석
    pipeline.py            — refresh_daily() (스크래핑+병렬 LLM+DB)
    telegram.py            — Top 5/5 메시지 빌더
  nodes/
    ai_news.py             — 8번째 ScoreNode

web-backend/stock-lab/tests/
  test_ai_news_scraper.py
  test_ai_news_analyzer.py
  test_ai_news_pipeline.py
  test_ai_news_telegram.py
  test_ai_news_node.py
```

수정 파일 (backend):
```
web-backend/stock-lab/app/screener/
  schema.py              — news_sentiment DDL + DEFAULT_WEIGHTS/PARAMS 보강
  registry.py            — NODE_REGISTRY["ai_news"] 등록
  engine.py              — ScreenContext에 news_sentiment 필드 + load 갱신
  router.py              — POST /snapshot/refresh-news-sentiment 라우트
web-backend/stock-lab/requirements.txt   — anthropic 추가

web-backend/agent-office/app/
  service_proxy.py       — refresh_ai_news_sentiment() helper
  agents/stock.py        — on_ai_news_schedule 메서드
  scheduler.py           — cron mon-fri 08:00 등록
```

수정 파일 (frontend):
```
web-ui/src/pages/stock/screener/components/canvas/constants/
  canvasLayout.js        — AI 노드 추가
  canvasLayout.test.js   — 카운트 갱신
```

---

### Task 1: 의존성 + DB 스키마 + 기본 설정 보강

**Files:**
- Modify: `web-backend/stock-lab/requirements.txt`
- Modify: `web-backend/stock-lab/app/screener/schema.py`

- [ ] **Step 1: `requirements.txt` 에 anthropic SDK 추가**

`anthropic==0.39.0` 한 줄을 적당한 위치(알파벳 순)에 추가.

- [ ] **Step 2: `schema.py` 의 `DEFAULT_WEIGHTS` / `DEFAULT_NODE_PARAMS` 에 ai_news 추가**

`DEFAULT_WEIGHTS` 딕셔너리 끝에:
```python
    "ai_news":      0.8,
```

`DEFAULT_NODE_PARAMS` 딕셔너리 끝에:
```python
    "ai_news":      {"min_news_count": 1},
```

- [ ] **Step 3: `schema.py` 의 `DDL` 문자열에 `news_sentiment` 테이블 추가**

기존 `screener_results` 테이블 정의 뒤에 추가:
```python
CREATE TABLE IF NOT EXISTS news_sentiment (
  ticker          TEXT NOT NULL,
  date            TEXT NOT NULL,
  score_raw       REAL NOT NULL,
  reason          TEXT NOT NULL DEFAULT '',
  news_count      INTEGER NOT NULL DEFAULT 0,
  tokens_input    INTEGER NOT NULL DEFAULT 0,
  tokens_output   INTEGER NOT NULL DEFAULT 0,
  model           TEXT NOT NULL DEFAULT 'claude-haiku-4-5-20251001',
  created_at      TEXT NOT NULL DEFAULT (datetime('now','localtime')),
  PRIMARY KEY (ticker, date)
);
CREATE INDEX IF NOT EXISTS idx_news_sentiment_date ON news_sentiment(date DESC);
```

- [ ] **Step 4: `ensure_screener_schema` 에 1회 마이그레이션 추가 — 기존 settings의 weights에 ai_news 누락 시 보충**

기존 함수 본문의 `conn.executescript(DDL)` 직후, `existing = ...` 이전에 다음 블록 추가:
```python
    # ai_news 키 누락 시 1회 보충 (이미 운영 중인 환경에 대해)
    row = conn.execute(
        "SELECT weights_json, node_params_json FROM screener_settings WHERE id=1"
    ).fetchone()
    if row is not None:
        w = json.loads(row[0])
        p = json.loads(row[1])
        changed = False
        if "ai_news" not in w:
            w["ai_news"] = DEFAULT_WEIGHTS["ai_news"]
            changed = True
        if "ai_news" not in p:
            p["ai_news"] = DEFAULT_NODE_PARAMS["ai_news"]
            changed = True
        if changed:
            conn.execute(
                "UPDATE screener_settings SET weights_json=?, node_params_json=? WHERE id=1",
                (json.dumps(w), json.dumps(p)),
            )
```

- [ ] **Step 5: 기존 테스트 실행 — 회귀 없음 확인**

```bash
cd C:\Users\jaeoh\Desktop\workspace\web-backend\stock-lab
python -m pytest app/test_screener_schema.py -v
```
Expected: 모든 기존 테스트 PASS (DDL 추가는 idempotent 라 충돌 없음).

- [ ] **Step 6: Commit**

```bash
git add app/screener/schema.py requirements.txt
git commit -m "feat(screener): add news_sentiment table + ai_news defaults + migration"
```

---

### Task 2: `ai_news/scraper.py` — 네이버 종목 뉴스 스크래핑 + 테스트

**Files:**
- Create: `web-backend/stock-lab/app/screener/ai_news/__init__.py`
- Create: `web-backend/stock-lab/app/screener/ai_news/scraper.py`
- Test: `web-backend/stock-lab/tests/test_ai_news_scraper.py`

- [ ] **Step 1: `__init__.py` 빈 파일 생성**

내용 없음, 파일만 존재.

- [ ] **Step 2: 실패하는 테스트 작성**

`tests/test_ai_news_scraper.py`:
```python
import pytest
from unittest.mock import AsyncMock
from app.screener.ai_news import scraper


SAMPLE_HTML = """
<html><body>
<table class="type5"><tbody>
<tr><td class="title"><a href="/news1">삼성전자, HBM 양산 가시화</a></td><td class="date">2026.05.13 07:30</td></tr>
<tr><td class="title"><a href="/news2">삼성, 4분기 어닝 쇼크 우려</a></td><td class="date">2026.05.13 06:00</td></tr>
<tr><td class="title"><a href="/news3">메모리 시장 회복세</a></td><td class="date">2026.05.12 18:00</td></tr>
</tbody></table>
</body></html>
"""

EMPTY_HTML = "<html><body><table class='type5'><tbody></tbody></table></body></html>"


def _mk_client(status_code=200, text=SAMPLE_HTML):
    client = AsyncMock()
    resp = AsyncMock()
    resp.status_code = status_code
    resp.text = text
    client.get = AsyncMock(return_value=resp)
    return client


@pytest.mark.asyncio
async def test_fetch_news_success_returns_n_items():
    client = _mk_client()
    out = await scraper.fetch_news(client, "005930", n=2)
    assert len(out) == 2
    assert out[0]["title"] == "삼성전자, HBM 양산 가시화"
    assert out[0]["date"] == "2026.05.13 07:30"


@pytest.mark.asyncio
async def test_fetch_news_404_returns_empty():
    client = _mk_client(status_code=404, text="")
    out = await scraper.fetch_news(client, "999999", n=5)
    assert out == []


@pytest.mark.asyncio
async def test_fetch_news_empty_table_returns_empty():
    client = _mk_client(text=EMPTY_HTML)
    out = await scraper.fetch_news(client, "005930", n=5)
    assert out == []


@pytest.mark.asyncio
async def test_fetch_news_n_caps_results():
    client = _mk_client()
    out = await scraper.fetch_news(client, "005930", n=2)
    assert len(out) == 2  # 샘플에 3개 있지만 n=2로 잘림
```

- [ ] **Step 3: 테스트 실패 확인**

```bash
cd C:\Users\jaeoh\Desktop\workspace\web-backend\stock-lab
python -m pytest tests/test_ai_news_scraper.py -v
```
Expected: FAIL — "No module named 'app.screener.ai_news.scraper'".

- [ ] **Step 4: `scraper.py` 구현**

```python
"""네이버 finance 종목 뉴스 스크래핑."""

from __future__ import annotations

import logging
from typing import Any, Dict, List

from bs4 import BeautifulSoup

log = logging.getLogger(__name__)

NAVER_NEWS_URL = "https://finance.naver.com/item/news_news.naver"
NAVER_HEADERS = {
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
    "Referer": "https://finance.naver.com/",
}


async def fetch_news(client, ticker: str, n: int = 5) -> List[Dict[str, Any]]:
    """Scrape top N news headlines for a ticker. Returns [] on any failure."""
    try:
        r = await client.get(NAVER_NEWS_URL, params={"code": ticker, "page": 1})
    except Exception as e:
        log.warning("ai_news scrape http error for %s: %s", ticker, e)
        return []
    if r.status_code != 200:
        return []
    soup = BeautifulSoup(r.text, "lxml")
    out: List[Dict[str, Any]] = []
    for row in soup.select("table.type5 tbody tr")[:n]:
        title_el = row.select_one("td.title a")
        date_el = row.select_one("td.date")
        if not title_el or not date_el:
            continue
        out.append({
            "title": title_el.get_text(strip=True),
            "date": date_el.get_text(strip=True),
        })
    return out
```

- [ ] **Step 5: 테스트 통과 확인**

```bash
python -m pytest tests/test_ai_news_scraper.py -v
```
Expected: PASS — 4 tests passed.

- [ ] **Step 6: Commit**

```bash
git add app/screener/ai_news/__init__.py app/screener/ai_news/scraper.py tests/test_ai_news_scraper.py
git commit -m "feat(screener): ai_news scraper (naver finance ticker news)"
```

---

### Task 3: `ai_news/analyzer.py` — Claude Haiku 분석 + 테스트

**Files:**
- Create: `web-backend/stock-lab/app/screener/ai_news/analyzer.py`
- Test: `web-backend/stock-lab/tests/test_ai_news_analyzer.py`

- [ ] **Step 1: 실패하는 테스트 작성**

`tests/test_ai_news_analyzer.py`:
```python
import json
import pytest
from unittest.mock import AsyncMock, MagicMock

from app.screener.ai_news import analyzer


def _mk_llm(content_text: str, in_tokens: int = 100, out_tokens: int = 20):
    llm = AsyncMock()
    resp = MagicMock()
    block = MagicMock()
    block.text = content_text
    resp.content = [block]
    resp.usage = MagicMock(input_tokens=in_tokens, output_tokens=out_tokens)
    llm.messages = MagicMock()
    llm.messages.create = AsyncMock(return_value=resp)
    return llm


NEWS = [{"title": "삼성전자, HBM 양산"}, {"title": "메모리 가격 반등"}]


@pytest.mark.asyncio
async def test_score_sentiment_success_parses_json():
    llm = _mk_llm(json.dumps({"score": 7.5, "reason": "HBM 호재"}))
    out = await analyzer.score_sentiment(llm, "005930", NEWS, name="삼성전자")
    assert out["ticker"] == "005930"
    assert out["score_raw"] == 7.5
    assert out["reason"] == "HBM 호재"
    assert out["news_count"] == 2
    assert out["tokens_input"] == 100
    assert out["tokens_output"] == 20


@pytest.mark.asyncio
async def test_score_sentiment_json_parse_fail_returns_zero():
    llm = _mk_llm("not valid json")
    out = await analyzer.score_sentiment(llm, "005930", NEWS)
    assert out["score_raw"] == 0.0
    assert "parse fail" in out["reason"]
    assert out["tokens_input"] == 100  # 호출은 발생했음


@pytest.mark.asyncio
async def test_score_sentiment_clamps_out_of_range():
    llm = _mk_llm(json.dumps({"score": 15.0, "reason": "초강세"}))
    out = await analyzer.score_sentiment(llm, "005930", NEWS)
    assert out["score_raw"] == 10.0  # +10 클램프


@pytest.mark.asyncio
async def test_score_sentiment_clamps_negative_out_of_range():
    llm = _mk_llm(json.dumps({"score": -42.0, "reason": "초악재"}))
    out = await analyzer.score_sentiment(llm, "005930", NEWS)
    assert out["score_raw"] == -10.0
```

- [ ] **Step 2: 테스트 실패 확인**

```bash
python -m pytest tests/test_ai_news_analyzer.py -v
```
Expected: FAIL — "No module named 'app.screener.ai_news.analyzer'".

- [ ] **Step 3: `analyzer.py` 구현**

```python
"""Claude Haiku 기반 종목 뉴스 호재/악재 분석."""

from __future__ import annotations

import json
import logging
import os
from typing import Any, Dict, List

log = logging.getLogger(__name__)

DEFAULT_MODEL = os.getenv("AI_NEWS_MODEL", "claude-haiku-4-5-20251001")

PROMPT_TEMPLATE = """다음은 종목 {name}({ticker})에 대한 최근 뉴스 {n}개의 헤드라인입니다.

{news_block}

이 뉴스들이 종목에 호재인지 악재인지 평가하세요.
score: -10(매우 강한 악재) ~ +10(매우 강한 호재) 사이의 실수. 0은 중립.
reason: 30자 이내 한 줄 근거.

JSON으로만 응답하세요. 다른 텍스트 금지:
{{"score": <float>, "reason": "<string>"}}"""


def _clamp(x: float, lo: float = -10.0, hi: float = 10.0) -> float:
    return max(lo, min(hi, x))


async def score_sentiment(
    llm,
    ticker: str,
    news: List[Dict[str, Any]],
    *,
    name: str | None = None,
    model: str = DEFAULT_MODEL,
) -> Dict[str, Any]:
    """Returns {ticker, score_raw, reason, news_count, tokens_input, tokens_output, model}."""
    news_block = "\n".join(f"- {n['title']}" for n in news)
    prompt = PROMPT_TEMPLATE.format(
        name=name or ticker, ticker=ticker,
        n=len(news), news_block=news_block,
    )
    resp = await llm.messages.create(
        model=model,
        max_tokens=200,
        messages=[{"role": "user", "content": prompt}],
    )
    text = resp.content[0].text if resp.content else ""
    in_tokens = int(getattr(resp.usage, "input_tokens", 0) or 0)
    out_tokens = int(getattr(resp.usage, "output_tokens", 0) or 0)

    try:
        data = json.loads(text)
        score = _clamp(float(data["score"]))
        reason = str(data["reason"])[:200]
        return {
            "ticker": ticker,
            "score_raw": score,
            "reason": reason,
            "news_count": len(news),
            "tokens_input": in_tokens,
            "tokens_output": out_tokens,
            "model": model,
        }
    except (json.JSONDecodeError, KeyError, TypeError, ValueError) as e:
        log.warning("ai_news parse fail for %s: %s (raw=%r)", ticker, e, text[:100])
        return {
            "ticker": ticker,
            "score_raw": 0.0,
            "reason": f"parse fail: {e!s}"[:200],
            "news_count": len(news),
            "tokens_input": in_tokens,
            "tokens_output": out_tokens,
            "model": model,
        }
```

- [ ] **Step 4: 테스트 통과 확인**

```bash
python -m pytest tests/test_ai_news_analyzer.py -v
```
Expected: PASS — 4 tests passed.

- [ ] **Step 5: Commit**

```bash
git add app/screener/ai_news/analyzer.py tests/test_ai_news_analyzer.py
git commit -m "feat(screener): ai_news Claude Haiku analyzer (-10~+10 + clamp + JSON-fail soft)"
```

---

### Task 4: `ai_news/pipeline.py` — refresh_daily 통합 + 테스트

**Files:**
- Create: `web-backend/stock-lab/app/screener/ai_news/pipeline.py`
- Test: `web-backend/stock-lab/tests/test_ai_news_pipeline.py`

- [ ] **Step 1: 실패하는 테스트 작성**

`tests/test_ai_news_pipeline.py`:
```python
import datetime as dt
import sqlite3
import pytest
from unittest.mock import AsyncMock, MagicMock, patch

from app.screener.ai_news import pipeline
from app.screener.schema import ensure_screener_schema


@pytest.fixture
def conn():
    c = sqlite3.connect(":memory:")
    c.row_factory = sqlite3.Row
    ensure_screener_schema(c)
    # 시총 상위 3종목 시드
    c.execute("INSERT INTO krx_master (ticker, name, market, market_cap, updated_at) "
              "VALUES (?, ?, 'KOSPI', ?, datetime('now'))", ("005930", "삼성전자", 9_000_000))
    c.execute("INSERT INTO krx_master (ticker, name, market, market_cap, updated_at) "
              "VALUES (?, ?, 'KOSPI', ?, datetime('now'))", ("000660", "SK하이닉스", 8_000_000))
    c.execute("INSERT INTO krx_master (ticker, name, market, market_cap, updated_at) "
              "VALUES (?, ?, 'KOSPI', ?, datetime('now'))", ("373220", "LG에너지솔루션", 7_000_000))
    c.commit()
    yield c
    c.close()


@pytest.mark.asyncio
async def test_refresh_daily_happy_path(conn):
    """3종목 mini integration — 각 종목별로 scraper/analyzer mock."""
    asof = dt.date(2026, 5, 13)
    fake_news = [{"title": "헤드라인"}]
    
    async def fake_fetch(client, ticker, n):
        return fake_news
    
    scores_by_ticker = {
        "005930": 7.5, "000660": 4.0, "373220": -6.0,
    }
    async def fake_score(llm, ticker, news, *, name=None, model="m"):
        return {
            "ticker": ticker, "score_raw": scores_by_ticker[ticker],
            "reason": f"r{ticker}", "news_count": 1,
            "tokens_input": 100, "tokens_output": 20, "model": model,
        }
    
    with patch.object(pipeline, "_scraper") as ms, \
         patch.object(pipeline, "_analyzer") as ma, \
         patch.object(pipeline, "_make_llm") as ml, \
         patch.object(pipeline, "_make_http") as mh:
        ms.fetch_news = fake_fetch
        ma.score_sentiment = fake_score
        ml.return_value.__aenter__.return_value = AsyncMock()
        ml.return_value.__aexit__.return_value = None
        mh.return_value.__aenter__.return_value = AsyncMock()
        mh.return_value.__aexit__.return_value = None
        result = await pipeline.refresh_daily(conn, asof, concurrency=3, rate_limit_sec=0)
    
    assert result["asof"] == "2026-05-13"
    assert result["updated"] == 3
    assert result["failures"] == []
    assert len(result["top_pos"]) == 3
    assert result["top_pos"][0]["ticker"] == "005930"  # 가장 큰 점수
    assert result["top_neg"][0]["ticker"] == "373220"  # 가장 작은 점수
    assert result["tokens_input"] == 300
    assert result["tokens_output"] == 60
    
    # DB upsert 확인
    rows = conn.execute("SELECT ticker, score_raw FROM news_sentiment WHERE date=?",
                        ("2026-05-13",)).fetchall()
    assert len(rows) == 3
    by_ticker = {r["ticker"]: r["score_raw"] for r in rows}
    assert by_ticker["005930"] == 7.5
    assert by_ticker["373220"] == -6.0


@pytest.mark.asyncio
async def test_refresh_daily_failures_isolated(conn):
    """한 종목이 예외 던져도 나머지 종목은 정상 처리."""
    asof = dt.date(2026, 5, 13)
    
    async def fake_fetch(client, ticker, n):
        return [{"title": "h"}]
    
    async def fake_score(llm, ticker, news, *, name=None, model="m"):
        if ticker == "000660":
            raise RuntimeError("llm exploded")
        return {
            "ticker": ticker, "score_raw": 5.0, "reason": "r", "news_count": 1,
            "tokens_input": 100, "tokens_output": 20, "model": model,
        }
    
    with patch.object(pipeline, "_scraper") as ms, \
         patch.object(pipeline, "_analyzer") as ma, \
         patch.object(pipeline, "_make_llm") as ml, \
         patch.object(pipeline, "_make_http") as mh:
        ms.fetch_news = fake_fetch
        ma.score_sentiment = fake_score
        ml.return_value.__aenter__.return_value = AsyncMock()
        ml.return_value.__aexit__.return_value = None
        mh.return_value.__aenter__.return_value = AsyncMock()
        mh.return_value.__aexit__.return_value = None
        result = await pipeline.refresh_daily(conn, asof, concurrency=3, rate_limit_sec=0)
    
    assert result["updated"] == 2
    assert len(result["failures"]) == 1


def test_top_market_cap_tickers(conn):
    out = pipeline._top_market_cap_tickers(conn, n=2)
    assert out == ["005930", "000660"]
```

- [ ] **Step 2: 테스트 실패 확인**

```bash
python -m pytest tests/test_ai_news_pipeline.py -v
```
Expected: FAIL — "No module named 'app.screener.ai_news.pipeline'".

- [ ] **Step 3: `pipeline.py` 구현**

```python
"""ai_news refresh pipeline — 시총 상위 N종목 병렬 처리."""

from __future__ import annotations

import asyncio
import datetime as dt
import logging
import os
import sqlite3
import time
from typing import Any, Dict, List, Optional

import httpx

from . import scraper as _scraper
from . import analyzer as _analyzer

log = logging.getLogger(__name__)

DEFAULT_TOP_N = 100
DEFAULT_CONCURRENCY = 10
DEFAULT_NEWS_PER_TICKER = 5
DEFAULT_RATE_LIMIT_SEC = 0.2


def _top_market_cap_tickers(conn: sqlite3.Connection, n: int) -> List[str]:
    rows = conn.execute(
        "SELECT ticker FROM krx_master "
        "WHERE market_cap IS NOT NULL AND is_preferred=0 AND is_spac=0 "
        "ORDER BY market_cap DESC LIMIT ?",
        (n,),
    ).fetchall()
    return [r[0] for r in rows]


def _make_http():
    return httpx.AsyncClient(timeout=10.0, headers=_scraper.NAVER_HEADERS)


def _make_llm():
    """Anthropic AsyncClient — env에 ANTHROPIC_API_KEY 필수."""
    from anthropic import AsyncAnthropic
    return AsyncAnthropic(api_key=os.environ["ANTHROPIC_API_KEY"])


async def _process_one(
    ticker: str, name: str, sem: asyncio.Semaphore,
    http_client, llm, news_per_ticker: int, rate_limit_sec: float, model: str,
) -> Dict[str, Any]:
    async with sem:
        if rate_limit_sec > 0:
            await asyncio.sleep(rate_limit_sec)
        news = await _scraper.fetch_news(http_client, ticker, n=news_per_ticker)
        if not news:
            return {
                "ticker": ticker, "score_raw": 0.0, "reason": "no news",
                "news_count": 0, "tokens_input": 0, "tokens_output": 0,
                "model": model,
            }
        return await _analyzer.score_sentiment(
            llm, ticker, news, name=name, model=model,
        )


def _upsert_news_sentiment(
    conn: sqlite3.Connection, asof: dt.date, rows: List[Dict[str, Any]]
) -> None:
    iso = asof.isoformat()
    data = [
        (
            r["ticker"], iso, r["score_raw"], r["reason"], r["news_count"],
            r["tokens_input"], r["tokens_output"], r["model"],
        )
        for r in rows
    ]
    conn.executemany(
        """INSERT INTO news_sentiment
             (ticker, date, score_raw, reason, news_count,
              tokens_input, tokens_output, model)
           VALUES (?, ?, ?, ?, ?, ?, ?, ?)
           ON CONFLICT(ticker, date) DO UPDATE SET
             score_raw=excluded.score_raw,
             reason=excluded.reason,
             news_count=excluded.news_count,
             tokens_input=excluded.tokens_input,
             tokens_output=excluded.tokens_output,
             model=excluded.model
        """,
        data,
    )
    conn.commit()


async def refresh_daily(
    conn: sqlite3.Connection,
    asof: dt.date,
    *,
    top_n: int = DEFAULT_TOP_N,
    concurrency: int = DEFAULT_CONCURRENCY,
    news_per_ticker: int = DEFAULT_NEWS_PER_TICKER,
    rate_limit_sec: float = DEFAULT_RATE_LIMIT_SEC,
    model: str = _analyzer.DEFAULT_MODEL,
) -> Dict[str, Any]:
    """Returns summary dict with top_pos/top_neg/token totals/failures."""
    started = time.time()
    tickers = _top_market_cap_tickers(conn, n=top_n)
    name_map = {
        r[0]: r[1] for r in conn.execute(
            f"SELECT ticker, name FROM krx_master WHERE ticker IN "
            f"({','.join('?' * len(tickers))})", tickers,
        ).fetchall()
    } if tickers else {}

    sem = asyncio.Semaphore(concurrency)

    async with _make_http() as http_client, _make_llm() as llm:
        tasks = [
            _process_one(
                t, name_map.get(t, t), sem, http_client, llm,
                news_per_ticker, rate_limit_sec, model,
            )
            for t in tickers
        ]
        raw_results = await asyncio.gather(*tasks, return_exceptions=True)

    successes: List[Dict[str, Any]] = []
    failures: List[str] = []
    for r in raw_results:
        if isinstance(r, BaseException):
            failures.append(repr(r))
        elif isinstance(r, dict):
            successes.append(r)

    if successes:
        _upsert_news_sentiment(conn, asof, successes)

    top_pos = sorted(successes, key=lambda r: -r["score_raw"])[:5]
    top_neg = sorted(successes, key=lambda r: r["score_raw"])[:5]

    return {
        "asof": asof.isoformat(),
        "updated": len(successes),
        "failures": failures,
        "duration_sec": round(time.time() - started, 2),
        "tokens_input": sum(r["tokens_input"] for r in successes),
        "tokens_output": sum(r["tokens_output"] for r in successes),
        "top_pos": top_pos,
        "top_neg": top_neg,
        "model": model,
    }
```

- [ ] **Step 4: 테스트 통과 확인**

```bash
python -m pytest tests/test_ai_news_pipeline.py -v
```
Expected: PASS — 3 tests passed.

- [ ] **Step 5: Commit**

```bash
git add app/screener/ai_news/pipeline.py tests/test_ai_news_pipeline.py
git commit -m "feat(screener): ai_news pipeline (top-100 parallel, fail-soft, upsert)"
```

---

### Task 5: `ai_news/telegram.py` — Top 5/5 메시지 빌더 + 테스트

**Files:**
- Create: `web-backend/stock-lab/app/screener/ai_news/telegram.py`
- Test: `web-backend/stock-lab/tests/test_ai_news_telegram.py`

- [ ] **Step 1: 실패하는 테스트 작성**

```python
from app.screener.ai_news import telegram as tg


def _row(ticker, score, reason="r"):
    return {"ticker": ticker, "score_raw": score, "reason": reason,
            "news_count": 5, "tokens_input": 100, "tokens_output": 20,
            "model": "m"}


def test_build_message_includes_top_sections():
    msg = tg.build_message(
        asof="2026-05-13",
        top_pos=[_row("005930", 8.5, "HBM 호재")],
        top_neg=[_row("373220", -6.3, "수주 지연")],
        tokens_input=10000, tokens_output=2000,
    )
    assert "AI 뉴스 분석" in msg
    assert "호재 Top" in msg
    assert "악재 Top" in msg
    assert "005930" in msg
    assert "8.5" in msg
    assert "HBM" in msg
    assert "373220" in msg


def test_build_message_escapes_markdownv2_specials():
    msg = tg.build_message(
        asof="2026-05-13",
        top_pos=[_row("005930", 3.0, "테스트(괄호) [대괄호]")],
        top_neg=[],
        tokens_input=100, tokens_output=20,
    )
    # MarkdownV2 특수문자 ( ) [ ] 이 escape 되어야 함
    assert r"\(" in msg or r"\)" in msg
    assert r"\[" in msg or r"\]" in msg


def test_build_message_cost_won_line():
    msg = tg.build_message(
        asof="2026-05-13", top_pos=[], top_neg=[],
        tokens_input=10000, tokens_output=2000,
    )
    # tokens_input × 0.0013 + tokens_output × 0.0065 = 13 + 13 = ₩26
    assert "₩26" in msg or "₩ 26" in msg or "₩" in msg


def test_build_message_empty_lists():
    msg = tg.build_message(
        asof="2026-05-13", top_pos=[], top_neg=[],
        tokens_input=0, tokens_output=0,
    )
    # 빈 리스트라도 헤더는 있어야 함
    assert "호재 Top" in msg
    assert "악재 Top" in msg
```

- [ ] **Step 2: 테스트 실패 확인**

```bash
python -m pytest tests/test_ai_news_telegram.py -v
```
Expected: FAIL — "No module named 'app.screener.ai_news.telegram'".

- [ ] **Step 3: `telegram.py` 구현**

```python
"""ai_news Top 5/5 텔레그램 메시지 빌더 (MarkdownV2)."""

from __future__ import annotations

from typing import Any, Dict, List


_MD_SPECIAL = r"_*[]()~`>#+-=|{}.!\\"


def _escape(text: str) -> str:
    return "".join("\\" + c if c in _MD_SPECIAL else c for c in str(text))


def _cost_won(tokens_input: int, tokens_output: int) -> int:
    """Claude Haiku 가격 환산 (대략): in $1/M × ₩1300, out $5/M × ₩1300."""
    return int(tokens_input * 0.0013 + tokens_output * 0.0065)


def _row_line(idx: int, r: Dict[str, Any]) -> str:
    score = r["score_raw"]
    sign = "+" if score >= 0 else ""
    return (
        f"{idx}\\. {_escape(r['ticker'])} \\({sign}{score:.1f}\\) — "
        f"{_escape(r['reason'])}"
    )


def build_message(
    *,
    asof: str,
    top_pos: List[Dict[str, Any]],
    top_neg: List[Dict[str, Any]],
    tokens_input: int,
    tokens_output: int,
) -> str:
    lines: List[str] = [
        f"🌅 *AI 뉴스 분석* \\({_escape(asof)} 08:00\\)",
        "",
        "📈 *호재 Top 5*",
    ]
    if top_pos:
        for i, r in enumerate(top_pos, 1):
            lines.append(_row_line(i, r))
    else:
        lines.append(_escape("- (없음)"))

    lines += ["", "📉 *악재 Top 5*"]
    if top_neg:
        for i, r in enumerate(top_neg, 1):
            lines.append(_row_line(i, r))
    else:
        lines.append(_escape("- (없음)"))

    cost = _cost_won(tokens_input, tokens_output)
    lines += [
        "",
        f"_분석: 시총 상위 100종목 · 토큰 {tokens_input:,} in / {tokens_output:,} out · "
        f"약 ₩{cost:,}_",
    ]
    return "\n".join(lines)
```

- [ ] **Step 4: 테스트 통과 확인**

```bash
python -m pytest tests/test_ai_news_telegram.py -v
```
Expected: PASS — 4 tests passed.

- [ ] **Step 5: Commit**

```bash
git add app/screener/ai_news/telegram.py tests/test_ai_news_telegram.py
git commit -m "feat(screener): ai_news telegram message builder (MarkdownV2 + cost line)"
```

---

### Task 6: `nodes/ai_news.py` — ScoreNode 통합 + 테스트

**Files:**
- Create: `web-backend/stock-lab/app/screener/nodes/ai_news.py`
- Test: `web-backend/stock-lab/tests/test_ai_news_node.py`

- [ ] **Step 1: 실패하는 테스트 작성**

```python
import datetime as dt
import pandas as pd
import pytest
from app.screener.nodes.ai_news import AiNewsSentiment


class FakeCtx:
    def __init__(self, df=None):
        self.news_sentiment = df
        self.asof = dt.date(2026, 5, 13)


def test_compute_empty_context():
    out = AiNewsSentiment().compute(FakeCtx(None), {"min_news_count": 1})
    assert out.empty


def test_compute_with_data_percentile_ranks():
    df = pd.DataFrame([
        {"ticker": "A", "score_raw": -5.0, "news_count": 3},
        {"ticker": "B", "score_raw":  0.0, "news_count": 3},
        {"ticker": "C", "score_raw":  8.0, "news_count": 3},
    ])
    out = AiNewsSentiment().compute(FakeCtx(df), {"min_news_count": 1})
    assert len(out) == 3
    # percentile rank: A (lowest) < B < C (highest)
    assert out.loc["A"] < out.loc["B"] < out.loc["C"]
    # all within [0, 100]
    assert (out >= 0).all() and (out <= 100).all()


def test_compute_filters_by_min_news_count():
    df = pd.DataFrame([
        {"ticker": "A", "score_raw": -5.0, "news_count": 0},  # 필터됨
        {"ticker": "B", "score_raw":  0.0, "news_count": 2},
        {"ticker": "C", "score_raw":  8.0, "news_count": 5},
    ])
    out = AiNewsSentiment().compute(FakeCtx(df), {"min_news_count": 1})
    assert "A" not in out.index
    assert "B" in out.index
    assert "C" in out.index


def test_compute_all_filtered_returns_empty():
    df = pd.DataFrame([
        {"ticker": "A", "score_raw": 5.0, "news_count": 0},
    ])
    out = AiNewsSentiment().compute(FakeCtx(df), {"min_news_count": 1})
    assert out.empty


def test_metadata():
    n = AiNewsSentiment()
    assert n.name == "ai_news"
    assert "AI" in n.label or "뉴스" in n.label
    assert n.default_params == {"min_news_count": 1}
    assert "min_news_count" in n.param_schema["properties"]
```

- [ ] **Step 2: 테스트 실패 확인**

```bash
python -m pytest tests/test_ai_news_node.py -v
```
Expected: FAIL — "No module named 'app.screener.nodes.ai_news'".

- [ ] **Step 3: `nodes/ai_news.py` 구현**

```python
"""AI 뉴스 호재/악재 점수 노드.

ScreenContext.news_sentiment (DataFrame: ticker, score_raw, news_count) 를
min_news_count 로 필터한 뒤 percentile_rank 로 0~100 변환.
"""

from __future__ import annotations

import pandas as pd

from .base import ScoreNode, percentile_rank


class AiNewsSentiment(ScoreNode):
    name  = "ai_news"
    label = "AI 뉴스 호재/악재"
    default_params = {"min_news_count": 1}
    param_schema = {
        "type": "object",
        "properties": {
            "min_news_count": {
                "type": "integer", "minimum": 0, "default": 1,
                "description": "최소 분석 뉴스 수. 미만이면 점수 미산출.",
            },
        },
    }

    def compute(self, ctx, params: dict) -> pd.Series:
        df = getattr(ctx, "news_sentiment", None)
        if df is None or df.empty:
            return pd.Series(dtype=float)
        min_news = int(params.get("min_news_count", 1))
        df = df[df["news_count"] >= min_news]
        if df.empty:
            return pd.Series(dtype=float)
        return percentile_rank(df.set_index("ticker")["score_raw"])
```

- [ ] **Step 4: 테스트 통과 확인**

```bash
python -m pytest tests/test_ai_news_node.py -v
```
Expected: PASS — 5 tests passed.

- [ ] **Step 5: Commit**

```bash
git add app/screener/nodes/ai_news.py tests/test_ai_news_node.py
git commit -m "feat(screener): AiNewsSentiment ScoreNode (percentile_rank + min_news_count)"
```

---

### Task 7: `engine.py` ScreenContext + load 갱신

**Files:**
- Modify: `web-backend/stock-lab/app/screener/engine.py`

- [ ] **Step 1: `ScreenContext` dataclass에 `news_sentiment` 필드 추가**

`@dataclass(frozen=True) class ScreenContext:` 본문 마지막 (`asof: dt.date` 다음) 줄에 추가:
```python
    news_sentiment: "pd.DataFrame | None" = None
```

기존 frozen 제약 때문에 default 값을 갖는 필드는 마지막에 와야 함. asof 다음으로 위치.

- [ ] **Step 2: `load` 메서드 안에 news_sentiment 로딩 추가**

`load` 메서드의 `flow = pd.read_sql_query(...)` 다음, KOSPI 처리 이전에 추가:
```python
        news_sentiment = pd.read_sql_query(
            "SELECT ticker, score_raw, news_count FROM news_sentiment WHERE date = ?",
            conn, params=(asof_iso,),
        )
```

`return cls(...)` 호출에 `news_sentiment=news_sentiment` 인자 추가:
```python
        return cls(
            master=master, prices=prices, flow=flow,
            kospi=kospi, asof=asof, news_sentiment=news_sentiment,
        )
```

- [ ] **Step 3: 기존 통합 테스트 회귀 검증**

```bash
cd C:\Users\jaeoh\Desktop\workspace\web-backend\stock-lab
python -m pytest app/test_screener_context.py -v
```
Expected: 기존 테스트 모두 PASS (default=None 으로 호환).

- [ ] **Step 4: Commit**

```bash
git add app/screener/engine.py
git commit -m "feat(screener): ScreenContext.news_sentiment field + load query"
```

---

### Task 8: `registry.py` 에 ai_news 등록

**Files:**
- Modify: `web-backend/stock-lab/app/screener/registry.py`

- [ ] **Step 1: import + NODE_REGISTRY 등록**

상단 import에 추가:
```python
from .nodes.ai_news      import AiNewsSentiment
```

`NODE_REGISTRY` 딕셔너리 마지막 항목 다음에 추가:
```python
    "ai_news":      AiNewsSentiment,
```

전체 결과:
```python
"""Registry of node classes (single source of truth for /nodes endpoint)."""

from .nodes.hygiene      import HygieneGate
from .nodes.foreign_buy  import ForeignBuy
from .nodes.volume_surge import VolumeSurge
from .nodes.momentum     import Momentum20
from .nodes.high52w      import High52WProximity
from .nodes.rs_rating    import RsRating
from .nodes.ma_alignment import MaAlignment
from .nodes.vcp_lite     import VcpLite
from .nodes.ai_news      import AiNewsSentiment

NODE_REGISTRY: dict = {
    "foreign_buy":  ForeignBuy,
    "volume_surge": VolumeSurge,
    "momentum":     Momentum20,
    "high52w":      High52WProximity,
    "rs_rating":    RsRating,
    "ma_alignment": MaAlignment,
    "vcp_lite":     VcpLite,
    "ai_news":      AiNewsSentiment,
}

GATE_REGISTRY: dict = {
    "hygiene": HygieneGate,
}
```

- [ ] **Step 2: sanity import 검증**

```bash
python -c "from app.screener.registry import NODE_REGISTRY; print(list(NODE_REGISTRY))"
```
Expected: `['foreign_buy', 'volume_surge', 'momentum', 'high52w', 'rs_rating', 'ma_alignment', 'vcp_lite', 'ai_news']`

- [ ] **Step 3: Commit**

```bash
git add app/screener/registry.py
git commit -m "feat(screener): register ai_news in NODE_REGISTRY"
```

---

### Task 9: `router.py` 에 `/snapshot/refresh-news-sentiment` 추가

**Files:**
- Modify: `web-backend/stock-lab/app/screener/router.py`

- [ ] **Step 1: 라우트 추가**

`router.py` 파일 마지막에 추가 (다른 라우트들 아래):
```python
# ---------- /snapshot/refresh-news-sentiment ----------

from . import ai_news as _ai_news_pkg


@router.post("/snapshot/refresh-news-sentiment")
async def post_refresh_news_sentiment(asof: Optional[str] = None):
    asof_date = dt.date.fromisoformat(asof) if asof else dt.date.today()
    if asof_date.weekday() >= 5:
        return {"asof": asof_date.isoformat(), "status": "skipped_weekend"}
    if _is_holiday(asof_date):
        return {"asof": asof_date.isoformat(), "status": "skipped_holiday"}
    with _conn() as c:
        summary = await _ai_news_pkg.pipeline.refresh_daily(c, asof_date)
    return summary
```

- [ ] **Step 2: ai_news 패키지가 라우터에서 import 가능한지 검증**

```bash
cd C:\Users\jaeoh\Desktop\workspace\web-backend\stock-lab
python -c "from app.screener.router import router; print([r.path for r in router.routes])"
```
Expected: 출력에 `/api/stock/screener/snapshot/refresh-news-sentiment` 포함.

- [ ] **Step 3: 라우터 테스트 (mock pipeline)**

`tests/test_ai_news_router.py` 생성:
```python
import datetime as dt
from unittest.mock import AsyncMock, patch
from fastapi.testclient import TestClient

from app.main import app


def test_refresh_news_sentiment_weekend_skip():
    # 2026-05-16 = Saturday
    client = TestClient(app)
    resp = client.post(
        "/api/stock/screener/snapshot/refresh-news-sentiment?asof=2026-05-16"
    )
    assert resp.status_code == 200
    assert resp.json()["status"] == "skipped_weekend"


def test_refresh_news_sentiment_weekday_invokes_pipeline():
    fake_summary = {
        "asof": "2026-05-13", "updated": 3, "failures": [],
        "duration_sec": 1.0, "tokens_input": 100, "tokens_output": 20,
        "top_pos": [], "top_neg": [], "model": "m",
    }
    with patch("app.screener.router._ai_news_pkg") as m:
        m.pipeline.refresh_daily = AsyncMock(return_value=fake_summary)
        client = TestClient(app)
        resp = client.post(
            "/api/stock/screener/snapshot/refresh-news-sentiment?asof=2026-05-13"
        )
    assert resp.status_code == 200
    body = resp.json()
    assert body["asof"] == "2026-05-13"
    assert body["updated"] == 3
```

```bash
python -m pytest tests/test_ai_news_router.py -v
```
Expected: PASS — 2 tests passed.

- [ ] **Step 4: Commit**

```bash
git add app/screener/router.py tests/test_ai_news_router.py
git commit -m "feat(screener): POST /snapshot/refresh-news-sentiment endpoint"
```

---

### Task 10: agent-office `service_proxy.refresh_ai_news_sentiment()`

**Files:**
- Modify: `web-backend/agent-office/app/service_proxy.py`

- [ ] **Step 1: helper 함수 추가**

`refresh_screener_snapshot` 함수 정의 직후 (line 44 근처) 추가:
```python
async def refresh_ai_news_sentiment() -> Dict[str, Any]:
    """stock-lab의 AI 뉴스 sentiment 분석 트리거 (08:00 cron).

    네이버 100종목 스크래핑 + Claude Haiku 100콜 병렬 = 약 30-60초.
    여유있게 240s timeout.
    """
    async with httpx.AsyncClient(timeout=240.0) as client:
        resp = await client.post(
            f"{STOCK_LAB_URL}/api/stock/screener/snapshot/refresh-news-sentiment"
        )
        resp.raise_for_status()
        return resp.json()
```

- [ ] **Step 2: import 검증**

```bash
cd C:\Users\jaeoh\Desktop\workspace\web-backend\agent-office
python -c "from app.service_proxy import refresh_ai_news_sentiment; print('ok')"
```
Expected: `ok`.

- [ ] **Step 3: Commit**

```bash
git add app/service_proxy.py
git commit -m "feat(agent-office): refresh_ai_news_sentiment service helper"
```

---

### Task 11: agent-office `StockAgent.on_ai_news_schedule`

**Files:**
- Modify: `web-backend/agent-office/app/agents/stock.py`

- [ ] **Step 1: 메서드 추가**

`on_command` 메서드 정의 직전에 다음 메서드 추가:
```python
    async def on_ai_news_schedule(self) -> None:
        """AI 뉴스 sentiment 분석 자동 잡 (평일 08:00 KST).

        흐름:
          1) stock-lab /snapshot/refresh-news-sentiment 호출
          2) status='skipped_weekend'/'skipped_holiday' → 종료 (텔레그램 미발신)
          3) updated=0 → 운영자 알림 (HTML)
          4) failures > 30% → 경고 알림 후 메인 메시지 발송
          5) 정상 → Top 5 호재/악재 메시지 발송 (MarkdownV2)
        """
        if self.state not in ("idle", "break"):
            return

        task_id = create_task(self.agent_id, "ai_news_sentiment", {})
        await self.transition("working", "AI 뉴스 분석 중...", task_id)

        try:
            result = await service_proxy.refresh_ai_news_sentiment()
        except Exception as e:
            err_msg = str(e)
            add_log(self.agent_id, f"AI 뉴스 분석 실패: {err_msg}", "error", task_id)
            update_task_status(task_id, "failed", {"error": err_msg})
            try:
                from ..telegram.messaging import send_raw
                await send_raw(
                    f"⚠️ <b>AI 뉴스 분석 실패</b>\n"
                    f"<code>{html.escape(err_msg)[:500]}</code>"
                )
            except Exception as notify_err:
                add_log(
                    self.agent_id,
                    f"operator notify failed: {notify_err}",
                    "warning", task_id,
                )
            await self.transition("idle", f"AI 뉴스 오류: {err_msg[:80]}")
            return

        status = result.get("status")
        if status in ("skipped_weekend", "skipped_holiday"):
            update_task_status(task_id, "succeeded", {"status": status})
            add_log(self.agent_id, f"AI 뉴스 건너뜀: {status}", "info", task_id)
            await self.transition("idle", f"휴일/주말 — 건너뜀")
            return

        updated = int(result.get("updated", 0))
        failures = result.get("failures", []) or []
        if updated == 0:
            update_task_status(task_id, "failed", {"reason": "0 tickers updated"})
            try:
                from ..telegram.messaging import send_raw
                await send_raw(
                    "⚠️ <b>AI 뉴스 분석 0종목</b>\n"
                    "스크래핑/LLM 전체 실패 — 어제 데이터 사용"
                )
            except Exception:
                pass
            await self.transition("idle", "AI 뉴스 0건")
            return

        # 실패율 경고 (별도 알림, 본 메시지는 계속 발송)
        failure_rate = len(failures) / max(1, updated + len(failures))
        if failure_rate > 0.3:
            try:
                from ..telegram.messaging import send_raw
                await send_raw(
                    f"⚠️ <b>AI 뉴스 실패율 {failure_rate:.0%}</b>\n"
                    f"updated={updated}, failures={len(failures)}"
                )
            except Exception:
                pass

        # 정상 — Top 5 메시지 (stock-lab이 빌드해서 응답에 telegram_text 동봉)
        text = result.get("telegram_text") or ""
        if not text:
            raise RuntimeError("telegram_text 누락")

        await self.transition("reporting", "AI 뉴스 알림 전송 중...")
        from ..telegram.messaging import send_raw
        tg = await send_raw(text, parse_mode="MarkdownV2")

        update_task_status(task_id, "succeeded", {
            "asof": result["asof"],
            "updated": updated,
            "failures": len(failures),
            "tokens_input": int(result.get("tokens_input", 0)),
            "tokens_output": int(result.get("tokens_output", 0)),
            "telegram_sent": tg.get("ok", False),
        })

        if not tg.get("ok"):
            desc = tg.get("description") or "unknown"
            code = tg.get("error_code")
            add_log(
                self.agent_id,
                f"AI news telegram send failed: [{code}] {desc}",
                "warning", task_id,
            )

        await self.transition("idle", "AI 뉴스 완료")
```

**설계 결정**: agent-office는 stock-lab의 모듈을 직접 import 불가 (별도 컨테이너). stock-lab API 응답에 `telegram_text` 필드를 동봉하여 agent-office는 그대로 전달만 한다.

- [ ] **Step 2: stock-lab 라우터가 `telegram_text` 를 응답에 동봉하도록 수정**

`web-backend/stock-lab/app/screener/router.py` 의 `post_refresh_news_sentiment` 수정:
```python
@router.post("/snapshot/refresh-news-sentiment")
async def post_refresh_news_sentiment(asof: Optional[str] = None):
    asof_date = dt.date.fromisoformat(asof) if asof else dt.date.today()
    if asof_date.weekday() >= 5:
        return {"asof": asof_date.isoformat(), "status": "skipped_weekend"}
    if _is_holiday(asof_date):
        return {"asof": asof_date.isoformat(), "status": "skipped_holiday"}
    with _conn() as c:
        summary = await _ai_news_pkg.pipeline.refresh_daily(c, asof_date)
    # 텔레그램 텍스트 동봉
    summary["telegram_text"] = _ai_news_pkg.telegram.build_message(
        asof=summary["asof"],
        top_pos=summary["top_pos"], top_neg=summary["top_neg"],
        tokens_input=summary["tokens_input"],
        tokens_output=summary["tokens_output"],
    )
    return summary
```

agent-office `on_ai_news_schedule` 의 step 1 코드에서 `text = build_message(...)` 부분을 `text = result["telegram_text"]` 로 변경:
```python
        # 정상 — Top 5 메시지 (stock-lab이 빌드해서 응답에 동봉)
        text = result.get("telegram_text") or ""
        if not text:
            raise RuntimeError("telegram_text 누락")
```

- [ ] **Step 3: import 검증**

```bash
cd C:\Users\jaeoh\Desktop\workspace\web-backend\agent-office
python -c "from app.agents.stock import StockAgent; print(hasattr(StockAgent, 'on_ai_news_schedule'))"
```
Expected: `True`.

- [ ] **Step 4: Task 9 라우터 테스트 회귀 (telegram_text 응답 검증)**

`tests/test_ai_news_router.py` 의 `test_refresh_news_sentiment_weekday_invokes_pipeline` 보강:
```python
def test_refresh_news_sentiment_weekday_invokes_pipeline():
    fake_summary = {
        "asof": "2026-05-13", "updated": 3, "failures": [],
        "duration_sec": 1.0, "tokens_input": 100, "tokens_output": 20,
        "top_pos": [], "top_neg": [], "model": "m",
    }
    with patch("app.screener.router._ai_news_pkg") as m:
        m.pipeline.refresh_daily = AsyncMock(return_value=fake_summary)
        m.telegram.build_message = lambda **kw: "BUILT_TEXT"
        client = TestClient(app)
        resp = client.post(
            "/api/stock/screener/snapshot/refresh-news-sentiment?asof=2026-05-13"
        )
    assert resp.status_code == 200
    body = resp.json()
    assert body["telegram_text"] == "BUILT_TEXT"
```

```bash
cd C:\Users\jaeoh\Desktop\workspace\web-backend\stock-lab
python -m pytest tests/test_ai_news_router.py -v
```
Expected: PASS.

- [ ] **Step 5: Commit (양쪽 repo)**

```bash
# stock-lab
cd C:\Users\jaeoh\Desktop\workspace\web-backend\stock-lab
git add app/screener/router.py tests/test_ai_news_router.py
git commit -m "feat(screener): include telegram_text in refresh-news-sentiment response"

# agent-office
cd C:\Users\jaeoh\Desktop\workspace\web-backend\agent-office
git add app/agents/stock.py
git commit -m "feat(agent-office): on_ai_news_schedule (cron handler + telegram dispatch)"
```

**주의**: 본 task부터 양쪽 디렉토리 모두 변경되므로 web-backend monorepo 안에서 작업한다 (`cd` 명령으로 sub-package 사이 이동).

---

### Task 12: scheduler.py cron 등록

**Files:**
- Modify: `web-backend/agent-office/app/scheduler.py`

- [ ] **Step 1: 기존 scheduler.py 의 on_screener_schedule 등록 패턴 확인**

```bash
cd C:\Users\jaeoh\Desktop\workspace\web-backend\agent-office
grep -nE "on_screener_schedule|add_job.*stock" app/scheduler.py
```

기존 등록 라인의 변수 이름 (stock_agent / agents["stock"] 등), 들여쓰기, id 명명 컨벤션을 그대로 모방. 예상 패턴:
```python
scheduler.add_job(
    stock_agent.on_screener_schedule,
    "cron", day_of_week="mon-fri", hour=16, minute=30,
    id="stock_screener", timezone="Asia/Seoul",
    replace_existing=True,
)
```

- [ ] **Step 2: 동일 패턴으로 ai_news cron 등록 추가**

기존 `on_screener_schedule` 등록 라인 바로 다음에 추가:
```python
scheduler.add_job(
    stock_agent.on_ai_news_schedule,
    "cron", day_of_week="mon-fri", hour=8, minute=0,
    id="stock_ai_news_sentiment",
    timezone="Asia/Seoul",
    replace_existing=True,
)
```

변수 참조는 기존 라인과 정확히 동일하게 (`stock_agent` 또는 `agents["stock"]`).

- [ ] **Step 3: scheduler import 검증**

```bash
cd C:\Users\jaeoh\Desktop\workspace\web-backend\agent-office
python -c "from app.scheduler import scheduler; print([j.id for j in scheduler.get_jobs()])"
```
Expected: 출력에 `stock_ai_news_sentiment` 포함.

(주의: scheduler를 실제 start하지 않고 단순 import 시 빈 리스트일 수 있음. 그 경우 코드 시각 검증으로 대체.)

- [ ] **Step 4: Commit**

```bash
git add app/scheduler.py
git commit -m "feat(agent-office): cron mon-fri 08:00 ai_news sentiment job"
```

---

### Task 13: frontend canvasLayout.js — AI 노드 추가

**Files:**
- Modify: `web-ui/src/pages/stock/screener/components/canvas/constants/canvasLayout.js`
- Modify: `web-ui/src/pages/stock/screener/components/canvas/constants/canvasLayout.test.js`

- [ ] **Step 1: 테스트 먼저 갱신 (실패 유도)**

`canvasLayout.test.js` 의 다음 항목들 갱신:
```javascript
  it('NODE_IDS — 12개 키, 모두 unique', () => {
    const ids = Object.values(NODE_IDS);
    expect(ids).toHaveLength(12);    // 11 → 12
    expect(new Set(ids).size).toBe(12);
  });

  it('EDGES — 18개, source/target이 모두 NODE_IDS 안에 존재', () => {
    expect(EDGES).toHaveLength(18);  // 16 → 18 (1+8+8+1)
    ...
  });

  it('EDGES — 8개 점수 노드는 모두 gate 입력 + combine 출력을 가짐', () => {
    const SCORE_IDS = [
      NODE_IDS.FOREIGN, NODE_IDS.VOLUME, NODE_IDS.MOMENTUM,
      NODE_IDS.HIGH52W, NODE_IDS.RS, NODE_IDS.MA, NODE_IDS.VCP,
      NODE_IDS.AI_NEWS,                  // 추가
    ];
    ...
  });

  it('SCORE_NODE_NAME_MAP — 8개 점수 노드 ID → backend node name', () => {
    expect(Object.keys(SCORE_NODE_NAME_MAP)).toHaveLength(8);  // 7 → 8
    expect(SCORE_NODE_NAME_MAP[NODE_IDS.FOREIGN]).toBe('foreign_buy');
    expect(SCORE_NODE_NAME_MAP[NODE_IDS.VOLUME]).toBe('volume_surge');
    expect(SCORE_NODE_NAME_MAP[NODE_IDS.AI_NEWS]).toBe('ai_news');  // 추가
  });
```

- [ ] **Step 2: 테스트 실패 확인**

```bash
cd C:\Users\jaeoh\Desktop\workspace\web-ui
npx vitest run src/pages/stock/screener/components/canvas/constants/canvasLayout.test.js
```
Expected: FAIL — NODE_IDS.AI_NEWS undefined 등.

- [ ] **Step 3: `canvasLayout.js` 갱신**

`NODE_IDS` 객체에 추가 (COMBINE 직전):
```javascript
  AI_NEWS:  'score-ai-news',
```

`NODE_KIND_MAP` 에 추가:
```javascript
  [NODE_IDS.AI_NEWS]:  'score',
```

`SCORE_NODE_NAME_MAP` 에 추가:
```javascript
  [NODE_IDS.AI_NEWS]:  'ai_news',
```

`INITIAL_NODE_POSITIONS` 에 추가 (VCP 다음 y=630):
```javascript
  [NODE_IDS.AI_NEWS]:  { x:  480, y: 630 },
```

`SCORE_KEYS` 배열 끝에 추가:
```javascript
const SCORE_KEYS = ['FOREIGN','VOLUME','MOMENTUM','HIGH52W','RS','MA','VCP','AI_NEWS'];
```

`SCORE_NODE_LABEL` 에 추가:
```javascript
  [NODE_IDS.AI_NEWS]:  { icon: '🤖', title: 'AI 뉴스' },
```

- [ ] **Step 4: 테스트 통과 확인**

```bash
npx vitest run src/pages/stock/screener/components/canvas/constants/canvasLayout.test.js
```
Expected: PASS — 6 tests passed (모두 8 score / 12 nodes / 18 edges 기준).

- [ ] **Step 5: 전체 frontend 테스트 회귀**

```bash
npx vitest run
```
Expected: 4 files, 20 tests pass (canvasLayout 외 변경 없음).

- [ ] **Step 6: Commit**

```bash
git add src/pages/stock/screener/components/canvas/constants/canvasLayout.js src/pages/stock/screener/components/canvas/constants/canvasLayout.test.js
git commit -m "feat(screener): canvas adds AI news node (12 nodes, 18 edges)"
```

---

### Task 14: 전체 백엔드 테스트 + 프론트 빌드

**Files:**
- (테스트/빌드만 실행)

- [ ] **Step 1: stock-lab 전체 테스트**

```bash
cd C:\Users\jaeoh\Desktop\workspace\web-backend\stock-lab
python -m pytest -v
```
Expected: 모든 신규 테스트 통과 + 기존 71개 테스트 회귀 없음.

- [ ] **Step 2: agent-office 전체 테스트**

```bash
cd C:\Users\jaeoh\Desktop\workspace\web-backend\agent-office
python -m pytest -v 2>&1 | tail -20
```
Expected: 기존 테스트 회귀 없음. on_ai_news_schedule 메서드 추가 단순이라 기존 테스트와 충돌 없음.

- [ ] **Step 3: frontend 빌드**

```bash
cd C:\Users\jaeoh\Desktop\workspace\web-ui
npm run build
```
Expected: 빌드 성공. `CanvasLayout-*.js` 청크 크기 거의 변화 없음 (canvasLayout.js +30줄 정도).

- [ ] **Step 4: frontend lint (회귀)**

```bash
npx eslint src/pages/stock/screener/components/canvas/constants/canvasLayout.js
```
Expected: 0 errors.

---

### Task 15: 수동 검증 + 배포

**Files:**
- (실행만, 수동 검증)

- [ ] **Step 1: backend push (Gitea 자격증명 필요 — 사용자 수동)**

```bash
cd C:\Users\jaeoh\Desktop\workspace\web-backend
git push origin main
```
실패 시: 사용자에게 자격증명 입력 요청. push 성공 시 Gitea webhook → deployer rsync → docker compose build 자동.

- [ ] **Step 2: NAS docker 컨테이너 갱신 확인 + 환경변수 점검**

NAS 콘솔에서:
```bash
docker logs stock-lab --tail 30
docker logs agent-office --tail 30
```
- stock-lab 환경에 `ANTHROPIC_API_KEY` 가 비어있지 않은지 확인 (`docker-compose.yml`의 stock-lab 환경변수 line 40)
- agent-office 의 scheduler 시작 시 `stock_ai_news_sentiment` job이 등록되었는지 로그 확인

- [ ] **Step 3: 수동 cron 트리거 (운영자 권한)**

agent-office API를 통한 수동 실행 (테스트):
```bash
curl -X POST "https://gahusb.synology.me/api/agent-office/command" \
  -H "Content-Type: application/json" \
  -d '{"agent_id":"stock","command":"run_ai_news"}'
```

(참고: `run_ai_news` 명령이 `on_command` 에서 지원되지 않으면 직접 cron 호출이 필요. 본 plan은 `on_command` 분기 추가까지는 포함하지 않으므로, NAS에서 다음으로 검증:)
```bash
docker exec agent-office python -c "
import asyncio
from app.agents.stock import StockAgent
from app.scheduler import stock_agent
asyncio.run(stock_agent.on_ai_news_schedule())
"
```

- [ ] **Step 4: 데이터 검증**

```bash
docker exec stock-lab sqlite3 /app/data/stock.db "
  SELECT ticker, score_raw, news_count, model FROM news_sentiment
  WHERE date = date('now') ORDER BY score_raw DESC LIMIT 10;
"
```
Expected: 약 100 종목 행 생성, score_raw 분포가 -10~+10 사이.

- [ ] **Step 5: 텔레그램 메시지 확인**

08:00 KST 가 되거나 step 3 수동 트리거 후 텔레그램에서 메시지 수신 확인. 형식:
- 🌅 AI 뉴스 분석 (YYYY-MM-DD 08:00)
- 📈 호재 Top 5
- 📉 악재 Top 5
- 토큰/비용 라인

- [ ] **Step 6: 16:30 스크리너 잡 다음 실행 시 ai_news 가중합 반영 확인**

스크리너 실행 후 텔레그램 결과 또는:
```bash
docker exec stock-lab sqlite3 /app/data/stock.db "
  SELECT rank, ticker, name, total_score, scores_json
  FROM screener_results sr JOIN screener_runs r ON sr.run_id=r.id
  WHERE r.asof=date('now') ORDER BY rank LIMIT 5;
"
```
`scores_json` 의 `ai_news` 키가 0~100 점수로 존재하는지 확인.

- [ ] **Step 7: 프론트 캔버스에 8번째 노드 표시 확인**

```bash
cd C:\Users\jaeoh\Desktop\workspace\web-ui
npm run release:nas
```
NAS 배포 후 https://gahusb.synology.me/stock/screener 캔버스 모드 진입:
- 🤖 AI 뉴스 노드가 8번째 score 노드로 표시
- gate → AI 뉴스 → combine 엣지가 그려짐
- weight=0 토글 시 흐릿 + 점선 엣지

- [ ] **Step 8: web-ui CLAUDE.md 갱신**

`Stock Screener` 행 또는 spec 인덱스에 AI 뉴스 노드 추가 (선택):
```bash
git add CLAUDE.md
git commit -m "docs(screener): note ai_news 8th score node"
git push origin main
```

- [ ] **Step 9: 메모리 업데이트 (controller 작업)**

`project_stock_screener.md` 갱신:
- ai_news 노드 완료 commit SHA 추가
- 후속 슬라이스 §14 에서 "AI 뉴스 호재/악재 노드" 제거 → 남은 8개로 갱신

---

## 완료 후 검증 체크리스트

본 plan 완료 시:
- [ ] backend: stock-lab 신규 5개 모듈 + 5개 테스트 파일 모두 PASS
- [ ] backend: agent-office on_ai_news_schedule + scheduler cron 등록
- [ ] frontend: 캔버스 모드에 🤖 AI 뉴스 노드 표시, 가중치 슬라이더 동작
- [ ] DB: news_sentiment 테이블 일별 100 종목 행 생성
- [ ] 텔레그램: 평일 08:00 KST Top 5/5 메시지 자동 발송
- [ ] 16:30 스크리너: ai_news 점수가 가중합에 반영 (scores_json 안)
- [ ] LLM 비용: ~$0.075/일 (텔레그램 메시지 하단 ₩ 표시)
- [ ] fail-soft: 한 종목 LLM 실패해도 전체 잡 성공
- [ ] localStorage / cron / DB 마이그레이션 모두 idempotent (재실행 안전)

## 후속 슬라이스 (이번 plan 완료 후)

spec §18에 명시된 8개:
1. URL 단위 캐싱 (비용 ~70% 절감)
2. 16:00 추가 sentiment cron
3. sentiment 트렌드 차트
4. 시총 200~500 확장
5. 백테스트
6. 다국어/거시 뉴스
7. 텔레그램 알림 토글
8. 종목별 sentiment 페이지
