Files
web-page/docs/superpowers/specs/2026-05-13-ai-news-sentiment-node-design.md
gahusb 5e66d96c61 docs(screener): AI news sentiment node design spec
8번째 점수 노드 ai_news 설계. 평일 08:00 KST agent-office cron 으로
시총 상위 100종목 네이버 뉴스 스크래핑 + Claude Haiku 호재/악재 분석,
news_sentiment 일별 저장, 호재/악재 Top 5 텔레그램 알림, 16:30
스크리너 잡이 percentile_rank 로 가중합에 활용.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:09:45 +09:00

559 lines
24 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.
# AI News Sentiment Node — Design
**작성일**: 2026-05-13
**작성자**: gahusb
**상태**: Approved for implementation
**선행 spec**: `2026-05-12-stock-screener-board-design.md` (§14 — AI 뉴스 호재/악재 노드 후속 슬라이스)
---
## 1. 목표
스크리너의 8번째 점수 노드 `AiNewsSentiment` 를 추가한다. 평일 **08:00 KST** 에 시총 상위 100종목의 네이버 종목 뉴스를 스크래핑하고 Claude Haiku로 호재/악재를 정량화하여, 그날의 sentiment를 (a) 텔레그램으로 호재/악재 Top 5 알림으로 전달하고, (b) 16:30 스크리너 자동 잡의 가중합에 percentile_rank 형태로 기여한다.
**Why**: 기존 7개 점수 노드는 모두 수치 기반(가격/거래량/외국인 수급)이며, 시장 정서(뉴스 호재/악재)는 반영되지 않는다. 트레이더 의사결정에 큰 영향을 주는 호재/악재 시그널을 LLM으로 정량화하면 정량 노드와 정성 노드를 한 점수 체계로 통합할 수 있다. 장 시작 전 알림으로 즉시 가치 전달.
---
## 2. 범위
**포함 (이번 슬라이스)**:
- 평일 08:00 KST agent-office cron → stock-lab `/snapshot/refresh-news-sentiment` 호출
- 시총 상위 100종목 × 네이버 종목 뉴스 (`/item/news_news.naver?code=XXX`) 스크래핑
- 종목당 Claude Haiku 1콜 (총 100콜 asyncio.gather 병렬, 동시성 10)
- `news_sentiment(ticker, date, score_raw, reason, news_count, tokens_input, tokens_output, model, created_at)` 테이블
- 8번째 ScoreNode `AiNewsSentiment` 등록 (registry, schema, ScreenContext, 가중합 통합)
- 호재 Top 5 + 악재 Top 5 텔레그램 메시지 (장 시작 전 발송)
- 프론트 캔버스 모드에 8번째 노드 추가 (`SCORE_KEYS` 한 줄 + `INITIAL_NODE_POSITIONS` 좌표 한 줄)
**범위 외 (NOT)**:
- 뉴스 URL 단위 캐싱 (비용이 충분히 낮음)
- 16:00 추가 cron (MVP 일 1회)
- 시장 전체 뉴스 종목 매핑 LLM (시총 상위 100 명시적 매핑)
- 백테스트 (sentiment 점수가 실수익에 미친 영향) — 별도 후속 슬라이스
- 가중치 자동 조정 — spec §14 별도 슬라이스
- 종목별 sentiment 트렌드 차트 — 데이터 누적 후 후속 슬라이스
- 종목 5-10위 외 sentiment 가시화 — Top 5 알림 외 별도 대시보드 없음
---
## 3. 아키텍처 개요
```
┌─────────────────────────────┐
[08:00 KST 평일] │ agent-office cron │
│ on_ai_news_schedule() │
└──────────────┬──────────────┘
│ HTTP POST
┌──────────────────────────────────────────────────────┐
│ stock-lab: /api/stock/screener/snapshot/ │
│ refresh-news-sentiment │
│ │
│ ai_news/pipeline.refresh_daily(asof): │
│ 1. krx_master 시총 상위 100 ticker 조회 │
│ 2. asyncio.gather(Semaphore=10) 100 종목 병렬: │
│ a. scraper.fetch_news(ticker) │
│ b. analyzer.score_sentiment(ticker, news[]) │
│ c. → {score: float, reason: str, ...} │
│ 3. news_sentiment 일괄 upsert │
│ 4. Top 5 호재/악재 추출 → 텔레그램 페이로드 빌드 │
│ 5. agent-office /telegram/send 호출 │
└──────────────────────────────────────────────────────┘
[16:30 KST 평일] ┌─────────────────────────────┐
│ agent-office on_screener_ │
│ schedule (변경 없음) │
└──────────────┬──────────────┘
│ HTTP POST
┌──────────────────────────────────────────────────────┐
│ stock-lab: /api/stock/screener/run mode=auto │
│ │
│ Screener.run(ctx): │
│ ctx.news_sentiment = SELECT * FROM news_sentiment │
│ WHERE date = asof │
│ AiNewsSentiment.compute(ctx, params) │
│ → percentile_rank(score_raw) for 100 tickers │
│ → 가중합에 ai_news weight × percentile 점수 기여 │
└──────────────────────────────────────────────────────┘
```
**의존성 추가**: `anthropic` Python SDK (stock-lab requirements.txt). `ANTHROPIC_API_KEY` 는 docker-compose.yml에 이미 stock-lab 환경변수로 존재.
---
## 4. 컴포넌트 분해 (신규 파일)
### 4.1 stock-lab
```
web-backend/stock-lab/app/
screener/
ai_news/ ← 신규 모듈
__init__.py
scraper.py ← 네이버 finance 종목 뉴스 스크래핑
analyzer.py ← Claude Haiku 호재/악재 분석
pipeline.py ← refresh_daily() 메인 (스크래핑+병렬 LLM+DB upsert)
telegram.py ← Top 5/Top 5 메시지 빌더
nodes/
ai_news.py ← 8번째 ScoreNode 클래스
schema.py ← (수정) news_sentiment 테이블 DDL 추가
registry.py ← (수정) NODE_REGISTRY["ai_news"] 등록
engine.py ← (수정) ScreenContext에 news_sentiment 로딩
router.py ← (수정) POST /snapshot/refresh-news-sentiment 라우트 추가
requirements.txt ← (수정) anthropic 추가
tests/
test_ai_news_scraper.py ← 네이버 HTML mock 파싱
test_ai_news_analyzer.py ← anthropic mock 응답
test_ai_news_pipeline.py ← 5종목 mini integration
test_ai_news_node.py ← percentile_rank + min_news_count 필터
```
### 4.2 agent-office
```
web-backend/agent-office/app/
agents/stock.py ← (수정) on_ai_news_schedule 메서드 추가
scheduler.py ← (수정) cron mon-fri 08:00 등록
service_proxy.py ← (수정) refresh_ai_news_sentiment() helper 추가
```
### 4.3 frontend
```
web-ui/src/pages/stock/screener/
components/canvas/constants/
canvasLayout.js ← (수정) AI 노드 추가 (NODE_IDS / NAME_MAP / LABEL / POSITIONS / SCORE_KEYS)
canvasLayout.test.js ← (수정) 카운트 8 점수 노드, 18 엣지로 갱신
```
---
## 5. DB 스키마 (1개 신규 테이블)
```sql
CREATE TABLE IF NOT EXISTS news_sentiment (
ticker TEXT NOT NULL,
date TEXT NOT NULL, -- YYYY-MM-DD
score_raw REAL NOT NULL, -- LLM 원점수 -10 ~ +10
reason TEXT NOT NULL DEFAULT '', -- LLM 한 줄 근거
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);
```
`schema.py``ensure_screener_schema(conn)` 함수에 이 DDL 추가. WAL + busy_timeout 패턴은 stock-lab `_conn()` 표준화로 이미 적용됨.
**기본 가중치 시드**: `DEFAULT_WEIGHTS["ai_news"] = 0.5` 추가 (다른 7노드의 default와 동일). 기존 settings 행이 있는 환경에서는 마이그레이션 1회 — `ensure_screener_schema()` 가 settings의 weights_json에 ai_news 키 누락 시 0.5로 보충하는 1회성 patch 적용.
---
## 6. ScoreNode 구현
```python
# stock-lab/app/screener/nodes/ai_news.py
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", "default": 1, "minimum": 0,
"description": "최소 분석 뉴스 수. 미만이면 NaN.",
},
},
}
def compute(self, ctx, params):
df = getattr(ctx, "news_sentiment", None)
if df is None or df.empty:
return pd.Series(dtype=float)
df = df[df["news_count"] >= params["min_news_count"]]
if df.empty:
return pd.Series(dtype=float)
return percentile_rank(df.set_index("ticker")["score_raw"])
```
`ScreenContext` dataclass에 `news_sentiment: Optional[pd.DataFrame] = None` 필드 추가 (default None 으로 기존 호출자 호환성 유지). `ScreenContext.load(conn, asof)` 에 로딩 한 줄 추가:
```python
news_sentiment = pd.read_sql_query(
"SELECT ticker, score_raw, news_count FROM news_sentiment WHERE date = ?",
conn, params=(asof.isoformat(),),
)
return ScreenContext(..., news_sentiment=news_sentiment)
```
기존 테스트 fixture에서 `ScreenContext(...)` 를 직접 생성하는 케이스는 default=None 으로 자동 호환. AiNewsSentiment.compute() 는 `getattr(ctx, "news_sentiment", None)` 로 안전 fallback.
---
## 7. 파이프라인 (`ai_news/pipeline.py`)
```python
async def refresh_daily(conn, asof, *, tickers=None, model=DEFAULT_MODEL,
concurrency=10, news_per_ticker=5):
"""
Returns:
{"asof": ..., "updated": N, "failures": [...], "duration_sec": ...,
"tokens_input": ..., "tokens_output": ..., "top_pos": [...], "top_neg": [...]}
"""
if tickers is None:
tickers = _top_market_cap_tickers(conn, n=100)
sem = asyncio.Semaphore(concurrency)
async with httpx.AsyncClient(...) as http_client, AsyncAnthropic(...) as llm:
tasks = [_process_ticker(t, sem, http_client, llm, news_per_ticker, model)
for t in tickers]
results = await asyncio.gather(*tasks, return_exceptions=True)
successes = [r for r in results if isinstance(r, dict)]
failures = [r for r in results if isinstance(r, BaseException)]
_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": [str(e) for e in failures],
"duration_sec": ...,
"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,
}
async def _process_ticker(ticker, sem, http_client, llm, news_per_ticker, model):
async with sem:
await asyncio.sleep(0.2) # rate limit
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}
return await analyzer.score_sentiment(llm, ticker, news, model=model)
```
---
## 8. Scraper (`ai_news/scraper.py`)
```python
NAVER_NEWS_URL = "https://finance.naver.com/item/news_news.naver"
async def fetch_news(client, ticker, n=5):
r = await client.get(NAVER_NEWS_URL, params={"code": ticker, "page": 1})
if r.status_code != 200:
return []
soup = BeautifulSoup(r.text, "lxml")
rows = soup.select("table.type5 tbody tr")[:n]
out = []
for row in rows:
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
```
Rate limit: pipeline 의 `Semaphore(10) + 0.2초 sleep` 으로 보호.
---
## 9. Analyzer (`ai_news/analyzer.py`)
```python
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>"}}"""
async def score_sentiment(llm, ticker, news, *, model=DEFAULT_MODEL, name=None):
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}],
)
try:
text = resp.content[0].text
data = json.loads(text)
return {
"ticker": ticker,
"score_raw": float(data["score"]),
"reason": str(data["reason"])[:200],
"news_count": len(news),
"tokens_input": resp.usage.input_tokens,
"tokens_output": resp.usage.output_tokens,
}
except (json.JSONDecodeError, KeyError, ValueError) as e:
log.warning("ai_news parse fail for %s: %s", ticker, e)
return {
"ticker": ticker, "score_raw": 0.0,
"reason": f"parse fail: {e!s}",
"news_count": len(news),
"tokens_input": resp.usage.input_tokens,
"tokens_output": resp.usage.output_tokens,
}
```
---
## 10. 텔레그램 메시지 (`ai_news/telegram.py`)
```python
def build_telegram_payload(*, asof, top_pos, top_neg,
tokens_input, tokens_output, model):
cost_won = int(tokens_input * 0.0013 + tokens_output * 0.0065) # ₩ 환산
lines = [
f"🌅 *AI 뉴스 분석* ({asof} 08:00)",
"",
"📈 *호재 Top 5*",
]
for i, r in enumerate(top_pos, 1):
lines.append(
f"{i}\\. {_escape(r['ticker'])} \\({r['score_raw']:+.1f}\\) — "
f"{_escape(r['reason'])}"
)
lines += ["", "📉 *악재 Top 5*"]
for i, r in enumerate(top_neg, 1):
lines.append(
f"{i}\\. {_escape(r['ticker'])} \\({r['score_raw']:+.1f}\\) — "
f"{_escape(r['reason'])}"
)
lines += [
"",
f"_분석: 시총 상위 100종목 · 토큰 {tokens_input:,} in / {tokens_output:,} out · "
f"약 ₩{cost_won:,}_",
]
return "\n".join(lines)
```
agent-office 가 텔레그램 발송 책임: stock-lab `/refresh-news-sentiment` 응답을 받아 `messaging.send_raw(text, parse_mode="MarkdownV2")` 호출.
---
## 11. agent-office 통합
### 11.1 `agents/stock.py`
```python
async def on_ai_news_schedule(self):
"""평일 08:00 KST cron."""
try:
result = await service_proxy.refresh_ai_news_sentiment()
except httpx.HTTPError as e:
await self.telegram.send_raw(f"⚠️ AI 뉴스 분석 실패: {e!s}")
return
if result.get("updated", 0) == 0:
await self.telegram.send_raw("⚠️ AI 뉴스: 0종목 분석됨 (스크래핑/LLM 전체 실패)")
return
failure_rate = len(result.get("failures", [])) / 100
if failure_rate > 0.3:
await self.telegram.send_raw(
f"⚠️ AI 뉴스 실패율 {failure_rate:.0%} — 어제 데이터 사용 가능성"
)
payload = build_telegram_payload(
asof=result["asof"],
top_pos=result["top_pos"], top_neg=result["top_neg"],
tokens_input=result["tokens_input"],
tokens_output=result["tokens_output"],
model=DEFAULT_MODEL,
)
await self.telegram.send_raw(payload, parse_mode="MarkdownV2")
```
### 11.2 `scheduler.py`
```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",
)
```
---
## 12. 에러 처리
| 상황 | 처리 |
|------|------|
| 네이버 뉴스 페이지 404/타임아웃 | 해당 종목 score_raw=0 + reason="no news", failures 별도 카운트 |
| BeautifulSoup 파싱 실패 (HTML 구조 변경) | 동일 처리 (failures 카운트) |
| LLM JSON 파싱 실패 | score_raw=0 + reason="parse fail", tokens는 그래도 누적 (실제 호출됨) |
| anthropic API 5xx | 자동 retry 1회 (SDK 기본), 실패 시 failures 카운트 |
| 전체 cron 실패 (네트워크 등) | agent-office 에러 텔레그램 + 16:30 잡은 어제 sentiment 데이터 사용 (date 비교로 자동) |
| 실패율 > 30% | 텔레그램 경고 알림. 단 부분 데이터는 그대로 DB 반영 |
| 16:30 시점 news_sentiment 비어 있음 | AiNewsSentiment.compute() 가 빈 Series 반환 → 가중합에서 이 노드 자동 제외 |
| LLM이 -10/+10 범위 벗어난 값 응답 | clamp `max(-10, min(10, score))` 적용 |
---
## 13. 동시성 & rate limit
- `asyncio.Semaphore(10)` — 동시 10종목 처리 (네이버 차단 회피)
- 종목 처리 사이 0.2초 sleep (semaphore 안에서)
- 100종목 ÷ 10 동시 × 평균 3초/종목 = **~30-60초 총 소요**
- agent-office httpx timeout = 180초 (충분한 여유)
- stock-lab _conn() 의 WAL + busy_timeout=120s 로 16:30 잡과 동시 실행 시 lock 보호
---
## 14. 비용 모니터링
- 종목당 평균: input ~500 tokens, output ~50 tokens
- 일 비용: 50K input × $1/M + 5K output × $5/M = **$0.075/일**
- 월 비용: **~$1.6** (텔레그램 메시지 하단에 매일 ₩72 형태로 표시)
- `news_sentiment.tokens_input/output` 컬럼으로 누적 추적 가능
- 환산: 1 USD ≈ ₩1,300, input $0.0013/K, output $0.0065/K (장기 평균)
---
## 15. 프론트엔드 변경
캔버스 모드에 8번째 점수 노드 추가. 아래 한 파일만 수정:
```js
// canvasLayout.js
export const NODE_IDS = {
...,
AI_NEWS: 'score-ai-news', // 신규
...,
};
export const NODE_KIND_MAP = { ..., [NODE_IDS.AI_NEWS]: 'score', ... };
export const SCORE_NODE_NAME_MAP = { ..., [NODE_IDS.AI_NEWS]: 'ai_news' };
export const SCORE_NODE_LABEL = {
...,
[NODE_IDS.AI_NEWS]: { icon: '🤖', title: 'AI 뉴스' },
};
export const INITIAL_NODE_POSITIONS = {
...,
// 기존 7개 score y: 0,90,180,270,360,450,540 → 8개 y: 0,90,...,630
[NODE_IDS.AI_NEWS]: { x: 480, y: 630 },
};
const SCORE_KEYS = [..., 'AI_NEWS']; // 한 줄 추가
```
폼 모드 `NodePanel` 은 백엔드 `/api/stock/screener/nodes` 응답 기반이라 백엔드 등록만으로 자동 반영.
테스트 갱신:
- `canvasLayout.test.js`: 8 score 노드, 18 엣지 (1+8+8+1), Object.keys(SCORE_NODE_NAME_MAP) === 8
---
## 16. 테스트 전략
### 16.1 backend 단위 테스트
| 파일 | 검증 |
|------|------|
| `test_ai_news_scraper.py` | 네이버 HTML mock 파싱 (3종목 fixture, 빈 HTML, 404 응답) |
| `test_ai_news_analyzer.py` | anthropic mock — success / JSON 파싱 실패 / score 범위 클램프 |
| `test_ai_news_pipeline.py` | 5종목 mini integration (scraper/analyzer monkeypatch) — top_pos/top_neg 정렬 검증, failures 격리 검증 |
| `test_ai_news_node.py` | AiNewsSentiment.compute() — percentile_rank 결과, min_news_count 필터, 빈 컨텍스트 |
| `test_screener_schema.py` | news_sentiment DDL 생성 확인 (기존 테스트 보강) |
| `test_screener_router.py` | POST /snapshot/refresh-news-sentiment 라우팅 검증 (mock pipeline) |
### 16.2 frontend 회귀 테스트
| 파일 | 검증 |
|------|------|
| `canvasLayout.test.js` (수정) | SCORE_NODE_NAME_MAP 8 entries, EDGES 18, AI_NEWS가 gate→score→combine 경로 가짐 |
### 16.3 수동 검증 체크리스트
배포 전 NAS에서:
- [ ] 08:00 cron 트리거 (수동 `agent-office.on_ai_news_schedule()`)
- [ ] news_sentiment 테이블에 100종목 행 생성 확인
- [ ] 텔레그램 메시지 호재/악재 Top 5 + 비용 라인 정상 표시
- [ ] 16:30 스크리너 잡이 ai_news 점수 가중합에 반영 (스크리너 결과의 scores.ai_news 컬럼 확인)
- [ ] 캔버스 모드에 🤖 AI 뉴스 노드 표시, 활성/비활성 토글 동작
- [ ] LLM 실패 시뮬레이션 (ANTHROPIC_API_KEY 잘못 설정 후 cron) → fail-soft 동작
---
## 17. 배포
- **백엔드**: stock-lab + agent-office 동시 변경 → git push → Gitea webhook → 자동 deployer rsync + docker compose build
- **DB 마이그레이션**: `ensure_screener_schema(conn)``CREATE TABLE IF NOT EXISTS` 로 자동 (기존 패턴)
- **환경변수**: stock-lab docker-compose.yml 에 `AI_NEWS_MODEL` (옵션) 추가 가능. 기본값 `claude-haiku-4-5-20251001`
- **프론트**: web-ui에서 `npm run release:nas` (캔버스 노드 1개 추가는 작은 변경)
---
## 18. 후속 슬라이스 후보 (이번 슬라이스 NOT)
본 슬라이스 완료 후 자연스럽게 이어질 작업:
1. **URL 단위 캐싱** — 뉴스 분석 비용 ~70% 절감
2. **장중 16:00 추가 sentiment cron** — 16:30 스크리너에 더 신선한 데이터 공급
3. **종목별 sentiment 트렌드 차트** — 데이터 1-2주 누적 후 시각화
4. **시총 200~500 확장** — 중소형주 sentiment 커버리지
5. **백테스트** — sentiment 점수가 실수익에 미친 영향 회귀
6. **다국어/거시 뉴스 통합** — 글로벌 시장 영향 변수 추가
7. **알림 토글** — 운영 중 텔레그램 알림 일시 정지 옵션
8. **종목별 sentiment 페이지** — 상세 뉴스 + 점수 + LLM 근거 가시화
---
## 19. 리스크와 완화
| 리스크 | 완화 |
|--------|------|
| 네이버 finance HTML 구조 변경 | 단위 테스트로 빠른 감지. fail-soft (해당 종목 skip). 운영 알림 (실패율 > 30%) |
| LLM 응답이 JSON 깨짐 | 종목당 1콜 + JSON-mode prompt + 파싱 실패 시 단일 종목만 skip. lotto curator에서 검증된 패턴 |
| 네이버 차단 (429) | Semaphore(10) + 0.2초 sleep + httpx User-Agent. 향후 429 응답 시 exponential backoff 추가 |
| anthropic API 비용 폭증 | 일 1회 100종목 = $0.075 상한. 토큰 모니터링 컬럼 + 텔레그램 표시로 즉시 감지 |
| 08:00 cron이 16:30 잡과 lock 충돌 | _conn() WAL + busy_timeout=120s 로 흡수. 두 cron 시간 8.5시간 차이로 실질 충돌 없음 |
| 16:30 시점 news_sentiment 비어 있음 (cron 실패) | AiNewsSentiment.compute() 가 빈 Series → 가중합에서 자동 제외. 다른 7노드 점수만 사용 |
---
## 20. 완료 조건 (Definition of Done)
- [ ] 평일 08:00 KST agent-office cron 등록, 수동 트리거로 실행 검증
- [ ] news_sentiment 테이블에 100종목 데이터 일별 생성
- [ ] 텔레그램에 호재/악재 Top 5 + 비용 라인 표시
- [ ] 16:30 스크리너 잡에서 ai_news 점수가 가중합에 반영 (scores.ai_news 노출)
- [ ] 캔버스 모드에 8번째 노드 🤖 AI 뉴스 표시, 가중치/활성/파라미터 편집 동작
- [ ] 폼 모드 NodePanel에 AI 뉴스 자동 노출 (백엔드 메타 기반)
- [ ] 16.1 단위 테스트 모두 통과
- [ ] 16.3 수동 검증 체크리스트 모두 통과
- [ ] LLM 실패 시 fail-soft 동작 (전체 잡은 성공으로 끝나고, 실패 종목만 누락)