Compare commits
52 Commits
feature/st
...
11bd223612
| Author | SHA1 | Date | |
|---|---|---|---|
| 11bd223612 | |||
| c3a5d7210f | |||
| 07c4459085 | |||
| c057304981 | |||
| d1245d040c | |||
| 34ca407ca2 | |||
| b1ef778fc5 | |||
| 30706e2eb6 | |||
| 6062445c12 | |||
| 13da2226c3 | |||
| 1e377e1559 | |||
| eb75d692f5 | |||
| 6c25866487 | |||
| 6ac7469f26 | |||
| d1b2b6a4ba | |||
| 2abfa5cb23 | |||
| 227e294bd3 | |||
| ace0339d33 | |||
| 8812bd870a | |||
| b3fac4f442 | |||
| 19aed304cb | |||
| bbe5221e57 | |||
| ec0ccf649e | |||
| 84d90f6e1c | |||
| ddfe0ca3eb | |||
| 943f676414 | |||
| 06162b1e6e | |||
| c3659eb6c5 | |||
| 16941d76e8 | |||
| 9f91dae1a4 | |||
| 2a552d3cc8 | |||
| f37b21a408 | |||
| df7a8d985e | |||
| c5d0c84183 | |||
| 53a78a1062 | |||
| ca8bcb3fed | |||
| 4b4f91c052 | |||
| 6c3a84b8ec | |||
| 2ff2645240 | |||
| f2143b3889 | |||
| 810cc76d40 | |||
| 0a91f43c46 | |||
| 3d321f2b4b | |||
| 6ba29599aa | |||
| 658ed13571 | |||
| 15ee3c3301 | |||
| 2b5009f864 | |||
| d9b612253a | |||
| db4322006d | |||
| a05e6ba8ca | |||
| 4a333434ac | |||
| 204cee67d6 |
22
CLAUDE.md
22
CLAUDE.md
@@ -7,7 +7,7 @@
|
||||
## 1. 프로젝트 개요
|
||||
|
||||
Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
||||
- **서비스**: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, personal, packs-lab, deployer (10개)
|
||||
- **서비스**: lotto-lab, stock, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, personal, packs-lab, deployer (10개)
|
||||
- **프론트엔드**: 별도 레포 (React + Vite SPA), 빌드 산출물만 NAS에 배포
|
||||
- **인프라**: Docker Compose (10컨테이너) + Nginx(리버스 프록시) + Gitea Webhook 자동 배포
|
||||
|
||||
@@ -32,7 +32,7 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
||||
/volume1
|
||||
├── docker/webpage/ # 운영 런타임 (Docker Compose 실행 위치)
|
||||
│ ├── lotto/ # lotto 소스 (rsync 동기화)
|
||||
│ ├── stock-lab/ # stock-lab 소스 (rsync 동기화)
|
||||
│ ├── stock/ # stock 소스 (rsync 동기화)
|
||||
│ ├── travel-proxy/ # travel-proxy 소스 (rsync 동기화)
|
||||
│ ├── deployer/ # deployer 소스 (rsync 동기화)
|
||||
│ ├── nginx/default.conf # Nginx 설정
|
||||
@@ -54,7 +54,7 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
||||
| 컨테이너 | 포트 | 역할 |
|
||||
|---------|------|------|
|
||||
| `lotto` | 18000 | 로또 데이터 수집·분석·추천 API |
|
||||
| `stock-lab` | 18500 | 주식 뉴스·AI 분석·KIS API 연동 |
|
||||
| `stock` | 18500 | 주식 뉴스·AI 분석·KIS API 연동 |
|
||||
| `music-lab` | 18600 | AI 음악 생성·라이브러리 관리 API |
|
||||
| `blog-lab` | 18700 | 블로그 마케팅 수익화 API |
|
||||
| `realestate-lab` | 18800 | 부동산 청약 자동 수집·매칭 API |
|
||||
@@ -73,9 +73,9 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
||||
|------|------------|------|
|
||||
| `/api/` | `lotto:8000` | lotto API (기본) |
|
||||
| `/api/travel/` | `travel-proxy:8000` | travel API |
|
||||
| `/api/stock/` | `stock-lab:8000` | stock API |
|
||||
| `/api/trade/` | `stock-lab:8000` | KIS 실계좌 API |
|
||||
| `/api/portfolio` | `stock-lab:8000` | trailing slash 유무 모두 매칭 |
|
||||
| `/api/stock/` | `stock:8000` | stock API |
|
||||
| `/api/trade/` | `stock:8000` | KIS 실계좌 API |
|
||||
| `/api/portfolio` | `stock:8000` | trailing slash 유무 모두 매칭 |
|
||||
| `/api/music/` | `music-lab:8000` | AI 음악 생성·라이브러리 API |
|
||||
| `/api/blog-marketing/` | `blog-lab:8000` | 블로그 마케팅 수익화 API |
|
||||
| `/api/realestate/` | `realestate-lab:8000` | 부동산 청약 API |
|
||||
@@ -205,14 +205,14 @@ docker compose up -d
|
||||
| GET | `/api/lotto/briefing/{draw_no}` | 특정 회차 브리핑 |
|
||||
| GET | `/api/lotto/briefing` | 브리핑 이력 |
|
||||
|
||||
### stock-lab (stock-lab/)
|
||||
### stock (stock/)
|
||||
- Windows AI 서버 연동: `WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000`
|
||||
- KIS API 연동으로 실계좌 잔고·거래 조회
|
||||
- 뉴스 스크래핑: 네이버 증권 + 해외 사이트
|
||||
- DB: `/app/data/stock.db` (articles, portfolio, broker_cash, asset_snapshots, sell_history 테이블)
|
||||
- 파일 구조: `main.py`, `db.py`, `scraper.py`, `price_fetcher.py`, `holidays.json`
|
||||
|
||||
**stock-lab API 목록**
|
||||
**stock API 목록**
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
@@ -512,7 +512,7 @@ docker compose up -d
|
||||
|
||||
### agent-office (agent-office/)
|
||||
- AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 에이전트가 실제 작업 수행
|
||||
- stock-lab/music-lab/realestate-lab 기존 API를 서비스 프록시로 호출 (직접 DB 접근 없음)
|
||||
- stock/music-lab/realestate-lab 기존 API를 서비스 프록시로 호출 (직접 DB 접근 없음)
|
||||
- 실시간 상태 동기화: WebSocket (`/api/agent-office/ws`)
|
||||
- 텔레그램 봇: 양방향 알림 + 승인 (인라인 키보드)
|
||||
- 청약 매칭 알림: realestate-lab이 신규 매칭 발견 시 push → `RealestateAgent.on_new_matches()` → 텔레그램 1통(인라인 [🔖 북마크]/[📄 공고] 또는 [전체 보기] 버튼)
|
||||
@@ -522,7 +522,7 @@ docker compose up -d
|
||||
**에이전트 FSM 상태**: idle → working → waiting (승인 대기) → reporting → break (휴식)
|
||||
|
||||
**환경변수**
|
||||
- `STOCK_LAB_URL`: stock-lab 내부 URL (기본 `http://stock-lab:8000`)
|
||||
- `STOCK_URL`: stock 내부 URL (기본 `http://stock:8000`)
|
||||
- `MUSIC_LAB_URL`: music-lab 내부 URL (기본 `http://music-lab:8000`)
|
||||
- `REALESTATE_LAB_URL`: realestate-lab 내부 URL (기본 `http://realestate-lab:8000`) — 북마크 콜백 프록시 대상
|
||||
- `REALESTATE_DASHBOARD_URL`: 텔레그램 [전체 보기] 버튼 URL (기본 `http://localhost:8080/realestate`)
|
||||
@@ -697,7 +697,7 @@ docker compose up -d
|
||||
- **캐시 전략**: `index.html`은 `no-store`, `assets/`는 1년 장기 캐시(immutable)
|
||||
- **Frontend 배포**: git push로 자동 배포되지 않음. 로컬 빌드 후 NAS에 수동 업로드
|
||||
- **.env 파일**: 절대 커밋 금지. `.env.example`만 레포에 포함
|
||||
- **공휴일 목록**: `stock-lab/app/holidays.json` 매년 수동 갱신 필요 (KRX 기준)
|
||||
- **공휴일 목록**: `stock/app/holidays.json` 매년 수동 갱신 필요 (KRX 기준)
|
||||
- **Windows AI 서버 IP**: `192.168.45.59` — 공유기 DHCP 고정 예약으로 고정. Tailscale은 Synology에서 TCP 불가(userspace 모드)라 로컬 IP 사용
|
||||
- **현재가 조회**: 네이버 모바일 API → HTML 파싱 폴백, 3분 TTL 캐시 (`price_fetcher.py`)
|
||||
- **시뮬레이션 교체 방식**: `best_picks`는 교체형 — 새 시뮬레이션 실행 시 `is_active=0`으로 비활성화 후 신규 입력
|
||||
|
||||
26
README.md
26
README.md
@@ -13,8 +13,8 @@ Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
|
||||
│ ├── 정적 SPA 서빙 (React + Vite) │
|
||||
│ └── API 리버스 프록시 │
|
||||
│ ├── /api/ → lotto-backend:8000 (로또·블로그·투두)│
|
||||
│ ├── /api/stock/, /trade/ → stock-lab:8000 │
|
||||
│ ├── /api/portfolio → stock-lab:8000 │
|
||||
│ ├── /api/stock/, /trade/ → stock:8000 │
|
||||
│ ├── /api/portfolio → stock:8000 │
|
||||
│ ├── /api/music/ → music-lab:8000 │
|
||||
│ ├── /api/blog-marketing/ → blog-lab:8000 │
|
||||
│ ├── /api/realestate/ → realestate-lab:8000 │
|
||||
@@ -29,7 +29,7 @@ Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
|
||||
| 컨테이너 | 포트 | 역할 |
|
||||
|---------|------|------|
|
||||
| `lotto-backend` | 18000 | 로또 데이터 수집·분석·추천 + 블로그·투두 API |
|
||||
| `stock-lab` | 18500 | 주식 뉴스·AI 요약·KIS 실계좌·포트폴리오·자산 추적 |
|
||||
| `stock` | 18500 | 주식 뉴스·AI 요약·KIS 실계좌·포트폴리오·자산 추적 |
|
||||
| `music-lab` | 18600 | AI 음악 생성 (Suno + 로컬 MusicGen 듀얼 프로바이더) |
|
||||
| `blog-lab` | 18700 | 블로그 마케팅 수익화 (키워드→글 생성→리뷰→발행) |
|
||||
| `realestate-lab` | 18800 | 청약 공고 자동 수집·프로필 매칭 |
|
||||
@@ -45,7 +45,7 @@ Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
|
||||
```
|
||||
web-backend/
|
||||
├── backend/ # lotto-backend (로또·블로그·투두)
|
||||
├── stock-lab/ # 주식·포트폴리오
|
||||
├── stock/ # 주식·포트폴리오
|
||||
├── music-lab/ # AI 음악 생성
|
||||
├── blog-lab/ # 블로그 마케팅 파이프라인
|
||||
├── realestate-lab/ # 청약 자동 수집·매칭
|
||||
@@ -75,7 +75,7 @@ curl http://localhost:18500/health
|
||||
|--------|----------|
|
||||
| Frontend + API | http://localhost:8080 |
|
||||
| lotto-backend | http://localhost:18000 |
|
||||
| stock-lab | http://localhost:18500 |
|
||||
| stock | http://localhost:18500 |
|
||||
| music-lab | http://localhost:18600 |
|
||||
| blog-lab | http://localhost:18700 |
|
||||
| realestate-lab | http://localhost:18800 |
|
||||
@@ -99,7 +99,7 @@ curl http://localhost:18500/health
|
||||
- 09:10 / 21:10 — 당첨번호 동기화 + 추천 채점
|
||||
- 00:05, 04:05, 08:05, 12:05, 16:05, 20:05 — 몬테카를로 시뮬레이션 (후보 20,000 → 상위 100 → best_picks 20쌍 교체)
|
||||
|
||||
### 2. stock-lab (`/api/stock/`, `/api/trade/`, `/api/portfolio`)
|
||||
### 2. stock (`/api/stock/`, `/api/trade/`, `/api/portfolio`)
|
||||
|
||||
주식 뉴스 스크래핑 + LLM 요약 + KIS 실계좌 연동 + 포트폴리오·자산 스냅샷.
|
||||
|
||||
@@ -152,7 +152,7 @@ curl http://localhost:18500/health
|
||||
|
||||
AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 4명의 에이전트가 실제 작업을 수행한다.
|
||||
|
||||
- **아키텍처**: stock-lab / music-lab / blog-lab / realestate-lab 기존 API를 서비스 프록시로 호출 (직접 DB 접근 없음)
|
||||
- **아키텍처**: stock / music-lab / blog-lab / realestate-lab 기존 API를 서비스 프록시로 호출 (직접 DB 접근 없음)
|
||||
- **FSM 상태**: `idle → working → waiting(승인 대기) → reporting → break`
|
||||
- **실시간 동기화**: WebSocket `/api/agent-office/ws` (init, agent_state, task_complete, command_result)
|
||||
- **텔레그램 연동**: 양방향 알림 + 인라인 키보드 승인
|
||||
@@ -224,7 +224,7 @@ Gitea Webhook 수신 → NAS 자동 배포.
|
||||
| 공동 출현 | 15% | 번호 쌍 동시 출현 빈도 |
|
||||
| 다양성 | 10% | 연속번호·범위·구간 커버리지 |
|
||||
|
||||
### LLM 요약 provider 추상화 (stock-lab)
|
||||
### LLM 요약 provider 추상화 (stock)
|
||||
|
||||
`ai_summarizer.py`는 provider 분리 구조. `summarize_news(articles)` 시그니처는 provider와 무관하게 고정.
|
||||
|
||||
@@ -232,7 +232,7 @@ Gitea Webhook 수신 → NAS 자동 배포.
|
||||
- `_summarize_with_ollama`: Ollama `/api/generate` (타임아웃 180s, qwen3:14b 첫 로드 대응)
|
||||
- 실패 시 `LLMError` (구 `OllamaError` alias 유지)
|
||||
|
||||
### 총 자산 스냅샷 (stock-lab)
|
||||
### 총 자산 스냅샷 (stock)
|
||||
|
||||
평일 15:40 자동 실행 → `holidays.json`으로 공휴일 스킵 → 포트폴리오 현재가 조회 + 예수금 합계 → `asset_snapshots` upsert (date UNIQUE).
|
||||
|
||||
@@ -266,7 +266,7 @@ git push → Gitea → X-Gitea-Signature (HMAC SHA256)
|
||||
| DB | 소유 서비스 | 주요 테이블 |
|
||||
|----|------------|-----------|
|
||||
| `lotto.db` | lotto-backend | draws, recommendations, simulation_runs/candidates, best_picks, purchase_history, strategy_performance/weights, weekly_reports, lotto_briefings, todos, blog_posts |
|
||||
| `stock.db` | stock-lab | articles, portfolio, broker_cash, asset_snapshots, sell_history |
|
||||
| `stock.db` | stock | articles, portfolio, broker_cash, asset_snapshots, sell_history |
|
||||
| `music.db` | music-lab | music_tasks, music_library (provider, lyrics, image_url, suno_id, file_hash, cover_images, wav_url, video_url, stem_urls) |
|
||||
| `blog_marketing.db` | blog-lab | keyword_analyses, blog_posts, brand_links, commissions, generation_tasks, prompt_templates |
|
||||
| `realestate.db` | realestate-lab | announcements, announcement_models, user_profile, match_results, collect_log |
|
||||
@@ -292,7 +292,7 @@ PGID=1000
|
||||
WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
|
||||
WEBHOOK_SECRET=your_secret_here
|
||||
|
||||
# LLM (stock-lab, blog-lab, agent-office 공통)
|
||||
# LLM (stock, blog-lab, agent-office 공통)
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
ANTHROPIC_MODEL=claude-haiku-4-5-20251001
|
||||
LLM_PROVIDER=claude # claude | ollama
|
||||
@@ -315,7 +315,7 @@ DATA_GO_KR_API_KEY=
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_CHAT_ID=
|
||||
TELEGRAM_WEBHOOK_URL=
|
||||
STOCK_LAB_URL=http://stock-lab:8000
|
||||
STOCK_URL=http://stock:8000
|
||||
MUSIC_LAB_URL=http://music-lab:8000
|
||||
BLOG_LAB_URL=http://blog-lab:8000
|
||||
REALESTATE_LAB_URL=http://realestate-lab:8000
|
||||
@@ -343,7 +343,7 @@ REALESTATE_LAB_URL=http://realestate-lab:8000
|
||||
- **라우트 순서** — `DELETE /api/todos/done`은 `/api/todos/{id}` 보다 먼저 등록 필수 (FastAPI prefix 매칭)
|
||||
- **캐시 전략** — `index.html`: no-store / `assets/`: 1년 immutable
|
||||
- **PUID/PGID** — travel-proxy는 NAS 파일 권한을 위해 환경변수 주입 필수
|
||||
- **공휴일 목록** — `stock-lab/app/holidays.json` 매년 수동 갱신 (KRX 기준)
|
||||
- **공휴일 목록** — `stock/app/holidays.json` 매년 수동 갱신 (KRX 기준)
|
||||
- **Windows AI 서버 IP** — `192.168.45.59` 공유기 DHCP 고정 예약. Synology Tailscale은 userspace 모드라 TCP 불가 → 로컬 IP 사용
|
||||
- **Suno CDN** — `cdn1.suno.ai` URL은 임시 만료 → 생성 즉시 로컬 다운로드 필수
|
||||
- **LLM provider 롤백** — Claude API 장애 시 `.env`의 `LLM_PROVIDER=ollama`로 전환 후 `docker compose up -d`
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
| 서비스 | 포트 | 상태 | 핵심 기능 |
|
||||
|--------|------|------|-----------|
|
||||
| `lotto-backend` | 18000 | ✅ | 로또 추천·통계·리포트·구매내역 + 블로그·투두 |
|
||||
| `stock-lab` | 18500 | ✅ | 주식 뉴스·지수·트레이딩·포트폴리오·자산 스냅샷 |
|
||||
| `stock` | 18500 | ✅ | 주식 뉴스·지수·트레이딩·포트폴리오·자산 스냅샷 |
|
||||
| `music-lab` | 18600 | ✅ | Suno + MusicGen + YouTube 수익화 + 컴파일 |
|
||||
| `blog-lab` | 18700 | ✅ | 블로그 마케팅 수익화 파이프라인 |
|
||||
| `realestate-lab` | 18800 | ✅ | 청약 수집·5티어 매칭·매칭 알림 |
|
||||
|
||||
@@ -51,7 +51,7 @@ class StockAgent(BaseAgent):
|
||||
await self.transition("working", "최신 뉴스 수집 중...", task_id)
|
||||
|
||||
try:
|
||||
# stock-lab cron(매일 8:00)이 7:30 브리핑보다 늦게 돌아 어제 뉴스가
|
||||
# stock cron(매일 8:00)이 7:30 브리핑보다 늦게 돌아 어제 뉴스가
|
||||
# 요약되던 문제 방지 — 요약 직전에 동기 스크랩으로 DB를 갱신한다.
|
||||
try:
|
||||
await service_proxy.scrape_stock_news()
|
||||
@@ -60,7 +60,7 @@ class StockAgent(BaseAgent):
|
||||
|
||||
await self.transition("working", "AI 뉴스 요약 생성 중...")
|
||||
|
||||
# AI 요약 호출 (LLM 처리는 stock-lab이 담당)
|
||||
# AI 요약 호출 (LLM 처리는 stock이 담당)
|
||||
result = await service_proxy.summarize_stock_news(limit=15)
|
||||
|
||||
await self.transition("reporting", "뉴스 요약 전송 중...")
|
||||
@@ -233,11 +233,118 @@ class StockAgent(BaseAgent):
|
||||
|
||||
await self.transition("idle", f"스크리너 오류: {err_msg[:80]}")
|
||||
|
||||
async def on_ai_news_schedule(self) -> None:
|
||||
"""AI 뉴스 sentiment 분석 자동 잡 (평일 08:00 KST).
|
||||
|
||||
흐름:
|
||||
1) stock /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", "휴일/주말 — 건너뜀")
|
||||
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이 빌드해서 응답에 telegram_text 동봉)
|
||||
text = result.get("telegram_text") or ""
|
||||
if not text:
|
||||
add_log(self.agent_id, "telegram_text 누락 — stock 응답 결함", "error", task_id)
|
||||
update_task_status(task_id, "failed", {"error": "telegram_text 누락"})
|
||||
await self.transition("idle", "AI 뉴스 응답 결함")
|
||||
return
|
||||
|
||||
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 뉴스 완료")
|
||||
|
||||
async def on_command(self, command: str, params: dict) -> dict:
|
||||
if command == "run_screener":
|
||||
await self.on_screener_schedule()
|
||||
return {"ok": True, "message": "스크리너 실행 트리거 완료"}
|
||||
|
||||
if command == "run_ai_news":
|
||||
await self.on_ai_news_schedule()
|
||||
return {"ok": True, "message": "AI 뉴스 분석 트리거 완료"}
|
||||
|
||||
if command == "test_telegram":
|
||||
from ..telegram import send_agent_message
|
||||
result = await send_agent_message(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import os
|
||||
|
||||
# Service URLs (Docker internal network)
|
||||
STOCK_LAB_URL = os.getenv("STOCK_LAB_URL", "http://localhost:18500")
|
||||
STOCK_URL = os.getenv("STOCK_URL", "http://localhost:18500")
|
||||
MUSIC_LAB_URL = os.getenv("MUSIC_LAB_URL", "http://localhost:18600")
|
||||
BLOG_LAB_URL = os.getenv("BLOG_LAB_URL", "http://localhost:18700")
|
||||
REALESTATE_LAB_URL = os.getenv("REALESTATE_LAB_URL", "http://localhost:18800")
|
||||
|
||||
@@ -9,9 +9,10 @@ from .config import DB_PATH
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH, timeout=10)
|
||||
conn = sqlite3.connect(DB_PATH, timeout=120.0)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=120000")
|
||||
return conn
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,11 @@ async def _run_stock_screener():
|
||||
if agent:
|
||||
await agent.on_screener_schedule()
|
||||
|
||||
async def _run_stock_ai_news():
|
||||
agent = AGENT_REGISTRY.get("stock")
|
||||
if agent:
|
||||
await agent.on_ai_news_schedule()
|
||||
|
||||
async def _run_blog_schedule():
|
||||
agent = AGENT_REGISTRY.get("blog")
|
||||
if agent:
|
||||
@@ -54,6 +59,14 @@ def init_scheduler():
|
||||
minute=30,
|
||||
id="stock_screener",
|
||||
)
|
||||
scheduler.add_job(
|
||||
_run_stock_ai_news,
|
||||
"cron",
|
||||
day_of_week="mon-fri",
|
||||
hour=8,
|
||||
minute=0,
|
||||
id="stock_ai_news_sentiment",
|
||||
)
|
||||
scheduler.add_job(_run_blog_schedule, "cron", hour=10, minute=0, id="blog_pipeline")
|
||||
scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=9, minute=0, id="lotto_curate")
|
||||
scheduler.add_job(_run_youtube_research, "cron", hour=9, minute=0, id="youtube_research")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import httpx
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .config import STOCK_LAB_URL, MUSIC_LAB_URL, BLOG_LAB_URL, REALESTATE_LAB_URL
|
||||
from .config import STOCK_URL, MUSIC_LAB_URL, BLOG_LAB_URL, REALESTATE_LAB_URL
|
||||
|
||||
_client = httpx.AsyncClient(timeout=30.0)
|
||||
|
||||
@@ -9,23 +9,23 @@ async def fetch_stock_news(limit: int = 10, category: str = None) -> List[Dict[s
|
||||
params = {"limit": limit}
|
||||
if category:
|
||||
params["category"] = category
|
||||
resp = await _client.get(f"{STOCK_LAB_URL}/api/stock/news", params=params)
|
||||
resp = await _client.get(f"{STOCK_URL}/api/stock/news", params=params)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def fetch_stock_indices() -> Dict[str, Any]:
|
||||
resp = await _client.get(f"{STOCK_LAB_URL}/api/stock/indices")
|
||||
resp = await _client.get(f"{STOCK_URL}/api/stock/indices")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def summarize_stock_news(limit: int = 15) -> Dict[str, Any]:
|
||||
"""stock-lab의 AI 요약 엔드포인트 호출.
|
||||
"""stock의 AI 요약 엔드포인트 호출.
|
||||
반환: {"summary": str, "tokens": {...}, "model": str, "duration_ms": int, "article_count": int}
|
||||
"""
|
||||
# stock-lab 내부 Ollama 호출이 180s까지 가능하므로 여유있게 200s
|
||||
# stock 내부 Ollama 호출이 180s까지 가능하므로 여유있게 200s
|
||||
async with httpx.AsyncClient(timeout=200.0) as client:
|
||||
resp = await client.post(
|
||||
f"{STOCK_LAB_URL}/api/stock/news/summarize",
|
||||
f"{STOCK_URL}/api/stock/news/summarize",
|
||||
json={"limit": limit},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
@@ -33,18 +33,32 @@ async def summarize_stock_news(limit: int = 15) -> Dict[str, Any]:
|
||||
|
||||
|
||||
async def refresh_screener_snapshot() -> Dict[str, Any]:
|
||||
"""stock-lab의 KRX 일봉 스냅샷 갱신 (스크리너 실행 전 호출).
|
||||
"""stock의 KRX 일봉 스냅샷 갱신 (스크리너 실행 전 호출).
|
||||
|
||||
네이버 금융 일괄 다운로드라 보통 30~120s, 여유있게 180s.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=180.0) as client:
|
||||
resp = await client.post(f"{STOCK_LAB_URL}/api/stock/screener/snapshot/refresh")
|
||||
resp = await client.post(f"{STOCK_URL}/api/stock/screener/snapshot/refresh")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def refresh_ai_news_sentiment() -> Dict[str, Any]:
|
||||
"""stock의 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_URL}/api/stock/screener/snapshot/refresh-news-sentiment"
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def run_stock_screener(mode: str = "auto") -> Dict[str, Any]:
|
||||
"""stock-lab의 스크리너 실행.
|
||||
"""stock의 스크리너 실행.
|
||||
|
||||
반환 status:
|
||||
- 'skipped_holiday': 공휴일/주말 — telegram_payload 없음
|
||||
@@ -53,7 +67,7 @@ async def run_stock_screener(mode: str = "auto") -> Dict[str, Any]:
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=180.0) as client:
|
||||
resp = await client.post(
|
||||
f"{STOCK_LAB_URL}/api/stock/screener/run",
|
||||
f"{STOCK_URL}/api/stock/screener/run",
|
||||
json={"mode": mode},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
@@ -61,13 +75,13 @@ async def run_stock_screener(mode: str = "auto") -> Dict[str, Any]:
|
||||
|
||||
|
||||
async def scrape_stock_news() -> Dict[str, Any]:
|
||||
"""stock-lab의 수동 뉴스 스크랩 트리거 — DB에 최신 뉴스 저장.
|
||||
"""stock의 수동 뉴스 스크랩 트리거 — DB에 최신 뉴스 저장.
|
||||
|
||||
아침 브리핑 직전 호출하여 어제 데이터가 아닌 오늘 새벽 뉴스를 보장한다.
|
||||
네이버 금융 단일 요청이라 보통 수 초 내 완료, 여유있게 60s.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
resp = await client.post(f"{STOCK_LAB_URL}/api/stock/scrap")
|
||||
resp = await client.post(f"{STOCK_URL}/api/stock/scrap")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""StockAgent.on_screener_schedule — 평일 16:30 KST 자동 잡 단위 테스트.
|
||||
|
||||
stock-lab HTTP 호출은 service_proxy mock, 텔레그램은 messaging.send_raw mock.
|
||||
stock HTTP 호출은 service_proxy mock, 텔레그램은 messaging.send_raw mock.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
@@ -138,7 +138,7 @@ def test_screener_run_failure_notifies_operator():
|
||||
from app.telegram import messaging
|
||||
|
||||
fake_snap = AsyncMock(return_value={"status": "ok"})
|
||||
fake_run = AsyncMock(side_effect=RuntimeError("stock-lab 500"))
|
||||
fake_run = AsyncMock(side_effect=RuntimeError("stock 500"))
|
||||
fake_send = AsyncMock(return_value={"ok": True, "message_id": 1})
|
||||
|
||||
with patch.object(service_proxy, "refresh_screener_snapshot", fake_snap), \
|
||||
|
||||
@@ -8,9 +8,10 @@ from .config import DB_PATH
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn = sqlite3.connect(DB_PATH, timeout=120.0)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=120000")
|
||||
return conn
|
||||
|
||||
|
||||
|
||||
@@ -22,12 +22,12 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
stock-lab:
|
||||
stock:
|
||||
build:
|
||||
context: ./stock-lab
|
||||
context: ./stock
|
||||
args:
|
||||
APP_VERSION: ${APP_VERSION:-dev}
|
||||
container_name: stock-lab
|
||||
container_name: stock
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18500:8000"
|
||||
@@ -43,6 +43,7 @@ services:
|
||||
- OLLAMA_URL=${OLLAMA_URL:-http://192.168.45.59:11435}
|
||||
- OLLAMA_MODEL=${OLLAMA_MODEL:-qwen3:14b}
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||
- WEBAI_API_KEY=${WEBAI_API_KEY:-}
|
||||
volumes:
|
||||
- ${RUNTIME_PATH}/data/stock:/app/data
|
||||
healthcheck:
|
||||
@@ -136,7 +137,7 @@ services:
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Seoul}
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||
- STOCK_LAB_URL=http://stock-lab:8000
|
||||
- STOCK_URL=http://stock:8000
|
||||
- MUSIC_LAB_URL=http://music-lab:8000
|
||||
- BLOG_LAB_URL=http://blog-lab:8000
|
||||
- REALESTATE_LAB_URL=http://realestate-lab:8000
|
||||
@@ -157,7 +158,7 @@ services:
|
||||
volumes:
|
||||
- ${RUNTIME_PATH:-.}/data/agent-office:/app/data
|
||||
depends_on:
|
||||
- stock-lab
|
||||
- stock
|
||||
- music-lab
|
||||
- blog-lab
|
||||
- realestate-lab
|
||||
@@ -241,9 +242,15 @@ services:
|
||||
container_name: frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- lotto
|
||||
- stock
|
||||
- music-lab
|
||||
- blog-lab
|
||||
- realestate-lab
|
||||
- agent-office
|
||||
- personal
|
||||
- packs-lab
|
||||
- travel-proxy
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
|
||||
2753
docs/superpowers/plans/2026-05-15-insta-agent-implementation.md
Normal file
2753
docs/superpowers/plans/2026-05-15-insta-agent-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
358
docs/superpowers/specs/2026-05-15-insta-agent-design.md
Normal file
358
docs/superpowers/specs/2026-05-15-insta-agent-design.md
Normal file
@@ -0,0 +1,358 @@
|
||||
# insta-agent 설계 — blog-lab 폐기, 인스타 카드 피드 파이프라인 신설
|
||||
|
||||
작성일: 2026-05-15
|
||||
상태: 사용자 승인 대기 → writing-plans 진입 예정
|
||||
|
||||
---
|
||||
|
||||
## 1. 목적·배경
|
||||
|
||||
기존 `blog-lab` 서비스(네이버 블로그 마케팅 수익화)를 폐기하고, 인스타그램 프로페셔널 계정에 올릴 카드 형식 피드(1080×1350, 10페이지)를 자동 생산하는 `insta-lab` 서비스로 대체한다.
|
||||
|
||||
핵심 가치 제안:
|
||||
- 매일 경제·심리학·연예 등 카테고리에서 화제 키워드를 자동 발견
|
||||
- 사용자가 키워드 1개를 선택하면 10페이지 카드 카피 + PNG 자동 생성
|
||||
- 텔레그램으로 카드 묶음 미디어 그룹 + 추천 캡션·해시태그 푸시
|
||||
- 사용자는 카드 다운로드 → 인스타 수동 업로드 (Graph API 미사용)
|
||||
|
||||
블로그 발행 자동화의 운영 부담(네이버 SEO, 브랜드커넥트 링크 관리, 커미션 추적)을 제거하고 카드 콘텐츠 생산에 집중한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 스코프
|
||||
|
||||
### 포함
|
||||
|
||||
- 신규 컨테이너 `insta-lab` (포트 18700 재활용)
|
||||
- 신규 에이전트 `insta-agent` (`agent-office/app/agents/insta.py`)
|
||||
- 뉴스 수집 → 키워드 추출 → 카드 카피 생성 → 카드 PNG 렌더 → 텔레그램 푸시 파이프라인
|
||||
- HTML/CSS 카드 템플릿 골격 (사용자가 디자인 직접 수정)
|
||||
- 카드 슬레이트·기사·키워드·자산 5테이블 (`insta.db`)
|
||||
- nginx 라우팅 변경 (`/api/blog-marketing/` 제거 → `/api/insta/`)
|
||||
- CLAUDE.md (workspace + web-backend) 갱신
|
||||
|
||||
### 제외
|
||||
|
||||
- 인스타그램 Graph API 자동 발행 (수동 업로드 사용)
|
||||
- 카드 디자인 비주얼 완성 (사용자가 직접 작업)
|
||||
- blog_marketing.db 데이터 마이그레이션 (clean slate)
|
||||
- 다국어 번역, A/B 테스트, 성과 추적
|
||||
|
||||
---
|
||||
|
||||
## 3. 서비스 구성·폐기 범위
|
||||
|
||||
### 폐기
|
||||
|
||||
| 대상 | 처리 |
|
||||
|------|------|
|
||||
| `blog-lab/` 디렉토리 | git rm 통째로 삭제 |
|
||||
| `blog_marketing.db` | 운영·로컬 모두 삭제 (clean slate) |
|
||||
| `agent-office/app/agents/blog.py` | 삭제 |
|
||||
| `service_proxy.py`의 blog_* 함수 | 삭제 |
|
||||
| `agent-office`의 blog 라우팅·텔레그램 명령 | 삭제 |
|
||||
| docker-compose의 `blog-lab` 서비스 정의 | 교체 |
|
||||
| nginx의 `/api/blog-marketing/` location | 교체 |
|
||||
| 환경변수 `BLOG_DATA_PATH` | 제거 |
|
||||
|
||||
### 신규
|
||||
|
||||
| 대상 | 비고 |
|
||||
|------|------|
|
||||
| `insta-lab/` 디렉토리 | 신규 생성 |
|
||||
| `insta-lab` 컨테이너 (포트 18700) | blog-lab 자리 재활용 |
|
||||
| `agents/insta.py` | 신규 에이전트 |
|
||||
| nginx `/api/insta/` → `insta-lab:8000` | 신규 |
|
||||
| 환경변수 `INSTA_DATA_PATH`, `CARD_TEMPLATE_DIR` | 신규 |
|
||||
|
||||
### 재사용 자산 (코드 패턴 차용)
|
||||
|
||||
- `naver_search.py` — 엔드포인트만 `news.json`으로 교체
|
||||
- `generation_tasks` 테이블 + BackgroundTask 폴링 패턴
|
||||
- `prompt_templates` 테이블 + DB 저장 프롬프트 패턴
|
||||
- agent-office의 텔레그램 인라인 키보드·승인 패턴 (`realestate_message.py` 참고)
|
||||
|
||||
---
|
||||
|
||||
## 4. 데이터 흐름
|
||||
|
||||
### 일일 사이클
|
||||
|
||||
```
|
||||
[09:30 매일 cron — agent-office 스케줄러]
|
||||
1. 뉴스 수집 ─ 카테고리별 시드 키워드로 NAVER news.json 검색
|
||||
─ 카테고리당 상위 30건 메타 + 본문 일부 → news_articles
|
||||
2. 키워드 추출 ─ 카테고리당 빈도 상위 + Claude Haiku 정제
|
||||
─ trending_keywords (score 내림차순)
|
||||
3. 텔레그램 푸시 ─ 카테고리별 후보 5개씩 인라인 키보드
|
||||
─ 사용자 선택 대기
|
||||
|
||||
[사용자가 텔레그램 인라인 버튼 선택]
|
||||
4. 카피 생성 ─ Claude로 10페이지 카피 (1=훅/커버, 2~9=본문 8장, 10=요약/CTA)
|
||||
─ card_slates 저장 (status='draft')
|
||||
5. 카드 렌더 ─ Jinja → HTML 1080×1350 → Playwright headless 스크린샷 10장
|
||||
─ /app/data/insta_cards/{slate_id}/01.png ~ 10.png
|
||||
6. 텔레그램 ─ 미디어 그룹 10장 + 추천 캡션·해시태그
|
||||
─ 사용자 다운로드 후 인스타 수동 업로드
|
||||
```
|
||||
|
||||
### 자동 모드 (옵션)
|
||||
|
||||
- agent-office의 `agent_config.custom_config.auto_select`(bool) 플래그로 제어
|
||||
- `auto_select=true` 설정 시 키워드 추출 직후 카테고리당 score 1위 키워드를 자동 선택해 4~6 단계까지 즉시 진행
|
||||
- 사용자가 텔레그램에서 결과만 확인 (인라인 후보 푸시 단계 skip)
|
||||
|
||||
---
|
||||
|
||||
## 5. 컴포넌트
|
||||
|
||||
### insta-lab (FastAPI 서비스)
|
||||
|
||||
```
|
||||
insta-lab/
|
||||
├── Dockerfile # python:3.12-slim + playwright install chromium --with-deps
|
||||
├── requirements.txt
|
||||
├── pytest.ini
|
||||
├── tests/
|
||||
└── app/
|
||||
├── main.py # FastAPI 라우터
|
||||
├── config.py # NAVER_*, ANTHROPIC_API_KEY, INSTA_DATA_PATH, CARD_TEMPLATE_DIR
|
||||
├── db.py # 6테이블 init + CRUD
|
||||
├── news_collector.py # 네이버 뉴스 API + 본문 정리
|
||||
├── keyword_extractor.py # 빈도 + LLM 정제
|
||||
├── card_writer.py # Claude 10페이지 카피 생성
|
||||
├── card_renderer.py # Jinja → Playwright 스크린샷
|
||||
└── templates/ # 사용자가 직접 수정 (rsync로 NAS 배포)
|
||||
└── default/
|
||||
└── card.html.j2
|
||||
```
|
||||
|
||||
### agent-office 변경
|
||||
|
||||
```
|
||||
agent-office/app/agents/insta.py (신규)
|
||||
- on_schedule: 09:30 → news collect → keyword extract → 텔레그램 후보 푸시
|
||||
- on_command: extract / render <keyword> / list_categories
|
||||
- on_callback: 텔레그램 inline button "render_<keyword_id>" → 카피·렌더·푸시
|
||||
|
||||
agent-office/app/service_proxy.py
|
||||
- blog_* 함수 모두 제거
|
||||
- insta_* 함수 신규 (collect, extract, list_keywords, create_slate, render_slate, get_slate, get_asset)
|
||||
|
||||
agent-office/app/telegram/agent_registry.py
|
||||
- blog 명령 등록 제거 → insta 명령 등록
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. DB 스키마 (insta.db)
|
||||
|
||||
| 테이블 | 핵심 컬럼 | 설명 |
|
||||
|--------|----------|------|
|
||||
| `news_articles` | id PK, category, title, link UNIQUE, summary, pub_date, fetched_at | 일일 수집 기사 메타 |
|
||||
| `trending_keywords` | id PK, keyword, category, score REAL, articles_count, suggested_at, used INTEGER | 카테고리별 화제 키워드 (used=1이면 이미 슬레이트 생성됨) |
|
||||
| `card_slates` | id PK, keyword, category, status (draft/rendered/sent/failed), cover_copy TEXT, body_copies TEXT(JSON 8개), cta_copy TEXT, suggested_caption TEXT, hashtags TEXT(JSON), created_at | 10페이지 카피 묶음 |
|
||||
| `card_assets` | id PK, slate_id FK→card_slates(id), page_index INTEGER 1~10, file_path, file_hash, created_at | 렌더된 PNG 자산 |
|
||||
| `generation_tasks` | id TEXT PK, type, status, progress, message, result_id INTEGER, error TEXT, params TEXT, created_at, updated_at | blog-lab 패턴 그대로 (collect/extract/write/render 통합) |
|
||||
| `prompt_templates` | id PK, name UNIQUE, description, template TEXT, updated_at | `slate_writer`, `keyword_extractor` 두 개 시드 |
|
||||
|
||||
**인덱스**:
|
||||
- `idx_na_category_fetched` ON news_articles(category, fetched_at DESC)
|
||||
- `idx_tk_score` ON trending_keywords(category, score DESC)
|
||||
- `idx_cs_created` ON card_slates(created_at DESC)
|
||||
- `idx_ca_slate` ON card_assets(slate_id, page_index)
|
||||
|
||||
---
|
||||
|
||||
## 7. 카드 렌더 (Playwright)
|
||||
|
||||
### 템플릿
|
||||
|
||||
`templates/default/card.html.j2` — Jinja 변수:
|
||||
|
||||
| 변수 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| `page_type` | str | "cover" / "body" / "cta" |
|
||||
| `headline` | str | 페이지 헤드라인 |
|
||||
| `body` | str | 본문 (markdown-lite 허용 — 줄바꿈 보존) |
|
||||
| `accent_color` | str | hex (예: "#FF5733") |
|
||||
| `page_no` | int | 1~10 |
|
||||
| `total_pages` | int | 10 |
|
||||
|
||||
컨테이너 CSS: `width: 1080px; height: 1350px; overflow: hidden;`
|
||||
|
||||
### 렌더 로직 (card_renderer.py)
|
||||
|
||||
1. Playwright async chromium browser 1회 launch
|
||||
2. browser.new_context(viewport={"width": 1080, "height": 1350}) → page
|
||||
3. 10번 반복:
|
||||
- Jinja 렌더 → temp HTML 파일 저장
|
||||
- page.goto(`file://...`)
|
||||
- page.screenshot(path=f"{page_no:02}.png", omit_background=False)
|
||||
4. browser.close
|
||||
|
||||
### Dockerfile
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.12-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
fonts-noto-cjk fonts-noto-cjk-extra \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN playwright install chromium --with-deps
|
||||
COPY app ./app
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
|
||||
이미지 사이즈 +500MB 예상. NAS Celeron J4025에서 카드 10장 렌더 ≤ 30초 목표.
|
||||
|
||||
---
|
||||
|
||||
## 8. API (insta-lab)
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/insta/status` | 서비스 상태 (NAVER/ANTHROPIC 키 여부) |
|
||||
| POST | `/api/insta/news/collect` | 뉴스 수집 수동 트리거 → BackgroundTask |
|
||||
| GET | `/api/insta/news/articles` | 수집 기사 목록 (category, days 필터) |
|
||||
| POST | `/api/insta/keywords/extract` | 키워드 추출 수동 트리거 → BackgroundTask |
|
||||
| GET | `/api/insta/keywords` | 트렌딩 키워드 (category, used 필터) |
|
||||
| POST | `/api/insta/slates` | 슬레이트 생성 (keyword, category) → BackgroundTask |
|
||||
| GET | `/api/insta/slates` | 슬레이트 목록 |
|
||||
| GET | `/api/insta/slates/{id}` | 슬레이트 상세 (카피 + 자산 경로) |
|
||||
| POST | `/api/insta/slates/{id}/render` | 카드 렌더 재시도 |
|
||||
| GET | `/api/insta/slates/{id}/assets/{page}` | 카드 PNG 다운로드 (1~10) |
|
||||
| DELETE | `/api/insta/slates/{id}` | 삭제 (slate + assets) |
|
||||
| GET | `/api/insta/tasks/{task_id}` | BackgroundTask 상태 폴링 |
|
||||
| GET/PUT | `/api/insta/templates/prompts/{name}` | 프롬프트 템플릿 조회·수정 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 키워드 추출 알고리즘
|
||||
|
||||
```python
|
||||
def extract_keywords(category: str, articles: list[Article]) -> list[Keyword]:
|
||||
# 1. 빈도 기반 후보 추출
|
||||
# - 명사 추출 (간단: 한글 2~6자 정규식 + 불용어 제거)
|
||||
# - 카테고리 시드 키워드와 코사인 유사도 ≥ 0.3 이상만
|
||||
raw_freq = count_nouns(articles)
|
||||
candidates = top_n(raw_freq, n=20)
|
||||
|
||||
# 2. Claude Haiku로 정제
|
||||
# - 시스템 프롬프트: "{category} 인스타 카드용 키워드"
|
||||
# - 입력: 후보 20개 + 각 후보가 등장한 기사 제목 3개
|
||||
# - 출력 JSON: [{"keyword": str, "score": 0~1, "reason": str}]
|
||||
refined = claude_haiku_refine(category, candidates, articles)
|
||||
|
||||
# 3. score 내림차순 → 상위 5개 trending_keywords로 저장
|
||||
return refined[:5]
|
||||
```
|
||||
|
||||
- `score`는 LLM이 평가한 "카드 콘텐츠 적합도" (호기심 유발성 + 시의성 + 구체성)
|
||||
- 시드 키워드는 `prompt_templates.name='category_seeds'`에서 카테고리별 JSON으로 관리
|
||||
|
||||
---
|
||||
|
||||
## 10. 카드 카피 생성 (slate_writer)
|
||||
|
||||
Claude 호출 1회로 10페이지 카피 생성:
|
||||
|
||||
```
|
||||
시스템 프롬프트 (DB 저장, 사용자가 수정 가능):
|
||||
- 너는 인스타그램 카드 뉴스 카피라이터다.
|
||||
- {category} 카테고리, 키워드: {keyword}
|
||||
- 출력은 JSON 객체:
|
||||
{
|
||||
"cover_copy": {"headline": str, "body": str, "accent_color": "#hex"},
|
||||
"body_copies": [
|
||||
{"headline": str, "body": str},
|
||||
... (8개)
|
||||
],
|
||||
"cta_copy": {"headline": str, "body": str, "cta": str},
|
||||
"suggested_caption": str,
|
||||
"hashtags": ["#tag1", ...]
|
||||
}
|
||||
|
||||
입력:
|
||||
- 키워드 + 관련 기사 제목·요약 5건
|
||||
```
|
||||
|
||||
`accent_color`는 카테고리별 기본값(경제=#0F62FE, 심리학=#A66CFF, 연예=#FF5C8A) 사용, LLM이 더 어울리면 override.
|
||||
|
||||
---
|
||||
|
||||
## 11. 에러 처리
|
||||
|
||||
| 단계 | 실패 시 |
|
||||
|------|---------|
|
||||
| 뉴스 수집 | 카테고리별 try/except, 한 카테고리 빈 결과여도 다른 카테고리 진행. 모두 실패 시 텔레그램 알림 |
|
||||
| 키워드 추출 | LLM 실패 시 빈도 기반 결과만 사용 (degrade). LLM 타임아웃 60s |
|
||||
| 카피 생성 | LLM 실패 시 BackgroundTask `failed`, 텔레그램 알림. JSON 파싱 실패 시 1회 retry |
|
||||
| 카드 렌더 | Playwright 크래시 시 retry 1회. 실패 시 slate.status='failed' + 텔레그램 알림. 일부 페이지만 실패 시 해당 페이지만 재렌더 가능 |
|
||||
| 텔레그램 미디어 그룹 | 텔레그램 API 10MB/장 제한 → PNG quality 90, 평균 < 500KB 예상. 초과 시 압축 후 재시도 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 테스트
|
||||
|
||||
- pytest 단위 테스트:
|
||||
- `news_collector` mocked HTTP, JSON 파싱 검증
|
||||
- `keyword_extractor` 빈도 추출 단위 + Claude mock
|
||||
- `card_writer` Claude mock, JSON 스키마 검증
|
||||
- `card_renderer` 작은 fixture HTML로 PNG 1장 생성 (실제 Playwright 통합 테스트 1건)
|
||||
- agent-office 통합: `agents/insta.py` mocked service_proxy로 on_schedule·on_command·on_callback 분기 검증
|
||||
|
||||
---
|
||||
|
||||
## 13. 운영·환경
|
||||
|
||||
### 환경변수 (insta-lab)
|
||||
|
||||
| 변수 | 기본값 | 설명 |
|
||||
|------|--------|------|
|
||||
| `NAVER_CLIENT_ID` | (필수) | 네이버 검색 API 키 |
|
||||
| `NAVER_CLIENT_SECRET` | (필수) | 네이버 검색 API 시크릿 |
|
||||
| `ANTHROPIC_API_KEY` | (필수) | Claude API 키 |
|
||||
| `INSTA_DATA_PATH` | `./data/insta` | DB + 카드 PNG 저장 경로 |
|
||||
| `CARD_TEMPLATE_DIR` | `/app/app/templates` | HTML/CSS 템플릿 디렉토리 |
|
||||
| `CORS_ALLOW_ORIGINS` | `*` | CORS 설정 |
|
||||
|
||||
### docker-compose.yml 변경
|
||||
|
||||
- `blog-lab` 서비스 블록 → `insta-lab` 서비스 블록 (포트 18700:8000 그대로)
|
||||
- 볼륨: `./data/insta:/app/data/insta`
|
||||
|
||||
### nginx default.conf 변경
|
||||
|
||||
```
|
||||
location /api/blog-marketing/ { # 제거
|
||||
...
|
||||
}
|
||||
|
||||
location /api/insta/ { # 신규
|
||||
proxy_pass http://insta-lab:8000;
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### CLAUDE.md 갱신
|
||||
|
||||
- workspace/CLAUDE.md: blog-lab 표 행 제거 → insta-lab 추가, `/api/blog-marketing/` 행 제거 → `/api/insta/` 추가, 컨테이너 이름·역할 업데이트
|
||||
- web-backend/CLAUDE.md: 9.x 섹션 blog-lab 통째로 → insta-lab 섹션, 4·5 표 갱신
|
||||
|
||||
---
|
||||
|
||||
## 14. 완료 정의
|
||||
|
||||
- [ ] blog-lab 디렉토리·DB 삭제, 컨테이너에서 더 이상 빌드 안 됨
|
||||
- [ ] insta-lab 컨테이너 빌드 및 헬스체크 통과
|
||||
- [ ] `POST /api/insta/news/collect` → news_articles에 카테고리당 30건 저장 확인
|
||||
- [ ] `POST /api/insta/keywords/extract` → trending_keywords 카테고리당 5개 저장
|
||||
- [ ] `POST /api/insta/slates` → 카피 생성 + 카드 PNG 10장 렌더 (수동 호출)
|
||||
- [ ] agent-office의 insta-agent 09:30 cron 등록, 텔레그램 인라인 키보드 후보 푸시 작동
|
||||
- [ ] 텔레그램 인라인 버튼 클릭 → 미디어 그룹 10장 발송 성공
|
||||
- [ ] CLAUDE.md 양쪽 갱신 후 커밋
|
||||
- [ ] pytest 전체 통과
|
||||
17
insta-lab/Dockerfile
Normal file
17
insta-lab/Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
FROM python:3.12-slim
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
fonts-noto-cjk fonts-noto-cjk-extra \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN playwright install --with-deps chromium
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
100
insta-lab/app/card_renderer.py
Normal file
100
insta-lab/app/card_renderer.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Jinja → HTML → Playwright headless screenshot."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from typing import List
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
from .config import CARDS_DIR, CARD_TEMPLATE_DIR
|
||||
from . import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_template_dir() -> str:
|
||||
"""Prefer config CARD_TEMPLATE_DIR if it exists; else fall back to in-repo templates/."""
|
||||
if os.path.isdir(CARD_TEMPLATE_DIR):
|
||||
return CARD_TEMPLATE_DIR
|
||||
return os.path.join(os.path.dirname(__file__), "templates")
|
||||
|
||||
|
||||
def _env() -> Environment:
|
||||
return Environment(
|
||||
loader=FileSystemLoader(_resolve_template_dir()),
|
||||
autoescape=select_autoescape(["html", "j2"]),
|
||||
)
|
||||
|
||||
|
||||
def _slate_dir(slate_id: int) -> str:
|
||||
out = os.path.join(CARDS_DIR, str(slate_id))
|
||||
os.makedirs(out, exist_ok=True)
|
||||
return out
|
||||
|
||||
|
||||
def _build_pages(slate: dict) -> List[dict]:
|
||||
cover = json.loads(slate["cover_copy"] or "{}")
|
||||
bodies = json.loads(slate["body_copies"] or "[]")
|
||||
cta = json.loads(slate["cta_copy"] or "{}")
|
||||
accent = cover.get("accent_color") or "#0F62FE"
|
||||
pages: List[dict] = []
|
||||
pages.append({
|
||||
"page_type": "cover", "page_no": 1, "total_pages": 10,
|
||||
"headline": cover.get("headline", ""), "body": cover.get("body", ""),
|
||||
"accent_color": accent, "cta": "",
|
||||
})
|
||||
for i, b in enumerate(bodies[:8]):
|
||||
pages.append({
|
||||
"page_type": "body", "page_no": i + 2, "total_pages": 10,
|
||||
"headline": b.get("headline", ""), "body": b.get("body", ""),
|
||||
"accent_color": accent, "cta": "",
|
||||
})
|
||||
pages.append({
|
||||
"page_type": "cta", "page_no": 10, "total_pages": 10,
|
||||
"headline": cta.get("headline", ""), "body": cta.get("body", ""),
|
||||
"accent_color": accent, "cta": cta.get("cta", ""),
|
||||
})
|
||||
return pages
|
||||
|
||||
|
||||
async def render_slate(slate_id: int, template: str = "default/card.html.j2") -> List[str]:
|
||||
slate = db.get_card_slate(slate_id)
|
||||
if not slate:
|
||||
raise ValueError(f"slate {slate_id} not found")
|
||||
env = _env()
|
||||
tmpl = env.get_template(template)
|
||||
pages = _build_pages(slate)
|
||||
out_dir = _slate_dir(slate_id)
|
||||
paths: List[str] = []
|
||||
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch()
|
||||
try:
|
||||
ctx = await browser.new_context(viewport={"width": 1080, "height": 1350})
|
||||
page = await ctx.new_page()
|
||||
for spec in pages:
|
||||
html_str = tmpl.render(**spec)
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False, encoding="utf-8") as f:
|
||||
f.write(html_str)
|
||||
html_path = f.name
|
||||
try:
|
||||
await page.goto(f"file://{html_path}", wait_until="networkidle")
|
||||
out_path = os.path.join(out_dir, f"{spec['page_no']:02d}.png")
|
||||
await page.screenshot(path=out_path, full_page=False, omit_background=False)
|
||||
with open(out_path, "rb") as fp:
|
||||
file_hash = hashlib.md5(fp.read()).hexdigest()
|
||||
db.add_card_asset(slate_id, spec["page_no"], out_path, file_hash)
|
||||
paths.append(out_path)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(html_path)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
await browser.close()
|
||||
return paths
|
||||
100
insta-lab/app/card_writer.py
Normal file
100
insta-lab/app/card_writer.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Claude로 10페이지 카드 카피를 한 번에 생성."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from anthropic import Anthropic
|
||||
|
||||
from .config import ANTHROPIC_API_KEY, ANTHROPIC_MODEL_SONNET
|
||||
from . import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_ACCENT_BY_CATEGORY = {
|
||||
"economy": "#0F62FE",
|
||||
"psychology": "#A66CFF",
|
||||
"celebrity": "#FF5C8A",
|
||||
}
|
||||
|
||||
DEFAULT_PROMPT = """너는 인스타그램 카드 뉴스 카피라이터다.
|
||||
카테고리: {category}
|
||||
키워드: {keyword}
|
||||
참고 기사:
|
||||
{articles}
|
||||
|
||||
10페이지 인스타 카드용 카피를 다음 JSON 한 객체로만 출력해라 (코드펜스 금지):
|
||||
{{
|
||||
"cover_copy": {{"headline": "<훅 한 줄>", "body": "<서브카피 1~2줄>", "accent_color": "#hex"}},
|
||||
"body_copies": [
|
||||
{{"headline": "<포인트 헤드라인>", "body": "<2~4문장 본문>"}},
|
||||
... (총 8개)
|
||||
],
|
||||
"cta_copy": {{"headline": "<요약 한 줄>", "body": "<마무리 1~2줄>", "cta": "팔로우/저장 등"}},
|
||||
"suggested_caption": "<인스타 캡션 본문>",
|
||||
"hashtags": ["#태그1", "#태그2", ...]
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def _client() -> Anthropic:
|
||||
return Anthropic(api_key=ANTHROPIC_API_KEY)
|
||||
|
||||
|
||||
def _strip_codefence(s: str) -> str:
|
||||
s = s.strip()
|
||||
if s.startswith("```"):
|
||||
s = re.sub(r"^```(?:json)?\s*|\s*```$", "", s).strip()
|
||||
return s
|
||||
|
||||
|
||||
def _load_prompt() -> str:
|
||||
pt = db.get_prompt_template("slate_writer")
|
||||
if pt and pt.get("template"):
|
||||
return pt["template"]
|
||||
return DEFAULT_PROMPT
|
||||
|
||||
|
||||
def write_slate(keyword: str, category: str,
|
||||
articles: Optional[list] = None) -> int:
|
||||
"""Claude로 10페이지 카피 생성 후 card_slates에 저장. slate_id 반환."""
|
||||
if articles is None:
|
||||
articles = db.list_news_articles(category=category, days=2)
|
||||
article_text = "\n".join(
|
||||
f"- {a['title']}: {a.get('summary', '')[:120]}" for a in articles[:8]
|
||||
) or "(참고 기사 없음)"
|
||||
|
||||
prompt = _load_prompt().format(category=category, keyword=keyword, articles=article_text)
|
||||
msg = _client().messages.create(
|
||||
model=ANTHROPIC_MODEL_SONNET,
|
||||
max_tokens=4000,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
raw = msg.content[0].text
|
||||
cleaned = _strip_codefence(raw)
|
||||
try:
|
||||
data: Dict[str, Any] = json.loads(cleaned)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning("slate JSON parse failed: %s", e)
|
||||
raise ValueError(f"Invalid JSON from LLM: {e}") from e
|
||||
|
||||
body_copies = data.get("body_copies") or []
|
||||
if len(body_copies) != 8:
|
||||
raise ValueError(f"body_copies must have 8 items, got {len(body_copies)}")
|
||||
|
||||
cover = data.get("cover_copy") or {}
|
||||
if not cover.get("accent_color"):
|
||||
cover["accent_color"] = DEFAULT_ACCENT_BY_CATEGORY.get(category, "#222831")
|
||||
|
||||
sid = db.add_card_slate({
|
||||
"keyword": keyword,
|
||||
"category": category,
|
||||
"status": "draft",
|
||||
"cover_copy": cover,
|
||||
"body_copies": body_copies,
|
||||
"cta_copy": data.get("cta_copy") or {},
|
||||
"suggested_caption": data.get("suggested_caption") or "",
|
||||
"hashtags": data.get("hashtags") or [],
|
||||
})
|
||||
return sid
|
||||
25
insta-lab/app/config.py
Normal file
25
insta-lab/app/config.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import os
|
||||
|
||||
NAVER_CLIENT_ID = os.getenv("NAVER_CLIENT_ID", "")
|
||||
NAVER_CLIENT_SECRET = os.getenv("NAVER_CLIENT_SECRET", "")
|
||||
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
|
||||
ANTHROPIC_MODEL_HAIKU = os.getenv("ANTHROPIC_MODEL_HAIKU", "claude-haiku-4-5-20251001")
|
||||
ANTHROPIC_MODEL_SONNET = os.getenv("ANTHROPIC_MODEL_SONNET", "claude-sonnet-4-6")
|
||||
|
||||
INSTA_DATA_PATH = os.getenv("INSTA_DATA_PATH", "/app/data")
|
||||
DB_PATH = os.path.join(INSTA_DATA_PATH, "insta.db")
|
||||
CARDS_DIR = os.path.join(INSTA_DATA_PATH, "insta_cards")
|
||||
CARD_TEMPLATE_DIR = os.getenv("CARD_TEMPLATE_DIR", "/app/app/templates")
|
||||
|
||||
CORS_ALLOW_ORIGINS = os.getenv(
|
||||
"CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080"
|
||||
)
|
||||
|
||||
NEWS_PER_CATEGORY = int(os.getenv("NEWS_PER_CATEGORY", "30"))
|
||||
KEYWORDS_PER_CATEGORY = int(os.getenv("KEYWORDS_PER_CATEGORY", "5"))
|
||||
|
||||
DEFAULT_CATEGORY_SEEDS = {
|
||||
"economy": ["금리", "인플레이션", "환율", "주식", "부동산"],
|
||||
"psychology": ["심리학", "스트레스", "우울증", "관계", "자존감"],
|
||||
"celebrity": ["연예인", "드라마", "예능", "K-POP", "영화"],
|
||||
}
|
||||
278
insta-lab/app/db.py
Normal file
278
insta-lab/app/db.py
Normal file
@@ -0,0 +1,278 @@
|
||||
import os
|
||||
import sqlite3
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .config import DB_PATH
|
||||
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH, timeout=120.0)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=120000")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS news_articles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
category TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
link TEXT NOT NULL UNIQUE,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
pub_date TEXT,
|
||||
fetched_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_na_category_fetched ON news_articles(category, fetched_at DESC)")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS trending_keywords (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
keyword TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
score REAL NOT NULL DEFAULT 0,
|
||||
articles_count INTEGER NOT NULL DEFAULT 0,
|
||||
suggested_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
used INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_tk_score ON trending_keywords(category, score DESC)")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS card_slates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
keyword TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
cover_copy TEXT NOT NULL DEFAULT '{}',
|
||||
body_copies TEXT NOT NULL DEFAULT '[]',
|
||||
cta_copy TEXT NOT NULL DEFAULT '{}',
|
||||
suggested_caption TEXT NOT NULL DEFAULT '',
|
||||
hashtags TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_cs_created ON card_slates(created_at DESC)")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS card_assets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slate_id INTEGER NOT NULL REFERENCES card_slates(id) ON DELETE CASCADE,
|
||||
page_index INTEGER NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
file_hash TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
UNIQUE (slate_id, page_index)
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_ca_slate ON card_assets(slate_id, page_index)")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS generation_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
progress INTEGER NOT NULL DEFAULT 0,
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
result_id INTEGER,
|
||||
error TEXT,
|
||||
params TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_gt_created ON generation_tasks(created_at DESC)")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS prompt_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
template TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
# ── news_articles ────────────────────────────────────────────────
|
||||
def add_news_article(row: Dict[str, Any]) -> int:
|
||||
with _conn() as conn:
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO news_articles(category, title, link, summary, pub_date) VALUES(?,?,?,?,?)",
|
||||
(row["category"], row["title"], row["link"], row.get("summary", ""), row.get("pub_date")),
|
||||
)
|
||||
return cur.lastrowid
|
||||
except sqlite3.IntegrityError:
|
||||
existing = conn.execute("SELECT id FROM news_articles WHERE link=?", (row["link"],)).fetchone()
|
||||
return existing["id"] if existing else 0
|
||||
|
||||
|
||||
def list_news_articles(category: Optional[str] = None, days: int = 1) -> List[Dict[str, Any]]:
|
||||
sql = "SELECT * FROM news_articles WHERE fetched_at >= datetime('now', ?)"
|
||||
params: List[Any] = [f"-{int(days)} days"]
|
||||
if category:
|
||||
sql += " AND category=?"
|
||||
params.append(category)
|
||||
sql += " ORDER BY fetched_at DESC"
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# ── trending_keywords ───────────────────────────────────────────
|
||||
def add_trending_keyword(row: Dict[str, Any]) -> int:
|
||||
with _conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO trending_keywords(keyword, category, score, articles_count) VALUES(?,?,?,?)",
|
||||
(row["keyword"], row["category"], float(row.get("score", 0.0)), int(row.get("articles_count", 0))),
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def list_trending_keywords(category: Optional[str] = None, used: Optional[bool] = None) -> List[Dict[str, Any]]:
|
||||
sql = "SELECT * FROM trending_keywords WHERE 1=1"
|
||||
params: List[Any] = []
|
||||
if category:
|
||||
sql += " AND category=?"
|
||||
params.append(category)
|
||||
if used is not None:
|
||||
sql += " AND used=?"
|
||||
params.append(1 if used else 0)
|
||||
sql += " ORDER BY score DESC, suggested_at DESC"
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def mark_keyword_used(keyword_id: int) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute("UPDATE trending_keywords SET used=1 WHERE id=?", (keyword_id,))
|
||||
|
||||
|
||||
def get_trending_keyword(keyword_id: int) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT * FROM trending_keywords WHERE id=?", (keyword_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
# ── card_slates ─────────────────────────────────────────────────
|
||||
def add_card_slate(row: Dict[str, Any]) -> int:
|
||||
with _conn() as conn:
|
||||
cur = conn.execute("""
|
||||
INSERT INTO card_slates(keyword, category, status, cover_copy, body_copies, cta_copy,
|
||||
suggested_caption, hashtags)
|
||||
VALUES(?,?,?,?,?,?,?,?)
|
||||
""", (
|
||||
row["keyword"], row["category"], row.get("status", "draft"),
|
||||
json.dumps(row.get("cover_copy", {}), ensure_ascii=False),
|
||||
json.dumps(row.get("body_copies", []), ensure_ascii=False),
|
||||
json.dumps(row.get("cta_copy", {}), ensure_ascii=False),
|
||||
row.get("suggested_caption", ""),
|
||||
json.dumps(row.get("hashtags", []), ensure_ascii=False),
|
||||
))
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def update_slate_status(slate_id: int, status: str) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE card_slates SET status=?, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?",
|
||||
(status, slate_id),
|
||||
)
|
||||
|
||||
|
||||
def get_card_slate(slate_id: int) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT * FROM card_slates WHERE id=?", (slate_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def list_card_slates(limit: int = 50) -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM card_slates ORDER BY created_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def delete_card_slate(slate_id: int) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute("DELETE FROM card_slates WHERE id=?", (slate_id,))
|
||||
|
||||
|
||||
# ── card_assets ─────────────────────────────────────────────────
|
||||
def add_card_asset(slate_id: int, page_index: int, file_path: str, file_hash: str = "") -> int:
|
||||
with _conn() as conn:
|
||||
cur = conn.execute("""
|
||||
INSERT INTO card_assets(slate_id, page_index, file_path, file_hash)
|
||||
VALUES(?,?,?,?)
|
||||
ON CONFLICT(slate_id, page_index) DO UPDATE SET
|
||||
file_path=excluded.file_path, file_hash=excluded.file_hash
|
||||
""", (slate_id, page_index, file_path, file_hash))
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def list_card_assets(slate_id: int) -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM card_assets WHERE slate_id=? ORDER BY page_index ASC",
|
||||
(slate_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# ── generation_tasks ────────────────────────────────────────────
|
||||
def create_task(task_type: str, params: Dict[str, Any]) -> str:
|
||||
tid = uuid.uuid4().hex
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO generation_tasks(id, type, params) VALUES(?,?,?)",
|
||||
(tid, task_type, json.dumps(params, ensure_ascii=False)),
|
||||
)
|
||||
return tid
|
||||
|
||||
|
||||
def update_task(task_id: str, status: str, progress: int = 0, message: str = "",
|
||||
result_id: Optional[int] = None, error: Optional[str] = None) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute("""
|
||||
UPDATE generation_tasks
|
||||
SET status=?, progress=?, message=?, result_id=?, error=?,
|
||||
updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
||||
WHERE id=?
|
||||
""", (status, progress, message, result_id, error, task_id))
|
||||
|
||||
|
||||
def get_task(task_id: str) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT * FROM generation_tasks WHERE id=?", (task_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
# ── prompt_templates ────────────────────────────────────────────
|
||||
def upsert_prompt_template(name: str, template: str, description: str = "") -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute("""
|
||||
INSERT INTO prompt_templates(name, description, template)
|
||||
VALUES(?,?,?)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
template=excluded.template,
|
||||
description=excluded.description,
|
||||
updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
||||
""", (name, description, template))
|
||||
|
||||
|
||||
def get_prompt_template(name: str) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT * FROM prompt_templates WHERE name=?", (name,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
83
insta-lab/app/keyword_extractor.py
Normal file
83
insta-lab/app/keyword_extractor.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""키워드 추출 — 한글 명사 빈도 + Claude Haiku 정제."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from collections import Counter
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from anthropic import Anthropic
|
||||
|
||||
from .config import ANTHROPIC_API_KEY, ANTHROPIC_MODEL_HAIKU, KEYWORDS_PER_CATEGORY
|
||||
from . import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_NOUN_RE = re.compile(r"[가-힣]{2,6}")
|
||||
_STOPWORDS = {
|
||||
"있다", "없다", "이다", "되다", "그리고", "하지만", "통해", "위해", "오늘", "이번",
|
||||
"지난", "관련", "대해", "또한", "다만", "한편", "최근", "앞서", "현재", "진행",
|
||||
"발생", "결과", "이상", "이하", "여러", "다양", "방법", "경우", "이유", "필요",
|
||||
}
|
||||
|
||||
|
||||
def _count_nouns(text: str) -> Dict[str, int]:
|
||||
tokens = _NOUN_RE.findall(text or "")
|
||||
return Counter(tokens)
|
||||
|
||||
|
||||
def _top_candidates(counts: Dict[str, int], n: int = 20) -> List[tuple]:
|
||||
filtered = [(k, c) for k, c in counts.items() if k not in _STOPWORDS]
|
||||
return sorted(filtered, key=lambda x: x[1], reverse=True)[:n]
|
||||
|
||||
|
||||
def _refine_with_llm(category: str, candidates: List[tuple], articles: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Claude Haiku로 후보 정제. JSON 리스트 [{keyword, score(0~1), reason}] 반환."""
|
||||
if not ANTHROPIC_API_KEY:
|
||||
return [{"keyword": k, "score": min(1.0, c / 10), "reason": "freq"} for k, c in candidates[:KEYWORDS_PER_CATEGORY]]
|
||||
|
||||
client = Anthropic(api_key=ANTHROPIC_API_KEY)
|
||||
titles = [a["title"] for a in articles[:15]]
|
||||
prompt = f"""너는 인스타그램 카드 뉴스 큐레이터다.
|
||||
카테고리: {category}
|
||||
빈도 상위 후보: {[k for k, _ in candidates]}
|
||||
관련 기사 제목 일부:
|
||||
{chr(10).join('- ' + t for t in titles)}
|
||||
|
||||
이 후보 중에서 인스타 카드 콘텐츠로 적합한 키워드를 score 내림차순으로 최대 {KEYWORDS_PER_CATEGORY}개 골라.
|
||||
출력 형식 (JSON 배열만):
|
||||
[{{"keyword": "...", "score": 0.0~1.0, "reason": "..."}}]
|
||||
"""
|
||||
msg = client.messages.create(
|
||||
model=ANTHROPIC_MODEL_HAIKU,
|
||||
max_tokens=600,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
text = msg.content[0].text.strip()
|
||||
if text.startswith("```"):
|
||||
text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text).strip()
|
||||
try:
|
||||
return json.loads(text)
|
||||
except Exception:
|
||||
logger.warning("LLM refine JSON parse failed, falling back to freq")
|
||||
return [{"keyword": k, "score": min(1.0, c / 10), "reason": "freq-fallback"} for k, c in candidates[:KEYWORDS_PER_CATEGORY]]
|
||||
|
||||
|
||||
def extract_for_category(category: str, limit: int = KEYWORDS_PER_CATEGORY) -> List[Dict[str, Any]]:
|
||||
"""카테고리 기사들에서 키워드를 뽑아 DB에 저장하고 결과 반환."""
|
||||
articles = db.list_news_articles(category=category, days=2)
|
||||
text_blob = "\n".join((a["title"] + " " + a.get("summary", "")) for a in articles)
|
||||
counts = _count_nouns(text_blob)
|
||||
candidates = _top_candidates(counts, n=20)
|
||||
refined = _refine_with_llm(category, candidates, articles)[:limit]
|
||||
|
||||
saved: List[Dict[str, Any]] = []
|
||||
for kw in refined:
|
||||
kid = db.add_trending_keyword({
|
||||
"keyword": kw["keyword"],
|
||||
"category": category,
|
||||
"score": float(kw.get("score", 0.0)),
|
||||
"articles_count": sum(1 for a in articles if kw["keyword"] in a["title"]),
|
||||
})
|
||||
saved.append({"id": kid, **kw, "category": category})
|
||||
return saved
|
||||
82
insta-lab/app/news_collector.py
Normal file
82
insta-lab/app/news_collector.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""NAVER 뉴스 검색 API 연동 — 카테고리별 시드 키워드로 일일 수집."""
|
||||
|
||||
import html
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from .config import NAVER_CLIENT_ID, NAVER_CLIENT_SECRET, NEWS_PER_CATEGORY
|
||||
from . import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NEWS_URL = "https://openapi.naver.com/v1/search/news.json"
|
||||
_HEADERS = {
|
||||
"X-Naver-Client-Id": NAVER_CLIENT_ID,
|
||||
"X-Naver-Client-Secret": NAVER_CLIENT_SECRET,
|
||||
}
|
||||
_TAG_RE = re.compile(r"<[^>]+>")
|
||||
|
||||
|
||||
def _clean(text: str) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
no_tag = _TAG_RE.sub("", text)
|
||||
return html.unescape(no_tag).strip()
|
||||
|
||||
|
||||
def search_news(keyword: str, display: int = 30, sort: str = "date") -> List[Dict[str, Any]]:
|
||||
"""NAVER news.json 단일 호출.
|
||||
|
||||
Returns: list of {title, link, summary, pub_date}
|
||||
"""
|
||||
resp = requests.get(
|
||||
NEWS_URL,
|
||||
headers=_HEADERS,
|
||||
params={"query": keyword, "display": display, "sort": sort},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return [
|
||||
{
|
||||
"title": _clean(item.get("title", "")),
|
||||
"link": item.get("link") or item.get("originallink", ""),
|
||||
"summary": _clean(item.get("description", "")),
|
||||
"pub_date": item.get("pubDate", ""),
|
||||
}
|
||||
for item in data.get("items", [])
|
||||
]
|
||||
|
||||
|
||||
def collect_for_category(category: str,
|
||||
seed_keywords: List[str],
|
||||
per_keyword: Optional[int] = None) -> int:
|
||||
"""카테고리에 대해 시드 키워드 각각으로 검색 후 DB에 삽입.
|
||||
UNIQUE(link)가 중복 삽입을 막음. 시도된 기사 수(중복 포함) 반환.
|
||||
"""
|
||||
per_kw = per_keyword if per_keyword is not None else max(1, NEWS_PER_CATEGORY // max(1, len(seed_keywords)))
|
||||
seen_links = set()
|
||||
attempted = 0
|
||||
for kw in seed_keywords:
|
||||
try:
|
||||
items = search_news(kw, display=per_kw)
|
||||
except Exception as e:
|
||||
logger.warning("search_news failed kw=%s err=%s", kw, e)
|
||||
continue
|
||||
for item in items:
|
||||
link = item["link"]
|
||||
if not link or link in seen_links:
|
||||
continue
|
||||
seen_links.add(link)
|
||||
db.add_news_article({
|
||||
"category": category,
|
||||
"title": item["title"],
|
||||
"link": link,
|
||||
"summary": item["summary"],
|
||||
"pub_date": item["pub_date"],
|
||||
})
|
||||
attempted += 1
|
||||
return attempted
|
||||
0
insta-lab/app/templates/__init__.py
Normal file
0
insta-lab/app/templates/__init__.py
Normal file
0
insta-lab/app/templates/default/.gitkeep
Normal file
0
insta-lab/app/templates/default/.gitkeep
Normal file
55
insta-lab/app/templates/default/card.html.j2
Normal file
55
insta-lab/app/templates/default/card.html.j2
Normal file
@@ -0,0 +1,55 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;700;900&display=swap');
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
width: 1080px; height: 1350px;
|
||||
font-family: 'Noto Sans KR', sans-serif;
|
||||
background: #F7F7FA; color: #14171A;
|
||||
}
|
||||
.card {
|
||||
width: 1080px; height: 1350px;
|
||||
padding: 80px 72px;
|
||||
display: flex; flex-direction: column; justify-content: space-between;
|
||||
background: linear-gradient(180deg, #FFFFFF 0%, #F7F7FA 100%);
|
||||
border-top: 16px solid {{ accent_color }};
|
||||
}
|
||||
.badge {
|
||||
display: inline-block; padding: 8px 20px; border-radius: 999px;
|
||||
background: {{ accent_color }}; color: #fff;
|
||||
font-size: 28px; font-weight: 700; letter-spacing: -0.02em;
|
||||
}
|
||||
.headline {
|
||||
font-size: {{ 96 if page_type == 'cover' else 72 }}px;
|
||||
font-weight: 900; line-height: 1.15; letter-spacing: -0.04em;
|
||||
margin-top: 32px;
|
||||
}
|
||||
.body {
|
||||
font-size: 40px; font-weight: 400; line-height: 1.55;
|
||||
margin-top: 40px; color: #2A2F35;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.footer {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
font-size: 28px; color: #6B7280; font-weight: 500;
|
||||
}
|
||||
.cta { font-weight: 700; color: {{ accent_color }}; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div>
|
||||
<span class="badge">{{ page_type|upper }}</span>
|
||||
<h1 class="headline">{{ headline }}</h1>
|
||||
<p class="body">{{ body }}</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<span>{{ page_no }} / {{ total_pages }}</span>
|
||||
{% if cta %}<span class="cta">{{ cta }}</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
3
insta-lab/pytest.ini
Normal file
3
insta-lab/pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
pythonpath = .
|
||||
9
insta-lab/requirements.txt
Normal file
9
insta-lab/requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
requests==2.32.3
|
||||
httpx>=0.27
|
||||
anthropic==0.52.0
|
||||
jinja2>=3.1.4
|
||||
playwright==1.48.0
|
||||
pytest>=8.0
|
||||
pytest-asyncio>=0.24
|
||||
0
insta-lab/tests/__init__.py
Normal file
0
insta-lab/tests/__init__.py
Normal file
48
insta-lab/tests/test_card_renderer.py
Normal file
48
insta-lab/tests/test_card_renderer.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from app import db as db_module
|
||||
from app import card_renderer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_db_and_dirs(monkeypatch, tmp_path):
|
||||
fd, path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
monkeypatch.setattr(db_module, "DB_PATH", path)
|
||||
monkeypatch.setattr(card_renderer, "CARDS_DIR", str(tmp_path / "cards"))
|
||||
db_module.init_db()
|
||||
yield path
|
||||
import gc
|
||||
gc.collect()
|
||||
for ext in ("", "-wal", "-shm"):
|
||||
try:
|
||||
os.remove(path + ext)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _seed_slate() -> int:
|
||||
return db_module.add_card_slate({
|
||||
"keyword": "테스트",
|
||||
"category": "economy",
|
||||
"status": "draft",
|
||||
"cover_copy": {"headline": "커버 헤드라인", "body": "서브카피", "accent_color": "#0F62FE"},
|
||||
"body_copies": [{"headline": f"본문 {i+1}", "body": f"내용 {i+1}"} for i in range(8)],
|
||||
"cta_copy": {"headline": "마무리", "body": "감사합니다", "cta": "팔로우"},
|
||||
})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_slate_produces_ten_pngs(tmp_db_and_dirs):
|
||||
sid = _seed_slate()
|
||||
paths = await card_renderer.render_slate(sid)
|
||||
assert len(paths) == 10
|
||||
for p in paths:
|
||||
assert os.path.exists(p)
|
||||
assert os.path.getsize(p) > 1000 # > 1 KB sanity
|
||||
db_module.update_slate_status(sid, "rendered")
|
||||
assets = db_module.list_card_assets(sid)
|
||||
assert {a["page_index"] for a in assets} == set(range(1, 11))
|
||||
75
insta-lab/tests/test_card_writer.py
Normal file
75
insta-lab/tests/test_card_writer.py
Normal file
@@ -0,0 +1,75 @@
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app import db as db_module
|
||||
from app import card_writer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_db(monkeypatch):
|
||||
fd, path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
monkeypatch.setattr(db_module, "DB_PATH", path)
|
||||
db_module.init_db()
|
||||
yield path
|
||||
import gc
|
||||
gc.collect()
|
||||
for ext in ("", "-wal", "-shm"):
|
||||
try:
|
||||
os.remove(path + ext)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
SAMPLE_LLM_JSON = {
|
||||
"cover_copy": {"headline": "금리 인상 단행", "body": "왜 지금?", "accent_color": "#0F62FE"},
|
||||
"body_copies": [
|
||||
{"headline": f"포인트 {i+1}", "body": f"본문 {i+1}"} for i in range(8)
|
||||
],
|
||||
"cta_copy": {"headline": "정리", "body": "바로 확인", "cta": "팔로우"},
|
||||
"suggested_caption": "금리에 대해 알아보자",
|
||||
"hashtags": ["#금리", "#경제"],
|
||||
}
|
||||
|
||||
|
||||
def _fake_messages_create(*_args, **_kwargs):
|
||||
msg = MagicMock()
|
||||
block = MagicMock()
|
||||
block.text = json.dumps(SAMPLE_LLM_JSON, ensure_ascii=False)
|
||||
msg.content = [block]
|
||||
return msg
|
||||
|
||||
|
||||
def test_write_slate_persists_full_payload(tmp_db, monkeypatch):
|
||||
db_module.add_news_article({
|
||||
"category": "economy", "title": "기준금리 인상 단행",
|
||||
"link": "https://example.com/1", "summary": "한국은행 발표",
|
||||
})
|
||||
fake_client = MagicMock()
|
||||
fake_client.messages.create = _fake_messages_create
|
||||
monkeypatch.setattr(card_writer, "_client", lambda: fake_client)
|
||||
|
||||
sid = card_writer.write_slate(keyword="기준금리", category="economy")
|
||||
slate = db_module.get_card_slate(sid)
|
||||
assert slate["status"] == "draft"
|
||||
body_copies = json.loads(slate["body_copies"])
|
||||
assert len(body_copies) == 8
|
||||
assert body_copies[0]["headline"] == "포인트 1"
|
||||
assert json.loads(slate["cover_copy"])["accent_color"] == "#0F62FE"
|
||||
|
||||
|
||||
def test_write_slate_raises_on_invalid_json(tmp_db, monkeypatch):
|
||||
fake_client = MagicMock()
|
||||
bad_msg = MagicMock()
|
||||
bad_block = MagicMock()
|
||||
bad_block.text = "not json"
|
||||
bad_msg.content = [bad_block]
|
||||
fake_client.messages.create.return_value = bad_msg
|
||||
monkeypatch.setattr(card_writer, "_client", lambda: fake_client)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
card_writer.write_slate(keyword="x", category="economy")
|
||||
96
insta-lab/tests/test_db.py
Normal file
96
insta-lab/tests/test_db.py
Normal file
@@ -0,0 +1,96 @@
|
||||
import os
|
||||
import json
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from app import db as db_module
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_db(monkeypatch):
|
||||
fd, path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
monkeypatch.setattr(db_module, "DB_PATH", path)
|
||||
db_module.init_db()
|
||||
yield path
|
||||
# Close all SQLite WAL files before removal (needed on Windows)
|
||||
import gc
|
||||
gc.collect()
|
||||
for ext in ("", "-wal", "-shm"):
|
||||
try:
|
||||
os.remove(path + ext)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def test_init_db_creates_six_tables(tmp_db):
|
||||
with db_module._conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
||||
).fetchall()
|
||||
names = sorted(r[0] for r in rows if not r[0].startswith("sqlite_"))
|
||||
assert names == sorted([
|
||||
"news_articles", "trending_keywords", "card_slates",
|
||||
"card_assets", "generation_tasks", "prompt_templates",
|
||||
])
|
||||
|
||||
|
||||
def test_news_article_roundtrip(tmp_db):
|
||||
aid = db_module.add_news_article({
|
||||
"category": "economy",
|
||||
"title": "금리 인상 발표",
|
||||
"link": "https://example.com/1",
|
||||
"summary": "한국은행이 기준금리를 인상했다.",
|
||||
"pub_date": "2026-05-15T08:00:00",
|
||||
})
|
||||
assert isinstance(aid, int)
|
||||
rows = db_module.list_news_articles(category="economy", days=7)
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["title"] == "금리 인상 발표"
|
||||
|
||||
|
||||
def test_trending_keyword_roundtrip(tmp_db):
|
||||
kid = db_module.add_trending_keyword({
|
||||
"keyword": "기준금리",
|
||||
"category": "economy",
|
||||
"score": 0.87,
|
||||
"articles_count": 12,
|
||||
})
|
||||
assert isinstance(kid, int)
|
||||
items = db_module.list_trending_keywords(category="economy", used=False)
|
||||
assert items[0]["score"] == pytest.approx(0.87)
|
||||
|
||||
|
||||
def test_card_slate_with_assets(tmp_db):
|
||||
sid = db_module.add_card_slate({
|
||||
"keyword": "기준금리",
|
||||
"category": "economy",
|
||||
"cover_copy": {"headline": "금리 인상", "body": "왜?", "accent_color": "#0F62FE"},
|
||||
"body_copies": [{"headline": f"H{i}", "body": f"B{i}"} for i in range(8)],
|
||||
"cta_copy": {"headline": "정리", "body": "바로 확인", "cta": "팔로우"},
|
||||
"suggested_caption": "금리에 대해 알아보자",
|
||||
"hashtags": ["#금리", "#경제"],
|
||||
})
|
||||
db_module.add_card_asset(sid, page_index=1, file_path="/tmp/01.png", file_hash="abc")
|
||||
slate = db_module.get_card_slate(sid)
|
||||
assert slate["status"] == "draft"
|
||||
assert json.loads(slate["body_copies"])[0]["headline"] == "H0"
|
||||
assets = db_module.list_card_assets(sid)
|
||||
assert assets[0]["page_index"] == 1
|
||||
|
||||
|
||||
def test_generation_task_lifecycle(tmp_db):
|
||||
tid = db_module.create_task("collect", {"category": "economy"})
|
||||
db_module.update_task(tid, status="processing", progress=50, message="..")
|
||||
db_module.update_task(tid, status="succeeded", progress=100, message="ok", result_id=123)
|
||||
t = db_module.get_task(tid)
|
||||
assert t["status"] == "succeeded"
|
||||
assert t["result_id"] == 123
|
||||
|
||||
|
||||
def test_prompt_template_upsert(tmp_db):
|
||||
db_module.upsert_prompt_template("slate_writer", "v1 template", "writer")
|
||||
db_module.upsert_prompt_template("slate_writer", "v2 template", "writer")
|
||||
pt = db_module.get_prompt_template("slate_writer")
|
||||
assert pt["template"] == "v2 template"
|
||||
65
insta-lab/tests/test_keyword_extractor.py
Normal file
65
insta-lab/tests/test_keyword_extractor.py
Normal file
@@ -0,0 +1,65 @@
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app import db as db_module
|
||||
from app import keyword_extractor
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_db(monkeypatch):
|
||||
fd, path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
monkeypatch.setattr(db_module, "DB_PATH", path)
|
||||
db_module.init_db()
|
||||
yield path
|
||||
# Windows-safe cleanup: close handles + remove sidecars
|
||||
import gc
|
||||
gc.collect()
|
||||
for ext in ("", "-wal", "-shm"):
|
||||
try:
|
||||
os.remove(path + ext)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def test_count_nouns_extracts_korean_nouns():
|
||||
text = "기준금리 인상으로 환율 급등. 기준금리 추가 인상 가능성"
|
||||
counts = keyword_extractor._count_nouns(text)
|
||||
assert counts["기준금리"] == 2
|
||||
assert counts["환율"] == 1
|
||||
|
||||
|
||||
def test_top_candidates_filters_stopwords():
|
||||
counts = {"기준금리": 5, "있다": 7, "환율": 3, "그리고": 4}
|
||||
top = keyword_extractor._top_candidates(counts, n=10)
|
||||
keywords = [k for k, _ in top]
|
||||
assert "있다" not in keywords
|
||||
assert "그리고" not in keywords
|
||||
assert "기준금리" in keywords
|
||||
|
||||
|
||||
def test_extract_for_category_persists(tmp_db):
|
||||
# seed articles
|
||||
for i in range(3):
|
||||
db_module.add_news_article({
|
||||
"category": "economy",
|
||||
"title": f"기준금리 인상 {i}",
|
||||
"link": f"https://example.com/{i}",
|
||||
"summary": "환율도 영향",
|
||||
})
|
||||
|
||||
# mock LLM refinement
|
||||
fake_refined = [
|
||||
{"keyword": "기준금리", "score": 0.92, "reason": "핵심 금융 이슈"},
|
||||
{"keyword": "환율", "score": 0.71, "reason": "시장 영향"},
|
||||
]
|
||||
with patch.object(keyword_extractor, "_refine_with_llm", return_value=fake_refined):
|
||||
kws = keyword_extractor.extract_for_category("economy", limit=2)
|
||||
|
||||
assert len(kws) == 2
|
||||
assert kws[0]["keyword"] == "기준금리"
|
||||
persisted = db_module.list_trending_keywords(category="economy")
|
||||
assert {p["keyword"] for p in persisted} == {"기준금리", "환율"}
|
||||
89
insta-lab/tests/test_news_collector.py
Normal file
89
insta-lab/tests/test_news_collector.py
Normal file
@@ -0,0 +1,89 @@
|
||||
from unittest.mock import patch, MagicMock
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from app import db as db_module
|
||||
from app import news_collector
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_db(monkeypatch):
|
||||
fd, path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
monkeypatch.setattr(db_module, "DB_PATH", path)
|
||||
db_module.init_db()
|
||||
yield path
|
||||
# Close all SQLite WAL files before removal (needed on Windows)
|
||||
import gc
|
||||
gc.collect()
|
||||
for ext in ("", "-wal", "-shm"):
|
||||
try:
|
||||
os.remove(path + ext)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
SAMPLE_RESPONSE = {
|
||||
"items": [
|
||||
{
|
||||
"title": "<b>금리</b> 인상 단행",
|
||||
"originallink": "https://news.example.com/1",
|
||||
"link": "https://n.news.naver.com/article/1",
|
||||
"description": "한국은행이 <b>기준금리</b>를 25bp 올렸다.",
|
||||
"pubDate": "Fri, 15 May 2026 08:00:00 +0900",
|
||||
},
|
||||
{
|
||||
"title": "환율 급등",
|
||||
"originallink": "https://news.example.com/2",
|
||||
"link": "https://n.news.naver.com/article/2",
|
||||
"description": "원달러 환율이 1400원을 돌파했다.",
|
||||
"pubDate": "Fri, 15 May 2026 09:00:00 +0900",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_strip_html_and_decode_entities():
|
||||
out = news_collector._clean(' <b>"테스트"</b> & 아이템 ')
|
||||
assert out == '"테스트" & 아이템'
|
||||
|
||||
|
||||
def test_search_news_parses_items(tmp_db):
|
||||
fake_resp = MagicMock()
|
||||
fake_resp.json.return_value = SAMPLE_RESPONSE
|
||||
fake_resp.raise_for_status.return_value = None
|
||||
with patch.object(news_collector.requests, "get", return_value=fake_resp):
|
||||
items = news_collector.search_news("금리", display=10)
|
||||
assert len(items) == 2
|
||||
assert items[0]["title"] == "금리 인상 단행"
|
||||
assert items[0]["summary"].startswith("한국은행")
|
||||
|
||||
|
||||
def test_collect_for_category_inserts(tmp_db):
|
||||
fake_resp = MagicMock()
|
||||
fake_resp.json.return_value = SAMPLE_RESPONSE
|
||||
fake_resp.raise_for_status.return_value = None
|
||||
with patch.object(news_collector.requests, "get", return_value=fake_resp):
|
||||
news_collector.collect_for_category("economy", seed_keywords=["금리"], per_keyword=10)
|
||||
rows = db_module.list_news_articles(category="economy", days=7)
|
||||
assert {r["link"] for r in rows} == {
|
||||
"https://n.news.naver.com/article/1",
|
||||
"https://n.news.naver.com/article/2",
|
||||
}
|
||||
|
||||
|
||||
def test_collect_dedupes_existing(tmp_db):
|
||||
db_module.add_news_article({
|
||||
"category": "economy", "title": "기존",
|
||||
"link": "https://n.news.naver.com/article/1", "summary": ""
|
||||
})
|
||||
fake_resp = MagicMock()
|
||||
fake_resp.json.return_value = SAMPLE_RESPONSE
|
||||
fake_resp.raise_for_status.return_value = None
|
||||
with patch.object(news_collector.requests, "get", return_value=fake_resp):
|
||||
news_collector.collect_for_category("economy", seed_keywords=["금리"])
|
||||
rows = db_module.list_news_articles(category="economy", days=7)
|
||||
# 1 pre-existing + 1 newly added (the other link); UNIQUE link blocks duplicate insert
|
||||
assert len(rows) == 2
|
||||
@@ -9,8 +9,10 @@ DB_PATH = "/app/data/lotto.db"
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn = sqlite3.connect(DB_PATH, timeout=120.0)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=120000")
|
||||
return conn
|
||||
|
||||
def _ensure_column(conn: sqlite3.Connection, table: str, col: str, ddl: str) -> None:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.30.6
|
||||
requests==2.32.3
|
||||
httpx==0.27.2
|
||||
beautifulsoup4==4.12.3
|
||||
APScheduler==3.10.4
|
||||
|
||||
@@ -9,8 +9,10 @@ DB_PATH = "/app/data/music.db"
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn = sqlite3.connect(DB_PATH, timeout=120.0)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=120000")
|
||||
return conn
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# /api/webai/* rate limit — web-ai pull worker (60 req/min, burst 20)
|
||||
limit_req_zone $binary_remote_addr zone=webai:5m rate=60r/m;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
@@ -132,6 +135,20 @@ server {
|
||||
proxy_pass http://travel-proxy:8000/api/travel/;
|
||||
}
|
||||
|
||||
# webai API — rate limited web-ai pull worker
|
||||
location /api/webai/ {
|
||||
limit_req zone=webai burst=20 nodelay;
|
||||
limit_req_status 429;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
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-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-WebAI-Key $http_x_webai_key;
|
||||
proxy_pass http://stock:8000;
|
||||
}
|
||||
|
||||
# stock API
|
||||
location /api/stock/ {
|
||||
proxy_http_version 1.1;
|
||||
@@ -139,17 +156,17 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://stock-lab:8000/api/stock/;
|
||||
proxy_pass http://stock:8000/api/stock/;
|
||||
}
|
||||
|
||||
# trade API (Stock Lab Proxy)
|
||||
# trade API (Stock Proxy)
|
||||
location /api/trade/ {
|
||||
proxy_http_version 1.1;
|
||||
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-Forwarded-Proto $scheme;
|
||||
proxy_pass http://stock-lab:8000/api/trade/;
|
||||
proxy_pass http://stock:8000/api/trade/;
|
||||
}
|
||||
|
||||
# blog-marketing API
|
||||
@@ -166,14 +183,14 @@ server {
|
||||
proxy_pass http://$blog_backend$request_uri;
|
||||
}
|
||||
|
||||
# portfolio API (Stock Lab) — trailing slash 유무 모두 매칭
|
||||
# portfolio API (Stock) — trailing slash 유무 모두 매칭
|
||||
location /api/portfolio {
|
||||
proxy_http_version 1.1;
|
||||
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-Forwarded-Proto $scheme;
|
||||
proxy_pass http://stock-lab:8000/api/portfolio;
|
||||
proxy_pass http://stock:8000/api/portfolio;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -9,9 +9,10 @@ DB_PATH = "/app/data/personal.db"
|
||||
|
||||
|
||||
def _conn():
|
||||
c = sqlite3.connect(DB_PATH, timeout=10)
|
||||
c = sqlite3.connect(DB_PATH, timeout=120.0)
|
||||
c.row_factory = sqlite3.Row
|
||||
c.execute("PRAGMA journal_mode=WAL;")
|
||||
c.execute("PRAGMA busy_timeout=120000;")
|
||||
c.execute("PRAGMA foreign_keys=ON;")
|
||||
return c
|
||||
|
||||
|
||||
@@ -12,9 +12,10 @@ DB_PATH = os.getenv("REALESTATE_DB_PATH", "/app/data/realestate.db")
|
||||
|
||||
|
||||
def _conn():
|
||||
c = sqlite3.connect(DB_PATH, timeout=10)
|
||||
c = sqlite3.connect(DB_PATH, timeout=120.0)
|
||||
c.row_factory = sqlite3.Row
|
||||
c.execute("PRAGMA journal_mode=WAL;")
|
||||
c.execute("PRAGMA busy_timeout=120000;")
|
||||
c.execute("PRAGMA foreign_keys=ON;")
|
||||
return c
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
# ── 서비스 목록 (한 곳에서만 관리) ──
|
||||
SERVICES="lotto travel-proxy deployer stock-lab music-lab blog-lab realestate-lab agent-office personal packs-lab nginx scripts"
|
||||
SERVICES="lotto travel-proxy deployer stock music-lab blog-lab realestate-lab agent-office personal packs-lab nginx scripts"
|
||||
|
||||
# 1. 자동 감지: Docker 컨테이너 내부인가?
|
||||
if [ -d "/repo" ] && [ -d "/runtime" ]; then
|
||||
|
||||
@@ -7,11 +7,11 @@ flock -n 200 || { echo "Deploy already running, skipping"; exit 0; }
|
||||
|
||||
# ── 서비스 목록 (한 곳에서만 관리) ──
|
||||
# docker compose 서비스명 (deployer 제외 — 자기 자신을 재빌드하면 스크립트 중단)
|
||||
BUILD_TARGETS="lotto travel-proxy stock-lab music-lab blog-lab realestate-lab agent-office personal packs-lab frontend"
|
||||
BUILD_TARGETS="lotto travel-proxy stock music-lab blog-lab realestate-lab agent-office personal packs-lab frontend"
|
||||
# 컨테이너 이름 (고아 정리용)
|
||||
CONTAINER_NAMES="lotto stock-lab music-lab blog-lab realestate-lab agent-office personal packs-lab travel-proxy frontend"
|
||||
CONTAINER_NAMES="lotto stock music-lab blog-lab realestate-lab agent-office personal packs-lab travel-proxy frontend"
|
||||
# 헬스체크 대상
|
||||
HEALTH_ENDPOINTS="lotto stock-lab travel-proxy music-lab blog-lab realestate-lab agent-office personal packs-lab"
|
||||
HEALTH_ENDPOINTS="lotto stock travel-proxy music-lab blog-lab realestate-lab agent-office personal packs-lab"
|
||||
# data 디렉토리 (packs-lab은 별도 media/packs 사용)
|
||||
DATA_DIRS="music stock blog realestate agent-office personal"
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from typing import List, Dict, Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger("stock-lab.ai_summarizer")
|
||||
logger = logging.getLogger("stock.ai_summarizer")
|
||||
|
||||
LLM_PROVIDER = os.getenv("LLM_PROVIDER", "claude").lower().strip()
|
||||
|
||||
37
stock/app/auth.py
Normal file
37
stock/app/auth.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import os
|
||||
import logging
|
||||
|
||||
from fastapi import Header, HTTPException
|
||||
from starlette.requests import Request
|
||||
|
||||
logger = logging.getLogger("stock")
|
||||
|
||||
_WEBAI_AUTH_WARNED = False
|
||||
|
||||
|
||||
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 무영향). 1회만 ERROR 로그.
|
||||
- 헤더 누락 또는 키 불일치 → 401 + logger.warning(ip)
|
||||
"""
|
||||
global _WEBAI_AUTH_WARNED
|
||||
configured = os.getenv("WEBAI_API_KEY", "").strip()
|
||||
if not configured:
|
||||
if not _WEBAI_AUTH_WARNED:
|
||||
logger.error("WEBAI_API_KEY not configured — refusing /api/webai/* requests")
|
||||
_WEBAI_AUTH_WARNED = True
|
||||
raise HTTPException(status_code=503, detail="webai auth not configured")
|
||||
|
||||
# env 가 다시 설정되면 flag 해제 → 미래 regression 시 다시 알림
|
||||
if _WEBAI_AUTH_WARNED:
|
||||
_WEBAI_AUTH_WARNED = False
|
||||
|
||||
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")
|
||||
@@ -12,8 +12,10 @@ def _conn() -> sqlite3.Connection:
|
||||
parent = os.path.dirname(db_path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn = sqlite3.connect(db_path, timeout=120.0)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=120000")
|
||||
return conn
|
||||
|
||||
def init_db():
|
||||
@@ -11,7 +11,7 @@ from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from pydantic import BaseModel
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
|
||||
logger = logging.getLogger("stock-lab")
|
||||
logger = logging.getLogger("stock")
|
||||
|
||||
from .db import (
|
||||
init_db, save_articles, get_latest_articles,
|
||||
@@ -24,6 +24,7 @@ from .db import (
|
||||
from .scraper import fetch_market_news, fetch_major_indices
|
||||
from .price_fetcher import get_current_prices, get_current_prices_detail
|
||||
from .ai_summarizer import summarize_news, OllamaError
|
||||
from .auth import verify_webai_key
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@@ -384,6 +385,74 @@ def get_portfolio():
|
||||
}
|
||||
|
||||
|
||||
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())
|
||||
|
||||
|
||||
def _fetch_news_sentiment_dump(date: str | None) -> dict:
|
||||
"""news_sentiment 일별 dump (krx_master JOIN, score DESC)."""
|
||||
from .db import _conn
|
||||
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)
|
||||
|
||||
|
||||
@app.post("/api/portfolio", status_code=201)
|
||||
def create_portfolio_item(req: PortfolioItemRequest):
|
||||
"""포트폴리오 종목 추가"""
|
||||
@@ -4,7 +4,7 @@ from bs4 import BeautifulSoup
|
||||
from typing import List, Dict, Any
|
||||
import time
|
||||
|
||||
logger = logging.getLogger("stock-lab.scraper")
|
||||
logger = logging.getLogger("stock.scraper")
|
||||
|
||||
# 네이버 파이낸스 주요 뉴스
|
||||
NAVER_FINANCE_NEWS_URL = "https://finance.naver.com/news/mainnews.naver"
|
||||
0
stock/app/screener/ai_news/__init__.py
Normal file
0
stock/app/screener/ai_news/__init__.py
Normal file
103
stock/app/screener/ai_news/analyzer.py
Normal file
103
stock/app/screener/ai_news/analyzer.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""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))
|
||||
|
||||
|
||||
def _format_news_block(news: List[Dict[str, Any]]) -> str:
|
||||
"""news dict 리스트 → prompt 에 들어가는 텍스트 블록.
|
||||
|
||||
summary 가 있으면 title 다음 줄에 indent 해서 포함 (최대 200자).
|
||||
pub_date 가 있으면 title 앞에 표시.
|
||||
"""
|
||||
lines: List[str] = []
|
||||
for n in news:
|
||||
date = (n.get("pub_date") or "").strip()
|
||||
title = (n.get("title") or "").strip()
|
||||
summary = (n.get("summary") or "").strip()
|
||||
prefix = f"[{date}] " if date else ""
|
||||
if summary:
|
||||
lines.append(f"- {prefix}{title}\n {summary[:200]}")
|
||||
else:
|
||||
lines.append(f"- {prefix}{title}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
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 = _format_news_block(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,
|
||||
temperature=0,
|
||||
system="너는 한국 주식 뉴스 감성 분석가다. JSON 객체 하나만 반환한다.",
|
||||
messages=[
|
||||
{"role": "user", "content": prompt},
|
||||
# Assistant prefill — 첫 토큰을 강제로 '{' 로 시작해 JSON 응답을 보장
|
||||
{"role": "assistant", "content": "{"},
|
||||
],
|
||||
)
|
||||
raw = resp.content[0].text if resp.content else ""
|
||||
# prefill '{' 이 응답에 포함되지 않으므로 다시 붙임
|
||||
text = "{" + raw if not raw.lstrip().startswith("{") else raw
|
||||
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,
|
||||
}
|
||||
70
stock/app/screener/ai_news/articles_source.py
Normal file
70
stock/app/screener/ai_news/articles_source.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""기존 articles 테이블에서 종목별 뉴스 매핑."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import logging
|
||||
import sqlite3
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def gather_articles_for_tickers(
|
||||
conn: sqlite3.Connection,
|
||||
tickers: List[str],
|
||||
asof: dt.date,
|
||||
*,
|
||||
window_days: int = 1,
|
||||
max_per_ticker: int = 5,
|
||||
) -> Tuple[Dict[str, List[Dict[str, Any]]], Dict[str, int]]:
|
||||
"""articles 에서 ticker.name substring 매칭으로 종목별 뉴스 dict 반환.
|
||||
|
||||
Returns:
|
||||
(
|
||||
{ticker: [{"title": str, "summary": str, "press": str, "pub_date": str}, ...]},
|
||||
{"total_articles": int, "matched_pairs": int, "hit_tickers": int},
|
||||
)
|
||||
"""
|
||||
out: Dict[str, List[Dict[str, Any]]] = {t: [] for t in tickers}
|
||||
stats = {"total_articles": 0, "matched_pairs": 0, "hit_tickers": 0}
|
||||
|
||||
if not tickers:
|
||||
return out, stats
|
||||
|
||||
cutoff = (asof - dt.timedelta(days=window_days)).isoformat()
|
||||
|
||||
placeholders = ",".join("?" * len(tickers))
|
||||
name_rows = conn.execute(
|
||||
f"SELECT ticker, name FROM krx_master WHERE ticker IN ({placeholders})",
|
||||
tickers,
|
||||
).fetchall()
|
||||
# 2글자 미만 회사명은 false positive 위험으로 제외
|
||||
name_map = {r[0]: r[1] for r in name_rows if r[1] and len(r[1]) >= 2}
|
||||
|
||||
articles = conn.execute(
|
||||
"SELECT title, summary, press, pub_date, crawled_at "
|
||||
"FROM articles WHERE crawled_at >= ? ORDER BY crawled_at DESC",
|
||||
(cutoff,),
|
||||
).fetchall()
|
||||
stats["total_articles"] = len(articles)
|
||||
|
||||
for a in articles:
|
||||
title = (a[0] or "").strip()
|
||||
summary = (a[1] or "").strip()
|
||||
haystack = title + " " + summary
|
||||
for ticker, name in name_map.items():
|
||||
if name not in haystack:
|
||||
continue
|
||||
if len(out[ticker]) >= max_per_ticker:
|
||||
continue
|
||||
out[ticker].append({
|
||||
"title": title,
|
||||
"summary": summary,
|
||||
"press": a[2] or "",
|
||||
"pub_date": a[3] or "",
|
||||
})
|
||||
stats["matched_pairs"] += 1
|
||||
|
||||
stats["hit_tickers"] = sum(1 for arts in out.values() if arts)
|
||||
return out, stats
|
||||
141
stock/app/screener/ai_news/pipeline.py
Normal file
141
stock/app/screener/ai_news/pipeline.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""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
|
||||
|
||||
from . import scraper as _scraper # legacy, kept for backward import
|
||||
from . import analyzer as _analyzer
|
||||
from . import articles_source # 신규
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_TOP_N = 100
|
||||
DEFAULT_CONCURRENCY = 10
|
||||
DEFAULT_NEWS_PER_TICKER = 5
|
||||
|
||||
|
||||
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_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, articles: List[Dict[str, Any]],
|
||||
sem: asyncio.Semaphore, llm, model: str,
|
||||
) -> Dict[str, Any]:
|
||||
async with sem:
|
||||
return await _analyzer.score_sentiment(
|
||||
llm, ticker, articles, name=name, model=model,
|
||||
)
|
||||
|
||||
|
||||
def _upsert_news_sentiment(
|
||||
conn: sqlite3.Connection, asof: dt.date,
|
||||
rows: List[Dict[str, Any]], *, source: str = "articles",
|
||||
) -> None:
|
||||
iso = asof.isoformat()
|
||||
data = [
|
||||
(
|
||||
r["ticker"], iso, r["score_raw"], r["reason"], r["news_count"],
|
||||
r["tokens_input"], r["tokens_output"], r["model"], source,
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
conn.executemany(
|
||||
"""INSERT INTO news_sentiment
|
||||
(ticker, date, score_raw, reason, news_count,
|
||||
tokens_input, tokens_output, model, source)
|
||||
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,
|
||||
source=excluded.source
|
||||
""",
|
||||
data,
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
async def refresh_daily(
|
||||
conn: sqlite3.Connection,
|
||||
asof: dt.date,
|
||||
*,
|
||||
top_n: int = DEFAULT_TOP_N,
|
||||
concurrency: int = DEFAULT_CONCURRENCY,
|
||||
max_news_per_ticker: int = DEFAULT_NEWS_PER_TICKER,
|
||||
window_days: int = 1,
|
||||
model: str = _analyzer.DEFAULT_MODEL,
|
||||
) -> Dict[str, Any]:
|
||||
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 {}
|
||||
|
||||
articles_by_ticker, mapping_stats = articles_source.gather_articles_for_tickers(
|
||||
conn, tickers, asof,
|
||||
window_days=window_days,
|
||||
max_per_ticker=max_news_per_ticker,
|
||||
)
|
||||
|
||||
sem = asyncio.Semaphore(concurrency)
|
||||
async with _make_llm() as llm:
|
||||
tasks = []
|
||||
for t in tickers:
|
||||
arts = articles_by_ticker.get(t, [])
|
||||
if not arts:
|
||||
continue # 매핑 0 — score 미생성
|
||||
tasks.append(_process_one(t, name_map.get(t, t), arts, sem, llm, model))
|
||||
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, source="articles")
|
||||
|
||||
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,
|
||||
"mapping": mapping_stats,
|
||||
}
|
||||
46
stock/app/screener/ai_news/scraper.py
Normal file
46
stock/app/screener/ai_news/scraper.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""[DEPRECATED] 네이버 finance 종목 뉴스 스크래핑.
|
||||
|
||||
본 모듈은 ai_news Phase 1 (2026-05-14) 에서 더 이상 파이프라인에서 사용되지 않음.
|
||||
데이터 소스는 stock 의 articles 테이블 (ai_news/articles_source.py) 로 전환됨.
|
||||
|
||||
삭제 시점: Phase 2 (DART 도입) 결정 후. IC 검증 4주 누적 후 노드 활성화
|
||||
여부에 따라 본 모듈을 (a) 완전 삭제 또는 (b) ensemble fallback 으로 재활용.
|
||||
"""
|
||||
|
||||
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
|
||||
73
stock/app/screener/ai_news/telegram.py
Normal file
73
stock/app/screener/ai_news/telegram.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""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"]
|
||||
# score 문자열 자체를 _escape 통과 — '+', '-', '.' 모두 MarkdownV2 reserved
|
||||
score_str = _escape(f"{score:+.1f}")
|
||||
name = r.get("name") or ""
|
||||
ticker = r["ticker"]
|
||||
label = (
|
||||
f"{_escape(name)} \\({_escape(ticker)}\\)"
|
||||
if name else _escape(ticker)
|
||||
)
|
||||
return f"{idx}\\. {label} \\({score_str}\\) — {_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,
|
||||
mapping: Dict[str, int] | None = None,
|
||||
) -> 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)
|
||||
mapping_part = ""
|
||||
if mapping:
|
||||
mapping_part = (
|
||||
f"매핑 {mapping['hit_tickers']}/100 ticker "
|
||||
f"\\({mapping['matched_pairs']}쌍 / articles {mapping['total_articles']}건\\) · "
|
||||
)
|
||||
lines += [
|
||||
"",
|
||||
f"_분석: 시총 상위 100종목 · {mapping_part}"
|
||||
f"토큰 {tokens_input:,} in / {tokens_output:,} out · "
|
||||
f"약 ₩{cost:,}_",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
125
stock/app/screener/ai_news/validation.py
Normal file
125
stock/app/screener/ai_news/validation.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""AI news sentiment validation — Spearman IC vs forward returns.
|
||||
|
||||
핵심 metric: 일자별 score_raw 와 다음 N일 forward return 의 Spearman 상관.
|
||||
4주+ 누적 후 IC mean > 0.05 면 weight 활성화 가치 있음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import sqlite3
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def _spearman(a: pd.Series, b: pd.Series) -> Optional[float]:
|
||||
"""Spearman rank correlation. None if insufficient/degenerate data."""
|
||||
if len(a) < 5 or len(b) < 5:
|
||||
return None
|
||||
if a.std(ddof=0) == 0 or b.std(ddof=0) == 0:
|
||||
return None
|
||||
return float(a.rank().corr(b.rank()))
|
||||
|
||||
|
||||
def compute_ic(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
days: int = 30,
|
||||
horizon: int = 1,
|
||||
min_news_count: int = 1,
|
||||
asof_today: Optional[dt.date] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Compute daily Spearman IC of ai_news.score_raw vs forward return.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"horizon_days": int,
|
||||
"min_news_count": int,
|
||||
"window_days": int,
|
||||
"ic_count": int, # 유효 일수
|
||||
"ic_mean": float | None,
|
||||
"ic_std": float | None,
|
||||
"ic_per_day": [{"date": "YYYY-MM-DD", "ic": float, "n": int}, ...],
|
||||
"verdict": "skip" | "weak" | "strong",
|
||||
}
|
||||
|
||||
verdict:
|
||||
- skip: ic_count < 10
|
||||
- weak: ic_mean in [-0.05, 0.05]
|
||||
- strong: |ic_mean| > 0.05
|
||||
"""
|
||||
asof_today = asof_today or dt.date.today()
|
||||
cutoff = (asof_today - dt.timedelta(days=days)).isoformat()
|
||||
|
||||
sentiment = pd.read_sql_query(
|
||||
"SELECT ticker, date, score_raw, news_count "
|
||||
"FROM news_sentiment WHERE date >= ? AND news_count >= ? ORDER BY date",
|
||||
conn, params=(cutoff, min_news_count),
|
||||
)
|
||||
if sentiment.empty:
|
||||
return _empty_result(days, horizon, min_news_count)
|
||||
|
||||
# forward return 조회: 각 (ticker, date) 에 대해 close[date+horizon] / close[date] - 1
|
||||
prices = pd.read_sql_query(
|
||||
"SELECT ticker, date, close FROM krx_daily_prices "
|
||||
"WHERE date >= ? ORDER BY ticker, date",
|
||||
conn, params=(cutoff,),
|
||||
)
|
||||
if prices.empty:
|
||||
return _empty_result(days, horizon, min_news_count)
|
||||
|
||||
prices = prices.sort_values(["ticker", "date"])
|
||||
prices["fwd_close"] = prices.groupby("ticker", group_keys=False)["close"].shift(-horizon)
|
||||
prices["fwd_ret"] = prices["fwd_close"] / prices["close"] - 1.0
|
||||
|
||||
merged = sentiment.merge(
|
||||
prices[["ticker", "date", "fwd_ret"]], on=["ticker", "date"], how="inner"
|
||||
)
|
||||
merged = merged.dropna(subset=["fwd_ret"])
|
||||
if merged.empty:
|
||||
return _empty_result(days, horizon, min_news_count)
|
||||
|
||||
ic_rows: List[Dict[str, Any]] = []
|
||||
for date, grp in merged.groupby("date"):
|
||||
ic = _spearman(grp["score_raw"], grp["fwd_ret"])
|
||||
if ic is not None:
|
||||
ic_rows.append({"date": date, "ic": ic, "n": int(len(grp))})
|
||||
|
||||
if not ic_rows:
|
||||
return _empty_result(days, horizon, min_news_count)
|
||||
|
||||
ic_series = pd.Series([r["ic"] for r in ic_rows], dtype=float)
|
||||
ic_mean = float(ic_series.mean())
|
||||
ic_std = float(ic_series.std(ddof=0)) if len(ic_series) > 1 else 0.0
|
||||
|
||||
if len(ic_rows) < 10:
|
||||
verdict = "skip"
|
||||
elif abs(ic_mean) > 0.05:
|
||||
verdict = "strong"
|
||||
else:
|
||||
verdict = "weak"
|
||||
|
||||
return {
|
||||
"horizon_days": horizon,
|
||||
"min_news_count": min_news_count,
|
||||
"window_days": days,
|
||||
"ic_count": len(ic_rows),
|
||||
"ic_mean": round(ic_mean, 4),
|
||||
"ic_std": round(ic_std, 4),
|
||||
"ic_per_day": ic_rows,
|
||||
"verdict": verdict,
|
||||
}
|
||||
|
||||
|
||||
def _empty_result(days: int, horizon: int, min_news_count: int) -> Dict[str, Any]:
|
||||
return {
|
||||
"horizon_days": horizon,
|
||||
"min_news_count": min_news_count,
|
||||
"window_days": days,
|
||||
"ic_count": 0,
|
||||
"ic_mean": None,
|
||||
"ic_std": None,
|
||||
"ic_per_day": [],
|
||||
"verdict": "skip",
|
||||
}
|
||||
@@ -17,6 +17,7 @@ class ScreenContext:
|
||||
flow: pd.DataFrame # cols: ticker,date,foreign_net,institution_net
|
||||
kospi: pd.Series # index=date(str), name="kospi"
|
||||
asof: dt.date
|
||||
news_sentiment: "pd.DataFrame | None" = None
|
||||
|
||||
@classmethod
|
||||
def load(cls, conn: sqlite3.Connection, asof: dt.date,
|
||||
@@ -38,6 +39,10 @@ class ScreenContext:
|
||||
"FROM krx_flow WHERE date BETWEEN ? AND ? ORDER BY date",
|
||||
conn, params=(cutoff, asof_iso),
|
||||
)
|
||||
news_sentiment = pd.read_sql_query(
|
||||
"SELECT ticker, score_raw, news_count FROM news_sentiment WHERE date = ?",
|
||||
conn, params=(asof_iso,),
|
||||
)
|
||||
|
||||
# KOSPI 지수: MVP에서는 005930(삼성전자) 종가를 시장 대용으로 사용.
|
||||
# 후속 슬라이스에서 ^KS11 별도 캐시.
|
||||
@@ -47,7 +52,8 @@ class ScreenContext:
|
||||
kospi = sub.copy()
|
||||
kospi.name = "kospi"
|
||||
|
||||
return cls(master=master, prices=prices, flow=flow, kospi=kospi, asof=asof)
|
||||
return cls(master=master, prices=prices, flow=flow, kospi=kospi, asof=asof,
|
||||
news_sentiment=news_sentiment)
|
||||
|
||||
def restrict(self, tickers) -> "ScreenContext":
|
||||
tickers = pd.Index(tickers)
|
||||
0
stock/app/screener/nodes/__init__.py
Normal file
0
stock/app/screener/nodes/__init__.py
Normal file
36
stock/app/screener/nodes/ai_news.py
Normal file
36
stock/app/screener/nodes/ai_news.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""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"])
|
||||
@@ -8,6 +8,7 @@ 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,
|
||||
@@ -17,6 +18,7 @@ NODE_REGISTRY: dict = {
|
||||
"rs_rating": RsRating,
|
||||
"ma_alignment": MaAlignment,
|
||||
"vcp_lite": VcpLite,
|
||||
"ai_news": AiNewsSentiment,
|
||||
}
|
||||
|
||||
GATE_REGISTRY: dict = {
|
||||
@@ -45,7 +45,13 @@ def _db_path() -> str:
|
||||
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
return sqlite3.connect(_db_path())
|
||||
# WAL 모드 + busy_timeout으로 동시 read/write lock 회피
|
||||
# WAL은 reader vs writer 동시성만 해결 — writer 두 명은 직렬이므로 busy_timeout이
|
||||
# snapshot/refresh의 write 시간보다 길어야 함 (네이버 스크래핑 ~20초 + DB upsert).
|
||||
conn = sqlite3.connect(_db_path(), timeout=120.0)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=120000")
|
||||
return conn
|
||||
|
||||
|
||||
# ---------- /nodes ----------
|
||||
@@ -270,6 +276,61 @@ def list_runs(limit: int = 30):
|
||||
]
|
||||
|
||||
|
||||
# ---------- /snapshot/refresh-news-sentiment ----------
|
||||
|
||||
from .ai_news import pipeline as _ai_pipeline
|
||||
from .ai_news import telegram as _ai_telegram
|
||||
from .ai_news import validation as _ai_validation
|
||||
|
||||
|
||||
@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_pipeline.refresh_daily(c, asof_date)
|
||||
# top_pos/top_neg 항목에 종목명 주입 (텔레그램 가독성)
|
||||
tickers = {r["ticker"] for r in summary["top_pos"] + summary["top_neg"]}
|
||||
if tickers:
|
||||
placeholders = ",".join("?" * len(tickers))
|
||||
name_map = {
|
||||
row[0]: row[1] for row in c.execute(
|
||||
f"SELECT ticker, name FROM krx_master WHERE ticker IN ({placeholders})",
|
||||
list(tickers),
|
||||
).fetchall()
|
||||
}
|
||||
for r in summary["top_pos"] + summary["top_neg"]:
|
||||
r["name"] = name_map.get(r["ticker"], "")
|
||||
summary["telegram_text"] = _ai_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"],
|
||||
mapping=summary.get("mapping"),
|
||||
)
|
||||
return summary
|
||||
|
||||
|
||||
# ---------- /ai-news/ic ----------
|
||||
|
||||
@router.get("/ai-news/ic")
|
||||
def get_ai_news_ic(days: int = 30, horizon: int = 1, min_news_count: int = 1):
|
||||
"""ai_news.score_raw 의 forward return IC (Spearman) 계산.
|
||||
|
||||
verdict:
|
||||
- skip: ic_count < 10 (데이터 부족)
|
||||
- weak: |ic_mean| <= 0.05
|
||||
- strong: |ic_mean| > 0.05 (gradient 활성화 가치 있음)
|
||||
"""
|
||||
with _conn() as c:
|
||||
return _ai_validation.compute_ic(
|
||||
c, days=days, horizon=horizon, min_news_count=min_news_count,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}")
|
||||
def get_run(run_id: int):
|
||||
with _conn() as c:
|
||||
@@ -12,6 +12,9 @@ DEFAULT_WEIGHTS = {
|
||||
"rs_rating": 1.2,
|
||||
"ma_alignment": 1.0,
|
||||
"vcp_lite": 0.8,
|
||||
# ai_news: 검증 전 gradient 차단 (4주 IC > 0.05 확인 후 활성화).
|
||||
# 데이터 수집은 계속, 가중합 영향만 0.
|
||||
"ai_news": 0.0,
|
||||
}
|
||||
DEFAULT_NODE_PARAMS = {
|
||||
"foreign_buy": {"window_days": 5},
|
||||
@@ -21,6 +24,7 @@ DEFAULT_NODE_PARAMS = {
|
||||
"rs_rating": {"weights": {"3m": 2, "6m": 1, "9m": 1, "12m": 1}},
|
||||
"ma_alignment": {"ma_periods": [50, 150, 200]},
|
||||
"vcp_lite": {"short_window": 40, "long_window": 252},
|
||||
"ai_news": {"min_news_count": 1},
|
||||
}
|
||||
DEFAULT_GATE_PARAMS = {
|
||||
"min_market_cap_won": 50_000_000_000,
|
||||
@@ -110,12 +114,76 @@ CREATE TABLE IF NOT EXISTS screener_results (
|
||||
FOREIGN KEY (run_id) REFERENCES screener_runs(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_results_run_rank ON screener_results(run_id, rank);
|
||||
|
||||
-- articles 테이블 (도메스틱/해외 뉴스 원본).
|
||||
-- 메인 app.db.init_db() 에서도 생성하지만, 테스트 환경 및 단독 screener 컨텍스트
|
||||
-- (ai_news.articles_source 등)에서도 참조 가능하도록 idempotent 하게 보장한다.
|
||||
CREATE TABLE IF NOT EXISTS articles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
hash TEXT UNIQUE NOT NULL,
|
||||
category TEXT DEFAULT 'domestic',
|
||||
title TEXT NOT NULL,
|
||||
link TEXT,
|
||||
summary TEXT,
|
||||
press TEXT,
|
||||
pub_date TEXT,
|
||||
crawled_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_articles_crawled ON articles(crawled_at DESC);
|
||||
|
||||
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',
|
||||
source TEXT NOT NULL DEFAULT 'articles',
|
||||
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);
|
||||
"""
|
||||
|
||||
|
||||
def ensure_screener_schema(conn: sqlite3.Connection) -> None:
|
||||
"""Create tables and seed default settings (idempotent)."""
|
||||
conn.executescript(DDL)
|
||||
# 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
|
||||
# One-time reset: ai_news default 0.8 → 0.0 (검증 전 gradient 차단).
|
||||
# 사용자가 명시적으로 0.8 외 값을 설정했다면 영향 없음.
|
||||
elif w.get("ai_news") == 0.8:
|
||||
w["ai_news"] = 0.0
|
||||
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)),
|
||||
)
|
||||
# news_sentiment.source 컬럼 1회 추가 (기존 운영 환경)
|
||||
cols = {r[1] for r in conn.execute(
|
||||
"PRAGMA table_info(news_sentiment)"
|
||||
).fetchall()}
|
||||
if "source" not in cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE news_sentiment "
|
||||
"ADD COLUMN source TEXT NOT NULL DEFAULT 'articles'"
|
||||
)
|
||||
existing = conn.execute("SELECT id FROM screener_settings WHERE id=1").fetchone()
|
||||
if existing is None:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
@@ -22,8 +22,11 @@ NAVER_HEADERS = {
|
||||
"Referer": "https://finance.naver.com/",
|
||||
}
|
||||
|
||||
DEFAULT_FLOW_TOP_N = 500
|
||||
DEFAULT_FLOW_TOP_N = 100
|
||||
DEFAULT_RATE_LIMIT_SEC = 0.2
|
||||
# 시총 상위 100종목 × 0.2초 = ~20초 — agent-office httpx timeout(180s) 안에 여유롭게 완료
|
||||
# 외국인 매수 시그널은 대형주에서 의미가 크므로 상위 100종목으로 충분.
|
||||
# 더 많은 종목이 필요하면 별도 cron으로 분리 권장.
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -4,14 +4,15 @@ from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
|
||||
NODE_ICONS = {
|
||||
"foreign_buy": "👤외",
|
||||
"volume_surge": "⚡거",
|
||||
"momentum": "🚀모",
|
||||
"high52w": "🆙고",
|
||||
"rs_rating": "💪RS",
|
||||
"ma_alignment": "📈MA",
|
||||
"vcp_lite": "🌀VCP",
|
||||
# 노드별 풀 라벨 (아이콘 대신 사용 — 사용자가 명확한 이름 선호)
|
||||
NODE_LABELS = {
|
||||
"foreign_buy": "외국인",
|
||||
"volume_surge": "거래량급증",
|
||||
"momentum": "20일모멘텀",
|
||||
"high52w": "52주신고가",
|
||||
"rs_rating": "RS레이팅",
|
||||
"ma_alignment": "이평선정배열",
|
||||
"vcp_lite": "VCP수축",
|
||||
}
|
||||
|
||||
PAGE_BASE = "https://gahusb.synology.me/stock/screener"
|
||||
@@ -25,9 +26,21 @@ def _escape_md(s: str) -> str:
|
||||
|
||||
|
||||
def _format_won(n) -> str:
|
||||
"""1,234,567원 형태 (None 시 '-')."""
|
||||
if n is None:
|
||||
return "-"
|
||||
return f"{int(n):,}"
|
||||
return "\\-"
|
||||
return f"{int(n):,}원"
|
||||
|
||||
|
||||
def _format_active_nodes(scores: dict, threshold: int = 70) -> str:
|
||||
"""70점 이상 노드를 '라벨 점수' 형태로 나열, 콤마 구분."""
|
||||
active = []
|
||||
for name, sc in scores.items():
|
||||
label = NODE_LABELS.get(name)
|
||||
if label is None or sc < threshold:
|
||||
continue
|
||||
active.append(f"{_escape_md(label)} {int(sc)}")
|
||||
return " · ".join(active) if active else "\\(70점 이상 노드 없음\\)"
|
||||
|
||||
|
||||
def build_telegram_payload(asof: dt.date, mode: str, survivors_count: int,
|
||||
@@ -40,17 +53,14 @@ def build_telegram_payload(asof: dt.date, mode: str, survivors_count: int,
|
||||
|
||||
lines = []
|
||||
for r in rows[:10]:
|
||||
icons = " ".join(
|
||||
NODE_ICONS[name] for name, sc in r["scores"].items()
|
||||
if sc >= 70 and name in NODE_ICONS
|
||||
)
|
||||
nodes_str = _format_active_nodes(r.get("scores", {}))
|
||||
score_str = f"{r['total_score']:.1f}"
|
||||
r_pct = r.get("r_pct")
|
||||
r_pct_str = f"{r_pct:.1f}" if r_pct is not None else "-"
|
||||
lines.append(
|
||||
f"{r['rank']}\\. *{_escape_md(r['name'])}* `{r['ticker']}` "
|
||||
f"⭐ {_escape_md(score_str)}\n"
|
||||
f" {icons}\n"
|
||||
f" {nodes_str}\n"
|
||||
f" 진입 {_format_won(r.get('entry_price'))} "
|
||||
f"손절 {_format_won(r.get('stop_price'))} "
|
||||
f"익절 {_format_won(r.get('target_price'))} "
|
||||
@@ -1,7 +1,7 @@
|
||||
"""price_fetcher._select_price_from_response 단위 테스트.
|
||||
|
||||
실행:
|
||||
cd web-backend/stock-lab
|
||||
cd web-backend/stock
|
||||
python -m unittest app.test_price_fetcher -v
|
||||
"""
|
||||
import os
|
||||
@@ -21,15 +21,16 @@ def client():
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_get_nodes_lists_7_score_and_1_gate(client):
|
||||
def test_get_nodes_lists_8_score_and_1_gate(client):
|
||||
r = client.get("/api/stock/screener/nodes")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert len(body["score_nodes"]) == 7
|
||||
assert len(body["score_nodes"]) == 8
|
||||
assert len(body["gate_nodes"]) == 1
|
||||
assert {n["name"] for n in body["score_nodes"]} == {
|
||||
"foreign_buy", "volume_surge", "momentum",
|
||||
"high52w", "rs_rating", "ma_alignment", "vcp_lite",
|
||||
"ai_news",
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ def test_build_payload_includes_top10_and_link():
|
||||
assert "42" in text # run_id 링크
|
||||
|
||||
|
||||
def test_score_threshold_filters_icons():
|
||||
def test_score_threshold_filters_node_labels():
|
||||
rows = [{
|
||||
"rank": 1, "ticker": "A", "name": "A주",
|
||||
"total_score": 80,
|
||||
@@ -41,11 +41,28 @@ def test_score_threshold_filters_icons():
|
||||
"target_price": 53750, "r_pct": 3.5,
|
||||
}]
|
||||
p = build_telegram_payload(dt.date(2026, 5, 12), "auto", 100, 1, rows, run_id=1)
|
||||
# foreign_buy(90), momentum(70), rs_rating(80), ma_alignment(80) 만 표시 (≥70)
|
||||
assert "👤외" in p["text"]
|
||||
assert "🚀모" in p["text"]
|
||||
assert "💪RS" in p["text"]
|
||||
assert "📈MA" in p["text"]
|
||||
assert "⚡거" not in p["text"]
|
||||
assert "🆙고" not in p["text"]
|
||||
assert "🌀VCP" not in p["text"]
|
||||
text = p["text"]
|
||||
# ≥70 노드만 풀 라벨로 표시 (foreign_buy=90, momentum=70, rs_rating=80, ma_alignment=80)
|
||||
assert "외국인 90" in text
|
||||
assert "20일모멘텀 70" in text
|
||||
assert "RS레이팅 80" in text
|
||||
assert "이평선정배열 80" in text
|
||||
# <70 노드는 숨김 (volume_surge=50, high52w=30, vcp_lite=60)
|
||||
assert "거래량급증" not in text
|
||||
assert "52주신고가" not in text
|
||||
assert "VCP수축" not in text
|
||||
|
||||
|
||||
def test_prices_have_won_suffix():
|
||||
rows = [{
|
||||
"rank": 1, "ticker": "A", "name": "A주",
|
||||
"total_score": 80,
|
||||
"scores": {"foreign_buy": 80},
|
||||
"close": 50000, "entry_price": 50250, "stop_price": 48500,
|
||||
"target_price": 53750, "r_pct": 3.5,
|
||||
}]
|
||||
p = build_telegram_payload(dt.date(2026, 5, 12), "auto", 100, 1, rows, run_id=1)
|
||||
text = p["text"]
|
||||
assert "50,250원" in text
|
||||
assert "48,500원" in text
|
||||
assert "53,750원" in text
|
||||
45
stock/app/test_webai_auth.py
Normal file
45
stock/app/test_webai_auth.py
Normal file
@@ -0,0 +1,45 @@
|
||||
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
|
||||
209
stock/app/test_webai_endpoints.py
Normal file
209
stock/app/test_webai_endpoints.py
Normal file
@@ -0,0 +1,209 @@
|
||||
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"]
|
||||
|
||||
|
||||
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]
|
||||
|
||||
|
||||
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"] == []
|
||||
@@ -1,4 +1,5 @@
|
||||
# 주식 서비스용 라이브러리
|
||||
anthropic==0.39.0
|
||||
requests==2.32.3
|
||||
httpx==0.27.2
|
||||
beautifulsoup4==4.12.3
|
||||
@@ -8,4 +9,6 @@ apscheduler==3.10.4
|
||||
python-dotenv==1.0.1
|
||||
finance-datareader==0.9.110
|
||||
lxml==6.1.0
|
||||
pytest==8.3.2
|
||||
pytest-asyncio==0.24.0
|
||||
|
||||
70
stock/tests/test_ai_news_analyzer.py
Normal file
70
stock/tests/test_ai_news_analyzer.py
Normal file
@@ -0,0 +1,70 @@
|
||||
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 양산", "summary": "1분기 영업이익 사상 최대", "pub_date": "2026-05-14"},
|
||||
{"title": "메모리 가격 반등", "summary": "", "pub_date": "2026-05-14"},
|
||||
]
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_score_sentiment_includes_summary_in_prompt():
|
||||
"""summary 가 있으면 prompt 에 포함, 없으면 title 만."""
|
||||
llm = _mk_llm(json.dumps({"score": 5.0, "reason": "ok"}))
|
||||
await analyzer.score_sentiment(llm, "005930", NEWS, name="삼성전자")
|
||||
call = llm.messages.create.call_args
|
||||
user_msg = call.kwargs["messages"][0]["content"]
|
||||
assert "1분기 영업이익 사상 최대" in user_msg # summary 포함
|
||||
assert "삼성전자, HBM 양산" in user_msg # title 포함
|
||||
assert "2026-05-14" in user_msg # pub_date 포함
|
||||
108
stock/tests/test_ai_news_articles_source.py
Normal file
108
stock/tests/test_ai_news_articles_source.py
Normal file
@@ -0,0 +1,108 @@
|
||||
import datetime as dt
|
||||
import sqlite3
|
||||
import pytest
|
||||
|
||||
from app.screener.ai_news import articles_source
|
||||
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)
|
||||
yield c
|
||||
c.close()
|
||||
|
||||
|
||||
def _seed_master(conn, ticker, name):
|
||||
conn.execute(
|
||||
"INSERT INTO krx_master (ticker, name, market, market_cap, updated_at) "
|
||||
"VALUES (?, ?, 'KOSPI', 1000000000, datetime('now'))",
|
||||
(ticker, name),
|
||||
)
|
||||
|
||||
|
||||
def _seed_article(conn, title, summary="", crawled_at="2026-05-14T07:30:00"):
|
||||
import hashlib
|
||||
h = hashlib.md5(f"{title}|x".encode()).hexdigest()
|
||||
conn.execute(
|
||||
"INSERT INTO articles (hash, title, summary, link, press, pub_date, crawled_at) "
|
||||
"VALUES (?, ?, ?, '', '', '2026-05-14', ?)",
|
||||
(h, title, summary, crawled_at),
|
||||
)
|
||||
|
||||
|
||||
ASOF = dt.date(2026, 5, 14)
|
||||
|
||||
|
||||
def test_single_ticker_match_in_title(conn):
|
||||
_seed_master(conn, "005930", "삼성전자")
|
||||
_seed_article(conn, "삼성전자, HBM 양산 가시화")
|
||||
conn.commit()
|
||||
out, stats = articles_source.gather_articles_for_tickers(
|
||||
conn, ["005930"], ASOF, window_days=1, max_per_ticker=5,
|
||||
)
|
||||
assert len(out["005930"]) == 1
|
||||
assert out["005930"][0]["title"] == "삼성전자, HBM 양산 가시화"
|
||||
assert stats["matched_pairs"] == 1
|
||||
assert stats["hit_tickers"] == 1
|
||||
|
||||
|
||||
def test_single_ticker_match_in_summary(conn):
|
||||
_seed_master(conn, "005930", "삼성전자")
|
||||
_seed_article(conn, "메모리 시장 회복세", summary="삼성전자가 1분기 어닝 서프라이즈")
|
||||
conn.commit()
|
||||
out, _ = articles_source.gather_articles_for_tickers(
|
||||
conn, ["005930"], ASOF, window_days=1, max_per_ticker=5,
|
||||
)
|
||||
assert len(out["005930"]) == 1
|
||||
|
||||
|
||||
def test_multi_ticker_match(conn):
|
||||
_seed_master(conn, "005930", "삼성전자")
|
||||
_seed_master(conn, "000660", "SK하이닉스")
|
||||
_seed_article(conn, "삼성전자와 SK하이닉스, 메모리 양산 경쟁")
|
||||
conn.commit()
|
||||
out, stats = articles_source.gather_articles_for_tickers(
|
||||
conn, ["005930", "000660"], ASOF, window_days=1, max_per_ticker=5,
|
||||
)
|
||||
assert len(out["005930"]) == 1
|
||||
assert len(out["000660"]) == 1
|
||||
assert stats["matched_pairs"] == 2
|
||||
assert stats["hit_tickers"] == 2
|
||||
|
||||
|
||||
def test_no_match_returns_empty_list(conn):
|
||||
_seed_master(conn, "005930", "삼성전자")
|
||||
_seed_article(conn, "엔비디아 실적 발표", summary="AI 칩 수요 견조")
|
||||
conn.commit()
|
||||
out, stats = articles_source.gather_articles_for_tickers(
|
||||
conn, ["005930"], ASOF, window_days=1, max_per_ticker=5,
|
||||
)
|
||||
assert out["005930"] == []
|
||||
assert stats["matched_pairs"] == 0
|
||||
assert stats["hit_tickers"] == 0
|
||||
|
||||
|
||||
def test_max_per_ticker_caps_results(conn):
|
||||
_seed_master(conn, "005930", "삼성전자")
|
||||
for i in range(6):
|
||||
_seed_article(conn, f"삼성전자 뉴스 #{i}", crawled_at=f"2026-05-14T0{i}:00:00")
|
||||
conn.commit()
|
||||
out, _ = articles_source.gather_articles_for_tickers(
|
||||
conn, ["005930"], ASOF, window_days=1, max_per_ticker=5,
|
||||
)
|
||||
assert len(out["005930"]) == 5
|
||||
|
||||
|
||||
def test_window_days_filters_old_articles(conn):
|
||||
_seed_master(conn, "005930", "삼성전자")
|
||||
_seed_article(conn, "삼성전자 최신 뉴스", crawled_at="2026-05-14T07:00:00")
|
||||
_seed_article(conn, "삼성전자 오래된 뉴스", crawled_at="2026-05-01T07:00:00")
|
||||
conn.commit()
|
||||
out, _ = articles_source.gather_articles_for_tickers(
|
||||
conn, ["005930"], ASOF, window_days=1, max_per_ticker=5,
|
||||
)
|
||||
assert len(out["005930"]) == 1
|
||||
assert "최신" in out["005930"][0]["title"]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user