refactor: rename stock-lab → stock (graduation)

- git mv stock-lab/ → stock/
- docker-compose.yml: 서비스 키 + container_name + build.context +
  frontend.depends_on + agent-office STOCK_LAB_URL → STOCK_URL
- agent-office/app: config.py, service_proxy.py, agents/stock.py, tests/
  STOCK_LAB_URL → STOCK_URL
- nginx/default.conf: proxy_pass http://stock-labhttp://stock (3 lines)
- CLAUDE.md / README.md / STATUS.md / scripts/ 문구 갱신
- stock/ 내부 자기 참조 갱신

lab 네이밍 정책 (feedback_lab_naming.md) graduation.
API URL / Python import / DB 파일명 변경 없음.
This commit is contained in:
2026-05-15 01:45:22 +09:00
parent 8812bd870a
commit ace0339d33
74 changed files with 67 additions and 67 deletions

View File

@@ -7,7 +7,7 @@
## 1. 프로젝트 개요 ## 1. 프로젝트 개요
Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포. 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에 배포 - **프론트엔드**: 별도 레포 (React + Vite SPA), 빌드 산출물만 NAS에 배포
- **인프라**: Docker Compose (10컨테이너) + Nginx(리버스 프록시) + Gitea Webhook 자동 배포 - **인프라**: Docker Compose (10컨테이너) + Nginx(리버스 프록시) + Gitea Webhook 자동 배포
@@ -32,7 +32,7 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
/volume1 /volume1
├── docker/webpage/ # 운영 런타임 (Docker Compose 실행 위치) ├── docker/webpage/ # 운영 런타임 (Docker Compose 실행 위치)
│ ├── lotto/ # lotto 소스 (rsync 동기화) │ ├── lotto/ # lotto 소스 (rsync 동기화)
│ ├── stock-lab/ # stock-lab 소스 (rsync 동기화) │ ├── stock/ # stock 소스 (rsync 동기화)
│ ├── travel-proxy/ # travel-proxy 소스 (rsync 동기화) │ ├── travel-proxy/ # travel-proxy 소스 (rsync 동기화)
│ ├── deployer/ # deployer 소스 (rsync 동기화) │ ├── deployer/ # deployer 소스 (rsync 동기화)
│ ├── nginx/default.conf # Nginx 설정 │ ├── nginx/default.conf # Nginx 설정
@@ -54,7 +54,7 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
| 컨테이너 | 포트 | 역할 | | 컨테이너 | 포트 | 역할 |
|---------|------|------| |---------|------|------|
| `lotto` | 18000 | 로또 데이터 수집·분석·추천 API | | `lotto` | 18000 | 로또 데이터 수집·분석·추천 API |
| `stock-lab` | 18500 | 주식 뉴스·AI 분석·KIS API 연동 | | `stock` | 18500 | 주식 뉴스·AI 분석·KIS API 연동 |
| `music-lab` | 18600 | AI 음악 생성·라이브러리 관리 API | | `music-lab` | 18600 | AI 음악 생성·라이브러리 관리 API |
| `blog-lab` | 18700 | 블로그 마케팅 수익화 API | | `blog-lab` | 18700 | 블로그 마케팅 수익화 API |
| `realestate-lab` | 18800 | 부동산 청약 자동 수집·매칭 API | | `realestate-lab` | 18800 | 부동산 청약 자동 수집·매칭 API |
@@ -73,9 +73,9 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|------|------------|------| |------|------------|------|
| `/api/` | `lotto:8000` | lotto API (기본) | | `/api/` | `lotto:8000` | lotto API (기본) |
| `/api/travel/` | `travel-proxy:8000` | travel API | | `/api/travel/` | `travel-proxy:8000` | travel API |
| `/api/stock/` | `stock-lab:8000` | stock API | | `/api/stock/` | `stock:8000` | stock API |
| `/api/trade/` | `stock-lab:8000` | KIS 실계좌 API | | `/api/trade/` | `stock:8000` | KIS 실계좌 API |
| `/api/portfolio` | `stock-lab:8000` | trailing slash 유무 모두 매칭 | | `/api/portfolio` | `stock:8000` | trailing slash 유무 모두 매칭 |
| `/api/music/` | `music-lab:8000` | AI 음악 생성·라이브러리 API | | `/api/music/` | `music-lab:8000` | AI 음악 생성·라이브러리 API |
| `/api/blog-marketing/` | `blog-lab:8000` | 블로그 마케팅 수익화 API | | `/api/blog-marketing/` | `blog-lab:8000` | 블로그 마케팅 수익화 API |
| `/api/realestate/` | `realestate-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/{draw_no}` | 특정 회차 브리핑 |
| GET | `/api/lotto/briefing` | 브리핑 이력 | | GET | `/api/lotto/briefing` | 브리핑 이력 |
### stock-lab (stock-lab/) ### stock (stock/)
- Windows AI 서버 연동: `WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000` - Windows AI 서버 연동: `WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000`
- KIS API 연동으로 실계좌 잔고·거래 조회 - KIS API 연동으로 실계좌 잔고·거래 조회
- 뉴스 스크래핑: 네이버 증권 + 해외 사이트 - 뉴스 스크래핑: 네이버 증권 + 해외 사이트
- DB: `/app/data/stock.db` (articles, portfolio, broker_cash, asset_snapshots, sell_history 테이블) - DB: `/app/data/stock.db` (articles, portfolio, broker_cash, asset_snapshots, sell_history 테이블)
- 파일 구조: `main.py`, `db.py`, `scraper.py`, `price_fetcher.py`, `holidays.json` - 파일 구조: `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/) ### agent-office (agent-office/)
- AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 에이전트가 실제 작업 수행 - AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 에이전트가 실제 작업 수행
- stock-lab/music-lab/realestate-lab 기존 API를 서비스 프록시로 호출 (직접 DB 접근 없음) - stock/music-lab/realestate-lab 기존 API를 서비스 프록시로 호출 (직접 DB 접근 없음)
- 실시간 상태 동기화: WebSocket (`/api/agent-office/ws`) - 실시간 상태 동기화: WebSocket (`/api/agent-office/ws`)
- 텔레그램 봇: 양방향 알림 + 승인 (인라인 키보드) - 텔레그램 봇: 양방향 알림 + 승인 (인라인 키보드)
- 청약 매칭 알림: realestate-lab이 신규 매칭 발견 시 push → `RealestateAgent.on_new_matches()` → 텔레그램 1통(인라인 [🔖 북마크]/[📄 공고] 또는 [전체 보기] 버튼) - 청약 매칭 알림: realestate-lab이 신규 매칭 발견 시 push → `RealestateAgent.on_new_matches()` → 텔레그램 1통(인라인 [🔖 북마크]/[📄 공고] 또는 [전체 보기] 버튼)
@@ -522,7 +522,7 @@ docker compose up -d
**에이전트 FSM 상태**: idle → working → waiting (승인 대기) → reporting → break (휴식) **에이전트 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`) - `MUSIC_LAB_URL`: music-lab 내부 URL (기본 `http://music-lab:8000`)
- `REALESTATE_LAB_URL`: realestate-lab 내부 URL (기본 `http://realestate-lab:8000`) — 북마크 콜백 프록시 대상 - `REALESTATE_LAB_URL`: realestate-lab 내부 URL (기본 `http://realestate-lab:8000`) — 북마크 콜백 프록시 대상
- `REALESTATE_DASHBOARD_URL`: 텔레그램 [전체 보기] 버튼 URL (기본 `http://localhost:8080/realestate`) - `REALESTATE_DASHBOARD_URL`: 텔레그램 [전체 보기] 버튼 URL (기본 `http://localhost:8080/realestate`)
@@ -697,7 +697,7 @@ docker compose up -d
- **캐시 전략**: `index.html``no-store`, `assets/`는 1년 장기 캐시(immutable) - **캐시 전략**: `index.html``no-store`, `assets/`는 1년 장기 캐시(immutable)
- **Frontend 배포**: git push로 자동 배포되지 않음. 로컬 빌드 후 NAS에 수동 업로드 - **Frontend 배포**: git push로 자동 배포되지 않음. 로컬 빌드 후 NAS에 수동 업로드
- **.env 파일**: 절대 커밋 금지. `.env.example`만 레포에 포함 - **.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 사용 - **Windows AI 서버 IP**: `192.168.45.59` — 공유기 DHCP 고정 예약으로 고정. Tailscale은 Synology에서 TCP 불가(userspace 모드)라 로컬 IP 사용
- **현재가 조회**: 네이버 모바일 API → HTML 파싱 폴백, 3분 TTL 캐시 (`price_fetcher.py`) - **현재가 조회**: 네이버 모바일 API → HTML 파싱 폴백, 3분 TTL 캐시 (`price_fetcher.py`)
- **시뮬레이션 교체 방식**: `best_picks`는 교체형 — 새 시뮬레이션 실행 시 `is_active=0`으로 비활성화 후 신규 입력 - **시뮬레이션 교체 방식**: `best_picks`는 교체형 — 새 시뮬레이션 실행 시 `is_active=0`으로 비활성화 후 신규 입력

View File

@@ -13,8 +13,8 @@ Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
│ ├── 정적 SPA 서빙 (React + Vite) │ │ ├── 정적 SPA 서빙 (React + Vite) │
│ └── API 리버스 프록시 │ │ └── API 리버스 프록시 │
│ ├── /api/ → lotto-backend:8000 (로또·블로그·투두)│ │ ├── /api/ → lotto-backend:8000 (로또·블로그·투두)│
│ ├── /api/stock/, /trade/ → stock-lab:8000 │ │ ├── /api/stock/, /trade/ → stock:8000 │
│ ├── /api/portfolio → stock-lab:8000 │ │ ├── /api/portfolio → stock:8000 │
│ ├── /api/music/ → music-lab:8000 │ │ ├── /api/music/ → music-lab:8000 │
│ ├── /api/blog-marketing/ → blog-lab:8000 │ │ ├── /api/blog-marketing/ → blog-lab:8000 │
│ ├── /api/realestate/ → realestate-lab:8000 │ │ ├── /api/realestate/ → realestate-lab:8000 │
@@ -29,7 +29,7 @@ Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
| 컨테이너 | 포트 | 역할 | | 컨테이너 | 포트 | 역할 |
|---------|------|------| |---------|------|------|
| `lotto-backend` | 18000 | 로또 데이터 수집·분석·추천 + 블로그·투두 API | | `lotto-backend` | 18000 | 로또 데이터 수집·분석·추천 + 블로그·투두 API |
| `stock-lab` | 18500 | 주식 뉴스·AI 요약·KIS 실계좌·포트폴리오·자산 추적 | | `stock` | 18500 | 주식 뉴스·AI 요약·KIS 실계좌·포트폴리오·자산 추적 |
| `music-lab` | 18600 | AI 음악 생성 (Suno + 로컬 MusicGen 듀얼 프로바이더) | | `music-lab` | 18600 | AI 음악 생성 (Suno + 로컬 MusicGen 듀얼 프로바이더) |
| `blog-lab` | 18700 | 블로그 마케팅 수익화 (키워드→글 생성→리뷰→발행) | | `blog-lab` | 18700 | 블로그 마케팅 수익화 (키워드→글 생성→리뷰→발행) |
| `realestate-lab` | 18800 | 청약 공고 자동 수집·프로필 매칭 | | `realestate-lab` | 18800 | 청약 공고 자동 수집·프로필 매칭 |
@@ -45,7 +45,7 @@ Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
``` ```
web-backend/ web-backend/
├── backend/ # lotto-backend (로또·블로그·투두) ├── backend/ # lotto-backend (로또·블로그·투두)
├── stock-lab/ # 주식·포트폴리오 ├── stock/ # 주식·포트폴리오
├── music-lab/ # AI 음악 생성 ├── music-lab/ # AI 음악 생성
├── blog-lab/ # 블로그 마케팅 파이프라인 ├── blog-lab/ # 블로그 마케팅 파이프라인
├── realestate-lab/ # 청약 자동 수집·매칭 ├── realestate-lab/ # 청약 자동 수집·매칭
@@ -75,7 +75,7 @@ curl http://localhost:18500/health
|--------|----------| |--------|----------|
| Frontend + API | http://localhost:8080 | | Frontend + API | http://localhost:8080 |
| lotto-backend | http://localhost:18000 | | lotto-backend | http://localhost:18000 |
| stock-lab | http://localhost:18500 | | stock | http://localhost:18500 |
| music-lab | http://localhost:18600 | | music-lab | http://localhost:18600 |
| blog-lab | http://localhost:18700 | | blog-lab | http://localhost:18700 |
| realestate-lab | http://localhost:18800 | | realestate-lab | http://localhost:18800 |
@@ -99,7 +99,7 @@ curl http://localhost:18500/health
- 09:10 / 21:10 — 당첨번호 동기화 + 추천 채점 - 09:10 / 21:10 — 당첨번호 동기화 + 추천 채점
- 00:05, 04:05, 08:05, 12:05, 16:05, 20:05 — 몬테카를로 시뮬레이션 (후보 20,000 → 상위 100 → best_picks 20쌍 교체) - 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 실계좌 연동 + 포트폴리오·자산 스냅샷. 주식 뉴스 스크래핑 + LLM 요약 + KIS 실계좌 연동 + 포트폴리오·자산 스냅샷.
@@ -152,7 +152,7 @@ curl http://localhost:18500/health
AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 4명의 에이전트가 실제 작업을 수행한다. 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` - **FSM 상태**: `idle → working → waiting(승인 대기) → reporting → break`
- **실시간 동기화**: WebSocket `/api/agent-office/ws` (init, agent_state, task_complete, command_result) - **실시간 동기화**: WebSocket `/api/agent-office/ws` (init, agent_state, task_complete, command_result)
- **텔레그램 연동**: 양방향 알림 + 인라인 키보드 승인 - **텔레그램 연동**: 양방향 알림 + 인라인 키보드 승인
@@ -224,7 +224,7 @@ Gitea Webhook 수신 → NAS 자동 배포.
| 공동 출현 | 15% | 번호 쌍 동시 출현 빈도 | | 공동 출현 | 15% | 번호 쌍 동시 출현 빈도 |
| 다양성 | 10% | 연속번호·범위·구간 커버리지 | | 다양성 | 10% | 연속번호·범위·구간 커버리지 |
### LLM 요약 provider 추상화 (stock-lab) ### LLM 요약 provider 추상화 (stock)
`ai_summarizer.py`는 provider 분리 구조. `summarize_news(articles)` 시그니처는 provider와 무관하게 고정. `ai_summarizer.py`는 provider 분리 구조. `summarize_news(articles)` 시그니처는 provider와 무관하게 고정.
@@ -232,7 +232,7 @@ Gitea Webhook 수신 → NAS 자동 배포.
- `_summarize_with_ollama`: Ollama `/api/generate` (타임아웃 180s, qwen3:14b 첫 로드 대응) - `_summarize_with_ollama`: Ollama `/api/generate` (타임아웃 180s, qwen3:14b 첫 로드 대응)
- 실패 시 `LLMError` (구 `OllamaError` alias 유지) - 실패 시 `LLMError` (구 `OllamaError` alias 유지)
### 총 자산 스냅샷 (stock-lab) ### 총 자산 스냅샷 (stock)
평일 15:40 자동 실행 → `holidays.json`으로 공휴일 스킵 → 포트폴리오 현재가 조회 + 예수금 합계 → `asset_snapshots` upsert (date UNIQUE). 평일 15:40 자동 실행 → `holidays.json`으로 공휴일 스킵 → 포트폴리오 현재가 조회 + 예수금 합계 → `asset_snapshots` upsert (date UNIQUE).
@@ -266,7 +266,7 @@ git push → Gitea → X-Gitea-Signature (HMAC SHA256)
| DB | 소유 서비스 | 주요 테이블 | | DB | 소유 서비스 | 주요 테이블 |
|----|------------|-----------| |----|------------|-----------|
| `lotto.db` | lotto-backend | draws, recommendations, simulation_runs/candidates, best_picks, purchase_history, strategy_performance/weights, weekly_reports, lotto_briefings, todos, blog_posts | | `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) | | `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 | | `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 | | `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 WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
WEBHOOK_SECRET=your_secret_here WEBHOOK_SECRET=your_secret_here
# LLM (stock-lab, blog-lab, agent-office 공통) # LLM (stock, blog-lab, agent-office 공통)
ANTHROPIC_API_KEY=sk-ant-... ANTHROPIC_API_KEY=sk-ant-...
ANTHROPIC_MODEL=claude-haiku-4-5-20251001 ANTHROPIC_MODEL=claude-haiku-4-5-20251001
LLM_PROVIDER=claude # claude | ollama LLM_PROVIDER=claude # claude | ollama
@@ -315,7 +315,7 @@ DATA_GO_KR_API_KEY=
TELEGRAM_BOT_TOKEN= TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID= TELEGRAM_CHAT_ID=
TELEGRAM_WEBHOOK_URL= TELEGRAM_WEBHOOK_URL=
STOCK_LAB_URL=http://stock-lab:8000 STOCK_URL=http://stock:8000
MUSIC_LAB_URL=http://music-lab:8000 MUSIC_LAB_URL=http://music-lab:8000
BLOG_LAB_URL=http://blog-lab:8000 BLOG_LAB_URL=http://blog-lab:8000
REALESTATE_LAB_URL=http://realestate-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 매칭) - **라우트 순서** — `DELETE /api/todos/done``/api/todos/{id}` 보다 먼저 등록 필수 (FastAPI prefix 매칭)
- **캐시 전략** — `index.html`: no-store / `assets/`: 1년 immutable - **캐시 전략** — `index.html`: no-store / `assets/`: 1년 immutable
- **PUID/PGID** — travel-proxy는 NAS 파일 권한을 위해 환경변수 주입 필수 - **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 사용 - **Windows AI 서버 IP** — `192.168.45.59` 공유기 DHCP 고정 예약. Synology Tailscale은 userspace 모드라 TCP 불가 → 로컬 IP 사용
- **Suno CDN** — `cdn1.suno.ai` URL은 임시 만료 → 생성 즉시 로컬 다운로드 필수 - **Suno CDN** — `cdn1.suno.ai` URL은 임시 만료 → 생성 즉시 로컬 다운로드 필수
- **LLM provider 롤백** — Claude API 장애 시 `.env``LLM_PROVIDER=ollama`로 전환 후 `docker compose up -d` - **LLM provider 롤백** — Claude API 장애 시 `.env``LLM_PROVIDER=ollama`로 전환 후 `docker compose up -d`

View File

@@ -12,7 +12,7 @@
| 서비스 | 포트 | 상태 | 핵심 기능 | | 서비스 | 포트 | 상태 | 핵심 기능 |
|--------|------|------|-----------| |--------|------|------|-----------|
| `lotto-backend` | 18000 | ✅ | 로또 추천·통계·리포트·구매내역 + 블로그·투두 | | `lotto-backend` | 18000 | ✅ | 로또 추천·통계·리포트·구매내역 + 블로그·투두 |
| `stock-lab` | 18500 | ✅ | 주식 뉴스·지수·트레이딩·포트폴리오·자산 스냅샷 | | `stock` | 18500 | ✅ | 주식 뉴스·지수·트레이딩·포트폴리오·자산 스냅샷 |
| `music-lab` | 18600 | ✅ | Suno + MusicGen + YouTube 수익화 + 컴파일 | | `music-lab` | 18600 | ✅ | Suno + MusicGen + YouTube 수익화 + 컴파일 |
| `blog-lab` | 18700 | ✅ | 블로그 마케팅 수익화 파이프라인 | | `blog-lab` | 18700 | ✅ | 블로그 마케팅 수익화 파이프라인 |
| `realestate-lab` | 18800 | ✅ | 청약 수집·5티어 매칭·매칭 알림 | | `realestate-lab` | 18800 | ✅ | 청약 수집·5티어 매칭·매칭 알림 |

View File

@@ -51,7 +51,7 @@ class StockAgent(BaseAgent):
await self.transition("working", "최신 뉴스 수집 중...", task_id) await self.transition("working", "최신 뉴스 수집 중...", task_id)
try: try:
# stock-lab cron(매일 8:00)이 7:30 브리핑보다 늦게 돌아 어제 뉴스가 # stock cron(매일 8:00)이 7:30 브리핑보다 늦게 돌아 어제 뉴스가
# 요약되던 문제 방지 — 요약 직전에 동기 스크랩으로 DB를 갱신한다. # 요약되던 문제 방지 — 요약 직전에 동기 스크랩으로 DB를 갱신한다.
try: try:
await service_proxy.scrape_stock_news() await service_proxy.scrape_stock_news()
@@ -60,7 +60,7 @@ class StockAgent(BaseAgent):
await self.transition("working", "AI 뉴스 요약 생성 중...") await self.transition("working", "AI 뉴스 요약 생성 중...")
# AI 요약 호출 (LLM 처리는 stock-lab이 담당) # AI 요약 호출 (LLM 처리는 stock이 담당)
result = await service_proxy.summarize_stock_news(limit=15) result = await service_proxy.summarize_stock_news(limit=15)
await self.transition("reporting", "뉴스 요약 전송 중...") await self.transition("reporting", "뉴스 요약 전송 중...")
@@ -237,7 +237,7 @@ class StockAgent(BaseAgent):
"""AI 뉴스 sentiment 분석 자동 잡 (평일 08:00 KST). """AI 뉴스 sentiment 분석 자동 잡 (평일 08:00 KST).
흐름: 흐름:
1) stock-lab /snapshot/refresh-news-sentiment 호출 1) stock /snapshot/refresh-news-sentiment 호출
2) status='skipped_weekend'/'skipped_holiday' → 종료 (텔레그램 미발신) 2) status='skipped_weekend'/'skipped_holiday' → 종료 (텔레그램 미발신)
3) updated=0 → 운영자 알림 (HTML) 3) updated=0 → 운영자 알림 (HTML)
4) failures > 30% → 경고 알림 후 메인 메시지 발송 4) failures > 30% → 경고 알림 후 메인 메시지 발송
@@ -304,10 +304,10 @@ class StockAgent(BaseAgent):
except Exception: except Exception:
pass pass
# 정상 — Top 5 메시지 (stock-lab이 빌드해서 응답에 telegram_text 동봉) # 정상 — Top 5 메시지 (stock이 빌드해서 응답에 telegram_text 동봉)
text = result.get("telegram_text") or "" text = result.get("telegram_text") or ""
if not text: if not text:
add_log(self.agent_id, "telegram_text 누락 — stock-lab 응답 결함", "error", task_id) add_log(self.agent_id, "telegram_text 누락 — stock 응답 결함", "error", task_id)
update_task_status(task_id, "failed", {"error": "telegram_text 누락"}) update_task_status(task_id, "failed", {"error": "telegram_text 누락"})
await self.transition("idle", "AI 뉴스 응답 결함") await self.transition("idle", "AI 뉴스 응답 결함")
return return

View File

@@ -1,7 +1,7 @@
import os import os
# Service URLs (Docker internal network) # 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") MUSIC_LAB_URL = os.getenv("MUSIC_LAB_URL", "http://localhost:18600")
BLOG_LAB_URL = os.getenv("BLOG_LAB_URL", "http://localhost:18700") BLOG_LAB_URL = os.getenv("BLOG_LAB_URL", "http://localhost:18700")
REALESTATE_LAB_URL = os.getenv("REALESTATE_LAB_URL", "http://localhost:18800") REALESTATE_LAB_URL = os.getenv("REALESTATE_LAB_URL", "http://localhost:18800")

View File

@@ -1,7 +1,7 @@
import httpx import httpx
from typing import Any, Dict, List, Optional 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) _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} params = {"limit": limit}
if category: if category:
params["category"] = 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() resp.raise_for_status()
return resp.json() return resp.json()
async def fetch_stock_indices() -> Dict[str, Any]: 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() resp.raise_for_status()
return resp.json() return resp.json()
async def summarize_stock_news(limit: int = 15) -> Dict[str, Any]: 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} 반환: {"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: async with httpx.AsyncClient(timeout=200.0) as client:
resp = await client.post( resp = await client.post(
f"{STOCK_LAB_URL}/api/stock/news/summarize", f"{STOCK_URL}/api/stock/news/summarize",
json={"limit": limit}, json={"limit": limit},
) )
resp.raise_for_status() resp.raise_for_status()
@@ -33,32 +33,32 @@ async def summarize_stock_news(limit: int = 15) -> Dict[str, Any]:
async def refresh_screener_snapshot() -> Dict[str, Any]: async def refresh_screener_snapshot() -> Dict[str, Any]:
"""stock-lab의 KRX 일봉 스냅샷 갱신 (스크리너 실행 전 호출). """stock의 KRX 일봉 스냅샷 갱신 (스크리너 실행 전 호출).
네이버 금융 일괄 다운로드라 보통 30~120s, 여유있게 180s. 네이버 금융 일괄 다운로드라 보통 30~120s, 여유있게 180s.
""" """
async with httpx.AsyncClient(timeout=180.0) as client: 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() resp.raise_for_status()
return resp.json() return resp.json()
async def refresh_ai_news_sentiment() -> Dict[str, Any]: async def refresh_ai_news_sentiment() -> Dict[str, Any]:
"""stock-lab의 AI 뉴스 sentiment 분석 트리거 (08:00 cron). """stock의 AI 뉴스 sentiment 분석 트리거 (08:00 cron).
네이버 100종목 스크래핑 + Claude Haiku 100콜 병렬 = 약 30-60초. 네이버 100종목 스크래핑 + Claude Haiku 100콜 병렬 = 약 30-60초.
여유있게 240s timeout. 여유있게 240s timeout.
""" """
async with httpx.AsyncClient(timeout=240.0) as client: async with httpx.AsyncClient(timeout=240.0) as client:
resp = await client.post( resp = await client.post(
f"{STOCK_LAB_URL}/api/stock/screener/snapshot/refresh-news-sentiment" f"{STOCK_URL}/api/stock/screener/snapshot/refresh-news-sentiment"
) )
resp.raise_for_status() resp.raise_for_status()
return resp.json() return resp.json()
async def run_stock_screener(mode: str = "auto") -> Dict[str, Any]: async def run_stock_screener(mode: str = "auto") -> Dict[str, Any]:
"""stock-lab의 스크리너 실행. """stock의 스크리너 실행.
반환 status: 반환 status:
- 'skipped_holiday': 공휴일/주말 — telegram_payload 없음 - 'skipped_holiday': 공휴일/주말 — telegram_payload 없음
@@ -67,7 +67,7 @@ async def run_stock_screener(mode: str = "auto") -> Dict[str, Any]:
""" """
async with httpx.AsyncClient(timeout=180.0) as client: async with httpx.AsyncClient(timeout=180.0) as client:
resp = await client.post( resp = await client.post(
f"{STOCK_LAB_URL}/api/stock/screener/run", f"{STOCK_URL}/api/stock/screener/run",
json={"mode": mode}, json={"mode": mode},
) )
resp.raise_for_status() resp.raise_for_status()
@@ -75,13 +75,13 @@ async def run_stock_screener(mode: str = "auto") -> Dict[str, Any]:
async def scrape_stock_news() -> Dict[str, Any]: async def scrape_stock_news() -> Dict[str, Any]:
"""stock-lab의 수동 뉴스 스크랩 트리거 — DB에 최신 뉴스 저장. """stock의 수동 뉴스 스크랩 트리거 — DB에 최신 뉴스 저장.
아침 브리핑 직전 호출하여 어제 데이터가 아닌 오늘 새벽 뉴스를 보장한다. 아침 브리핑 직전 호출하여 어제 데이터가 아닌 오늘 새벽 뉴스를 보장한다.
네이버 금융 단일 요청이라 보통 수 초 내 완료, 여유있게 60s. 네이버 금융 단일 요청이라 보통 수 초 내 완료, 여유있게 60s.
""" """
async with httpx.AsyncClient(timeout=60.0) as client: 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() resp.raise_for_status()
return resp.json() return resp.json()

View File

@@ -1,6 +1,6 @@
"""StockAgent.on_screener_schedule — 평일 16:30 KST 자동 잡 단위 테스트. """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 os
import sys import sys
@@ -138,7 +138,7 @@ def test_screener_run_failure_notifies_operator():
from app.telegram import messaging from app.telegram import messaging
fake_snap = AsyncMock(return_value={"status": "ok"}) 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}) fake_send = AsyncMock(return_value={"ok": True, "message_id": 1})
with patch.object(service_proxy, "refresh_screener_snapshot", fake_snap), \ with patch.object(service_proxy, "refresh_screener_snapshot", fake_snap), \

View File

@@ -22,12 +22,12 @@ services:
timeout: 5s timeout: 5s
retries: 3 retries: 3
stock-lab: stock:
build: build:
context: ./stock-lab context: ./stock
args: args:
APP_VERSION: ${APP_VERSION:-dev} APP_VERSION: ${APP_VERSION:-dev}
container_name: stock-lab container_name: stock
restart: unless-stopped restart: unless-stopped
ports: ports:
- "18500:8000" - "18500:8000"
@@ -136,7 +136,7 @@ services:
environment: environment:
- TZ=${TZ:-Asia/Seoul} - TZ=${TZ:-Asia/Seoul}
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080} - 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 - MUSIC_LAB_URL=http://music-lab:8000
- BLOG_LAB_URL=http://blog-lab:8000 - BLOG_LAB_URL=http://blog-lab:8000
- REALESTATE_LAB_URL=http://realestate-lab:8000 - REALESTATE_LAB_URL=http://realestate-lab:8000
@@ -157,7 +157,7 @@ services:
volumes: volumes:
- ${RUNTIME_PATH:-.}/data/agent-office:/app/data - ${RUNTIME_PATH:-.}/data/agent-office:/app/data
depends_on: depends_on:
- stock-lab - stock
- music-lab - music-lab
- blog-lab - blog-lab
- realestate-lab - realestate-lab
@@ -242,7 +242,7 @@ services:
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- lotto - lotto
- stock-lab - stock
- music-lab - music-lab
- blog-lab - blog-lab
- realestate-lab - realestate-lab

View File

@@ -139,17 +139,17 @@ server {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; 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/ { location /api/trade/ {
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; 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 # blog-marketing API
@@ -166,14 +166,14 @@ server {
proxy_pass http://$blog_backend$request_uri; proxy_pass http://$blog_backend$request_uri;
} }
# portfolio API (Stock Lab) — trailing slash 유무 모두 매칭 # portfolio API (Stock) — trailing slash 유무 모두 매칭
location /api/portfolio { location /api/portfolio {
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://stock-lab:8000/api/portfolio; proxy_pass http://stock:8000/api/portfolio;
} }

View File

@@ -2,7 +2,7 @@
set -euo pipefail 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 컨테이너 내부인가? # 1. 자동 감지: Docker 컨테이너 내부인가?
if [ -d "/repo" ] && [ -d "/runtime" ]; then if [ -d "/repo" ] && [ -d "/runtime" ]; then

View File

@@ -7,11 +7,11 @@ flock -n 200 || { echo "Deploy already running, skipping"; exit 0; }
# ── 서비스 목록 (한 곳에서만 관리) ── # ── 서비스 목록 (한 곳에서만 관리) ──
# docker compose 서비스명 (deployer 제외 — 자기 자신을 재빌드하면 스크립트 중단) # 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 디렉토리 (packs-lab은 별도 media/packs 사용)
DATA_DIRS="music stock blog realestate agent-office personal" DATA_DIRS="music stock blog realestate agent-office personal"

View File

@@ -14,7 +14,7 @@ from typing import List, Dict, Any
import httpx import httpx
logger = logging.getLogger("stock-lab.ai_summarizer") logger = logging.getLogger("stock.ai_summarizer")
LLM_PROVIDER = os.getenv("LLM_PROVIDER", "claude").lower().strip() LLM_PROVIDER = os.getenv("LLM_PROVIDER", "claude").lower().strip()

View File

@@ -11,7 +11,7 @@ from apscheduler.schedulers.background import BackgroundScheduler
from pydantic import BaseModel from pydantic import BaseModel
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s") 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 ( from .db import (
init_db, save_articles, get_latest_articles, init_db, save_articles, get_latest_articles,

View File

@@ -4,7 +4,7 @@ from bs4 import BeautifulSoup
from typing import List, Dict, Any from typing import List, Dict, Any
import time import time
logger = logging.getLogger("stock-lab.scraper") logger = logging.getLogger("stock.scraper")
# 네이버 파이낸스 주요 뉴스 # 네이버 파이낸스 주요 뉴스
NAVER_FINANCE_NEWS_URL = "https://finance.naver.com/news/mainnews.naver" NAVER_FINANCE_NEWS_URL = "https://finance.naver.com/news/mainnews.naver"

View File

@@ -1,7 +1,7 @@
"""[DEPRECATED] 네이버 finance 종목 뉴스 스크래핑. """[DEPRECATED] 네이버 finance 종목 뉴스 스크래핑.
모듈은 ai_news Phase 1 (2026-05-14) 에서 이상 파이프라인에서 사용되지 않음. 모듈은 ai_news Phase 1 (2026-05-14) 에서 이상 파이프라인에서 사용되지 않음.
데이터 소스는 stock-lab articles 테이블 (ai_news/articles_source.py) 전환됨. 데이터 소스는 stock articles 테이블 (ai_news/articles_source.py) 전환됨.
삭제 시점: Phase 2 (DART 도입) 결정 . IC 검증 4 누적 노드 활성화 삭제 시점: Phase 2 (DART 도입) 결정 . IC 검증 4 누적 노드 활성화
여부에 따라 모듈을 (a) 완전 삭제 또는 (b) ensemble fallback 으로 재활용. 여부에 따라 모듈을 (a) 완전 삭제 또는 (b) ensemble fallback 으로 재활용.

View File

@@ -1,7 +1,7 @@
"""price_fetcher._select_price_from_response 단위 테스트. """price_fetcher._select_price_from_response 단위 테스트.
실행: 실행:
cd web-backend/stock-lab cd web-backend/stock
python -m unittest app.test_price_fetcher -v python -m unittest app.test_price_fetcher -v
""" """
import os import os