Compare commits
2 Commits
ea93dc522b
...
feat/bugfi
| Author | SHA1 | Date | |
|---|---|---|---|
| dc9a49586e | |||
| 5da7a0040b |
@@ -124,6 +124,7 @@ PACK_DATA_PATH=./data/packs
|
||||
PACK_BASE_DIR=/app/data/packs
|
||||
|
||||
# DSM·Supabase에 노출되는 NAS 호스트 절대경로 (PACK_DATA_PATH와 같은 디렉토리를 호스트 시점에서 가리킴).
|
||||
# 운영 NAS는 반드시 /volume1/docker/webpage/media/packs 같은 절대경로 설정.
|
||||
# 미설정 시 PACK_DATA_PATH로 fallback (로컬 개발용).
|
||||
PACK_HOST_DIR=/docker/webpage/media/packs
|
||||
# 운영 NAS는 반드시 /volume1/docker/webpage/media/packs 같은 절대경로 설정. 미설정 시 PACK_DATA_PATH로 fallback (로컬 개발용).
|
||||
# DSM API는 일반 사용자 권한에서 /volume1/... 절대경로를 거부(408).
|
||||
# shared folder 시점(/docker/...)이 운영 표준 (CLAUDE.md와 일치).
|
||||
PACK_HOST_DIR=/volume1/docker/webpage/media/packs
|
||||
|
||||
8
.gitignore
vendored
@@ -66,11 +66,3 @@ temp/
|
||||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
|
||||
################################
|
||||
# Local working files
|
||||
################################
|
||||
# Superpowers 스킬 캐시·세션 메타
|
||||
.superpowers/
|
||||
# 임시 코드 리뷰 노트 (작업 끝나면 폐기 또는 docs/로 이동)
|
||||
CODE_REVIEW.md
|
||||
|
||||
209
CHECK_POINT.md
@@ -1,209 +0,0 @@
|
||||
# web-backend CHECK_POINT
|
||||
|
||||
> NAS Docker 11 컨테이너(9 백엔드 + frontend + deployer). Synology Celeron J4025 (2C 2.0GHz) 18GB.
|
||||
> 2026-05-18 작성 — uvicorn CPU 폭주 진단 결과 정리.
|
||||
|
||||
## 🔴 즉시 (오늘, 총 1시간 5분)
|
||||
|
||||
### 1. 09:00 cron 5분 스태거링 ⭐ 가장 큰 효과
|
||||
|
||||
**파일**: `agent-office/app/scheduler.py:72-76`
|
||||
```python
|
||||
# 변경 전 — 09:00 동시 실행 (CPU 폭주 원인 #1)
|
||||
scheduler.add_job(_run_insta_trends_collect, "cron", hour=9, minute=0)
|
||||
scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=9, minute=0)
|
||||
scheduler.add_job(_run_youtube_research, "cron", hour=9, minute=0)
|
||||
|
||||
# 변경 후 — 5분 스태거링
|
||||
scheduler.add_job(_run_insta_trends_collect, "cron", hour=9, minute=0, id="insta_trends")
|
||||
scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=9, minute=5, id="lotto_curate")
|
||||
scheduler.add_job(_run_youtube_research, "cron", hour=9, minute=10, id="youtube_research")
|
||||
```
|
||||
|
||||
**파일**: `realestate-lab/app/main.py:51`
|
||||
```python
|
||||
# 변경 전
|
||||
scheduler.add_job(scheduled_collect, "cron", hour=9, minute=0, id="collect")
|
||||
|
||||
# 변경 후
|
||||
scheduler.add_job(scheduled_collect, "cron", hour=9, minute=15, id="collect")
|
||||
```
|
||||
|
||||
- [x] agent-office scheduler.py 수정 (2026-05-18)
|
||||
- [x] realestate-lab main.py 수정 (2026-05-18)
|
||||
- [ ] git commit + push (Gitea Webhook 자동 빌드)
|
||||
|
||||
---
|
||||
|
||||
### 2. insta-lab Playwright Semaphore(1) ⭐
|
||||
|
||||
**파일**: `insta-lab/app/main.py` (모듈 레벨 추가)
|
||||
```python
|
||||
import asyncio
|
||||
|
||||
# 모듈 레벨에 한 번만 선언
|
||||
RENDER_SEMAPHORE = asyncio.Semaphore(1) # Chromium 동시 실행 1개로 제한
|
||||
|
||||
# 카드 렌더 백그라운드 함수에 감싸기
|
||||
async def _bg_render(task_id: str, slate_id: int):
|
||||
async with RENDER_SEMAPHORE:
|
||||
await card_renderer.render_slate(slate_id, ...)
|
||||
```
|
||||
|
||||
- [x] card_renderer.render_slate를 Semaphore(1)로 감쌈 (2026-05-18, lazy init)
|
||||
- [ ] 동시 2개 요청 테스트 (curl 동시 2회 → 순차 처리되는지 확인)
|
||||
|
||||
---
|
||||
|
||||
### 3. healthcheck interval 60s
|
||||
|
||||
**파일**: `docker-compose.yml` (모든 9 컨테이너)
|
||||
```yaml
|
||||
# 변경 전
|
||||
healthcheck:
|
||||
interval: 30s
|
||||
|
||||
# 변경 후
|
||||
healthcheck:
|
||||
interval: 60s
|
||||
```
|
||||
|
||||
- [x] docker-compose.yml 10개 healthcheck 일괄 변경 (9 백엔드 + frontend, 2026-05-18)
|
||||
- [ ] `docker compose up -d` 재기동
|
||||
- [ ] `docker stats` 로 CPU 5% 정도 감소 확인
|
||||
|
||||
---
|
||||
|
||||
### 4. uvicorn --workers 1 명시
|
||||
|
||||
**모든 Dockerfile CMD**:
|
||||
```dockerfile
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
```
|
||||
|
||||
영향 9 파일 (모두 2026-05-18 적용):
|
||||
- [x] lotto/Dockerfile
|
||||
- [x] stock/Dockerfile
|
||||
- [x] music-lab/Dockerfile
|
||||
- [x] insta-lab/Dockerfile
|
||||
- [x] realestate-lab/Dockerfile
|
||||
- [x] agent-office/Dockerfile
|
||||
- [x] personal/Dockerfile
|
||||
- [x] packs-lab/Dockerfile
|
||||
- [x] travel-proxy/Dockerfile
|
||||
|
||||
→ `docker compose build --no-cache` 후 재기동.
|
||||
|
||||
---
|
||||
|
||||
### 5. lotto Monte Carlo 08:05 → 08:30
|
||||
|
||||
**파일**: `lotto/app/main.py:86`
|
||||
```python
|
||||
# 변경 전 — stock 08:00과 5분 차이로 겹침
|
||||
scheduler.add_job(_run_simulation_job, "cron", hour="0,4,8,12,16,20", minute=5)
|
||||
|
||||
# 변경 후 — 25분 분리
|
||||
scheduler.add_job(_run_simulation_job, "cron", hour="0,4,8,12,16,20", minute=30)
|
||||
```
|
||||
|
||||
- [x] lotto/app/main.py 수정 (2026-05-18)
|
||||
|
||||
---
|
||||
|
||||
## 🟡 중기 (1~2주)
|
||||
|
||||
### 6. Chromium Browser Pool 재설계 (insta-lab) ✅ 2026-05-18
|
||||
- 매번 launch X → 1개 인스턴스 재사용
|
||||
- 카드 10장 렌더 시간 30% 단축 기대
|
||||
- [x] `card_renderer.py` 내부에 모듈 레벨 `_PLAYWRIGHT`/`_BROWSER` + `init_browser`/`shutdown_browser` 함수 (별도 모듈 분리 안 함, 같은 파일에 인접 배치)
|
||||
- [x] `_render_slate_locked` 본체에서 `_get_browser()` 재사용 (crashed 시 lazy 재초기화)
|
||||
- [x] `main.py` startup hook에서 `init_browser()`, shutdown hook에서 `shutdown_browser()`
|
||||
|
||||
### 7. stock 뉴스 스크랩 비동기화 — ⚠️ 보류 2026-05-18
|
||||
- **재진단**: stock은 `BackgroundScheduler` 사용 중 → main loop 블로킹 없음 (이미 별도 thread)
|
||||
- `fetch_market_news`의 4개 동기 `requests.get`은 network I/O wait라 CPU 거의 사용 안 함
|
||||
- `to_thread`로 wrap해도 BackgroundScheduler 환경에서 사실상 의미 없음
|
||||
- 진짜 효과를 보려면 AsyncIOScheduler 전환 + scraper.py 4개 fetch를 `aiohttp` 병렬로 — **큰 리팩토링 vs 효과 불명확**
|
||||
- [ ] 박재오 판단: 큰 리팩토링 진행 여부
|
||||
|
||||
### 8. realestate 수집 병렬화 ✅ 2026-05-18
|
||||
- **파일**: `realestate-lab/app/main.py:scheduled_collect`
|
||||
- `collect_all()` + `delete_old_completed_announcements()` 병렬
|
||||
- BackgroundScheduler 환경이라 `asyncio.gather` 대신 `ThreadPoolExecutor(max_workers=2)` 사용 (효과 동일)
|
||||
- 매칭은 순차 유지 (DB 일관성)
|
||||
- [x] ThreadPoolExecutor 적용
|
||||
|
||||
### 9. lotto Monte Carlo 시뮬레이션 빈도 검토
|
||||
- 현재 6회/일 (00·04·08·12·16·20)
|
||||
- 실제 필요 빈도 박재오 결정 — 3회/일(아침·점심·저녁)로 줄이면 CPU 50% 감소
|
||||
- [ ] 박재오 의사결정 후 cron 변경
|
||||
|
||||
---
|
||||
|
||||
## 🟢 장기 (1개월+)
|
||||
|
||||
### 10. 무거운 작업 Windows AI 서버로 이전 ✅ 이미 적용 상태 (2026-05-18 확인)
|
||||
- **확인 결과**: NAS `.env`가 이미 `LLM_PROVIDER=claude` + `OLLAMA_URL=http://192.168.45.59:11435`로 설정됨
|
||||
- 실 운영은 Anthropic Claude (원격 API) — NAS Celeron에서 LLM 추론 안 함
|
||||
- Ollama fallback 사용 시에도 Windows AI 서버로 통일
|
||||
- stock 외 다른 컨테이너에 ollama/qwen 호출 코드 없음
|
||||
- 결론: 코드/설정 변경 불필요
|
||||
|
||||
### 11. 컨테이너 리소스 제한 — ❌ 진행 금지 (박재오 명시 2026-05-18)
|
||||
- J4025 2C 환경에서 cpus 0.5 제한은 오히려 throughput 손해
|
||||
- 향후 작업자 무심코 도입하지 말 것
|
||||
|
||||
### 12. NAS 업그레이드 검토 — ⏸️ 보류 (박재오 명시 2026-05-18)
|
||||
- 현재: Celeron J4025 (2C 2.0GHz)
|
||||
- 대안: Ryzen N5105 (4C 2.0GHz) NAS — 4코어로 병렬성 2배
|
||||
- 자금·우선순위 결정 대기
|
||||
|
||||
---
|
||||
|
||||
## ✅ 최근 완료 (참고)
|
||||
|
||||
- 2026-05-15: insta-lab 신설 (포트 18700, Jinja2 + Playwright + Claude Sonnet)
|
||||
- 2026-05-16: insta-lab Playwright 1080×1350 PNG 렌더 완성
|
||||
- 2026-05-17: agent-office random idle 제거, ADMIN_API_KEY 강화 (stock)
|
||||
- 2026-05-17: insta-lab minimal theme + design_importer 추가
|
||||
- 2026-05-17: blog-lab 트랙 완전 폐기 (docker-compose에 없음, 위키 정정 완료)
|
||||
- 2026-05-18: 🔴 즉시 5건 일괄 적용 — 09:00 cron 스태거링(insta/lotto/youtube/realestate), lotto Monte Carlo 08:30, insta-lab Semaphore(1), healthcheck 60s, uvicorn --workers 1 명시 (사용자 push + NAS deployer 재기동 대기)
|
||||
- 2026-05-18: 🟡 중기 2건 적용 — #6 insta-lab Chromium Browser Pool (lifecycle hook), #8 realestate ThreadPoolExecutor 병렬 (collect/delete). #7 stock async는 BackgroundScheduler 사용 중이라 재진단 후 보류 (효과 미미). #9 Monte Carlo 빈도는 박재오 결정 대기.
|
||||
- 2026-05-18: 🟢 장기 진단·결정 — #10은 이미 적용 상태 확인 (LLM_PROVIDER=claude, OLLAMA_URL=Windows AI). #11 컨테이너 리소스 제한 박재오 진행 금지. #12 NAS 업그레이드 보류. web-ai V1(:8000)+V2(:8001) 4개 process 종료 — NAS API polling 부담 즉시 감소.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 진단 커맨드 (NAS bash)
|
||||
|
||||
```bash
|
||||
# 실시간 CPU 사용 (상위 15)
|
||||
top -b -n 1 | head -25
|
||||
|
||||
# 프로세스별 CPU 정렬
|
||||
ps aux --sort=-%cpu | head -15
|
||||
|
||||
# uvicorn·chromium·python 프로세스만
|
||||
ps aux | grep -E "uvicorn|chromium|python" | grep -v grep
|
||||
|
||||
# 스케줄러 실행 로그 (최근 50)
|
||||
docker logs agent-office 2>&1 | grep -E "APScheduler|executing" | tail -50
|
||||
|
||||
# insta-lab Chromium 프로세스 개수
|
||||
docker exec insta-lab ps aux | grep chromium | wc -l
|
||||
|
||||
# 컨테이너별 CPU/메모리 실시간
|
||||
docker stats --no-stream
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 참고
|
||||
|
||||
- 진단 풀 보고서: `C:\Users\jaeoh\Documents\Obsidian Vault\raw\2026-05-18-NAS-uvicorn-CPU-진단-개선안.md`
|
||||
- 위키 페이지: [[사업-개인-웹-플랫폼]] (CPU 부하 진단 섹션 + 컨테이너 표)
|
||||
- docker-compose.yml: 본 디렉토리 루트
|
||||
|
||||
## 변경 이력
|
||||
|
||||
- 2026-05-18: 페이지 신설. 즉시 5건 + 중기 4건 + 장기 3건. 진단 커맨드.
|
||||
26
CLAUDE.md
@@ -467,7 +467,6 @@ docker compose up -d
|
||||
- `ANTHROPIC_MODEL_HAIKU` / `ANTHROPIC_MODEL_SONNET`: 모델명 오버라이드
|
||||
- `INSTA_DATA_PATH`: SQLite + 카드 PNG 저장 경로 (기본 `/app/data`)
|
||||
- `CARD_TEMPLATE_DIR`: HTML 템플릿 디렉토리 (기본 `/app/app/templates`)
|
||||
- `INSTA_DEFAULT_THEME`: 카드 렌더에 사용할 theme 디렉토리명 (기본 `default`). `templates/<theme>/card.html.j2`가 없으면 자동으로 default 폴백
|
||||
- `NEWS_PER_CATEGORY` / `KEYWORDS_PER_CATEGORY`: 수집·추출 limit 튜닝
|
||||
|
||||
**카테고리 시드 키워드**
|
||||
@@ -483,31 +482,6 @@ docker compose up -d
|
||||
- 09:30 매일 — `_run_insta_schedule` (insta_pipeline) → 뉴스 수집 → 키워드 추출 → 텔레그램 후보 푸시
|
||||
- `agent_config.custom_config.auto_select=True`이면 카테고리당 1위 키워드 자동 슬레이트 생성·발송
|
||||
|
||||
**디자인 import (사용자 디자인 PNG → Claude Vision → Jinja HTML 자동 생성)**
|
||||
- `insta-lab/app/templates/<theme>/pages/*.png` (10장, 4:5 비율 권장 1080×1350, placeholder 텍스트 박혀있는 형태) → Claude Sonnet Vision → `templates/<theme>/card.html.j2` 자동 생성
|
||||
- 파일명 자동 매핑: `cover`/`start`/`intro` → page 1, `cta`/`outro`/`finish`/`end` → page 10, 나머지 알파벳 순 → page 2~9
|
||||
- 매핑 override: `pages/_order.json`에 `{filename: page_no}` 명시 (10장 + page 1~10 완전 매핑일 때만 적용)
|
||||
- Vision prompt에 placeholder 마스킹 요구 포함 (2-layer: 마스킹 박스 + 동적 텍스트 layer)
|
||||
- 기존 HTML 자동 백업 (`card.html.j2.bak.YYYYMMDD-HHMMSS`)
|
||||
- Jinja 문법 깨진 응답은 `card.html.j2.error.txt`로 보존 + ValueError
|
||||
- 활성화: `.env`에 `INSTA_DEFAULT_THEME=<theme>` 추가 + `docker compose restart insta-lab` (테마 디렉토리에 `card.html.j2` 없으면 렌더러가 default로 폴백)
|
||||
- 토큰 비용: 1회당 ~15K tokens (~$0.05 Sonnet 기준)
|
||||
|
||||
**⚠️ 실행 위치 — 로컬 권장, NAS docker exec 금지**
|
||||
- docker-compose의 insta-lab volume은 `/app/data`만 마운트. **`/app/app/templates`는 컨테이너 ephemeral state**.
|
||||
- NAS에서 `docker exec insta-lab python -m app.design_importer <theme>`로 돌리면 `card.html.j2`가 컨테이너 안에만 생성되고 다음 image rebuild(다른 push의 webhook이라도) 때 사라짐 → 렌더러가 default로 폴백.
|
||||
- **로컬 실행** (host repo working tree에 영속화 → git push → 자동 배포):
|
||||
```bash
|
||||
cd insta-lab
|
||||
pip install anthropic Pillow jinja2 # 이미 있으면 skip
|
||||
export ANTHROPIC_API_KEY=sk-ant-...
|
||||
python -m app.design_importer <theme> --templates-dir ./app/templates
|
||||
git add app/templates/<theme>/card.html.j2
|
||||
git commit -m "feat(insta-lab): <theme> 디자인 import"
|
||||
git push # → Gitea webhook → NAS rebuild → 영구 활성화
|
||||
```
|
||||
- 응급 hotfix로 NAS에서 돌렸다면 `docker cp insta-lab:/app/app/templates/<theme>/card.html.j2 ./` 후 즉시 host repo에 commit + push 필요
|
||||
|
||||
**insta-lab API 목록**
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|
||||
126
README.md
@@ -1,7 +1,7 @@
|
||||
# web-backend
|
||||
|
||||
Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
|
||||
로또 분석, 주식 포트폴리오, AI 음악 생성, 인스타 카드 피드, 부동산 청약, AI 에이전트 오피스, 여행 앨범, 개인 서비스(포트폴리오·블로그·투두), NAS 자료 다운로드 자동화를 하나의 Docker Compose 스택으로 운영한다.
|
||||
로또 분석, 주식 포트폴리오, AI 음악 생성, 블로그 마케팅, 부동산 청약, AI 에이전트 오피스, 여행 앨범을 하나의 Docker Compose 스택으로 운영한다.
|
||||
|
||||
---
|
||||
|
||||
@@ -9,37 +9,33 @@ Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ frontend (Nginx:8080) │
|
||||
│ lotto-frontend (Nginx:8080) │
|
||||
│ ├── 정적 SPA 서빙 (React + Vite) │
|
||||
│ └── API 리버스 프록시 │
|
||||
│ ├── /api/ → lotto:8000 (로또) │
|
||||
│ ├── /api/stock/, /trade/ → stock:8000 │
|
||||
│ ├── /api/portfolio → stock:8000 │
|
||||
│ ├── /api/music/ → music-lab:8000 │
|
||||
│ ├── /api/insta/ → insta-lab:8000 │
|
||||
│ ├── /api/realestate/ → realestate-lab:8000 │
|
||||
│ ├── /api/agent-office/ → agent-office:8000 (+ WebSocket) │
|
||||
│ ├── /api/profile/, /todos, /blog/ → personal:8000 │
|
||||
│ ├── /api/packs/ → packs-lab:8000 (HMAC + 5GB upload) │
|
||||
│ ├── /api/travel/ → travel-proxy:8000 │
|
||||
│ ├── /media/music/, /media/videos/ (nginx 직접 서빙, 미디어) │
|
||||
│ ├── /api/ → lotto-backend: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 │
|
||||
│ ├── /api/agent-office/ → agent-office:8000 (+ WebSocket) │
|
||||
│ ├── /api/travel/ → travel-proxy:8000 │
|
||||
│ ├── /media/music/… (nginx 직접 서빙, 생성 오디오) │
|
||||
│ ├── /media/travel/… (nginx 직접 서빙, 사진/썸네일) │
|
||||
│ └── /webhook → deployer:9000 │
|
||||
│ └── /webhook → deployer:9000 │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 컨테이너 | 포트 | 역할 |
|
||||
|---------|------|------|
|
||||
| `lotto` | 18000 | 로또 데이터 수집·분석·추천 API |
|
||||
| `lotto-backend` | 18000 | 로또 데이터 수집·분석·추천 + 블로그·투두 API |
|
||||
| `stock` | 18500 | 주식 뉴스·AI 요약·KIS 실계좌·포트폴리오·자산 추적 |
|
||||
| `music-lab` | 18600 | AI 음악 생성 (Suno + 로컬 MusicGen 듀얼 프로바이더) + YouTube 수익화 |
|
||||
| `insta-lab` | 18700 | 인스타 카드 피드 자동 생성 (뉴스→키워드→10페이지 카드, Playwright) |
|
||||
| `realestate-lab` | 18800 | 청약 공고 자동 수집·5티어 매칭·신규 매칭 push |
|
||||
| `music-lab` | 18600 | AI 음악 생성 (Suno + 로컬 MusicGen 듀얼 프로바이더) |
|
||||
| `blog-lab` | 18700 | 블로그 마케팅 수익화 (키워드→글 생성→리뷰→발행) |
|
||||
| `realestate-lab` | 18800 | 청약 공고 자동 수집·프로필 매칭 |
|
||||
| `agent-office` | 18900 | AI 에이전트 가상 오피스 (WebSocket + 텔레그램 봇) |
|
||||
| `personal` | 18850 | 개인 서비스 — 포트폴리오·블로그·투두 통합 |
|
||||
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (DSM 공유 링크 + 5GB 청크 업로드) |
|
||||
| `travel-proxy` | 19000 | 여행 사진 API + 온디맨드 썸네일 |
|
||||
| `frontend` | 8080 | SPA 서빙 + 리버스 프록시 |
|
||||
| `lotto-frontend` | 8080 | SPA 서빙 + 리버스 프록시 |
|
||||
| `webpage-deployer` | 19010 | Gitea Webhook → 자동 배포 |
|
||||
|
||||
---
|
||||
@@ -48,14 +44,12 @@ Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
|
||||
|
||||
```
|
||||
web-backend/
|
||||
├── lotto/ # 로또 추천·통계·시뮬레이션
|
||||
├── stock/ # 주식·포트폴리오·KIS 연동
|
||||
├── music-lab/ # AI 음악 생성 + YouTube 수익화
|
||||
├── insta-lab/ # 인스타 카드 피드 자동 생성 (Playwright)
|
||||
├── realestate-lab/ # 청약 자동 수집·5티어 매칭
|
||||
├── backend/ # lotto-backend (로또·블로그·투두)
|
||||
├── stock/ # 주식·포트폴리오
|
||||
├── music-lab/ # AI 음악 생성
|
||||
├── blog-lab/ # 블로그 마케팅 파이프라인
|
||||
├── realestate-lab/ # 청약 자동 수집·매칭
|
||||
├── agent-office/ # AI 에이전트 오피스 (WS + 텔레그램)
|
||||
├── personal/ # 포트폴리오·블로그·투두 통합
|
||||
├── packs-lab/ # NAS 자료 다운로드 자동화 (HMAC + Supabase)
|
||||
├── travel-proxy/ # 여행 사진 + 썸네일
|
||||
├── deployer/ # Gitea Webhook 수신 → 자동 배포
|
||||
├── nginx/default.conf # 리버스 프록시 + SPA + 캐시
|
||||
@@ -80,14 +74,12 @@ curl http://localhost:18500/health
|
||||
| 서비스 | 로컬 URL |
|
||||
|--------|----------|
|
||||
| Frontend + API | http://localhost:8080 |
|
||||
| lotto | http://localhost:18000 |
|
||||
| lotto-backend | http://localhost:18000 |
|
||||
| stock | http://localhost:18500 |
|
||||
| music-lab | http://localhost:18600 |
|
||||
| insta-lab | http://localhost:18700 |
|
||||
| blog-lab | http://localhost:18700 |
|
||||
| realestate-lab | http://localhost:18800 |
|
||||
| personal | http://localhost:18850 |
|
||||
| agent-office | http://localhost:18900 |
|
||||
| packs-lab | http://localhost:18950 |
|
||||
| travel-proxy | http://localhost:19000 |
|
||||
|
||||
---
|
||||
@@ -131,23 +123,20 @@ curl http://localhost:18500/health
|
||||
- **라이브러리**: 생성 파일은 `/app/data/music/`에 저장되고 Nginx가 `/media/music/`으로 직접 서빙
|
||||
- **가사 도구**: 저장·편집·타임스탬프 기반 가라오케 동기
|
||||
|
||||
### 4. insta-lab (`/api/insta/`)
|
||||
### 4. blog-lab (`/api/blog-marketing/`)
|
||||
|
||||
인스타그램 카드 피드 자동 생성 — 뉴스 모니터링 → 키워드 추출 → 10페이지 카드 카피·PNG 렌더 → 텔레그램 푸시 → 사용자 수동 업로드.
|
||||
블로그 마케팅 수익화 4단계 파이프라인 (`draft → marketed → reviewed → published`).
|
||||
|
||||
```
|
||||
NAVER 뉴스 + YouTube 인기 (외부 트렌드)
|
||||
→ 카테고리별 빈도 + Claude Haiku 정제 → 트렌딩 키워드
|
||||
→ 사용자가 키워드 선택
|
||||
→ Claude Sonnet으로 10페이지 카피 추론 (커버 1 + 본문 8 + CTA 1)
|
||||
→ Jinja2 + Playwright 1080×1350 PNG 10장 렌더
|
||||
→ 텔레그램 미디어 그룹 + 추천 캡션·해시태그
|
||||
리서치(Naver Search + 상위 블로그 본문 크롤링)
|
||||
→ 작가(AI 초안 생성)
|
||||
→ 마케터(전환율 강화 + 브랜드 링크 삽입)
|
||||
→ 평가자(6기준×10점, 42/60 통과 시 published)
|
||||
```
|
||||
|
||||
- **AI 엔진**: Claude Sonnet (카피) + Claude Haiku (키워드 분류)
|
||||
- **데이터 소스**: NAVER 뉴스 검색 + YouTube Data API v3 mostPopular(KR)
|
||||
- **카테고리 가중치**: 사용자가 economy/psychology/celebrity 등 카테고리별 가중치 설정 → 자동 추출 비율에 반영
|
||||
- **카드 디자인**: `insta-lab/app/templates/default/card.html.j2` — 사용자가 자유 수정 (Tailwind 등)
|
||||
- **AI 엔진**: Claude API (`claude-sonnet-4-20250514`)
|
||||
- **키워드 분석**: 네이버 검색(블로그+쇼핑) API + 경쟁도/기회 점수
|
||||
- **수익 추적**: 포스트별 월간 클릭/구매/수익 기록
|
||||
- **프롬프트 템플릿**: DB에 저장 → 코드 배포 없이 수정 가능
|
||||
|
||||
### 5. realestate-lab (`/api/realestate/`)
|
||||
@@ -163,7 +152,7 @@ NAVER 뉴스 + YouTube 인기 (외부 트렌드)
|
||||
|
||||
AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 4명의 에이전트가 실제 작업을 수행한다.
|
||||
|
||||
- **아키텍처**: stock / music-lab / insta-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)
|
||||
- **텔레그램 연동**: 양방향 알림 + 인라인 키보드 승인
|
||||
@@ -176,28 +165,22 @@ AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 4명의 에
|
||||
|---------|--------|-----|----------|
|
||||
| 📈 **주식 트레이더** (`stock`) | 08:00 매일 | — | 뉴스 요약 (LLM) → 텔레그램 아침 브리핑, 종목 알람 등록 |
|
||||
| 🎵 **음악 프로듀서** (`music`) | 수동 트리거 | ✅ 작곡 | 프롬프트 수신 → 승인 → Suno API 작곡 → 트랙 푸시 |
|
||||
| 🎴 **인스타 큐레이터** (`insta`) | 09:00 / 09:30 매일 | — | 09:00 외부 트렌드(NAVER + YouTube) 수집 → 09:30 가중치 기반 키워드 추출 → 텔레그램 후보 5개씩 카테고리당 인라인 버튼 푸시 → 사용자 선택 시 카드 10장 미디어 그룹 |
|
||||
| 🏢 **청약 애널리스트** (`realestate`) | realestate-lab push trigger | — | realestate-lab이 신규 매칭 발견 시 push → 인라인 [북마크] 버튼 포함 텔레그램 알림 |
|
||||
| 🎬 **YouTube 리서처** (`youtube`) | 09:00 매일 | — | 한국 YouTube 트렌딩 + Google Trends + Billboard → music-lab market_trends push |
|
||||
| ✍️ **블로그 마케터** (`blog`) | 10:00 매일 | ✅ 발행 | 트렌드 키워드 1개 선택 → 리서치→작가→마케터→평가 자동 실행 → 점수·본문을 텔레그램 승인 요청 → 승인 시 `published` 전환, 거절 시 재생성 |
|
||||
| 🏢 **청약 애널리스트** (`realestate`) | 09:15 매일 | — | realestate-lab 수집 트리거 → 신규 매칭 상위 5건 + 대시보드 요약을 텔레그램 리포트 (읽음 처리 자동) |
|
||||
|
||||
#### 에이전트별 명령
|
||||
|
||||
**Stock** — `fetch_news`, `list_alerts`, `add_alert`, `test_telegram`
|
||||
**Music** — `compose` (승인 필요), `credits`
|
||||
**Insta** — `extract`, `render <keyword_id>`, `collect_trends`
|
||||
**Blog** — `research {keyword}`, `add_trend_keyword`, `list_trend_keywords`
|
||||
**Realestate** — `fetch_matches`, `dashboard`
|
||||
**YouTube** — `research {countries: [...]}`
|
||||
|
||||
#### 스케줄러 잡
|
||||
|
||||
- 07:00 월요일 — Lotto: AI 큐레이터 브리핑 (5세트 + 내러티브)
|
||||
- 07:30 — Stock: 뉴스 요약
|
||||
- 08:00 평일 — Stock: AI 뉴스 sentiment 분석
|
||||
- 09:00 — YouTube: 한국 트렌딩 수집
|
||||
- 09:00 — Insta: 외부 트렌드 수집 (NAVER 인기 + YouTube mostPopular)
|
||||
- 09:30 — Insta: 키워드 추출 (가중치 적용) + 텔레그램 후보 푸시
|
||||
- 15:40 평일 — Stock: 총 자산 스냅샷
|
||||
- 16:30 평일 — Stock: 스크리너 실행
|
||||
- 09:15 — Realestate: 매칭 리포트
|
||||
- 10:00 — Blog: 자동 파이프라인 (리서치→생성→리뷰→승인 대기)
|
||||
- 60초 interval — 유휴 에이전트 휴식 체크
|
||||
|
||||
### 7. travel-proxy (`/api/travel/`)
|
||||
@@ -282,15 +265,13 @@ git push → Gitea → X-Gitea-Signature (HMAC SHA256)
|
||||
|
||||
| DB | 소유 서비스 | 주요 테이블 |
|
||||
|----|------------|-----------|
|
||||
| `lotto.db` | lotto | draws, recommendations, simulation_runs/candidates, best_picks, purchase_history, strategy_performance/weights, weekly_reports, lotto_briefings |
|
||||
| `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 | 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), video_projects, revenue_records, market_trends, trend_reports |
|
||||
| `insta.db` | insta-lab | news_articles, trending_keywords (source 컬럼), card_slates, card_assets, generation_tasks, prompt_templates, account_preferences |
|
||||
| `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 |
|
||||
| `agent_office.db` | agent-office | agent_config, agent_tasks, agent_logs, telegram_state, conversation_messages |
|
||||
| `personal.db` | personal | profile, careers, projects, skills, introductions, todos, blog_posts |
|
||||
| `travel.db` | travel-proxy | photos (album, filename, mtime, has_thumb), album_covers |
|
||||
| `pack_files` (외부 Supabase) | packs-lab | filename, host_path, mime, byte_size, sha256, deleted_at |
|
||||
|
||||
---
|
||||
|
||||
@@ -311,50 +292,33 @@ PGID=1000
|
||||
WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
|
||||
WEBHOOK_SECRET=your_secret_here
|
||||
|
||||
# LLM (stock, insta-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
|
||||
OLLAMA_URL=http://192.168.45.59:11435
|
||||
OLLAMA_MODEL=qwen3:14b
|
||||
|
||||
# stock admin protection (CODE_REVIEW F2)
|
||||
ADMIN_API_KEY=
|
||||
ALLOW_UNAUTHENTICATED_ADMIN=false
|
||||
|
||||
# music-lab
|
||||
SUNO_API_KEY=
|
||||
MUSIC_AI_SERVER_URL=
|
||||
MUSIC_MEDIA_BASE=/media/music
|
||||
|
||||
# insta-lab + agent-office (NAVER 검색 + YouTube Data API 공유)
|
||||
# blog-lab
|
||||
NAVER_CLIENT_ID=
|
||||
NAVER_CLIENT_SECRET=
|
||||
YOUTUBE_DATA_API_KEY=
|
||||
|
||||
# realestate-lab
|
||||
DATA_GO_KR_API_KEY=
|
||||
|
||||
# packs-lab (DSM + Supabase)
|
||||
DSM_HOST=
|
||||
DSM_USER=
|
||||
DSM_PASS=
|
||||
BACKEND_HMAC_SECRET=
|
||||
SUPABASE_URL=
|
||||
SUPABASE_SERVICE_KEY=
|
||||
PACK_HOST_DIR=/docker/webpage/media/packs # shared folder 시점 (CLAUDE.md F5)
|
||||
|
||||
# agent-office
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_CHAT_ID=
|
||||
TELEGRAM_WEBHOOK_URL=
|
||||
STOCK_URL=http://stock:8000
|
||||
MUSIC_LAB_URL=http://music-lab:8000
|
||||
INSTA_LAB_URL=http://insta-lab:8000
|
||||
BLOG_LAB_URL=http://blog-lab:8000
|
||||
REALESTATE_LAB_URL=http://realestate-lab:8000
|
||||
|
||||
# personal (포트폴리오 편집 인증)
|
||||
PORTFOLIO_EDIT_PASSWORD=
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
34
STATUS.md
@@ -1,42 +1,40 @@
|
||||
# web-backend — 구현 현황 & 로드맵
|
||||
|
||||
> 최종 갱신: 2026-05-17
|
||||
> 최종 갱신: 2026-05-07
|
||||
> 자세한 서비스·환경변수·DB 표는 [CLAUDE.md](./CLAUDE.md), 설계는 `docs/superpowers/specs/`, 실행 계획은 `docs/superpowers/plans/` 참조.
|
||||
|
||||
---
|
||||
|
||||
## 1. 서비스 구현 현황
|
||||
|
||||
### 1-1. 운영 중인 컨테이너 (11개)
|
||||
### 1-1. 운영 중인 컨테이너 (10개)
|
||||
|
||||
| 서비스 | 포트 | 상태 | 핵심 기능 |
|
||||
|--------|------|------|-----------|
|
||||
| `lotto` | 18000 | ✅ | 로또 추천·통계·리포트·구매내역·AI 큐레이터 |
|
||||
| `stock` | 18500 | ✅ | 주식 뉴스·지수·트레이딩·포트폴리오·자산 스냅샷·스크리너 |
|
||||
| `lotto-backend` | 18000 | ✅ | 로또 추천·통계·리포트·구매내역 + 블로그·투두 |
|
||||
| `stock` | 18500 | ✅ | 주식 뉴스·지수·트레이딩·포트폴리오·자산 스냅샷 |
|
||||
| `music-lab` | 18600 | ✅ | Suno + MusicGen + YouTube 수익화 + 컴파일 |
|
||||
| `insta-lab` | 18700 | ✅ | 인스타 카드 피드 자동 생성 (NAVER + YouTube 트렌드 → 10페이지 카드, Playwright) |
|
||||
| `realestate-lab` | 18800 | ✅ | 청약 수집·5티어 매칭·매칭 알림 push |
|
||||
| `personal` | 18850 | ✅ | 포트폴리오·블로그·투두 통합 (개인 서비스) |
|
||||
| `agent-office` | 18900 | ✅ | AI 에이전트 (WebSocket + 텔레그램 + InstaAgent + YouTubeResearcher) |
|
||||
| `packs-lab` | 18950 | ✅ | NAS 자료 다운로드 자동화 (HMAC + Supabase + 5GB chunked upload) |
|
||||
| `blog-lab` | 18700 | ✅ | 블로그 마케팅 수익화 파이프라인 |
|
||||
| `realestate-lab` | 18800 | ✅ | 청약 수집·5티어 매칭·매칭 알림 |
|
||||
| `agent-office` | 18900 | ✅ | AI 에이전트 (WebSocket + 텔레그램 + YouTubeResearcher) |
|
||||
| `packs-lab` | 18950 | ✅ | NAS 자료 다운로드 자동화 (HMAC + Supabase) — 2026-05-05 |
|
||||
| `travel-proxy` | 19000 | ✅ | 여행 사진 API + 썸네일 + 지역 관리 |
|
||||
| `frontend` (nginx) | 8080 | ✅ | SPA + 리버스 프록시 (5GB body limit, 인스타 라우팅 포함) |
|
||||
| `webpage-deployer` | 19010 | ✅ | Gitea Webhook 자동 배포 (BUILDKIT timeout 600s, healthcheck via docker inspect) |
|
||||
| `nginx` | 8080 | ✅ | SPA + 리버스 프록시 (5GB body limit) |
|
||||
| `webpage-deployer` | 19010 | ✅ | Gitea Webhook 자동 배포 |
|
||||
|
||||
### 1-2. 최근 큰 작업 (2026-05)
|
||||
### 1-2. 최근 큰 작업 (2026-04 ~ 05)
|
||||
|
||||
| 시기 | 영역 | 핵심 |
|
||||
|------|------|------|
|
||||
| 2026-05-17 | 보안 / 정합성 | CODE_REVIEW F1 (packs-lab path traversal `startswith→relative_to`) + F2 (stock admin auth 503 거부) + F4 (portfolio total_buy 수량 곱산) |
|
||||
| 2026-05-17 | insta-lab | Google Trends API 폐기 대응 → YouTube Data API v3로 source 교체. trend_collector 재작성 |
|
||||
| 2026-05-16 | insta-lab | Trends 탭 추가 — 외부 트렌드 수집 (NAVER 인기 + YouTube) + 카테고리 가중치 (`account_preferences`) + 가중치 기반 키워드 추출 |
|
||||
| 2026-05-15 | insta-lab | blog-lab 폐기 → insta-lab 신설. 뉴스 모니터링 → 키워드 추출 → 10페이지 카드 카피·PNG → 텔레그램 푸시 → 수동 인스타 업로드 파이프라인 |
|
||||
| 2026-05-05 | packs-lab | sign-link / upload / list / delete + admin mint-token + 5GB nginx body limit + Supabase DDL |
|
||||
| 2026-05-01~06 | music-lab | YouTube 수익화 백엔드 (market_trends·trend_reports DB + 5개 API) + 다중 트랙 FFmpeg concat MP4 |
|
||||
| 2026-04-28 | realestate-lab | targeting enhancement (5티어 매칭·5축 점수·알림 대상 카운트, realestate-lab push → agent-office RealestateAgent) |
|
||||
| 2026-04-28 | realestate-lab | targeting enhancement (5티어 매칭·5축 점수·알림 대상 카운트) |
|
||||
| 2026-04-27 | personal | personal 서비스 분리 마이그레이션 (블로그·투두·포트폴리오 인증) |
|
||||
| 2026-04-27 | agent-office | v2 — youtube_researcher (YouTube API + pytrends + Billboard) + 알림 |
|
||||
| 2026-04-15 | lotto | AI 큐레이터 (Claude 기반 주간 브리핑 자동 생성) |
|
||||
| 2026-04-24 | travel-proxy | 갤러리 리디자인 + 성능 개선 (썸네일/페이지네이션) |
|
||||
| 2026-04-15 | lotto-backend | AI 큐레이터 (Claude 기반 주간 브리핑 자동 생성) |
|
||||
| 2026-04-08 | music-lab | Suno enhancement + MusicGen 통합 |
|
||||
| 2026-04-06 | blog-lab | 마케팅 파이프라인 (research → generate → market → review) |
|
||||
|
||||
### 1-3. 인프라 / DX
|
||||
|
||||
|
||||
@@ -7,4 +7,4 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import asyncio
|
||||
import random
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from ..config import IDLE_BREAK_THRESHOLD, BREAK_DURATION_MIN, BREAK_DURATION_MAX
|
||||
from ..db import add_log
|
||||
|
||||
VALID_STATES = ("idle", "working", "waiting", "reporting")
|
||||
VALID_STATES = ("idle", "working", "waiting", "reporting", "break")
|
||||
|
||||
class BaseAgent:
|
||||
agent_id: str = ""
|
||||
@@ -11,6 +14,7 @@ class BaseAgent:
|
||||
state: str = "idle"
|
||||
state_detail: str = ""
|
||||
_idle_since: float = 0.0
|
||||
_break_until: float = 0.0
|
||||
_ws_manager = None
|
||||
|
||||
def __init__(self):
|
||||
@@ -28,6 +32,9 @@ class BaseAgent:
|
||||
|
||||
if new_state == "idle":
|
||||
self._idle_since = time.time()
|
||||
elif new_state == "break":
|
||||
duration = random.randint(BREAK_DURATION_MIN, BREAK_DURATION_MAX)
|
||||
self._break_until = time.time() + duration
|
||||
|
||||
add_log(self.agent_id, f"State: {old} -> {new_state} ({detail})")
|
||||
|
||||
@@ -41,6 +48,19 @@ class BaseAgent:
|
||||
await self._ws_manager.send_notification(
|
||||
self.agent_id, "task_completed", task_id, detail or "작업 완료"
|
||||
)
|
||||
if new_state == "break":
|
||||
await self._ws_manager.send_agent_move(self.agent_id, "break_room")
|
||||
elif old == "break" and new_state == "idle":
|
||||
await self._ws_manager.send_agent_move(self.agent_id, "desk")
|
||||
|
||||
async def check_idle_break(self) -> None:
|
||||
now = time.time()
|
||||
if self.state == "idle" and (now - self._idle_since) > IDLE_BREAK_THRESHOLD:
|
||||
if random.random() < 0.5:
|
||||
break_type = random.choice(["커피 타임", "잠깐 산책", "졸고 있음"])
|
||||
await self.transition("break", break_type)
|
||||
elif self.state == "break" and now > self._break_until:
|
||||
await self.transition("idle", "휴식 완료")
|
||||
|
||||
async def on_schedule(self) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -46,7 +46,7 @@ class InstaAgent(BaseAgent):
|
||||
async def on_schedule(self) -> None:
|
||||
"""09:30 매일: 뉴스 수집 → 키워드 추출 → 텔레그램 후보 푸시.
|
||||
custom_config.auto_select=True면 카테고리당 1위 키워드 자동 슬레이트 생성."""
|
||||
if self.state != "idle":
|
||||
if self.state not in ("idle", "break"):
|
||||
return
|
||||
config = get_agent_config(self.agent_id) or {}
|
||||
custom = config.get("custom_config", {}) or {}
|
||||
|
||||
@@ -8,7 +8,7 @@ class LottoAgent(BaseAgent):
|
||||
display_name = "로또 큐레이터"
|
||||
|
||||
async def on_schedule(self) -> None:
|
||||
if self.state != "idle":
|
||||
if self.state not in ("idle", "break"):
|
||||
return
|
||||
await self._run(source="auto")
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ class StockAgent(BaseAgent):
|
||||
display_name = "주식 트레이더"
|
||||
|
||||
async def on_schedule(self) -> None:
|
||||
if self.state != "idle":
|
||||
if self.state not in ("idle", "break"):
|
||||
return
|
||||
|
||||
task_id = create_task(self.agent_id, "news_summary", {"limit": 15})
|
||||
@@ -129,7 +129,7 @@ class StockAgent(BaseAgent):
|
||||
4) status=='success' → telegram_payload.text 를 parse_mode 그대로 전송
|
||||
5) 예외/실패 → 운영자에게 별도 텔레그램 알림 (HTML)
|
||||
"""
|
||||
if self.state != "idle":
|
||||
if self.state not in ("idle", "break"):
|
||||
return
|
||||
|
||||
task_id = create_task(self.agent_id, "screener_run", {"mode": "auto"})
|
||||
@@ -243,7 +243,7 @@ class StockAgent(BaseAgent):
|
||||
4) failures > 30% → 경고 알림 후 메인 메시지 발송
|
||||
5) 정상 → Top 5 호재/악재 메시지 발송 (MarkdownV2)
|
||||
"""
|
||||
if self.state != "idle":
|
||||
if self.state not in ("idle", "break"):
|
||||
return
|
||||
|
||||
task_id = create_task(self.agent_id, "ai_news_sentiment", {})
|
||||
|
||||
@@ -26,6 +26,11 @@ CORS_ALLOW_ORIGINS = os.getenv(
|
||||
"CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080"
|
||||
)
|
||||
|
||||
# Idle break threshold (seconds)
|
||||
IDLE_BREAK_THRESHOLD = int(os.getenv("IDLE_BREAK_THRESHOLD", "300")) # 5 min
|
||||
BREAK_DURATION_MIN = int(os.getenv("BREAK_DURATION_MIN", "60")) # 1 min
|
||||
BREAK_DURATION_MAX = int(os.getenv("BREAK_DURATION_MAX", "180")) # 3 min
|
||||
|
||||
# Lotto Curator
|
||||
LOTTO_BACKEND_URL = os.getenv("LOTTO_BACKEND_URL", "http://lotto:8000")
|
||||
LOTTO_CURATOR_MODEL = os.getenv("LOTTO_CURATOR_MODEL", "claude-sonnet-4-5")
|
||||
|
||||
@@ -5,6 +5,10 @@ from .agents import AGENT_REGISTRY
|
||||
|
||||
scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
|
||||
|
||||
async def _check_idle_breaks():
|
||||
for agent in AGENT_REGISTRY.values():
|
||||
await agent.check_idle_break()
|
||||
|
||||
async def _run_stock_schedule():
|
||||
agent = AGENT_REGISTRY.get("stock")
|
||||
if agent:
|
||||
@@ -70,10 +74,10 @@ def init_scheduler():
|
||||
id="stock_ai_news_sentiment",
|
||||
)
|
||||
scheduler.add_job(_run_insta_schedule, "cron", hour=9, minute=30, id="insta_pipeline")
|
||||
# 09:00 cron 스태거링 — Celeron 2C/2.0GHz에서 동시 실행 시 CPU 폭주 (CHECK_POINT FU-A)
|
||||
scheduler.add_job(_run_insta_trends_collect, "cron", hour=9, minute=0, id="insta_trends_collect")
|
||||
scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=9, minute=5, id="lotto_curate")
|
||||
scheduler.add_job(_run_youtube_research, "cron", hour=9, minute=10, id="youtube_research")
|
||||
scheduler.add_job(_run_insta_trends_collect, "cron", hour=9, minute=0, id="insta_trends_collect")
|
||||
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")
|
||||
scheduler.add_job(_send_youtube_weekly_report, "cron", day_of_week="mon", hour=8, minute=0, id="youtube_weekly_report")
|
||||
scheduler.add_job(_check_idle_breaks, "interval", seconds=60, id="idle_check")
|
||||
scheduler.add_job(_poll_pipelines, "interval", seconds=30, id="pipeline_poll")
|
||||
scheduler.start()
|
||||
|
||||
@@ -18,7 +18,7 @@ services:
|
||||
- ${RUNTIME_PATH}/data:/app/data
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 60s
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -48,7 +48,7 @@ services:
|
||||
- ${RUNTIME_PATH}/data/stock:/app/data
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 60s
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -82,7 +82,7 @@ services:
|
||||
- ${RUNTIME_PATH:-.}/data/videos:/app/data/videos
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 60s
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -103,17 +103,12 @@ services:
|
||||
- YOUTUBE_DATA_API_KEY=${YOUTUBE_DATA_API_KEY:-}
|
||||
- INSTA_DATA_PATH=/app/data
|
||||
- CARD_TEMPLATE_DIR=/app/app/templates
|
||||
- INSTA_DEFAULT_THEME=${INSTA_DEFAULT_THEME:-default}
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
|
||||
volumes:
|
||||
- ${RUNTIME_PATH}/data/insta:/app/data
|
||||
depends_on:
|
||||
- redis
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 60s
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -133,7 +128,7 @@ services:
|
||||
- ${RUNTIME_PATH}/data/realestate:/app/data
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 60s
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -174,7 +169,7 @@ services:
|
||||
- realestate-lab
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 60s
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -193,7 +188,7 @@ services:
|
||||
- ${RUNTIME_PATH:-.}/data/personal:/app/data
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 60s
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -220,7 +215,7 @@ services:
|
||||
- ${PACK_DATA_PATH:-./data/packs}:${PACK_BASE_DIR:-/app/data/packs}
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 60s
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -243,7 +238,7 @@ services:
|
||||
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:rw
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 60s
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -270,12 +265,11 @@ services:
|
||||
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:ro
|
||||
- ${RUNTIME_PATH}/data/music:/data/music:ro
|
||||
- ${RUNTIME_PATH}/data/videos:/data/videos:ro
|
||||
- ${RUNTIME_PATH}/data/insta/insta_cards:/data/insta_cards:ro
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:80/"]
|
||||
interval: 60s
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -295,18 +289,3 @@ services:
|
||||
- ${RUNTIME_PATH}:/runtime:rw
|
||||
- ${RUNTIME_PATH}/scripts:/scripts:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- ${RUNTIME_PATH}/redis-data:/data
|
||||
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -1,635 +0,0 @@
|
||||
# Plan-B-Base — NAS Redis 컨테이너 + Windows WSL2/Docker/Tailscale/SMB Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 분산 아키텍처 base 인프라 셋업 — NAS에 24/7 Redis 컨테이너 신설 + Windows AI 머신에 WSL2 + Docker Engine + Tailscale + NAS SMB 마운트 구성. 후속 Plan-B-Insta/Music/Video/Infra 트랙의 prerequisite.
|
||||
|
||||
**Architecture:** SP-1 (NAS Redis) = docker-compose service 추가 + deployer auto-rebuild. SP-2 (Windows) = 박재오 머신 192.168.45.59에서 직접 셋업 (WSL2 Ubuntu 22.04 + Docker Engine + Tailscale + cifs-utils로 NAS SMB 마운트). 두 SP가 모두 끝나야 후속 트랙의 worker가 NAS ↔ Windows 양방향 통신 가능.
|
||||
|
||||
**Tech Stack:** Redis 7-alpine, WSL2, Ubuntu 22.04, Docker Engine 24+, Tailscale, cifs-utils (SMB 3.0). PowerShell (관리자) + bash (WSL2 내부).
|
||||
|
||||
**Spec:** `web-backend/docs/superpowers/specs/2026-05-18-nas-windows-distributed-architecture-design.md` §4 SP-1·SP-2, §10 SP-1·SP-2 상세
|
||||
|
||||
---
|
||||
|
||||
## 사전 확인 사항
|
||||
|
||||
- **박재오 자격증명 필요**: NAS SMB 마운트용 user/password (Synology DSM 사용자, SMB 권한 보유)
|
||||
- **Windows AI 머신 직접 접근 필요**: WSL2 설치는 관리자 PowerShell + 재부팅 1회. Claude는 별도 머신이라 명령 직접 실행 불가 — **Task 4~7은 박재오가 콘솔에서 직접 수행**. 명령어와 검증 방법 명시.
|
||||
- **NAS deployer 사용자**: Gitea webhook으로 docker compose up -d 자동 실행. 새 redis 서비스도 추가 시 자동 startup.
|
||||
|
||||
## File Structure
|
||||
|
||||
### SP-1 — NAS 측 (Modify)
|
||||
|
||||
| 파일 | 변경 | 책임 |
|
||||
|------|------|------|
|
||||
| `web-backend/docker-compose.yml` | `redis:` 서비스 블록 추가 | 컨테이너 정의 (image, volume, healthcheck) |
|
||||
|
||||
### SP-2 — Windows 측 (Create, 박재오 머신 로컬)
|
||||
|
||||
| 파일/위치 | 변경 | 책임 |
|
||||
|----------|------|------|
|
||||
| (Windows AI) WSL2 Ubuntu-22.04 | install | Linux 런타임 |
|
||||
| WSL2 `/etc/apt/keyrings/docker.gpg` | install | Docker Engine apt key |
|
||||
| WSL2 `/etc/apt/sources.list.d/docker.list` | install | Docker Engine apt source |
|
||||
| (Windows AI) Tailscale | install + auth | 사설망 100.x.x.x |
|
||||
| WSL2 `/etc/nas-smb-credentials` (신규) | NAS user/password | SMB 자격증명 (chmod 600) |
|
||||
| WSL2 `/etc/fstab` (수정) | SMB 마운트 항목 추가 | 부팅 시 자동 마운트 |
|
||||
| WSL2 `/mnt/nas` | mkdir | 마운트 포인트 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: NAS docker-compose.yml에 redis 서비스 추가
|
||||
|
||||
**Files:**
|
||||
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml`
|
||||
|
||||
- [ ] **Step 1: 현재 docker-compose.yml 끝부분 확인 (deployer 위치)**
|
||||
|
||||
Run: `tail -20 C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml`
|
||||
Expected: `deployer` 서비스가 마지막. line ~277-293 영역.
|
||||
|
||||
- [ ] **Step 2: redis 서비스 블록 추가**
|
||||
|
||||
`C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml` 파일 **끝**에 (deployer 서비스 다음, volumes 블록 있다면 그 전에) 다음 블록 추가. 들여쓰기는 다른 서비스(`lotto:`, `stock:` 등)와 동일하게 services 아래 2칸 들여쓰기:
|
||||
|
||||
```yaml
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- ${RUNTIME_PATH}/redis-data:/data
|
||||
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
networks:
|
||||
- default
|
||||
```
|
||||
|
||||
**주의:**
|
||||
- 파일 끝에 추가하되, 만약 `networks:` / `volumes:` top-level 블록이 services 다음에 있다면 그 블록들 **앞에** 삽입
|
||||
- 첫 줄에 빈 줄 1개 두기 (deployer와 분리)
|
||||
- `${RUNTIME_PATH}` 환경변수는 다른 서비스에서도 사용 중. 자동 적용됨
|
||||
|
||||
- [ ] **Step 3: yaml 문법 검증**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
python -c "import yaml; yaml.safe_load(open('C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml'))" && echo "yaml OK"
|
||||
```
|
||||
Expected: `yaml OK`
|
||||
|
||||
만약 실패하면 indent 또는 trailing space 확인.
|
||||
|
||||
- [ ] **Step 4: redis 서비스가 services dict에 들어갔는지 확인**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
python -c "import yaml; d=yaml.safe_load(open('C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml')); print(sorted(d['services'].keys()))"
|
||||
```
|
||||
Expected: 리스트에 `'redis'` 포함. 다른 서비스(`lotto`, `stock`, `music-lab`, `insta-lab`, `realestate-lab`, `agent-office`, `personal`, `packs-lab`, `travel-proxy`, `frontend`, `deployer`)도 모두 그대로.
|
||||
|
||||
- [ ] **Step 5: 커밋**
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||||
git add docker-compose.yml
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(infra): add redis container as 24/7 queue + cache base (SP-1)
|
||||
|
||||
redis:7-alpine, 256MB maxmemory, AOF appendonly ON, allkeys-lru.
|
||||
docker volume ${RUNTIME_PATH}/redis-data로 영속화.
|
||||
Plan-B 후속 트랙(insta-render/music-render/video-render Windows
|
||||
워커)의 BLPOP 큐 + NAS↔Windows pub/sub의 base.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
- [ ] **Step 6: push (Gitea webhook → NAS deployer 자동 적용)**
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||||
git push origin main
|
||||
```
|
||||
|
||||
자격증명 prompt 시 입력. 1회 실패 시 1회 재시도 패턴.
|
||||
|
||||
Expected: push 성공. NAS deployer가 webhook 수신 → `git pull` → `docker compose up -d redis` 자동 실행.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: NAS Redis 컨테이너 헬스 확인
|
||||
|
||||
**Files:** 없음 (NAS 검증)
|
||||
|
||||
- [ ] **Step 1: deployer 완료까지 대기 (통상 30초~2분)**
|
||||
|
||||
Run (Windows 로컬에서):
|
||||
```bash
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" https://gahusb.synology.me/api/stock/news -m 5)
|
||||
echo "[try $i] HTTP $code"
|
||||
if [ "$code" = "200" ]; then break; fi
|
||||
sleep 15
|
||||
done
|
||||
```
|
||||
|
||||
Expected: HTTP 200 응답 — NAS 컨테이너 안정 상태. redis 컨테이너는 별도 endpoint 없으나 deployer가 build 완료했음을 시사.
|
||||
|
||||
- [ ] **Step 2: NAS에서 redis 컨테이너 확인 (박재오 SSH)**
|
||||
|
||||
NAS bash:
|
||||
```bash
|
||||
ssh -p 22 박재오@gahusb.synology.me
|
||||
cd /volume1/docker/webpage
|
||||
docker compose ps redis
|
||||
```
|
||||
|
||||
또는 한 번에:
|
||||
```bash
|
||||
ssh -p 22 박재오@gahusb.synology.me "cd /volume1/docker/webpage && docker compose ps redis && docker exec redis redis-cli PING"
|
||||
```
|
||||
|
||||
Expected:
|
||||
- `docker compose ps redis` → `redis ... healthy` 또는 `Up X seconds (health: starting)` 후 곧 healthy
|
||||
- `redis-cli PING` → `PONG`
|
||||
|
||||
만약 `docker compose ps`에 redis가 안 보이면:
|
||||
```bash
|
||||
cd /volume1/docker/webpage && docker compose up -d redis
|
||||
```
|
||||
|
||||
수동 실행해서 startup 확인.
|
||||
|
||||
- [ ] **Step 3: redis-data 볼륨 생성 확인 (Z: drive로)**
|
||||
|
||||
Run (Windows):
|
||||
```powershell
|
||||
Test-Path "Z:\webpage\redis-data"
|
||||
```
|
||||
|
||||
또는 NAS bash:
|
||||
```bash
|
||||
ls -la /volume1/docker/webpage/redis-data/
|
||||
```
|
||||
|
||||
Expected: 디렉토리 존재. 그 안에 `appendonlydir/` 또는 `dump.rdb` 등의 redis 데이터 파일.
|
||||
|
||||
- [ ] **Step 4: AOF append-only 작동 확인 (선택, 데이터 영속성 검증)**
|
||||
|
||||
```bash
|
||||
ssh -p 22 박재오@gahusb.synology.me 'docker exec redis redis-cli SET test_key "hello"'
|
||||
ssh -p 22 박재오@gahusb.synology.me 'docker exec redis redis-cli RESTART' # 또는 docker restart
|
||||
# 잠시 대기
|
||||
ssh -p 22 박재오@gahusb.synology.me 'docker exec redis redis-cli GET test_key'
|
||||
```
|
||||
|
||||
Expected: `"hello"` — 재시작 후에도 값 유지 (AOF 영속화 작동).
|
||||
|
||||
테스트 후 정리: `docker exec redis redis-cli DEL test_key`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Windows AI에 WSL2 + Ubuntu 22.04 설치
|
||||
|
||||
**Files:** 없음 (Windows AI 머신 192.168.45.59에서 박재오 직접 실행)
|
||||
|
||||
**전제:** Windows 10 build 19041+ 또는 Windows 11. 박재오 9800X3D 머신 충족.
|
||||
|
||||
- [ ] **Step 1: 관리자 PowerShell 실행**
|
||||
|
||||
박재오 Windows AI 머신에서 시작 메뉴 → "PowerShell" 우클릭 → "관리자 권한으로 실행".
|
||||
|
||||
- [ ] **Step 2: WSL2 + Ubuntu 22.04 설치**
|
||||
|
||||
```powershell
|
||||
wsl --install -d Ubuntu-22.04
|
||||
```
|
||||
|
||||
Expected: 다운로드 progress + "Ubuntu-22.04 has been installed". **재부팅 필요할 수 있음.**
|
||||
|
||||
- [ ] **Step 3: 재부팅 (필요 시)**
|
||||
|
||||
설치 완료 메시지에 "재시작이 필요합니다"가 보이면 재부팅. 자동 재부팅 안 됨.
|
||||
|
||||
- [ ] **Step 4: Ubuntu 초기 설정 (재부팅 후 자동 실행 또는 시작 메뉴에서 "Ubuntu" 클릭)**
|
||||
|
||||
새 콘솔이 열리고 다음 입력 요청됨:
|
||||
- 새 UNIX username: `jaeoh` 또는 박재오 선호 username (이후 모든 sudo에 사용)
|
||||
- 비밀번호: 박재오가 정하는 값. 잘 기억할 것.
|
||||
|
||||
Expected: `jaeoh@<hostname>:~$` 프롬프트 표시 → WSL2 진입 성공.
|
||||
|
||||
- [ ] **Step 5: WSL 버전 확인**
|
||||
|
||||
WSL2 내부에서 PowerShell로 잠시 돌아와서:
|
||||
```powershell
|
||||
wsl -l -v
|
||||
```
|
||||
|
||||
Expected:
|
||||
```
|
||||
NAME STATE VERSION
|
||||
* Ubuntu-22.04 Running 2
|
||||
```
|
||||
|
||||
VERSION=2 확인. 만약 1이면:
|
||||
```powershell
|
||||
wsl --set-version Ubuntu-22.04 2
|
||||
```
|
||||
|
||||
- [ ] **Step 6: WSL2 안 진입 (이후 작업)**
|
||||
|
||||
```powershell
|
||||
wsl -d Ubuntu-22.04
|
||||
```
|
||||
|
||||
이후 Task 4~7은 모두 WSL2 안 bash에서 실행.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: WSL2 안 Docker Engine 설치 (Docker Desktop 사용 X)
|
||||
|
||||
**Files:** (WSL2 내부) `/etc/apt/keyrings/docker.gpg`, `/etc/apt/sources.list.d/docker.list`
|
||||
|
||||
**위치:** WSL2 Ubuntu-22.04 bash 프롬프트.
|
||||
|
||||
- [ ] **Step 1: 패키지 인덱스 + 기본 의존성 설치**
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y ca-certificates curl gnupg lsb-release
|
||||
```
|
||||
|
||||
Expected: 에러 없이 완료.
|
||||
|
||||
- [ ] **Step 2: Docker apt key 등록**
|
||||
|
||||
```bash
|
||||
sudo install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
sudo chmod a+r /etc/apt/keyrings/docker.gpg
|
||||
```
|
||||
|
||||
Expected: 에러 없이 완료. `/etc/apt/keyrings/docker.gpg` 파일 생성.
|
||||
|
||||
- [ ] **Step 3: Docker repository 추가**
|
||||
|
||||
```bash
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | \
|
||||
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
sudo apt update
|
||||
```
|
||||
|
||||
Expected: `Hit:N https://download.docker.com/linux/ubuntu jammy InRelease` 라인 보임.
|
||||
|
||||
- [ ] **Step 4: Docker Engine + Compose 설치**
|
||||
|
||||
```bash
|
||||
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
```
|
||||
|
||||
Expected: 설치 완료. 용량 ~400MB.
|
||||
|
||||
- [ ] **Step 5: 현재 사용자를 docker 그룹에 추가**
|
||||
|
||||
```bash
|
||||
sudo usermod -aG docker $USER
|
||||
```
|
||||
|
||||
Expected: 출력 없음 (정상). **새 셸 열어야 적용됨.**
|
||||
|
||||
- [ ] **Step 6: Docker 서비스 시작 + 자동 시작 설정**
|
||||
|
||||
```bash
|
||||
sudo systemctl enable docker
|
||||
sudo systemctl start docker
|
||||
sudo systemctl status docker | head -5
|
||||
```
|
||||
|
||||
Expected: `Active: active (running)`.
|
||||
|
||||
만약 `systemctl: command not found` 또는 systemd 미지원 시:
|
||||
```bash
|
||||
sudo service docker start
|
||||
```
|
||||
|
||||
WSL2 systemd 활성화는 `/etc/wsl.conf`에 `[boot]\nsystemd=true` 추가 후 PowerShell에서 `wsl --shutdown` 후 재진입. (Ubuntu-22.04는 보통 기본 활성)
|
||||
|
||||
- [ ] **Step 7: docker 명령 동작 확인**
|
||||
|
||||
새 셸로 (PowerShell에서 다시 `wsl -d Ubuntu-22.04` 또는 현재 셸 종료 후 재진입):
|
||||
|
||||
```bash
|
||||
docker version
|
||||
docker run --rm hello-world
|
||||
```
|
||||
|
||||
Expected:
|
||||
- `docker version`: Client + Server 둘 다 표시 (Server에 Engine version)
|
||||
- `hello-world`: "Hello from Docker!" 출력
|
||||
|
||||
---
|
||||
|
||||
## Task 5: WSL2 안 Tailscale 설치 + 가입
|
||||
|
||||
**Files:** Tailscale은 systemd service 등록 (별도 path 신경 안 써도 됨)
|
||||
|
||||
- [ ] **Step 1: Tailscale 설치**
|
||||
|
||||
WSL2 bash:
|
||||
```bash
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
```
|
||||
|
||||
Expected: 패키지 install 후 "Installation complete!" 출력.
|
||||
|
||||
- [ ] **Step 2: Tailscale 가입 (브라우저 OAuth)**
|
||||
|
||||
```bash
|
||||
sudo tailscale up
|
||||
```
|
||||
|
||||
Expected: `To authenticate, visit: https://login.tailscale.com/a/...` URL 표시.
|
||||
|
||||
브라우저에서 그 URL 열기 → Google/Microsoft/GitHub 등으로 로그인 → 박재오 Tailscale 네트워크에 가입 (기존 계정 없으면 생성).
|
||||
|
||||
- [ ] **Step 3: 가입 완료 확인**
|
||||
|
||||
```bash
|
||||
tailscale status
|
||||
```
|
||||
|
||||
Expected:
|
||||
- 첫 줄에 Windows AI 머신의 100.x.x.x IP 표시
|
||||
- (이미 가입된) NAS도 같은 네트워크에 있다면 NAS의 100.x.x.x IP도 표시
|
||||
|
||||
- [ ] **Step 4: NAS와 Tailscale ping (양방향 사설망 확인)**
|
||||
|
||||
NAS의 Tailscale IP를 `tailscale status` 출력에서 찾아 (예: `100.64.0.10`):
|
||||
```bash
|
||||
tailscale ping 100.64.0.10
|
||||
```
|
||||
|
||||
Expected: `pong from <NAS hostname>` (직접 LAN 또는 DERP 중계). 만약 NAS가 Tailscale 미가입이면 별도로 NAS DSM Tailscale 패키지 셋업 필요 — 이는 박재오 결정 사항이라 plan 외.
|
||||
|
||||
> **참고:** Tailscale은 spec §3 sense의 사설망 layer 보조. LAN(192.168.45.0/24) 안에서만 작업한다면 Tailscale 없이도 작동. 외부 출장 등에서 NAS↔Windows 통신을 위해 권장.
|
||||
|
||||
---
|
||||
|
||||
## Task 6: WSL2 안 NAS SMB 자격증명 파일 + 마운트 포인트 준비
|
||||
|
||||
**Files:** `/etc/nas-smb-credentials`, `/mnt/nas`
|
||||
|
||||
- [ ] **Step 1: cifs-utils 설치 (SMB 마운트 패키지)**
|
||||
|
||||
```bash
|
||||
sudo apt install -y cifs-utils
|
||||
```
|
||||
|
||||
Expected: 설치 완료.
|
||||
|
||||
- [ ] **Step 2: SMB 자격증명 파일 생성**
|
||||
|
||||
박재오 NAS 계정의 username과 password를 사용. 파일 위치는 system-wide `/etc/`.
|
||||
|
||||
```bash
|
||||
sudo bash -c 'cat > /etc/nas-smb-credentials <<EOF
|
||||
username=박재오NAS사용자명
|
||||
password=박재오NAS비밀번호
|
||||
domain=
|
||||
EOF'
|
||||
```
|
||||
|
||||
**위 명령 실행 전 `박재오NAS사용자명` / `박재오NAS비밀번호`를 실제 값으로 교체.** Synology DSM Control Panel → User & Group 에서 SMB 접근 권한 있는 계정 사용. 비밀번호에 특수문자 있을 시 escape 필요 (특히 `!`, `$`, `\`).
|
||||
|
||||
- [ ] **Step 3: 자격증명 파일 권한 보호**
|
||||
|
||||
```bash
|
||||
sudo chmod 600 /etc/nas-smb-credentials
|
||||
sudo chown root:root /etc/nas-smb-credentials
|
||||
```
|
||||
|
||||
Expected: 출력 없음.
|
||||
|
||||
```bash
|
||||
ls -la /etc/nas-smb-credentials
|
||||
```
|
||||
|
||||
Expected: `-rw------- 1 root root ... /etc/nas-smb-credentials`
|
||||
|
||||
- [ ] **Step 4: 마운트 포인트 생성**
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /mnt/nas
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: NAS SMB 마운트 (수동 마운트 + fstab 자동화)
|
||||
|
||||
**Files:** `/etc/fstab` (수정)
|
||||
|
||||
- [ ] **Step 1: 수동 마운트 시도 (자격증명·경로 검증)**
|
||||
|
||||
```bash
|
||||
sudo mount -t cifs //gahusb.synology.me/docker /mnt/nas \
|
||||
-o credentials=/etc/nas-smb-credentials,vers=3.0,uid=1000,gid=1000,_netdev
|
||||
```
|
||||
|
||||
Expected: 출력 없음 (성공). 만약 `mount error(13)` (permission) → 자격증명 오류. `mount error(2)` (no such file) → share name `docker` 확인.
|
||||
|
||||
> **share name 변형:** 박재오 NAS는 메모리(`feedback_nas_deploy_paths.md`)에 따르면 SMB 매핑이 `/volume1/docker/`를 share `docker`로 노출. 만약 다른 share name(예: `webpage`)이라면 그것으로 교체.
|
||||
|
||||
- [ ] **Step 2: 마운트 결과 확인**
|
||||
|
||||
```bash
|
||||
ls /mnt/nas/
|
||||
```
|
||||
|
||||
Expected: `webpage/` 디렉토리 + 다른 share 내 디렉토리 보임.
|
||||
|
||||
```bash
|
||||
ls /mnt/nas/webpage/data/
|
||||
```
|
||||
|
||||
Expected: `insta/`, `music/` 등 후속 트랙에서 사용할 디렉토리. 없으면 후속 트랙에서 생성됨.
|
||||
|
||||
- [ ] **Step 3: 마운트 해제 후 fstab으로 자동화**
|
||||
|
||||
```bash
|
||||
sudo umount /mnt/nas
|
||||
```
|
||||
|
||||
Expected: 출력 없음.
|
||||
|
||||
`/etc/fstab` 끝에 다음 라인 추가:
|
||||
```bash
|
||||
sudo bash -c 'cat >> /etc/fstab <<EOF
|
||||
|
||||
# NAS Synology SMB mount for web-ai-services workers (2026-05-18)
|
||||
//gahusb.synology.me/docker /mnt/nas cifs credentials=/etc/nas-smb-credentials,vers=3.0,uid=1000,gid=1000,_netdev,nofail 0 0
|
||||
EOF'
|
||||
```
|
||||
|
||||
`nofail` 옵션은 부팅 시 NAS 미접속이어도 boot 진행 (production 안전).
|
||||
|
||||
- [ ] **Step 4: fstab 적용 + 검증**
|
||||
|
||||
```bash
|
||||
sudo mount -a
|
||||
ls /mnt/nas/webpage/data/ 2>&1 | head -5
|
||||
mount | grep cifs
|
||||
```
|
||||
|
||||
Expected:
|
||||
- `mount -a` 출력 없음 (성공)
|
||||
- `ls /mnt/nas/webpage/data/` 디렉토리 내용 표시
|
||||
- `mount | grep cifs` 라인에 마운트 정보 보임
|
||||
|
||||
- [ ] **Step 5: WSL2 재시작 시 자동 마운트 확인**
|
||||
|
||||
PowerShell에서 (관리자 권한 불필요):
|
||||
```powershell
|
||||
wsl --shutdown
|
||||
wsl -d Ubuntu-22.04
|
||||
```
|
||||
|
||||
WSL2 다시 진입 후:
|
||||
```bash
|
||||
ls /mnt/nas/webpage/data/
|
||||
```
|
||||
|
||||
Expected: 정상 디렉토리 목록. 자동 마운트 성공.
|
||||
|
||||
만약 마운트 안 됨:
|
||||
- `dmesg | grep cifs` 확인
|
||||
- `nofail` 때문에 boot은 통과했으나 마운트 실패 가능. 수동 `sudo mount -a` 후 동작 확인 → fstab syntax 재검토
|
||||
|
||||
---
|
||||
|
||||
## Task 8: 통합 검증 — base 인프라 동작 확인
|
||||
|
||||
**Files:** 없음 (검증)
|
||||
|
||||
- [ ] **Step 1: NAS Redis 외부 ping (Windows 로컬에서)**
|
||||
|
||||
```powershell
|
||||
# Windows AI 또는 박재오 PC에서
|
||||
Test-NetConnection -ComputerName 192.168.45.54 -Port 6379
|
||||
```
|
||||
|
||||
Expected: `TcpTestSucceeded : True`
|
||||
|
||||
> 외부 6379 노출은 LAN 한정. 가능하면 NAS firewall (DSM Control Panel)에서 6379 LAN-only allowed로 한정 권장. (이번 plan에 포함 안 됨, 별도 사용자 작업)
|
||||
|
||||
- [ ] **Step 2: WSL2에서 NAS Redis 접속**
|
||||
|
||||
WSL2 bash:
|
||||
```bash
|
||||
docker run --rm redis:7-alpine redis-cli -h 192.168.45.54 PING
|
||||
```
|
||||
|
||||
또는 Tailscale 사용 시:
|
||||
```bash
|
||||
docker run --rm redis:7-alpine redis-cli -h <NAS_TAILSCALE_IP> PING
|
||||
```
|
||||
|
||||
Expected: `PONG`
|
||||
|
||||
- [ ] **Step 3: NAS volume 쓰기 테스트 (Windows→NAS 양방향)**
|
||||
|
||||
WSL2 bash:
|
||||
```bash
|
||||
echo "Plan-B-Base test $(date)" | sudo tee /mnt/nas/webpage/data/.plan-b-test.txt
|
||||
cat /mnt/nas/webpage/data/.plan-b-test.txt
|
||||
sudo rm /mnt/nas/webpage/data/.plan-b-test.txt
|
||||
```
|
||||
|
||||
Expected:
|
||||
- `tee` 출력에 같은 내용 + 파일 생성됨
|
||||
- `cat` 으로 확인 성공
|
||||
- 파일 삭제 성공
|
||||
|
||||
`sudo` 필요 시 chmod로 uid 1000 쓰기 권한 확인. 또는 mount option `uid=1000,gid=1000` 적용 후 일반 사용자도 쓰기 가능. 만약 안 되면 NAS DSM에서 SMB user의 write 권한 확인.
|
||||
|
||||
- [ ] **Step 4: WSL2 Docker로 hello-world 한 번 더 (재진입 후 상태 확인)**
|
||||
|
||||
```bash
|
||||
docker run --rm hello-world
|
||||
```
|
||||
|
||||
Expected: "Hello from Docker!"
|
||||
|
||||
- [ ] **Step 5: 모든 검증 완료 후 보고 — 후속 트랙으로 진입 가능 상태**
|
||||
|
||||
다음 plan(Plan-B-Insta 등)이 가정하는 상태:
|
||||
- ✅ NAS `redis:6379` PING/PONG 성공
|
||||
- ✅ Windows WSL2 Ubuntu-22.04 작동 + Docker Engine 실행
|
||||
- ✅ `/mnt/nas/webpage/data/` 양방향 read·write 성공
|
||||
- ✅ Tailscale 가입 (선택, 외부 출장 시 필요)
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
### Spec 커버리지
|
||||
|
||||
| Spec 요구사항 | 구현 Task |
|
||||
|---------------|-----------|
|
||||
| §4 SP-1: NAS Redis 컨테이너 | Task 1 (compose 추가) + Task 2 (헬스 검증) |
|
||||
| §10 SP-1: redis:7-alpine + 256MB + AOF + healthcheck | Task 1 Step 2 |
|
||||
| §4 SP-2: Windows WSL2 + Docker Engine | Task 3 (WSL2) + Task 4 (Docker) |
|
||||
| §10 SP-2: Tailscale | Task 5 |
|
||||
| §10 SP-2: NAS SMB mount `/mnt/nas` | Task 6 (자격증명·포인트) + Task 7 (마운트+fstab) |
|
||||
| §10 SP-2: 검증 (docker ps, tailscale status, ls /mnt/nas) | Task 8 |
|
||||
| §6 Redis 키 컨벤션 사용 가능 | Task 2 Step 2 (PING) — 컨벤션 자체는 후속 트랙에서 RPUSH로 시작 |
|
||||
|
||||
### Placeholder 스캔
|
||||
|
||||
- TBD/TODO 없음 ✓
|
||||
- 모든 명령어가 그대로 실행 가능한 형태 ✓
|
||||
- 한 가지 예외: Task 6 Step 2 — `박재오NAS사용자명/박재오NAS비밀번호`는 사용자 자격증명이라 placeholder가 의도된 것. 실행 전 교체 명시 ✓
|
||||
- Task 5 Step 4 — `<NAS 의 Tailscale IP>`는 `tailscale status` 출력에서 박재오가 보고 입력. 사용자 환경에서만 결정 가능, plan에 명시 ✓
|
||||
|
||||
### Type/이름 consistency
|
||||
|
||||
- `redis` 서비스명 (Task 1, 2, 8 모두 동일) ✓
|
||||
- `/mnt/nas` 마운트 포인트 (Task 6, 7, 8 모두 동일) ✓
|
||||
- `/etc/nas-smb-credentials` 자격증명 파일 (Task 6, 7 동일) ✓
|
||||
- share name `docker` (Task 7 Step 1, fstab 동일) ✓
|
||||
- Ubuntu-22.04 (Task 3, 4 동일) ✓
|
||||
|
||||
### 위험·주의
|
||||
|
||||
| 위험 | 완화 |
|
||||
|------|------|
|
||||
| Windows 재부팅 시 WSL2 자동 시작 안 함 | 향후 Plan-B-Infra(SP-9)에서 NSSM으로 자동 시작 |
|
||||
| WSL2 systemd 미지원 시 docker service 자동 시작 안 함 | Task 4 Step 6의 fallback `sudo service docker start` 또는 `/etc/wsl.conf` 수정 |
|
||||
| SMB 마운트 자격증명 노출 | `/etc/nas-smb-credentials` chmod 600 + root:root |
|
||||
| NAS firewall에서 6379 외부 노출 | 권장: LAN(192.168.45.0/24) only allow. 본 plan 외 (DSM 수동) |
|
||||
| Tailscale 미가입 시 NAS↔Windows 외부 통신 불가 | LAN 내에선 작동. 외부 출장 시 필요할 때만 가입 |
|
||||
| /mnt/nas 쓰기 권한 부족 | uid=1000 mount option + NAS DSM에서 SMB user의 share write 권한 확인 |
|
||||
|
||||
---
|
||||
|
||||
## 완료 후 다음 단계
|
||||
|
||||
Plan-B-Base 완료 후 spec §14 권장 순서대로:
|
||||
|
||||
1. **Plan-B-Insta** — SP-3 (insta-render Windows worker) + SP-4 (NAS insta-lab 분할)
|
||||
2. **Plan-B-Music** — SP-5 + SP-6
|
||||
3. **Plan-B-Video** — SP-7 + SP-8
|
||||
4. **Plan-B-Infra** — SP-9 (NSSM 자동 시작) + SP-10 (task-watcher)
|
||||
|
||||
각 후속 plan은 본 plan이 제공한 base 인프라(Redis + WSL2/Docker + /mnt/nas)에 의존.
|
||||
@@ -1,656 +0,0 @@
|
||||
# Track A — NAS↔Windows API 부하 캐시 강화 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** web-ai → NAS stock 호출량을 분당 12회 → 분당 3~4회로 축소하여, V2 재시작 시점부터 즉시 NAS CPU 부담 70% 감소.
|
||||
|
||||
**Architecture:** 2-layer cache. (1) web-ai client side: 3개 endpoint TTL 60/300/60 → 180/600/300으로 증가. (2) NAS stock server side: 동일 endpoint에 in-memory TTLCache 추가하여 web-ai 캐시 miss 시에도 KIS·LLM 재호출 차단. 두 layer가 cumulative하게 작동.
|
||||
|
||||
**Tech Stack:** Python 3.12 / FastAPI / pytest / `cachetools.TTLCache`. **two repos**: `web-ai` (signal_v2/) + `web-backend` (stock/).
|
||||
|
||||
**Spec:** `web-backend/docs/superpowers/specs/2026-05-18-nas-windows-distributed-architecture-design.md` §4 SP-A1·A2, §10 상세
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### SP-A1 — web-ai 캐시 TTL (Modify)
|
||||
|
||||
| 파일 | 변경 | 책임 |
|
||||
|------|------|------|
|
||||
| `web-ai/signal_v2/stock_client.py:13-17` | `_TTL` dict 3개 값 변경 | endpoint별 client-side cache TTL |
|
||||
| `web-ai/signal_v2/tests/test_stock_client_ttl.py` (Create) | TTL 값 회귀 테스트 | 미래 변경 시 의도하지 않은 회귀 방지 |
|
||||
|
||||
### SP-A2 — NAS stock TTLCache (Modify + Create)
|
||||
|
||||
| 파일 | 변경 | 책임 |
|
||||
|------|------|------|
|
||||
| `web-backend/stock/requirements.txt` | `cachetools>=5.3` 추가 | 의존성 |
|
||||
| `web-backend/stock/app/webai_cache.py` (Create) | 3개 TTLCache + helper 함수 | server-side cache 중앙화 |
|
||||
| `web-backend/stock/app/main.py:419-422` | `get_webai_portfolio()` cache 적용 | NAS portfolio 캐시 |
|
||||
| `web-backend/stock/app/main.py:467-470` | `get_webai_news_sentiment(date)` cache 적용 | date별 캐시 |
|
||||
| `web-backend/stock/app/screener/router.py:173` | `post_run()` cache 적용 (mode=preview만) | screener preview 캐시 |
|
||||
| `web-backend/stock/app/test_webai_cache.py` (Create) | cache 동작 + TTL + key 분기 | 캐시 hit/miss 검증 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: web-ai SP-A1 — `_TTL` dict 회귀 테스트 작성
|
||||
|
||||
**Files:**
|
||||
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/signal_v2/tests/test_stock_client_ttl.py`
|
||||
|
||||
- [ ] **Step 1: 실패하는 테스트 작성**
|
||||
|
||||
```python
|
||||
# tests/test_stock_client_ttl.py
|
||||
"""SP-A1 회귀 — _TTL이 NAS 부담 완화를 위한 값으로 설정되어 있어야 함."""
|
||||
from signal_v2.stock_client import _TTL
|
||||
|
||||
|
||||
def test_portfolio_ttl_is_180s():
|
||||
"""portfolio TTL은 180초 이상 (3분 폴링에서 1회 fetch가 3 폴링 커버)."""
|
||||
assert _TTL["portfolio"] >= 180.0
|
||||
|
||||
|
||||
def test_news_sentiment_ttl_is_600s():
|
||||
"""news-sentiment TTL은 600초 이상 (10분, 뉴스 sentiment는 자주 안 바뀜)."""
|
||||
assert _TTL["news-sentiment"] >= 600.0
|
||||
|
||||
|
||||
def test_screener_preview_ttl_is_300s():
|
||||
"""screener-preview TTL은 300초 이상 (5분, Top-20은 분 단위로 거의 안 바뀜)."""
|
||||
assert _TTL["screener-preview"] >= 300.0
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 테스트 실패 확인**
|
||||
|
||||
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai && python -m pytest signal_v2/tests/test_stock_client_ttl.py -v`
|
||||
Expected: FAIL — 현재 _TTL 값은 60/300/60. portfolio·screener-preview 모두 < 180/300.
|
||||
|
||||
- [ ] **Step 3: `_TTL` 값 변경**
|
||||
|
||||
`C:/Users/jaeoh/Desktop/workspace/web-ai/signal_v2/stock_client.py` line 13-17:
|
||||
|
||||
변경 전:
|
||||
```python
|
||||
_TTL = {
|
||||
"portfolio": 60.0,
|
||||
"news-sentiment": 300.0,
|
||||
"screener-preview": 60.0,
|
||||
}
|
||||
```
|
||||
|
||||
변경 후:
|
||||
```python
|
||||
# Cache TTL by endpoint (seconds).
|
||||
# 2026-05-18 — NAS 인바운드 호출 부담 완화 (Plan-A SP-A1).
|
||||
_TTL = {
|
||||
"portfolio": 180.0, # 3분 (1분 폴링 시 3 폴링당 1회 실제 fetch)
|
||||
"news-sentiment": 600.0, # 10분 (뉴스 sentiment는 자주 안 바뀜)
|
||||
"screener-preview": 300.0, # 5분 (Top-20은 분 단위로 거의 안 바뀜)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 테스트 통과 확인**
|
||||
|
||||
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai && python -m pytest signal_v2/tests/test_stock_client_ttl.py -v`
|
||||
Expected: PASS — 3개 모두 통과.
|
||||
|
||||
- [ ] **Step 5: 전체 회귀 확인 (기존 56 tests + 신규 3 tests)**
|
||||
|
||||
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai && python -m pytest signal_v2/tests/ -v 2>&1 | tail -5`
|
||||
Expected: 59 tests 모두 PASS (기존 56 + 신규 3).
|
||||
|
||||
- [ ] **Step 6: 커밋**
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-ai
|
||||
git add signal_v2/stock_client.py signal_v2/tests/test_stock_client_ttl.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
perf(signal_v2): raise stock_client TTL for NAS load relief (SP-A1)
|
||||
|
||||
portfolio 60s → 180s (3분 폴링 → 3회당 1회 fetch)
|
||||
news-sent 300s → 600s (sentiment는 자주 안 바뀜)
|
||||
screener 60s → 300s (Top-20 분 단위 변화 미미)
|
||||
|
||||
V2 재시작 시점부터 NAS stock에 대한 인바운드 호출이
|
||||
분당 12 → 분당 3~4 로 감소 예상. 캐시 hit ratio 0~50% → 66~80%.
|
||||
회귀 테스트 3건 추가로 미래 의도치 않은 TTL 변경 차단.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: NAS SP-A2 — `cachetools` 의존성 추가
|
||||
|
||||
**Files:**
|
||||
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/requirements.txt`
|
||||
|
||||
- [ ] **Step 1: 현재 requirements.txt 확인**
|
||||
|
||||
Run: `cat C:/Users/jaeoh/Desktop/workspace/web-backend/stock/requirements.txt`
|
||||
파일 끝 확인 — 마지막 줄 newline 여부 확인 (sed/append 안전).
|
||||
|
||||
- [ ] **Step 2: cachetools 추가**
|
||||
|
||||
`C:/Users/jaeoh/Desktop/workspace/web-backend/stock/requirements.txt` 끝에 한 줄 추가:
|
||||
|
||||
```
|
||||
cachetools>=5.3
|
||||
```
|
||||
|
||||
(파일 마지막에 newline 없으면 newline 먼저, 그 다음 cachetools 줄.)
|
||||
|
||||
- [ ] **Step 3: 로컬 import 가능 여부 확인 (선택, NAS rebuild가 정본)**
|
||||
|
||||
Run (Windows 로컬에서 docker 외부 검증용, 선택):
|
||||
```bash
|
||||
python -c "import cachetools; print(cachetools.__version__)" 2>&1
|
||||
```
|
||||
|
||||
로컬 미설치라면 skip — NAS deployer가 rebuild 시 install. 이 plan은 코드 정합성만 보장.
|
||||
|
||||
- [ ] **Step 4: 커밋 (단독 커밋, deps만)**
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||||
git add stock/requirements.txt
|
||||
git commit -m "$(cat <<'EOF'
|
||||
chore(stock): add cachetools for server-side TTLCache (SP-A2 prep)
|
||||
|
||||
다음 커밋에서 /api/webai/portfolio·news-sentiment·screener/run에
|
||||
in-memory TTLCache 적용 예정.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: NAS SP-A2 — `webai_cache.py` 모듈 + 단위 테스트
|
||||
|
||||
**Files:**
|
||||
- Create: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/webai_cache.py`
|
||||
- Create: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/test_webai_cache.py`
|
||||
|
||||
- [ ] **Step 1: 실패하는 테스트 작성**
|
||||
|
||||
`C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/test_webai_cache.py`:
|
||||
|
||||
```python
|
||||
"""SP-A2 — webai_cache module의 cache hit/miss + key 분기 검증."""
|
||||
import time
|
||||
import pytest
|
||||
from app.webai_cache import (
|
||||
PORTFOLIO_CACHE, NEWS_CACHE, SCREENER_CACHE,
|
||||
cache_get_portfolio, cache_set_portfolio,
|
||||
cache_get_news, cache_set_news,
|
||||
cache_get_screener, cache_set_screener,
|
||||
_screener_key,
|
||||
)
|
||||
|
||||
|
||||
def _clear_all():
|
||||
PORTFOLIO_CACHE.clear()
|
||||
NEWS_CACHE.clear()
|
||||
SCREENER_CACHE.clear()
|
||||
|
||||
|
||||
def test_portfolio_cache_miss_then_hit():
|
||||
_clear_all()
|
||||
assert cache_get_portfolio() is None
|
||||
cache_set_portfolio({"holdings": [], "cash": 0})
|
||||
assert cache_get_portfolio() == {"holdings": [], "cash": 0}
|
||||
|
||||
|
||||
def test_news_cache_key_by_date():
|
||||
"""date가 다르면 별도 캐시 슬롯."""
|
||||
_clear_all()
|
||||
cache_set_news("2026-05-18", {"count": 5})
|
||||
cache_set_news("2026-05-17", {"count": 3})
|
||||
assert cache_get_news("2026-05-18") == {"count": 5}
|
||||
assert cache_get_news("2026-05-17") == {"count": 3}
|
||||
assert cache_get_news("2026-05-16") is None # not cached
|
||||
|
||||
|
||||
def test_news_cache_latest_key_normalized():
|
||||
"""date=None은 'latest' 키로 정규화되어 동일 슬롯."""
|
||||
_clear_all()
|
||||
cache_set_news(None, {"count": 9})
|
||||
assert cache_get_news(None) == {"count": 9}
|
||||
|
||||
|
||||
def test_screener_key_includes_mode_and_top_n():
|
||||
"""screener key는 mode + top_n + weights hash로 분기."""
|
||||
k_preview = _screener_key("preview", 20, None)
|
||||
k_preview_w = _screener_key("preview", 20, {"news": 0.3})
|
||||
k_auto = _screener_key("auto", 20, None)
|
||||
assert k_preview != k_preview_w
|
||||
assert k_preview != k_auto
|
||||
|
||||
|
||||
def test_screener_cache_roundtrip():
|
||||
_clear_all()
|
||||
payload = {"asof": "2026-05-18", "survivors_count": 17}
|
||||
cache_set_screener("preview", 20, None, payload)
|
||||
assert cache_get_screener("preview", 20, None) == payload
|
||||
assert cache_get_screener("preview", 20, {"news": 0.3}) is None
|
||||
|
||||
|
||||
def test_ttl_expiry_portfolio():
|
||||
"""짧은 ttl로 만료 확인 — 직접 시간 조작 대신 TTLCache 내부 동작 신뢰."""
|
||||
from cachetools import TTLCache
|
||||
short = TTLCache(maxsize=1, ttl=0.1) # 0.1초
|
||||
short["result"] = "x"
|
||||
assert short.get("result") == "x"
|
||||
time.sleep(0.2)
|
||||
assert short.get("result") is None
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 테스트 실패 확인**
|
||||
|
||||
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -m pytest app/test_webai_cache.py -v`
|
||||
Expected: FAIL — `app.webai_cache` 모듈 존재 안 함.
|
||||
|
||||
- [ ] **Step 3: `webai_cache.py` 작성**
|
||||
|
||||
`C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/webai_cache.py`:
|
||||
|
||||
```python
|
||||
"""SP-A2 — NAS stock의 /api/webai/* 엔드포인트 in-memory TTLCache.
|
||||
|
||||
web-ai 측 캐시(stock_client._TTL)가 miss됐을 때도 NAS에서 같은 데이터를
|
||||
KIS·LLM 재호출 없이 즉시 반환하기 위한 2-layer 캐시의 server 측.
|
||||
V1+V2가 동시 호출해도 NAS는 1회만 계산.
|
||||
|
||||
TTL 정책 (spec §10 SP-A2):
|
||||
- portfolio: 120s (web-ai TTL 180s 보다 짧게 — 변경 감지 가능)
|
||||
- news: 600s (sentiment는 일 단위)
|
||||
- screener: 180s
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
from cachetools import TTLCache
|
||||
|
||||
|
||||
PORTFOLIO_CACHE: TTLCache = TTLCache(maxsize=1, ttl=120.0)
|
||||
NEWS_CACHE: TTLCache = TTLCache(maxsize=10, ttl=600.0)
|
||||
SCREENER_CACHE: TTLCache = TTLCache(maxsize=10, ttl=180.0)
|
||||
|
||||
|
||||
# ----- portfolio -----
|
||||
|
||||
def cache_get_portfolio() -> Optional[Any]:
|
||||
return PORTFOLIO_CACHE.get("result")
|
||||
|
||||
|
||||
def cache_set_portfolio(value: Any) -> None:
|
||||
PORTFOLIO_CACHE["result"] = value
|
||||
|
||||
|
||||
# ----- news-sentiment -----
|
||||
|
||||
def _news_key(date: Optional[str]) -> str:
|
||||
return date if date else "latest"
|
||||
|
||||
|
||||
def cache_get_news(date: Optional[str]) -> Optional[Any]:
|
||||
return NEWS_CACHE.get(_news_key(date))
|
||||
|
||||
|
||||
def cache_set_news(date: Optional[str], value: Any) -> None:
|
||||
NEWS_CACHE[_news_key(date)] = value
|
||||
|
||||
|
||||
# ----- screener -----
|
||||
|
||||
def _screener_key(mode: str, top_n: int, weights: Optional[dict]) -> str:
|
||||
"""mode + top_n + weights canonical hash. weights 객체 동등성을 키로."""
|
||||
if weights is None:
|
||||
w_repr = "none"
|
||||
else:
|
||||
# canonical: sorted keys → md5 hex (긴 weights도 짧은 키로)
|
||||
canon = json.dumps(weights, sort_keys=True, ensure_ascii=False)
|
||||
w_repr = hashlib.md5(canon.encode("utf-8")).hexdigest()[:12]
|
||||
return f"{mode}:{top_n}:{w_repr}"
|
||||
|
||||
|
||||
def cache_get_screener(mode: str, top_n: int, weights: Optional[dict]) -> Optional[Any]:
|
||||
return SCREENER_CACHE.get(_screener_key(mode, top_n, weights))
|
||||
|
||||
|
||||
def cache_set_screener(mode: str, top_n: int, weights: Optional[dict], value: Any) -> None:
|
||||
SCREENER_CACHE[_screener_key(mode, top_n, weights)] = value
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 테스트 통과 확인**
|
||||
|
||||
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -m pytest app/test_webai_cache.py -v`
|
||||
Expected: PASS — 6개 모두 통과.
|
||||
|
||||
- [ ] **Step 5: 커밋**
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||||
git add stock/app/webai_cache.py stock/app/test_webai_cache.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(stock): webai_cache module (TTLCache for SP-A2)
|
||||
|
||||
3개의 TTLCache (portfolio 120s · news 600s · screener 180s) +
|
||||
헬퍼 함수. screener key는 mode + top_n + weights canonical hash로
|
||||
분기. 다음 커밋에서 /api/webai/portfolio·news-sentiment·screener/run
|
||||
3 endpoint에 적용.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: NAS SP-A2 — `/api/webai/portfolio` 캐시 적용
|
||||
|
||||
**Files:**
|
||||
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/main.py:419-422`
|
||||
|
||||
- [ ] **Step 1: 현재 endpoint 코드 확인**
|
||||
|
||||
`web-backend/stock/app/main.py` 419-422 line은 spec §10 SP-A2와 일치:
|
||||
```python
|
||||
@app.get("/api/webai/portfolio", dependencies=[Depends(verify_webai_key)])
|
||||
def get_webai_portfolio():
|
||||
"""web-ai 전용 portfolio (인증 필수, pnl_pct 비율 필드 추가)."""
|
||||
return _augment_portfolio_with_pnl_pct(get_portfolio())
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 캐시 적용으로 교체**
|
||||
|
||||
`web-backend/stock/app/main.py` 419-422 line을 다음으로 교체:
|
||||
|
||||
```python
|
||||
@app.get("/api/webai/portfolio", dependencies=[Depends(verify_webai_key)])
|
||||
def get_webai_portfolio():
|
||||
"""web-ai 전용 portfolio (인증 필수, pnl_pct 비율 필드 추가).
|
||||
|
||||
SP-A2 server-side TTLCache 적용. V1+V2 동시 호출도 NAS에서 1회 계산.
|
||||
"""
|
||||
cached = webai_cache.cache_get_portfolio()
|
||||
if cached is not None:
|
||||
return cached
|
||||
result = _augment_portfolio_with_pnl_pct(get_portfolio())
|
||||
webai_cache.cache_set_portfolio(result)
|
||||
return result
|
||||
```
|
||||
|
||||
- [ ] **Step 3: import 추가 (파일 상단)**
|
||||
|
||||
`web-backend/stock/app/main.py` 파일 상단 import 블록 (다른 `from .xxx import` 들과 같은 위치)에 추가:
|
||||
|
||||
```python
|
||||
from . import webai_cache
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 빠른 import sanity 체크**
|
||||
|
||||
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -c "from app import main; print('OK')"` 2>&1 | tail -3
|
||||
|
||||
(`cachetools` 미설치 환경에선 ImportError 가능 → 그 경우 `pip install cachetools` 후 재시도. 실제 검증은 NAS rebuild 후.)
|
||||
Expected: `OK` 또는 cachetools 누락 메시지 (의도된 상태).
|
||||
|
||||
---
|
||||
|
||||
## Task 5: NAS SP-A2 — `/api/webai/news-sentiment` 캐시 적용
|
||||
|
||||
**Files:**
|
||||
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/main.py:467-470`
|
||||
|
||||
- [ ] **Step 1: 캐시 적용**
|
||||
|
||||
`web-backend/stock/app/main.py` 467-470 line을 다음으로 교체:
|
||||
|
||||
```python
|
||||
@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.
|
||||
|
||||
SP-A2 server-side TTLCache 적용. date 파라미터별로 별도 슬롯.
|
||||
"""
|
||||
cached = webai_cache.cache_get_news(date)
|
||||
if cached is not None:
|
||||
return cached
|
||||
result = _fetch_news_sentiment_dump(date)
|
||||
webai_cache.cache_set_news(date, result)
|
||||
return result
|
||||
```
|
||||
|
||||
- [ ] **Step 2: import sanity 체크**
|
||||
|
||||
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -c "from app import main; print('OK')" 2>&1 | tail -3`
|
||||
Expected: `OK`
|
||||
|
||||
---
|
||||
|
||||
## Task 6: NAS SP-A2 — `/api/stock/screener/run` 캐시 적용 (preview 모드만)
|
||||
|
||||
**Files:**
|
||||
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/screener/router.py:173-...`
|
||||
|
||||
- [ ] **Step 1: 현재 함수 확인 (참고)**
|
||||
|
||||
`web-backend/stock/app/screener/router.py:173` 시작 `def post_run(body: schemas.RunRequest):` — 함수 본체는 mode 분기 후 _conn() + KIS 호출 등. 단, `mode == "auto"` 는 휴장일/실 운영 트리거이므로 캐시하지 않음 (매 호출이 다른 의미). `mode == "preview"` 는 frontend·web-ai 폴링용 → 캐시 적용.
|
||||
|
||||
- [ ] **Step 2: 함수 진입부에 cache 분기 추가**
|
||||
|
||||
`web-backend/stock/app/screener/router.py:173` `@router.post("/run", ...)` 의 `def post_run(...)` 본체 **첫 줄들에** 다음 캐시 분기 추가:
|
||||
|
||||
변경 전 (line 173-179 근처):
|
||||
```python
|
||||
@router.post("/run", response_model=schemas.RunResponse)
|
||||
def post_run(body: schemas.RunRequest):
|
||||
from .registry import NODE_REGISTRY as _NR, GATE_REGISTRY as _GR
|
||||
started_at = dt.datetime.utcnow().isoformat()
|
||||
with _conn() as c:
|
||||
asof = _resolve_asof(body.asof, c)
|
||||
```
|
||||
|
||||
변경 후:
|
||||
```python
|
||||
@router.post("/run", response_model=schemas.RunResponse)
|
||||
def post_run(body: schemas.RunRequest):
|
||||
from .registry import NODE_REGISTRY as _NR, GATE_REGISTRY as _GR
|
||||
# SP-A2 — preview 모드는 web-ai/frontend 폴링이라 캐시 적용.
|
||||
# auto 모드는 실제 운영 트리거(휴장일 게이트 등)라 캐시 미적용.
|
||||
if body.mode == "preview":
|
||||
cached = webai_cache.cache_get_screener(body.mode, body.top_n, body.weights)
|
||||
if cached is not None:
|
||||
return cached
|
||||
started_at = dt.datetime.utcnow().isoformat()
|
||||
with _conn() as c:
|
||||
asof = _resolve_asof(body.asof, c)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 함수 끝 부분 — preview 결과를 캐시에 저장**
|
||||
|
||||
`post_run`의 반환부 직전에 (preview 모드일 때만) 캐시 저장. `post_run` 함수는 결과를 `schemas.RunResponse(...)` 로 만들어 return하는 구조일 것. 정확한 return 위치 확인 후, return 직전에:
|
||||
|
||||
`web-backend/stock/app/screener/router.py` `post_run` 함수의 마지막 return 직전에:
|
||||
|
||||
```python
|
||||
# SP-A2 — preview 모드 결과 캐시 저장.
|
||||
if body.mode == "preview":
|
||||
webai_cache.cache_set_screener(body.mode, body.top_n, body.weights, response)
|
||||
return response
|
||||
```
|
||||
|
||||
(`response` 라는 변수가 없으면, 기존 return 표현식을 `response = ...` 로 binding 후 위 코드 추가.)
|
||||
|
||||
> **주의:** post_run의 정확한 return 라인을 먼저 확인. `grep -n "return " app/screener/router.py | head` 로 위치 파악 후 적용.
|
||||
|
||||
- [ ] **Step 4: import 추가 (router.py 상단)**
|
||||
|
||||
`web-backend/stock/app/screener/router.py` 상단 import 블록에 추가:
|
||||
|
||||
```python
|
||||
from .. import webai_cache
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 빠른 import sanity 체크**
|
||||
|
||||
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -c "from app.screener import router; print('OK')" 2>&1 | tail -3`
|
||||
Expected: `OK`
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 통합 검증 — 기존 테스트 회귀 + SP-A2 신규 테스트
|
||||
|
||||
**Files:** (조회만)
|
||||
|
||||
- [ ] **Step 1: stock 전체 pytest 실행**
|
||||
|
||||
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -m pytest -v 2>&1 | tail -30`
|
||||
Expected: 기존 모든 테스트 + SP-A2 신규 6 tests 모두 PASS. **0 failed**.
|
||||
|
||||
- [ ] **Step 2: 회귀 발견 시 처리**
|
||||
|
||||
회귀가 발견되면:
|
||||
- import 누락 → `from . import webai_cache` 또는 `from .. import webai_cache` 위치 재확인
|
||||
- screener test가 cache hit으로 fail → test가 `_clear_all()` 또는 cache fixture 통해 격리되어 있는지 확인. 필요 시 conftest에 `autouse=True` cache reset fixture 추가:
|
||||
|
||||
```python
|
||||
# conftest.py에 추가 (선택)
|
||||
import pytest
|
||||
from app import webai_cache
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_webai_cache():
|
||||
webai_cache.PORTFOLIO_CACHE.clear()
|
||||
webai_cache.NEWS_CACHE.clear()
|
||||
webai_cache.SCREENER_CACHE.clear()
|
||||
yield
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 커밋 (SP-A2 endpoint 통합 + 회귀 확인)**
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||||
git add stock/app/main.py stock/app/screener/router.py
|
||||
# (필요 시) git add stock/app/conftest.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(stock): apply webai_cache to portfolio/news/screener-preview (SP-A2)
|
||||
|
||||
3 endpoint cache 적용 — /api/webai/portfolio, /api/webai/news-sentiment,
|
||||
/api/stock/screener/run (preview 모드만, auto는 캐시 미적용).
|
||||
V1+V2 동시 호출도 NAS에서 1회 계산. web-ai 측 SP-A1 캐시와 2-layer로
|
||||
작동하여 NAS 인바운드 부담 70% 감소 예상.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: 양쪽 push + NAS deploy 트리거
|
||||
|
||||
**Files:** 없음 (git 작업)
|
||||
|
||||
- [ ] **Step 1: web-ai push**
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-ai
|
||||
git push origin main
|
||||
```
|
||||
|
||||
Expected: success. 인증 prompt 뜨면 자격증명 입력. 1회 실패 시 1회 재시도 (캐시 패턴).
|
||||
|
||||
> **참고:** web-ai는 NAS deployer가 별도 webhook 없음 (Windows 머신 코드). push는 백업/이력 동기화 목적. 실제 적용은 V2 재시작 시점.
|
||||
|
||||
- [ ] **Step 2: web-backend push (NAS deployer 트리거)**
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||||
git push origin main
|
||||
```
|
||||
|
||||
Expected: success. NAS deployer가 webhook 수신 → `git pull` → `docker compose build stock --no-cache` (cachetools 신규 설치) → `docker compose up -d stock`. 통상 2~3분 소요.
|
||||
|
||||
- [ ] **Step 3: NAS stock 컨테이너 헬스 확인**
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w "HTTP %{http_code}\n" https://gahusb.synology.me/api/stock/news -m 10
|
||||
```
|
||||
|
||||
Expected: `HTTP 200`. (NAS deploy 완료 후 통상 30초 ~ 2분 대기 필요.)
|
||||
|
||||
- [ ] **Step 4: webai 캐시 효과 확인 (선택)**
|
||||
|
||||
연속 2회 호출 시 두 번째가 즉시 응답하는지 (cached):
|
||||
|
||||
```bash
|
||||
# 인증키 필요. .env의 WEBAI_API_KEY 사용 또는 NAS에서 직접 호출.
|
||||
# Windows 로컬에서:
|
||||
# 첫 호출
|
||||
time curl -s -H "X-WebAI-Key: $WEBAI_API_KEY" https://gahusb.synology.me/api/webai/portfolio -o /dev/null
|
||||
# 즉시 두번째 (캐시 hit 기대, 첫 호출 < 1s + DB / 두번째 < 100ms)
|
||||
time curl -s -H "X-WebAI-Key: $WEBAI_API_KEY" https://gahusb.synology.me/api/webai/portfolio -o /dev/null
|
||||
```
|
||||
|
||||
Expected: 두 번째 호출이 첫 번째보다 명확히 빠름 (DB·계산 skip).
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
### Spec 커버리지
|
||||
|
||||
| Spec 요구사항 | 구현 Task |
|
||||
|---------------|-----------|
|
||||
| §4 SP-A1: web-ai 캐시 TTL 증가 (180/600/300) | Task 1 |
|
||||
| §4 SP-A2: NAS stock TTLCache | Task 2~7 |
|
||||
| §10 SP-A2: 3 endpoint (portfolio/news/screener) 적용 | Task 4 (portfolio), Task 5 (news), Task 6 (screener preview) |
|
||||
| §10 SP-A2: cachetools 의존성 | Task 2 |
|
||||
| §8: X-WebAI-Key 인증 (기존 verify_webai_key 유지) | 기존 dependency 그대로, 변경 없음 |
|
||||
| §6: server cache 별개 (Redis 캐시 옵션) | in-memory TTLCache 선택 (Redis는 SP-1 이후 도입 검토) |
|
||||
|
||||
§4의 SP-A2는 `/api/webai/portfolio`, `/api/webai/news-sentiment`, `/api/stock/screener/run` 3건만 명시. 추가 endpoint 캐시는 out of scope (별도 plan에서).
|
||||
|
||||
### Placeholder 스캔
|
||||
|
||||
- TBD/TODO/"implement later" 패턴 없음 ✓
|
||||
- 모든 code step에 완전 코드 포함 ✓
|
||||
- Task 6에 한 가지 conditional ("`post_run`의 정확한 return 라인을 먼저 확인") — 이건 plan 실행 시 grep 명령으로 즉시 해결 가능한 단순 lookup이라 placeholder가 아님. 그러나 안전성 위해 helper note 그대로 유지.
|
||||
|
||||
### Type consistency
|
||||
|
||||
- `webai_cache.cache_get_portfolio()` / `cache_set_portfolio(value)` — Task 3에서 정의, Task 4에서 사용. 시그니처 일치 ✓
|
||||
- `cache_get_news(date)` — Task 3·5 일치 ✓
|
||||
- `cache_get_screener(mode, top_n, weights)` / `cache_set_screener(mode, top_n, weights, value)` — Task 3·6 일치 ✓
|
||||
- 변수명 `cached`, `result`, `payload` — 각 함수 안에서만 사용, 충돌 없음 ✓
|
||||
|
||||
### 위험·주의
|
||||
|
||||
- **NAS deployer rebuild**: `requirements.txt` 변경은 docker image rebuild 필요. deployer가 변경 감지 시 rebuild 트리거. 만약 deployer가 변경 미감지(예: requirements.txt만 변경 시 rebuild 안 함)라면 NAS에서 `docker compose build stock --no-cache && docker compose up -d stock` 수동 실행 필요.
|
||||
- **Cache stale**: TTL이 충분히 짧아 stale 문제 미미. portfolio 120s = web-ai 폴링 주기(1분) 2배. 변경 감지에 최대 2분 지연.
|
||||
- **Cache miss thunder herd**: V1+V2가 정확히 동시에 캐시 miss 시 KIS 동시 호출 가능. 현재 V1/V2 둘 다 정지 상태라 risk 0. 향후 재시작 시 KIS rate limit 모니터링 필요 (별도 plan 항목).
|
||||
|
||||
---
|
||||
|
||||
## 완료 후 다음 단계
|
||||
|
||||
Plan-A 완료 후 spec §14 "차후 plan 작성 순서 권장"대로:
|
||||
|
||||
1. **Plan-B-Base** — SP-1 (Redis) + SP-2 (WSL2)
|
||||
2. **Plan-B-Insta** — SP-3 + SP-4
|
||||
3. **Plan-B-Music** — SP-5 + SP-6
|
||||
4. **Plan-B-Video** — SP-7 + SP-8
|
||||
5. **Plan-B-Infra** — SP-9 + SP-10
|
||||
|
||||
각각은 별도 brainstorm 없이 spec §10에서 직접 plan 작성 가능 (이미 명세 충분).
|
||||
@@ -1,294 +0,0 @@
|
||||
# insta-lab Design Importer — Claude Vision으로 이미지 디자인 → Jinja HTML 자동 생성
|
||||
|
||||
작성일: 2026-05-17
|
||||
상태: 사용자 승인 대기 → writing-plans 진입 예정
|
||||
연관 문서: `2026-05-15-insta-agent-design.md`, `2026-05-16-insta-trends-design.md`, `feedback_external_data_sources.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 목적·배경
|
||||
|
||||
insta-lab의 카드 렌더는 현재 `templates/default/card.html.j2` 한 골격만 사용 (단순 그라데이션 + Noto Sans KR). 사용자가 직접 디자인한 10장 카드 이미지(`templates/minimal/pages/insta_card_*.png`)를 이미 NAS에 배포한 상태인데, 이 이미지들이 카드 렌더에 반영되지 않음.
|
||||
|
||||
이 spec은 사용자가 만든 디자인 이미지를 **카드 렌더 파이프라인에 통합**하는 메커니즘을 정의한다. 핵심은 Claude Vision으로 10장 PNG를 분석해 페이지별 텍스트 영역·색·폰트·레이아웃을 도출하고, 이를 그대로 모방한 단일 Jinja2 HTML 파일을 자동 생성하는 것이다. 생성된 HTML은 동적 카피(headline, body, cta)를 사용자 디자인 위에 layer로 얹어 일관된 시각 + 동적 텍스트를 동시에 확보한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 스코프
|
||||
|
||||
### 포함
|
||||
|
||||
- 신규 백엔드 모듈 `insta-lab/app/design_importer.py` — 10장 PNG → Claude Sonnet Vision → `card.html.j2` 생성
|
||||
- CLI 진입점 `python -m app.design_importer <theme_name>` (운영자가 한 번씩 실행)
|
||||
- 환경변수 `INSTA_DEFAULT_THEME` 신규 (default="default") — 모든 슬레이트가 이 theme 사용
|
||||
- `card_renderer.render_slate`에 theme 전달 (기존 `template` 인자 활용, 호출자만 변경)
|
||||
- pytest: Vision 호출 mock + 출력 HTML 파싱 검증
|
||||
|
||||
### 제외 (후속)
|
||||
|
||||
- API endpoint `POST /api/insta/templates/import` — UI에서 트리거 가능
|
||||
- `card_slates.theme` 컬럼 — 슬레이트별 다른 theme 선택
|
||||
- 다중 theme 비교/A·B 테스트 UI
|
||||
- 자동 theme 추천 (트렌드 카테고리별 다른 theme)
|
||||
|
||||
---
|
||||
|
||||
## 3. 데이터·디렉토리 구조
|
||||
|
||||
```
|
||||
insta-lab/app/templates/
|
||||
├── default/ # 기존 — 폴백 / 초기 골격
|
||||
│ ├── card.html.j2
|
||||
│ └── .gitkeep
|
||||
└── <theme_name>/ # 사용자 디자인 1세트 (반복 가능)
|
||||
├── pages/ # 사용자가 git commit으로 업로드
|
||||
│ ├── insta_card_start.png # 의미 있는 이름 권장 (Claude가 페이지 의도 파악에 활용)
|
||||
│ ├── insta_card_keyword.png
|
||||
│ ├── ... (총 10장)
|
||||
│ └── README.md (선택, 디자인 의도 메모)
|
||||
└── card.html.j2 # design_importer가 자동 생성
|
||||
```
|
||||
|
||||
**파일명 컨벤션**:
|
||||
- 페이지 번호 매핑은 사용자가 제공하지 않음. design_importer가 다음 순서로 자동 매핑:
|
||||
1. 파일명에 `cover` > `start` > `intro` 키워드 포함 (우선순위 순서) → page 1 (커버). 여러 파일이 매치되면 가장 앞 키워드를 가진 파일만 선택, 나머지는 본문 풀로
|
||||
2. 파일명에 `cta` > `outro` > `finish` > `end` 키워드 포함 (우선순위 순서) → page 10. 동일하게 첫 매치만 page 10, 나머지는 본문 풀로
|
||||
3. 남은 8장은 알파벳 정렬 순으로 page 2~9 (본문)
|
||||
- **현재 운영 케이스**: `insta_card_start.png`(start=1순위) → page 1, `insta_card_cta.png`(cta=1순위) → page 10, `insta_card_finish.png`는 finish=3순위인데 cta가 이미 page 10이므로 본문 풀로 떨어져 알파벳 순에 따라 page 2~9 어딘가 배치됨
|
||||
- 사용자가 매핑을 override하려면 `pages/_order.json` 파일에 `{"insta_card_start.png": 1, "insta_card_finish.png": 10, ...}` 명시 가능 (충돌·의도 명시 시 강력 권장)
|
||||
- 매핑이 의도와 어긋나면 importer 실행 결과 dict의 `page_mapping` 필드로 확인 후 `_order.json` 추가하고 재실행
|
||||
|
||||
---
|
||||
|
||||
## 4. 핵심 모듈 `design_importer.py`
|
||||
|
||||
### 4-1. Public API
|
||||
|
||||
```python
|
||||
def import_design_theme(theme_name: str) -> dict:
|
||||
"""templates/<theme>/pages/*.png 10장 → Claude Sonnet Vision → card.html.j2 생성.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"theme_name": str,
|
||||
"html_path": str,
|
||||
"page_mapping": {filename: page_no, ...},
|
||||
"analysis_summary": str, # Claude가 도출한 디자인 분석 짧은 요약
|
||||
"tokens_used": int,
|
||||
}
|
||||
|
||||
Raises:
|
||||
ValueError: pages/ 폴더에 PNG 10장 미만이거나 매핑 실패
|
||||
anthropic.APIError: Vision 호출 실패 (retry 1회 후)
|
||||
"""
|
||||
```
|
||||
|
||||
### 4-2. 처리 흐름
|
||||
|
||||
1. `templates/<theme>/pages/` 폴더 스캔 → PNG 10장 검증 (10장 정확히)
|
||||
2. 파일명 → 페이지 매핑 결정 (3장 규칙 + 선택적 `_order.json` override)
|
||||
3. 각 PNG base64 인코딩
|
||||
4. Claude Sonnet(`claude-sonnet-4-6`) Vision 호출 1회:
|
||||
- 시스템 프롬프트: 디자이너 역할 + 출력 형식 명세
|
||||
- 사용자 메시지: 10장 이미지 + 페이지 매핑 정보 + 변수 명세 (`page_no`, `headline`, `body`, `cta`)
|
||||
- 출력 요청: 단일 Jinja2 HTML 파일 (page_no 분기 + 텍스트 영역 절대 위치 CSS + `background-image: url('pages/{{filename}}')`)
|
||||
5. 응답 HTML 파싱 + Jinja Environment로 sanity render 1회 (분기·문법 검증)
|
||||
6. `templates/<theme>/card.html.j2`에 저장
|
||||
7. dict 반환
|
||||
|
||||
### 4-3. Vision 프롬프트 스킴 (placeholder 텍스트 마스킹 포함)
|
||||
|
||||
**중요 제약**: 사용자 PNG에는 **placeholder 텍스트가 이미 박혀있다**. 동적 카피(headline, body, cta)로 교체해야 하며 원본 placeholder 텍스트는 보이면 안 된다. 따라서 단순히 텍스트 layer를 얹는 것만으로는 부족하고, 원본 텍스트가 있던 영역을 그 영역의 **배경색으로 덮은 후** 그 위에 새 텍스트를 그려야 한다.
|
||||
|
||||
시스템 프롬프트 (요약):
|
||||
```
|
||||
너는 인스타그램 카드 뉴스 디자인을 모방하는 프론트엔드 디자이너다.
|
||||
입력: 10장의 카드 디자인 이미지 (각 1080×1350, placeholder 텍스트 포함) + 페이지 번호 매핑.
|
||||
출력: 단일 Jinja2 HTML 파일.
|
||||
|
||||
요구사항:
|
||||
- 컨테이너 width 1080px, height 1350px
|
||||
- background-image로 해당 페이지 PNG를 url('pages/{{filename}}')로 로드
|
||||
- 각 페이지에서 placeholder 텍스트가 있는 영역을 식별하고, 다음 두 layer를 그 위에 그린다:
|
||||
(a) 마스킹 박스: position: absolute로 텍스트 영역과 같은 좌표·크기.
|
||||
background는 PNG의 그 영역 주변 픽셀 색 (보통 카드 배경색)에서 추출.
|
||||
placeholder가 완전히 가려지도록 padding 8px 정도 여유.
|
||||
(b) 동적 텍스트 layer: 마스킹 박스와 동일 좌표.
|
||||
font-size·font-weight·color는 원본 placeholder 폰트 스타일을 그대로 모방.
|
||||
`{{ headline }}`, `{{ body }}`, `{{ cta }}` (page_no=10에서만) Jinja 변수 사용.
|
||||
- 페이지 종류별 영역 추정:
|
||||
· page 1 (cover): 메인 헤드라인 1개 영역. 보통 화면 상단 1/3 또는 중앙
|
||||
· page 2~9 (body): 헤드라인 1개 + 본문 1개 영역 (보통 헤드라인 상단, 본문 그 아래)
|
||||
· page 10 (cta): 헤드라인 1개 + 본문 1개 + CTA 강조 텍스트 1개 영역
|
||||
- page_no 1~10 분기: {% if page_no == N %}...{% endif %} 구조
|
||||
- 폰트는 Noto Sans KR (Google Fonts CDN), letter-spacing -0.02em
|
||||
- 텍스트 영역은 word-wrap: break-word + overflow: hidden으로 길이 초과 시도 마스킹 박스 밖으로 새지 않게
|
||||
- 출력은 <!DOCTYPE html>로 시작하는 완전한 HTML 본문만 (```html 코드펜스·설명 텍스트 금지)
|
||||
```
|
||||
|
||||
사용자 메시지에 각 이미지 + filename + page_no 매핑 포함.
|
||||
|
||||
**시각 품질 보장 절차** (importer 운영 후 사용자 검증):
|
||||
1. 첫 import 후 1개 슬레이트 생성해서 PNG 10장 육안 확인
|
||||
2. placeholder 텍스트가 비치거나 마스킹 박스가 어색하면 — `card.html.j2`를 직접 수정해서 영역 좌표·색 fine-tune (백업 자동 보존)
|
||||
3. 새 디자인을 import할 일 있을 때까지는 수동 수정본 그대로 사용
|
||||
|
||||
### 4-4. 캐시 / 재실행 정책
|
||||
|
||||
- 이미 `card.html.j2`가 존재하면 덮어쓰기 (사용자 명시적 재import 의도)
|
||||
- 백업: 기존 HTML이 있으면 `card.html.j2.bak.YYYYMMDD-HHMMSS`로 rename 후 새 파일 작성
|
||||
- 분석 결과 캐시 X (재실행할 때마다 최신 결과)
|
||||
|
||||
---
|
||||
|
||||
## 5. CLI 진입점
|
||||
|
||||
```bash
|
||||
# 컨테이너 내부에서 실행
|
||||
docker exec insta-lab python -m app.design_importer <theme_name>
|
||||
|
||||
# 결과 stdout (예시)
|
||||
{
|
||||
"theme_name": "minimal",
|
||||
"html_path": "/app/app/templates/minimal/card.html.j2",
|
||||
"page_mapping": {
|
||||
"insta_card_start.png": 1,
|
||||
"insta_card_keyword.png": 2,
|
||||
...
|
||||
"insta_card_cta.png": 10
|
||||
},
|
||||
"analysis_summary": "미니멀 카드 — 흰 배경 + 검정 헤드라인 + 회색 본문...",
|
||||
"tokens_used": 15234
|
||||
}
|
||||
```
|
||||
|
||||
`__main__` 가드: argparse로 `theme_name` 위치 인자 + `--force` (기존 HTML 백업 없이 덮어쓰기) 옵션. 실패 시 exit 1.
|
||||
|
||||
---
|
||||
|
||||
## 6. 카드 렌더 통합
|
||||
|
||||
### 6-1. 환경변수 추가 (`config.py`)
|
||||
|
||||
```python
|
||||
INSTA_DEFAULT_THEME = os.getenv("INSTA_DEFAULT_THEME", "default")
|
||||
```
|
||||
|
||||
### 6-2. `main.py:_bg_create_slate` 호출 변경
|
||||
|
||||
기존:
|
||||
```python
|
||||
await card_renderer.render_slate(sid)
|
||||
```
|
||||
|
||||
신규:
|
||||
```python
|
||||
template_path = f"{INSTA_DEFAULT_THEME}/card.html.j2"
|
||||
await card_renderer.render_slate(sid, template=template_path)
|
||||
```
|
||||
|
||||
`card_renderer.render_slate`는 이미 `template` 인자를 받으며 default 값이 `"default/card.html.j2"`. 변경 없음.
|
||||
|
||||
### 6-3. `card_renderer` 폴백 가드
|
||||
|
||||
`render_slate` 시작부에 template 파일 존재 확인 추가:
|
||||
```python
|
||||
template_full = Path(_resolve_template_dir()) / template
|
||||
if not template_full.exists():
|
||||
logger.warning("Template %s 없음, default로 폴백", template)
|
||||
template = "default/card.html.j2"
|
||||
```
|
||||
|
||||
→ env에 `INSTA_DEFAULT_THEME=minimal` 설정했는데 `minimal/card.html.j2`가 아직 import 안 됐으면 자동 default 폴백.
|
||||
|
||||
### 6-4. 운영 활성화 절차
|
||||
|
||||
```bash
|
||||
# 1. 이미지 commit + push (이미 완료 — minimal/pages/ 10장)
|
||||
# 2. NAS 머지 후 design_importer 실행
|
||||
docker exec insta-lab python -m app.design_importer minimal
|
||||
|
||||
# 3. NAS .env에 추가
|
||||
echo "INSTA_DEFAULT_THEME=minimal" >> /volume1/docker/webpage/.env
|
||||
|
||||
# 4. 컨테이너 재시작 (env 재로드)
|
||||
docker compose restart insta-lab
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 에러 처리
|
||||
|
||||
| 상황 | 처리 |
|
||||
|------|------|
|
||||
| `pages/` 폴더 없음 또는 PNG 10장 미만 | ValueError + 어떤 파일이 빠졌는지 명시. 모든 이미지가 1080×1350인지도 검증 (Pillow로 size 체크) |
|
||||
| Vision 호출 실패 (network, rate limit) | retry 1회 (5초 대기), 그래도 실패 시 anthropic.APIError 전파 |
|
||||
| Vision 응답이 HTML이 아님 / Jinja 문법 깨짐 | Jinja Environment로 sanity render 시도 → 실패 시 raw 응답을 `card.html.j2.error.txt`에 저장 + ValueError 전파 (운영자가 수동 수정 가능) |
|
||||
| Vision 응답이 max_tokens(16K) 초과 → 잘림 | 응답 끝이 닫힌 `</html>` 없으면 잘렸다고 판단, max_tokens 24K로 retry 1회 |
|
||||
| 이미지 base64 인코딩 실패 (파일 깨짐) | 어느 파일이 문제인지 로그 + ValueError |
|
||||
| `_order.json` 형식 깨짐 | log warning + 자동 매핑 규칙으로 폴백 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 테스트
|
||||
|
||||
### `insta-lab/tests/test_design_importer.py` (~6 케이스)
|
||||
|
||||
1. `test_auto_page_mapping_with_cover_and_cta`: 의미 이름 파일 10개 → cover→1, cta→10, 나머지 알파벳 순
|
||||
2. `test_explicit_order_json_overrides`: `_order.json` 있으면 그것 우선
|
||||
3. `test_validates_exactly_ten_pngs`: 9장 또는 11장이면 ValueError
|
||||
4. `test_validates_image_dimensions`: 1080×1350 아닌 이미지 있으면 ValueError + 어떤 파일인지
|
||||
5. `test_import_generates_html_via_mocked_claude`: Anthropic Vision mock, 응답 HTML이 Jinja 렌더 가능한 형식인지 검증
|
||||
6. `test_import_falls_back_on_jinja_parse_failure`: mock이 깨진 HTML 반환 시 ValueError + `.error.txt` 저장
|
||||
|
||||
### `insta-lab/tests/test_card_renderer.py` (기존, 보강 1개)
|
||||
|
||||
7. `test_render_falls_back_to_default_when_theme_html_missing`: `template="ghost/card.html.j2"` 지정 시 파일 없어도 default로 폴백 + 정상 PNG 생성
|
||||
|
||||
---
|
||||
|
||||
## 9. 운영 영향
|
||||
|
||||
| 항목 | 영향 |
|
||||
|------|------|
|
||||
| Anthropic 토큰 비용 | +1회당 ~15K 토큰 (이미지 10장 × ~1K + 프롬프트 + HTML 출력). Claude Sonnet 단가 기준 ~$0.05/import. 자주 실행 X |
|
||||
| 빌드 시간 | 영향 없음 (코드 변경만, 의존성 추가 없음) |
|
||||
| 카드 렌더 시간 | 영향 없음 (Playwright는 background-image까지 wait_until="networkidle"로 처리) |
|
||||
| 디스크 | 사용자 디자인 PNG 12MB (이미 push됨) + 자동 생성 HTML ~10KB |
|
||||
| 운영 중 카드 품질 | env `INSTA_DEFAULT_THEME=minimal` 설정 후 다음 슬레이트부터 사용자 디자인 적용. 기존 슬레이트는 default 그대로 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 마이그레이션 절차
|
||||
|
||||
배포 후 사용자가 운영 NAS에서 수동 실행:
|
||||
|
||||
1. PR 머지 → webhook으로 `design_importer.py` 코드 배포 + minimal/ 디렉토리는 이미 배포됨
|
||||
2. SSH NAS:
|
||||
```bash
|
||||
docker exec insta-lab python -m app.design_importer minimal
|
||||
```
|
||||
3. 결과 JSON에서 `html_path`와 `page_mapping` 확인. 매핑이 의도와 다르면 `pages/_order.json`로 override 후 재실행
|
||||
4. `.env`에 `INSTA_DEFAULT_THEME=minimal` 추가
|
||||
5. `docker compose restart insta-lab` (env 재로드)
|
||||
6. 새 슬레이트 1개 만들어서 시각 검증 (Insta 페이지 Trends 탭 또는 수동 트리거)
|
||||
|
||||
생성된 `card.html.j2`가 마음에 안 들면:
|
||||
- `pages/_order.json`으로 페이지 순서 조정 후 importer 재실행
|
||||
- 또는 자동 생성 HTML을 사용자가 직접 수정 (importer 재실행 안 함)
|
||||
- 백업본 `card.html.j2.bak.YYYYMMDD-HHMMSS`로 롤백 가능
|
||||
|
||||
---
|
||||
|
||||
## 11. 완료 정의
|
||||
|
||||
- [ ] `insta-lab/app/design_importer.py` 작성, CLI `python -m app.design_importer` 작동
|
||||
- [ ] `_resolve_page_mapping` + 의미 이름 기반 자동 매핑 + `_order.json` override
|
||||
- [ ] Vision 호출 mock 기반 pytest 6 케이스 PASS
|
||||
- [ ] `card_renderer.render_slate`에 theme 폴백 가드 추가, 테스트 1 케이스 PASS
|
||||
- [ ] `insta-lab/app/config.py`에 `INSTA_DEFAULT_THEME` 추가
|
||||
- [ ] `insta-lab/app/main.py:_bg_create_slate`가 `INSTA_DEFAULT_THEME` 사용
|
||||
- [ ] `docker-compose.yml` insta-lab 환경변수에 `INSTA_DEFAULT_THEME=${INSTA_DEFAULT_THEME:-default}` 추가
|
||||
- [ ] CLAUDE.md 9.x insta-lab 섹션에 design_importer + INSTA_DEFAULT_THEME 항목 추가
|
||||
- [ ] 운영 NAS에서 `docker exec insta-lab python -m app.design_importer minimal` 실행 → `card.html.j2` 생성 확인
|
||||
- [ ] `.env` 설정 + 새 슬레이트 1개 생성 → 시각적으로 minimal 디자인 반영 확인
|
||||
@@ -1,584 +0,0 @@
|
||||
# NAS ↔ Windows 분산 아키텍처 — Design Spec
|
||||
|
||||
**Date:** 2026-05-18
|
||||
**Author:** CEO (with Claude)
|
||||
**Scope:** `web-backend` + `web-ai` + 신규 `web-ai-services` (Windows WSL2 컨테이너 모음)
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경 & 목적
|
||||
|
||||
NAS Synology J4025 (Celeron 2C/2.0GHz, 18GB)에서 11개 docker 컨테이너가 CPU를 과점유. 진단 결과 가장 큰 원인은 **외부 인바운드 API 호출 빈도** (web-ai signal_v1/v2가 분당 12회 NAS stock 호출) + **insta-lab Playwright Chromium의 동시 launch 비용**이었다.
|
||||
|
||||
박재오 통찰: *"NAS = 24/7 표출·게이트웨이 / Windows = 트레이딩 메인 + 트리거 기반 컴퓨팅"*. 박재오가 이미 7건의 의사결정을 마쳤고 1주 셋업 가이드도 정리되어 있다 (`Obsidian Vault/raw/2026-05-18-Windows-NAS-아키텍처-7결정-통합.md`).
|
||||
|
||||
본 spec은 그 위에 **실행 단위 분할(SP) + 의존성 그래프 + 통합 패턴 + 데이터 플로우**를 정리해서 실제 구현 plan으로 진입 가능한 형태로 만든다.
|
||||
|
||||
### 박재오 7결정 (수용된 결정 사항)
|
||||
|
||||
1. **d+b 조합** — Windows 작업 감지 큐 정지 + 트레이딩 우선순위 High
|
||||
2. **insta-lab Playwright 1순위** 이전 (NAS → Windows)
|
||||
3. **트리거 B(비동기) + C(예약)** — 즉시 응답 X, task_id 발급 + 폴링
|
||||
4. **외부 영상 생성 도구** (Runway·Sora·Veo·Pika·Kling·Luma)
|
||||
5. **Redis NAS 컨테이너** — 24/7 안정 큐
|
||||
6. **옵션 4 하이브리드** — 트레이딩 Native Python / 신규 WSL2 Docker Engine
|
||||
7. **NSSM** — Windows Service 도구 (자동 시작·우선순위)
|
||||
|
||||
---
|
||||
|
||||
## 2. 전체 아키텍처
|
||||
|
||||
```
|
||||
[사용자 브라우저]
|
||||
↓ HTTPS
|
||||
[NAS Synology J4025] ─── 24/7 안정 · 표출 · 게이트웨이 · 상태(state)
|
||||
├─ frontend (nginx :8080) React SPA
|
||||
├─ redis (:6379) ⭐ NEW 24/7 큐 + 캐시
|
||||
├─ stock (:18500) +TTLCache 메타 + KIS data + WebAI gateway
|
||||
├─ insta-lab (:18700) 분할 후 카피 생성 + DB + Redis push
|
||||
├─ music-lab (:18600) 분할 후 메타 + Redis push (Suno/MusicGen 미실행)
|
||||
├─ video-lab (:18XXX) ⭐ NEW 영상 게이트웨이 + Redis push
|
||||
├─ agent-office (:18900) 텔레그램 라우팅 + scheduler
|
||||
├─ lotto / realestate-lab / personal / packs-lab / travel-proxy
|
||||
└─ deployer (:19010)
|
||||
↓ Redis BLPOP / 직접 HTTP webhook
|
||||
[Windows AI Server 192.168.45.59] ─── 트레이딩 최우선 · 트리거 컴퓨팅
|
||||
├─ 🔵 Native Python (NSSM HIGH priority)
|
||||
│ ├─ signal_v2 (:8001) ⭐ 트레이딩 절대 우선
|
||||
│ ├─ Ollama qwen3:14b (:11435)
|
||||
│ └─ MusicGen (:8765)
|
||||
└─ 🟢 WSL2 + Docker Engine (NORMAL priority)
|
||||
├─ insta-render (:18710) ⭐ NEW Playwright Chromium pool
|
||||
├─ music-render (:18711) ⭐ NEW Suno API + MusicGen orchestration
|
||||
├─ video-render (:18712) ⭐ NEW 외부 영상 API gateway (6 provider)
|
||||
└─ task-watcher 박재오 작업 감지 + 시간대 분기
|
||||
```
|
||||
|
||||
### 핵심 원칙
|
||||
|
||||
1. **NAS = state(DB) + view(nginx 미디어 서빙)**, **Windows = stateless compute**
|
||||
2. **트레이딩 절대 우선** — 시간대 조건부 (아래 §3 참조)
|
||||
3. **무거운 작업 시간대 분리** — 데드존 23:30–04:30 + 주말·휴장일 = 골든타임
|
||||
|
||||
---
|
||||
|
||||
## 3. 시간대별 우선순위 모드
|
||||
|
||||
| 모드 | 조건 | signal_v2 | task-watcher 정책 |
|
||||
|------|------|-----------|------------------|
|
||||
| 🔴 트레이딩 | 평일 비휴장일 07:00–16:30 | NSSM HIGH, polling 활성 | 박재오 활동 감지 시 `queue:paused` SET |
|
||||
| 🟡 일반 | 평일 16:30–23:30 (NXT) | NSSM HIGH 유지 (5분 폴링 가벼움) | 박재오 활동 감지 시 SET |
|
||||
| 🟢 자유 | 주말·휴장일 + 평일 23:30–04:30 | 자동 idle (휴장일 polling 미실행) | `queue:paused` DEL 유지 — 큐 항상 활성 |
|
||||
|
||||
### 구현 위치
|
||||
- **signal_v2의 휴장일 인식**: `web-ai` CHECK_POINT #7 `holidays.json` 자동 동기화 항목. 휴장일·주말에 polling 자체 미실행.
|
||||
- **휴장일 단일 소스**: `web-backend/stock/app/holidays.json` 정본. NAS stock이 `GET /api/stock/holidays`로 노출. signal_v2 + task-watcher가 매일 00:00 갱신.
|
||||
- **task-watcher 시간대 분기**: `current_mode()` 함수가 30초 폴링마다 모드 판정 → `queue:paused` 토글.
|
||||
|
||||
---
|
||||
|
||||
## 4. Sub-project 카탈로그 (12개)
|
||||
|
||||
| SP | 명칭 | 트랙 | 위치 | 소요 |
|
||||
|----|------|------|------|------|
|
||||
| **SP-A1** | web-ai 캐시 TTL 증가 | A | `web-ai/signal_v2/stock_client.py` | 10분 |
|
||||
| **SP-A2** | NAS stock TTLCache | A | `web-backend/stock/app/*` | 30분 |
|
||||
| **SP-1** | NAS Redis 컨테이너 | B (Base) | `web-backend/docker-compose.yml` | 30분 |
|
||||
| **SP-2** | Windows WSL2 + Docker Engine | B (Base) | (Windows AI) | 2h |
|
||||
| **SP-3** | insta-render Windows 서비스 | B | `web-ai-services/insta-render/` (신규) | 4h |
|
||||
| **SP-4** | NAS insta-lab 분할 | B | `web-backend/insta-lab` | 2h |
|
||||
| **SP-5** | music-render Windows 서비스 | B | `web-ai-services/music-render/` (신규) | 3h |
|
||||
| **SP-6** | NAS music-lab 분할 | B | `web-backend/music-lab` | 2h |
|
||||
| **SP-7** | video-render Windows 서비스 | B | `web-ai-services/video-render/` (신규) | 3h |
|
||||
| **SP-8** | NAS video-lab 신설 | B | `web-backend/video-lab/` (신규 컨테이너) | 2h |
|
||||
| **SP-9** | NSSM 자동 시작 + 우선순위 | B | (Windows) | 1h |
|
||||
| **SP-10** | task-watcher (시간대 + 활동 감지) | B | `web-ai-services/task-watcher/` (신규) | 2h |
|
||||
|
||||
**총 작업시간**: ~22.5h (1주 일정에 부합)
|
||||
|
||||
### 의존성 그래프
|
||||
|
||||
```
|
||||
A 트랙 (병행, ~40분)
|
||||
SP-A1 ─╮
|
||||
├── V2 재시작 시 효과
|
||||
SP-A2 ─╯
|
||||
|
||||
B 트랙 Base (Day 1~2)
|
||||
SP-1 (Redis) ─┐
|
||||
├── 인스타·음악·영상 3 트랙 모두 의존
|
||||
SP-2 (WSL2) ──┘
|
||||
|
||||
인스타 트랙 (Day 3~4)
|
||||
SP-3 (insta-render) ──→ SP-4 (NAS insta-lab 분할)
|
||||
|
||||
음악 트랙 (Day 4~5)
|
||||
SP-5 (music-render) ──→ SP-6 (NAS music-lab 분할)
|
||||
|
||||
영상 트랙 (Day 5~6)
|
||||
SP-7 (video-render) ──→ SP-8 (NAS video-lab 신설)
|
||||
|
||||
인프라 마무리 (Day 6~7)
|
||||
SP-9 (NSSM) ──→ SP-10 (task-watcher)
|
||||
```
|
||||
|
||||
### Critical Path
|
||||
`SP-1 ∥ SP-2` → `SP-3` → `SP-4` → `SP-9` → `SP-10` (최단 약 11.5h)
|
||||
|
||||
병렬화: SP-1(NAS)·SP-2(Windows)는 다른 머신이라 동시 진행. 인스타·음악·영상 트랙은 패턴이 같아 한 번 정착 후 빠르게 복제.
|
||||
|
||||
---
|
||||
|
||||
## 5. 통합 패턴 — "Windows Render Worker"
|
||||
|
||||
인스타·음악·영상 3 트랙이 **완전히 같은 패턴**. 한 번만 정의하고 3번 재사용한다.
|
||||
|
||||
### 시퀀스
|
||||
|
||||
```
|
||||
사용자 ─POST /api/{kind}/generate ...──→ NAS {kind}-lab
|
||||
│
|
||||
├─ DB.create_task() → task_id
|
||||
├─ Redis RPUSH queue:{kind}-render {task_id, params, ...}
|
||||
└─ 200 {task_id} ─→ 사용자
|
||||
|
||||
[Windows {kind}-render]
|
||||
│ (queue:paused 체크 후 BLPOP queue:{kind}-render)
|
||||
│
|
||||
├─ POST /api/internal/{kind}/update
|
||||
│ {status: "processing", progress: 30} ─→ NAS DB update
|
||||
│
|
||||
├─ 무거운 작업 (Playwright / Suno / Runway 등)
|
||||
│ 결과 파일 → /mnt/nas/data/{kind}/{id}/{file} (SMB direct write)
|
||||
│
|
||||
└─ POST /api/internal/{kind}/update
|
||||
{status: "succeeded", progress: 100,
|
||||
result_path: "/media/{kind}/{id}/{file}"} ─→ NAS DB update
|
||||
|
||||
사용자 ─GET /api/{kind}/tasks/{task_id}──→ NAS {kind}-lab
|
||||
└─ DB.get_task() → {status, progress, result_path}
|
||||
─→ 사용자 (폴링)
|
||||
```
|
||||
|
||||
### 4가지 미세 개선 (반영됨)
|
||||
|
||||
1. **결과물 저장**: SMB direct write (`/mnt/nas/data/`) — 별도 HTTP upload 단계 제거
|
||||
2. **NAS 알림**: Windows → NAS internal webhook (`POST /api/internal/{kind}/update`) — NAS polling 부담 0
|
||||
3. **사용자 응답**: 폴링 유지 (YAGNI, 미래 SSE 검토)
|
||||
4. **인증 키 분리**: `X-WebAI-Key`(read, web-ai→NAS) vs `X-Internal-Key`(write, Windows→NAS)
|
||||
|
||||
---
|
||||
|
||||
## 6. Redis 키 컨벤션
|
||||
|
||||
| 키 | 종류 | TTL | 용도 |
|
||||
|----|------|-----|------|
|
||||
| `queue:insta-render` | list | (없음) | 인스타 카드 렌더 작업 큐 |
|
||||
| `queue:music-render` | list | (없음) | 음악 생성 작업 큐 |
|
||||
| `queue:video-render` | list | (없음) | 영상 생성 작업 큐 |
|
||||
| `queue:paused` | string `"1"` | 600s | task-watcher가 set/del. worker가 BLPOP 전 확인 |
|
||||
| (옵션) `cache:stock:*` | string (json) | 120~600s | NAS stock Redis 캐시 (SP-A2와 별개 옵션) |
|
||||
|
||||
### 큐 payload 표준 (JSON)
|
||||
|
||||
```json
|
||||
{
|
||||
"task_id": "uuid-...",
|
||||
"kind": "insta|music|video",
|
||||
"params": { ... },
|
||||
"submitted_at": "2026-05-18T08:30:00+09:00"
|
||||
}
|
||||
```
|
||||
|
||||
Worker는 `BLPOP queue:{kind}-render` (1초 timeout) → `queue:paused` 체크 → 처리.
|
||||
|
||||
---
|
||||
|
||||
## 7. NAS 볼륨 레이아웃 + nginx 서빙
|
||||
|
||||
### 실 파일 시스템
|
||||
```
|
||||
/volume1/docker/webpage/data/
|
||||
├── insta/{slate_id}/01.png ~ 10.png
|
||||
├── music/{track_id}/{file}.mp3
|
||||
└── video/{video_id}/{file}.mp4
|
||||
```
|
||||
|
||||
### WSL2 마운트
|
||||
```bash
|
||||
# WSL2 /etc/fstab
|
||||
//gahusb.synology.me/docker/webpage/data /mnt/nas cifs username=...,vers=3.0,uid=1000,_netdev 0 0
|
||||
```
|
||||
|
||||
### nginx 서빙
|
||||
```
|
||||
https://gahusb.synology.me/media/insta/{id}/01.png
|
||||
/music/{id}/...
|
||||
/video/{id}/...
|
||||
```
|
||||
|
||||
→ nginx `location /media/` 블록은 `/volume1/docker/webpage/data/`를 alias로 서빙 (기존 패턴).
|
||||
|
||||
---
|
||||
|
||||
## 8. NAS internal webhook 명세
|
||||
|
||||
### Endpoint
|
||||
`POST /api/internal/{kind}/update` (kind ∈ `insta`|`music`|`video`)
|
||||
|
||||
### 인증 — 3-layer 차단
|
||||
1. **nginx IP 화이트리스트** (Layer 1·2):
|
||||
```nginx
|
||||
location /api/internal/ {
|
||||
allow 192.168.45.0/24; # LAN 화이트리스트
|
||||
allow 100.64.0.0/10; # Tailscale CGNAT 대역
|
||||
deny all;
|
||||
...
|
||||
}
|
||||
```
|
||||
2. **`X-Internal-Key` 헤더 검증** (Layer 3): `verify_internal_key` dependency
|
||||
|
||||
### Payload
|
||||
```json
|
||||
{
|
||||
"task_id": "uuid-...",
|
||||
"status": "processing|succeeded|failed",
|
||||
"progress": 0-100,
|
||||
"result_path": "/media/insta/123/01.png", // succeeded일 때만, nginx 경로
|
||||
"error": "exception message" // failed일 때만
|
||||
}
|
||||
```
|
||||
|
||||
### NAS 측 처리
|
||||
1. `tasks` 테이블 row update (status, progress, result_path, error)
|
||||
2. (옵션) Redis PUBLISH `task:{id}` — 미래 SSE 통합 시 활용
|
||||
3. 200 응답 (또는 401 if invalid key)
|
||||
|
||||
### 인증 키 정책
|
||||
|
||||
| 키 | 방향 | 권한 | 위치 |
|
||||
|----|------|------|------|
|
||||
| `X-WebAI-Key` | web-ai → NAS | read-only (`GET /api/webai/*`) | NAS `.env` + web-ai `.env` |
|
||||
| `X-Internal-Key` | Windows worker → NAS | write-only (`POST /api/internal/*`) | NAS `.env` + Windows `.env` |
|
||||
|
||||
분리 사유: Principle of Least Privilege, 독립 로테이션, 감사 로그 명확성.
|
||||
|
||||
### 인증 helper (NAS 공통 모듈, `web-backend/_shared/auth.py` 또는 각 컨테이너 복제)
|
||||
|
||||
```python
|
||||
from fastapi import Header, HTTPException
|
||||
import os
|
||||
|
||||
async def verify_internal_key(x_internal_key: str = Header(...)):
|
||||
expected = os.getenv("INTERNAL_API_KEY")
|
||||
if not expected or x_internal_key != expected:
|
||||
raise HTTPException(401, "Invalid X-Internal-Key")
|
||||
|
||||
# 라우터 사용
|
||||
@app.post("/api/internal/insta/update", dependencies=[Depends(verify_internal_key)])
|
||||
async def insta_update(payload: InternalUpdate): ...
|
||||
```
|
||||
|
||||
기존 `verify_webai_key` 패턴(메모리 `reference_webai_auth_pattern.md`)을 복제.
|
||||
|
||||
---
|
||||
|
||||
## 9. Suno + 외부 영상 API 키 이전
|
||||
|
||||
NAS `.env`에서 다음 키들을 **제거** → Windows `.env`로 이전:
|
||||
|
||||
| 키 | NAS 이전 | Windows 이후 |
|
||||
|-----|---------|-------------|
|
||||
| `SUNO_API_KEY` | music-lab | music-render |
|
||||
| `RUNWAY_API_KEY` | (없음) | video-render |
|
||||
| `OPENAI_API_KEY` (Sora) | (있을 수도) | video-render |
|
||||
| `GEMINI_API_KEY` (Veo) | (없음) | video-render |
|
||||
| `PIKA_API_KEY` / `KLING_API_KEY` / `LUMA_API_KEY` | (없음) | video-render |
|
||||
|
||||
→ NAS music-lab + video-lab은 외부 API 호출 코드를 가지지 않음. Redis push만.
|
||||
|
||||
---
|
||||
|
||||
## 10. SP 상세 명세
|
||||
|
||||
### SP-A1 — web-ai 캐시 TTL 증가 (10분)
|
||||
|
||||
**파일**: `web-ai/signal_v2/stock_client.py`
|
||||
|
||||
변경:
|
||||
```python
|
||||
# 변경 전
|
||||
PORTFOLIO_TTL = 60
|
||||
NEWS_TTL = 300
|
||||
SCREENER_TTL = 60
|
||||
|
||||
# 변경 후
|
||||
PORTFOLIO_TTL = 180 # 3분
|
||||
NEWS_TTL = 600 # 10분
|
||||
SCREENER_TTL = 300 # 5분
|
||||
```
|
||||
|
||||
**효과**: 분당 12 → 3~4 호출 (~70% 감소), 캐시 hit ratio 0~50% → 66~80%
|
||||
|
||||
### SP-A2 — NAS stock TTLCache (30분)
|
||||
|
||||
**파일**: `web-backend/stock/app/*` (webai endpoint 위치 확인 후)
|
||||
|
||||
```python
|
||||
from cachetools import TTLCache
|
||||
|
||||
_PORTFOLIO_CACHE = TTLCache(maxsize=1, ttl=120)
|
||||
_NEWS_CACHE = TTLCache(maxsize=10, ttl=600)
|
||||
_SCREENER_CACHE = TTLCache(maxsize=10, ttl=180)
|
||||
|
||||
@app.get("/api/webai/portfolio", dependencies=[Depends(verify_webai_key)])
|
||||
async def portfolio():
|
||||
if "result" in _PORTFOLIO_CACHE:
|
||||
return _PORTFOLIO_CACHE["result"]
|
||||
result = await compute_portfolio()
|
||||
_PORTFOLIO_CACHE["result"] = result
|
||||
return result
|
||||
```
|
||||
|
||||
3 endpoint 적용: `/api/webai/portfolio` · `/api/webai/news-sentiment` · `/api/stock/screener/run`. `cachetools` 의존성 requirements.txt 확인.
|
||||
|
||||
**효과**: V1+V2 동시 호출도 NAS에서 1회 계산. KIS·LLM 재호출 방지.
|
||||
|
||||
### SP-1 — NAS Redis 컨테이너 (30분)
|
||||
|
||||
**파일**: `web-backend/docker-compose.yml`에 추가
|
||||
|
||||
```yaml
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- ${RUNTIME_PATH}/redis-data:/data
|
||||
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
```
|
||||
|
||||
검증: `docker exec redis redis-cli PING` → `PONG`
|
||||
|
||||
### SP-2 — Windows WSL2 + Docker Engine + Tailscale (2h)
|
||||
|
||||
박재오 Windows AI Server에서 (관리자 PowerShell):
|
||||
|
||||
```powershell
|
||||
wsl --install -d Ubuntu-22.04
|
||||
# 재부팅 후
|
||||
wsl -d Ubuntu-22.04
|
||||
```
|
||||
|
||||
WSL2 안:
|
||||
```bash
|
||||
# Docker Engine
|
||||
sudo apt update && sudo apt install -y ca-certificates curl gnupg
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | \
|
||||
sudo tee /etc/apt/sources.list.d/docker.list
|
||||
sudo apt update && sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
sudo usermod -aG docker $USER
|
||||
|
||||
# Tailscale
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
sudo tailscale up
|
||||
|
||||
# NAS SMB mount
|
||||
sudo mkdir -p /mnt/nas
|
||||
echo "//gahusb.synology.me/docker/webpage/data /mnt/nas cifs username=...,vers=3.0,uid=1000,_netdev 0 0" | sudo tee -a /etc/fstab
|
||||
sudo mount -a
|
||||
```
|
||||
|
||||
검증: `docker ps`, `tailscale status`, `ls /mnt/nas`
|
||||
|
||||
### SP-3 — insta-render Windows 서비스 (4h)
|
||||
|
||||
**디렉토리**: `C:\Users\jaeoh\Desktop\workspace\web-ai-services\insta-render\`
|
||||
|
||||
```
|
||||
insta-render/
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── requirements.txt
|
||||
├── .env
|
||||
├── main.py
|
||||
├── worker.py
|
||||
└── card_renderer.py # 기존 NAS insta-lab/app/card_renderer.py 이식
|
||||
```
|
||||
|
||||
핵심 로직:
|
||||
- `worker.py`: Redis BLPOP `queue:insta-render` (paused 체크)
|
||||
- `card_renderer.py`: Browser pool (`init_browser`/`shutdown_browser`) + `render_slate`
|
||||
- `main.py`: 시작 시 browser init + worker async task spawn
|
||||
- 완료 시 `/mnt/nas/data/insta/{slate_id}/` 저장 + NAS webhook `POST /api/internal/insta/update`
|
||||
|
||||
### SP-4 — NAS insta-lab 분할 (2h)
|
||||
|
||||
**파일**: `web-backend/insta-lab/app/main.py` + `app/card_renderer.py`
|
||||
|
||||
변경:
|
||||
```python
|
||||
# 변경 전 — NAS에서 직접 렌더
|
||||
async def _bg_render(task_id: str, slate_id: int):
|
||||
async with RENDER_SEMAPHORE:
|
||||
await card_renderer.render_slate(slate_id, ...)
|
||||
|
||||
# 변경 후 — Redis 큐에 push만
|
||||
import redis.asyncio as aioredis
|
||||
redis_client = aioredis.from_url(os.getenv("REDIS_URL", "redis://redis:6379"))
|
||||
|
||||
async def _bg_render(task_id: str, slate_id: int):
|
||||
payload = {"task_id": task_id, "kind": "insta",
|
||||
"params": {"slate_id": slate_id, "theme": "hedgy75"},
|
||||
"submitted_at": datetime.now(KST).isoformat()}
|
||||
await redis_client.rpush("queue:insta-render", json.dumps(payload))
|
||||
```
|
||||
|
||||
추가: `POST /api/internal/insta/update` endpoint (Windows webhook 수신).
|
||||
삭제: `card_renderer.py` Playwright 코드 (Browser pool, Semaphore 등), `requirements.txt`에서 `playwright` 제거, Dockerfile에서 Chromium install 제거.
|
||||
|
||||
### SP-5 — music-render Windows 서비스 (3h)
|
||||
|
||||
**디렉토리**: `web-ai-services/music-render/`
|
||||
|
||||
- Suno API client (외부 SaaS, polling 1~5분)
|
||||
- MusicGen local call (Windows localhost:8765)
|
||||
- Redis BLPOP `queue:music-render`
|
||||
- 결과 mp3 → `/mnt/nas/data/music/{track_id}/{file}.mp3`
|
||||
- NAS webhook `POST /api/internal/music/update`
|
||||
|
||||
`SUNO_API_KEY` Windows `.env`에 단독 보관.
|
||||
|
||||
### SP-6 — NAS music-lab 분할 (2h)
|
||||
|
||||
Suno 호출 코드 + MusicGen 호출 코드 삭제. `_bg_generate` 함수를 Redis push로 변경. `POST /api/internal/music/update` endpoint 추가.
|
||||
|
||||
### SP-7 — video-render Windows 서비스 (3h)
|
||||
|
||||
**디렉토리**: `web-ai-services/video-render/`
|
||||
|
||||
6 provider gateway (Runway·Sora·Veo·Pika·Kling·Luma) — provider 선택은 payload에서. 각 외부 API 호출 + 결과 mp4 다운로드 → `/mnt/nas/data/video/{id}/`. NAS webhook.
|
||||
|
||||
### SP-8 — NAS video-lab 신설 (2h)
|
||||
|
||||
새 docker 컨테이너. `web-backend/video-lab/`:
|
||||
- `app/main.py`: 2 endpoint
|
||||
- `POST /api/video/generate` → Redis push `queue:video-render` + task_id 반환
|
||||
- `GET /api/video/tasks/{id}` → DB 조회
|
||||
- `app/db.py`: video_tasks 테이블 (sqlite)
|
||||
- `POST /api/internal/video/update` (Windows webhook)
|
||||
- Dockerfile, requirements, docker-compose.yml entry
|
||||
|
||||
매우 가벼움 (NAS CPU 부담 미미).
|
||||
|
||||
### SP-9 — NSSM 자동 시작 + 우선순위 (1h)
|
||||
|
||||
Windows AI에서 NSSM 다운로드 후:
|
||||
|
||||
```powershell
|
||||
# 트레이딩 (Native, HIGH)
|
||||
nssm install signal_v2 "C:\Python312\python.exe" "-m uvicorn main:app --host 0.0.0.0 --port 8001"
|
||||
nssm set signal_v2 AppDirectory "C:\Users\jaeoh\Desktop\workspace\web-ai\signal_v2"
|
||||
nssm set signal_v2 Priority HIGH_PRIORITY_CLASS
|
||||
nssm set signal_v2 AppStartup AUTO
|
||||
|
||||
# WSL2 Docker (NORMAL)
|
||||
nssm install wsl_docker "wsl" "-d Ubuntu-22.04 -- sudo service docker start && cd /workspace/web-ai-services && docker compose up -d"
|
||||
nssm set wsl_docker Priority NORMAL_PRIORITY_CLASS
|
||||
nssm set wsl_docker AppStartup AUTO
|
||||
|
||||
nssm start signal_v2
|
||||
nssm start wsl_docker
|
||||
```
|
||||
|
||||
### SP-10 — task-watcher (2h)
|
||||
|
||||
**디렉토리**: `web-ai-services/task-watcher/`
|
||||
|
||||
WSL2 Docker 컨테이너. 30초마다:
|
||||
1. `current_mode()` 판정 (시간대 + holidays.json 체크 + KST 시각)
|
||||
2. `is_user_active()` 판정 (마우스/키보드 idle < 5분 또는 게임 process 감지)
|
||||
3. 모드 + 활동 → `queue:paused` 토글
|
||||
- `mode == "free"` → `DEL queue:paused`
|
||||
- `mode != "free" and active` → `SET queue:paused 1 EX 600`
|
||||
- `mode != "free" and idle` → `DEL queue:paused`
|
||||
|
||||
---
|
||||
|
||||
## 11. 데이터 플로우 검증 — 인스타 사례 end-to-end
|
||||
|
||||
```
|
||||
1. 사용자 클릭 "카드 생성"
|
||||
POST /api/insta/slates/123/render
|
||||
↓ NAS insta-lab
|
||||
2. NAS insta-lab
|
||||
- db.create_task("slate_render", {slate_id: 123}) → task_id="t-abc"
|
||||
- redis.rpush("queue:insta-render", {task_id: "t-abc", kind: "insta", params: {slate_id: 123, theme: "hedgy75"}})
|
||||
- 응답 {task_id: "t-abc"}
|
||||
↓ 즉시 사용자
|
||||
3. Windows insta-render worker
|
||||
- redis.blpop("queue:insta-render", 1)
|
||||
- paused 체크 → 통과
|
||||
- webhook(processing, 10%) → NAS DB update
|
||||
- Playwright 카드 10장 렌더 → /mnt/nas/data/insta/123/01.png..10.png
|
||||
- webhook(processing, 90%) 진행률 보고
|
||||
- webhook(succeeded, 100, result_path="/media/insta/123/01.png") → NAS DB update
|
||||
4. 사용자 폴링
|
||||
GET /api/insta/tasks/t-abc → {status: "succeeded", result_path: "/media/insta/123/01.png"}
|
||||
브라우저에서 <img src="/media/insta/123/01.png" /> 렌더
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Out of Scope
|
||||
|
||||
- V1/V2 재시작 결정 (사용자 보류, 두 process 정지 유지)
|
||||
- NAS 하드웨어 업그레이드 (#12 보류)
|
||||
- 컨테이너 리소스 제한 cpus 0.5 (#11 박재오 진행 금지)
|
||||
- SSE/WS push 모델 (YAGNI, 폴링 유지)
|
||||
- Grafana 모니터링 (NAS 자산 활용 옵션, 향후)
|
||||
|
||||
## 13. 위험 요소
|
||||
|
||||
| 위험 | 완화 |
|
||||
|------|------|
|
||||
| Windows 재부팅 시 worker 중단 | NSSM AppStartup AUTO + WSL2 자동 시작 (SP-9) |
|
||||
| Windows ↔ NAS 네트워크 단절 | task가 큐에 남음, NAS 측 timeout 처리 (예: 30분 timeout → failed) |
|
||||
| 박재오 게임·작업 중 worker 충돌 | task-watcher queue:paused (SP-10) + NORMAL priority |
|
||||
| Suno API rate limit | music-render 내부에서 retry + 큐 직렬 처리 |
|
||||
| SMB 마운트 실패 | WSL2 부팅 시 `mount -a`, 실패 시 alarm (로그) |
|
||||
| Redis 다운 | docker restart unless-stopped + healthcheck. 다운 시 모든 worker idle (NAS는 응답 계속) |
|
||||
| 키 노출 | 3-layer 차단 (IP 화이트리스트 + nginx + X-Internal-Key) |
|
||||
|
||||
## 14. 첫 plan 작성 대상
|
||||
|
||||
**옵션 A — Track A만 (사용자 선택 확정)**:
|
||||
- SP-A1: web-ai 캐시 TTL 증가 (10분)
|
||||
- SP-A2: NAS stock TTLCache (30분)
|
||||
|
||||
이 plan은 즉시 NAS CPU 70% 감소 효과 (V2 재시작 시). Track B는 별도 spec/plan으로 차후 진행.
|
||||
|
||||
차후 plan 작성 순서 권장:
|
||||
1. **Plan-A (이번)** — SP-A1 + SP-A2
|
||||
2. **Plan-B-Base** — SP-1 + SP-2
|
||||
3. **Plan-B-Insta** — SP-3 + SP-4 (1순위 패턴 정착)
|
||||
4. **Plan-B-Music** — SP-5 + SP-6
|
||||
5. **Plan-B-Video** — SP-7 + SP-8
|
||||
6. **Plan-B-Infra** — SP-9 + SP-10
|
||||
|
||||
## 15. 참고
|
||||
|
||||
- 박재오 7결정 통합: `Obsidian Vault/raw/2026-05-18-Windows-NAS-아키텍처-7결정-통합.md`
|
||||
- API 부하 해결: `Obsidian Vault/raw/2026-05-18-NAS-Window-AI-API-부하-해결방안.md`
|
||||
- 역할 분담 최적화: `Obsidian Vault/raw/2026-05-18-NAS-Windows-역할-분담-최적화.md`
|
||||
- web-backend CHECK_POINT.md (즉시·중기·장기 + 7결정 매핑)
|
||||
- web-ai CHECK_POINT.md (Phase 진행도)
|
||||
- 기존 인증 패턴: 메모리 `reference_webai_auth_pattern.md`
|
||||
@@ -3,15 +3,24 @@ ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Korean fonts (insta-lab가 자체 텍스트 처리는 안 하지만 향후 thumbnail 생성 등 위해 유지)
|
||||
# Korean fonts + Chromium runtime deps (Debian 12 / bookworm)
|
||||
# `playwright install --with-deps`를 쓰지 않는 이유: 그 명령은 Ubuntu 패키지명을
|
||||
# 사용해 Debian에서 ttf-ubuntu-font-family / ttf-unifont 등 없는 패키지를 시도
|
||||
# → apt 실패. 대신 Chromium이 실제 필요로 하는 라이브러리만 명시 설치.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
fonts-noto-cjk fonts-noto-cjk-extra \
|
||||
libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 \
|
||||
libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 \
|
||||
libxfixes3 libxrandr2 libgbm1 libxshmfence1 libpango-1.0-0 \
|
||||
libcairo2 libasound2 libatspi2.0-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
# --timeout 600 --retries 5: NAS 느린 네트워크/CPU에서 pip 다운로드 timeout 방지
|
||||
RUN pip install --no-cache-dir --timeout 600 --retries 5 -r requirements.txt
|
||||
RUN playwright install chromium
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
"""SP-4 — Windows worker → NAS internal webhook 인증.
|
||||
|
||||
X-Internal-Key 헤더를 .env의 INTERNAL_API_KEY와 비교.
|
||||
서버 측 키 미설정 시 401 (안전한 기본값).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from fastapi import Header, HTTPException
|
||||
|
||||
|
||||
def verify_internal_key(x_internal_key: str = Header(...)):
|
||||
expected = os.getenv("INTERNAL_API_KEY")
|
||||
if not expected:
|
||||
raise HTTPException(401, "INTERNAL_API_KEY not configured on server")
|
||||
if x_internal_key != expected:
|
||||
raise HTTPException(401, "Invalid X-Internal-Key")
|
||||
@@ -1,7 +1,100 @@
|
||||
"""DEPRECATED 2026-05-19 — NAS에서 카드 렌더 안 함. Windows insta-render 워커로 이전됨.
|
||||
"""Jinja → HTML → Playwright headless screenshot."""
|
||||
|
||||
기존 render_slate, init_browser, shutdown_browser는 모두 web-ai/services/insta-render/card_renderer.py로 이식.
|
||||
NAS insta-lab은 Redis push (queue:insta-render)만 담당.
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from typing import List
|
||||
|
||||
이 파일은 임포트 호환성 위해서만 존재. 새 코드는 이 모듈을 import하지 말 것.
|
||||
"""
|
||||
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
|
||||
|
||||
@@ -11,7 +11,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")
|
||||
INSTA_DEFAULT_THEME = os.getenv("INSTA_DEFAULT_THEME", "default")
|
||||
|
||||
CORS_ALLOW_ORIGINS = os.getenv(
|
||||
"CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080"
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
"""사용자 디자인 PNG 10장 → Claude Sonnet Vision → Jinja card.html.j2 자동 생성.
|
||||
|
||||
⚠️ 실행 위치 — 로컬 권장:
|
||||
docker-compose의 insta-lab volume은 /app/data만 마운트. /app/app/templates는
|
||||
컨테이너 ephemeral이라 NAS docker exec로 돌리면 다음 rebuild에 결과물 소실됨.
|
||||
|
||||
로컬:
|
||||
cd insta-lab
|
||||
export ANTHROPIC_API_KEY=sk-ant-...
|
||||
python -m app.design_importer <theme> --templates-dir ./app/templates
|
||||
git add app/templates/<theme>/card.html.j2 && git commit + push
|
||||
|
||||
응급 hotfix만 NAS:
|
||||
docker exec insta-lab python -m app.design_importer <theme>
|
||||
docker cp insta-lab:/app/app/templates/<theme>/card.html.j2 ./<dst>
|
||||
# → 즉시 host repo에 commit + push (안 그러면 다음 rebuild에 소실)
|
||||
"""
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from anthropic import Anthropic
|
||||
from jinja2 import BaseLoader, Environment, TemplateSyntaxError
|
||||
from PIL import Image
|
||||
|
||||
from .config import ANTHROPIC_API_KEY, ANTHROPIC_MODEL_SONNET
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"_resolve_page_mapping",
|
||||
"_validate_images",
|
||||
"_call_vision",
|
||||
"_validate_html_template",
|
||||
"import_design_theme",
|
||||
]
|
||||
|
||||
# 페이지 1 (커버) 키워드 우선순위 — 먼저 매치된 키워드를 가진 첫 파일만 page 1
|
||||
_COVER_KEYWORDS = ("cover", "start", "intro")
|
||||
# 페이지 10 (CTA) 키워드 우선순위
|
||||
_CTA_KEYWORDS = ("cta", "outro", "finish", "end")
|
||||
|
||||
# 인스타그램 카드 규격 (세로형 4:5 비율)
|
||||
_EXPECTED_SIZE = (1080, 1350)
|
||||
|
||||
|
||||
def _resolve_page_mapping(pages_dir: Path) -> Dict[str, int]:
|
||||
"""templates/<theme>/pages/ 안의 PNG 10장을 page 1~10에 매핑.
|
||||
|
||||
우선순위:
|
||||
1. `_order.json` 있으면 그 매핑 그대로 사용 (검증 통과 시 반환)
|
||||
2. 자동 매핑:
|
||||
- _COVER_KEYWORDS 우선순위 순서로 가장 앞 키워드를 가진 첫 PNG → page 1
|
||||
- _CTA_KEYWORDS 우선순위 순서로 가장 앞 키워드를 가진 첫 PNG → page 10
|
||||
- 남은 8장은 알파벳 정렬 → page 2~9
|
||||
"""
|
||||
pages_dir = Path(pages_dir)
|
||||
pngs = sorted([p.name for p in pages_dir.glob("*.png")])
|
||||
if len(pngs) != 10:
|
||||
raise ValueError(
|
||||
f"{pages_dir}에 PNG 10장 필요, 발견 {len(pngs)}장: {pngs}"
|
||||
)
|
||||
|
||||
order_path = pages_dir / "_order.json"
|
||||
if order_path.exists():
|
||||
try:
|
||||
mapping = json.loads(order_path.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
logger.warning("_order.json 파싱 실패, 자동 매핑으로 폴백: %s", e)
|
||||
else:
|
||||
if set(mapping.keys()) == set(pngs) and set(mapping.values()) == set(range(1, 11)):
|
||||
return {k: int(v) for k, v in mapping.items()}
|
||||
logger.warning(
|
||||
"_order.json 형식 오류 (파일 누락·page 중복), 자동 매핑으로 폴백"
|
||||
)
|
||||
|
||||
return _build_mapping(pngs)
|
||||
|
||||
|
||||
def _pick_by_keywords(names: List[str], keywords: tuple) -> str | None:
|
||||
"""names 중 keywords의 우선순위에 따라 첫 매치 파일명 반환 (없으면 None)."""
|
||||
lower_names = [(n, n.lower()) for n in names]
|
||||
for kw in keywords:
|
||||
for orig, low in lower_names:
|
||||
if kw in low:
|
||||
return orig
|
||||
return None
|
||||
|
||||
|
||||
def _build_mapping(pngs: List[str]) -> Dict[str, int]:
|
||||
"""자동 매핑 알고리즘 본체."""
|
||||
mapping: Dict[str, int] = {}
|
||||
remaining = list(pngs)
|
||||
|
||||
cover = _pick_by_keywords(remaining, _COVER_KEYWORDS)
|
||||
if cover:
|
||||
mapping[cover] = 1
|
||||
remaining.remove(cover)
|
||||
|
||||
cta = _pick_by_keywords(remaining, _CTA_KEYWORDS)
|
||||
if cta:
|
||||
mapping[cta] = 10
|
||||
remaining.remove(cta)
|
||||
|
||||
remaining_sorted = sorted(remaining)
|
||||
free_pages = sorted(set(range(1, 11)) - set(mapping.values()))
|
||||
for name, page in zip(remaining_sorted, free_pages):
|
||||
mapping[name] = page
|
||||
|
||||
return mapping
|
||||
|
||||
|
||||
_EXPECTED_RATIO = 1080 / 1350 # 4:5 = 0.8
|
||||
_RATIO_TOLERANCE = 0.02 # ±2% (1122/1402 ≈ 0.80028도 통과)
|
||||
|
||||
|
||||
def _validate_images(pages_dir: Path) -> None:
|
||||
"""모든 PNG가 4:5 종횡비(1080x1350 권장)에 가까운지 검증.
|
||||
|
||||
Vision은 base64로 원본을 분석하고 Playwright는 background-size: cover로
|
||||
1080x1350 컨테이너에 fit하므로 절대 사이즈는 유연. 단 종횡비가 어긋나면
|
||||
카드가 늘어나거나 잘리므로 ±2% 허용 범위 내에서만 통과.
|
||||
|
||||
early-exit 하지 않고 전체 파일을 검사한 뒤 한 메시지에 모아 raise.
|
||||
"""
|
||||
pages_dir = Path(pages_dir)
|
||||
bad = []
|
||||
for png_path in sorted(pages_dir.glob("*.png")):
|
||||
with Image.open(png_path) as img:
|
||||
w, h = img.size
|
||||
if h == 0:
|
||||
bad.append((png_path.name, img.size))
|
||||
continue
|
||||
ratio = w / h
|
||||
if abs(ratio - _EXPECTED_RATIO) > _RATIO_TOLERANCE:
|
||||
bad.append((png_path.name, img.size))
|
||||
if bad:
|
||||
msg = "; ".join(f"{n}: {s[0]}x{s[1]}" for n, s in bad)
|
||||
raise ValueError(
|
||||
f"카드 디자인은 4:5 비율(1080x1350 권장)이어야 함. 잘못된 파일: {msg}"
|
||||
)
|
||||
|
||||
|
||||
# ── Vision 호출 + HTML 생성 ───────────────────────────────────────────────────
|
||||
|
||||
_VISION_SYSTEM_PROMPT = """너는 인스타그램 카드 뉴스 디자인을 모방하는 프론트엔드 디자이너다.
|
||||
|
||||
입력: 10장의 카드 디자인 이미지 (각 1080×1350, placeholder 텍스트가 박혀있음) + 파일명 → 페이지 번호 매핑.
|
||||
출력: 단일 Jinja2 HTML 파일 본문 (코드펜스·설명 텍스트 금지).
|
||||
|
||||
핵심 제약 — placeholder 텍스트 마스킹:
|
||||
PNG에는 디자인 placeholder 텍스트가 이미 그려져 있다. 동적 카피로 교체할 때
|
||||
원본 텍스트가 비치면 안 된다. 각 텍스트 영역마다 두 layer를 그려라:
|
||||
(a) 마스킹 박스: position: absolute로 placeholder 영역과 같은 좌표.
|
||||
background는 그 영역 주변 픽셀 색 (카드 배경색)에서 추출. padding 8px 여유.
|
||||
(b) 동적 텍스트 layer: 마스킹 박스와 동일 좌표.
|
||||
font-size·font-weight·color는 원본 placeholder의 스타일을 모방.
|
||||
{{ headline }} / {{ body }} / {{ cta }} Jinja 변수 사용.
|
||||
|
||||
페이지 종류별 영역 가이드:
|
||||
- page 1 (cover): 메인 headline 1개 영역
|
||||
- page 2~9 (body): headline 영역 + body 영역
|
||||
- page 10 (cta): headline + body + cta 영역
|
||||
|
||||
요구사항:
|
||||
- 컨테이너 width 1080px, height 1350px
|
||||
- 각 페이지마다 `background-image: url('pages/{{filename}}')`로 사용자 PNG 로드
|
||||
- page_no 1~10 분기: {% if page_no == N %}...{% endif %} 구조
|
||||
- 폰트는 Noto Sans KR (Google Fonts CDN). letter-spacing -0.02em, line-height 1.3 기본
|
||||
- 텍스트 영역은 word-wrap: break-word + overflow: hidden (동적 카피가 길어도 마스킹 박스 밖으로 안 새도록)
|
||||
- HTML <head>에 <style>로 모든 CSS 인라인. <link> 외부 stylesheet 금지
|
||||
- 출력은 <!DOCTYPE html>로 시작하는 완전한 HTML 문서
|
||||
"""
|
||||
|
||||
|
||||
def _call_vision(images_with_pages: List[Tuple[str, int, bytes]],
|
||||
theme_name: str) -> Dict[str, Any]:
|
||||
"""Claude Sonnet Vision 호출. images_with_pages: [(filename, page_no, png_bytes), ...].
|
||||
|
||||
Returns: {"html": str, "tokens": int, "summary": str}
|
||||
"""
|
||||
if not ANTHROPIC_API_KEY:
|
||||
raise RuntimeError("ANTHROPIC_API_KEY 미설정 — design_importer 사용 불가")
|
||||
|
||||
client = Anthropic(api_key=ANTHROPIC_API_KEY)
|
||||
content: List[Dict[str, Any]] = []
|
||||
for filename, page_no, png_bytes in sorted(images_with_pages, key=lambda x: x[1]):
|
||||
content.append({
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": base64.b64encode(png_bytes).decode("ascii"),
|
||||
},
|
||||
})
|
||||
content.append({
|
||||
"type": "text",
|
||||
"text": f"위 이미지 = '{filename}' = page {page_no}",
|
||||
})
|
||||
content.append({
|
||||
"type": "text",
|
||||
"text": (
|
||||
f"theme 이름: '{theme_name}'. 위 10장 디자인을 모방한 단일 Jinja2 HTML을 출력해라."
|
||||
),
|
||||
})
|
||||
|
||||
msg = client.messages.create(
|
||||
model=ANTHROPIC_MODEL_SONNET,
|
||||
max_tokens=16000,
|
||||
system=_VISION_SYSTEM_PROMPT,
|
||||
messages=[{"role": "user", "content": content}],
|
||||
)
|
||||
raw = msg.content[0].text.strip()
|
||||
# 코드펜스 자르기
|
||||
if raw.startswith("```"):
|
||||
raw = re.sub(r"^```(?:html)?\s*|\s*```$", "", raw).strip()
|
||||
summary = raw[:200].replace("\n", " ") # 첫 200자만 분석 요약으로
|
||||
return {
|
||||
"html": raw,
|
||||
"tokens": msg.usage.input_tokens + msg.usage.output_tokens,
|
||||
"summary": summary,
|
||||
}
|
||||
|
||||
|
||||
def _validate_html_template(html: str) -> None:
|
||||
"""Jinja2 Environment로 sanity render. 문법 오류면 TemplateSyntaxError 전파."""
|
||||
env = Environment(loader=BaseLoader())
|
||||
env.from_string(html) # 파싱만으로도 syntax error 검출
|
||||
|
||||
|
||||
def import_design_theme(theme_dir: str) -> Dict[str, Any]:
|
||||
"""templates/<theme>/pages/*.png 10장 → Vision → card.html.j2 생성.
|
||||
|
||||
Args:
|
||||
theme_dir: theme 디렉토리 절대 경로 (예: /app/app/templates/minimal)
|
||||
Returns:
|
||||
{theme_name, html_path, page_mapping, analysis_summary, tokens_used}
|
||||
"""
|
||||
theme_path = Path(theme_dir)
|
||||
theme_name = theme_path.name
|
||||
pages_dir = theme_path / "pages"
|
||||
|
||||
# 1. 매핑 + 검증
|
||||
mapping = _resolve_page_mapping(pages_dir)
|
||||
_validate_images(pages_dir)
|
||||
|
||||
# 2. Vision 호출
|
||||
images_with_pages = []
|
||||
for filename, page_no in mapping.items():
|
||||
png_bytes = (pages_dir / filename).read_bytes()
|
||||
images_with_pages.append((filename, page_no, png_bytes))
|
||||
|
||||
vision_result = _call_vision(images_with_pages, theme_name)
|
||||
html = vision_result["html"]
|
||||
|
||||
# 3. Jinja sanity
|
||||
html_path = theme_path / "card.html.j2"
|
||||
try:
|
||||
_validate_html_template(html)
|
||||
except TemplateSyntaxError as e:
|
||||
error_path = theme_path / "card.html.j2.error.txt"
|
||||
error_path.write_text(html, encoding="utf-8")
|
||||
raise ValueError(
|
||||
f"Vision 응답이 Jinja 문법 오류: {e}. 원본 HTML은 {error_path}에 저장됨"
|
||||
)
|
||||
|
||||
# 4. 백업 + 저장
|
||||
if html_path.exists():
|
||||
ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
backup_path = theme_path / f"card.html.j2.bak.{ts}"
|
||||
html_path.rename(backup_path)
|
||||
logger.info("기존 HTML 백업: %s", backup_path)
|
||||
|
||||
html_path.write_text(html, encoding="utf-8")
|
||||
|
||||
return {
|
||||
"theme_name": theme_name,
|
||||
"html_path": str(html_path),
|
||||
"page_mapping": mapping,
|
||||
"analysis_summary": vision_result["summary"],
|
||||
"tokens_used": vision_result["tokens"],
|
||||
}
|
||||
|
||||
|
||||
# ── CLI entrypoint ───────────────────────────────────────────────────────────
|
||||
|
||||
def main_cli():
|
||||
"""CLI: python -m app.design_importer <theme_name> [--templates-dir PATH]"""
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="design_importer",
|
||||
description="사용자 카드 디자인 PNG 10장을 Claude Vision으로 분석해 card.html.j2 생성",
|
||||
)
|
||||
parser.add_argument("theme_name", help="templates/<theme_name>/ 디렉토리명")
|
||||
parser.add_argument(
|
||||
"--templates-dir",
|
||||
default="/app/app/templates",
|
||||
help="templates 루트 디렉토리 (기본 컨테이너 내부 경로)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
theme_dir = Path(args.templates_dir) / args.theme_name
|
||||
if not theme_dir.is_dir():
|
||||
print(f"ERROR: theme 디렉토리 없음: {theme_dir}")
|
||||
raise SystemExit(1)
|
||||
|
||||
try:
|
||||
result = import_design_theme(str(theme_dir))
|
||||
except Exception as e:
|
||||
print(f"ERROR: {e}")
|
||||
raise SystemExit(1)
|
||||
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main_cli()
|
||||
@@ -1,69 +0,0 @@
|
||||
"""SP-4 — Windows insta-render → NAS internal webhook.
|
||||
|
||||
POST /api/internal/insta/update
|
||||
- X-Internal-Key 인증 필수
|
||||
- task DB row update (status, progress, result_path, error)
|
||||
- result_path는 nginx 서빙 경로 (예: /media/insta/{slate_id}/01.png)
|
||||
- succeeded 시 params에서 slate_id 추출 → result_id 세팅
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import db
|
||||
from .auth import verify_internal_key
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class UpdatePayload(BaseModel):
|
||||
task_id: str
|
||||
status: str = Field(..., description="processing|succeeded|failed")
|
||||
progress: int = Field(..., ge=0, le=100)
|
||||
result_path: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/internal/insta/update",
|
||||
dependencies=[Depends(verify_internal_key)],
|
||||
)
|
||||
def insta_update(payload: UpdatePayload):
|
||||
task = db.get_task(payload.task_id)
|
||||
if task is None:
|
||||
raise HTTPException(404, f"task not found: {payload.task_id}")
|
||||
|
||||
result_id = None
|
||||
if payload.status == "succeeded":
|
||||
try:
|
||||
# DB stores params (not input_data) from create_task
|
||||
params_data = json.loads(task.get("params") or "{}")
|
||||
result_id = params_data.get("slate_id")
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
db.update_task(
|
||||
payload.task_id,
|
||||
payload.status,
|
||||
payload.progress,
|
||||
message=payload.result_path or "",
|
||||
result_id=result_id,
|
||||
error=payload.error,
|
||||
)
|
||||
# succeeded 시 slate_status도 'rendered'로 갱신 (cutover 후 NAS가 처리)
|
||||
if payload.status == "succeeded" and result_id is not None:
|
||||
try:
|
||||
db.update_slate_status(result_id, "rendered")
|
||||
except Exception:
|
||||
logger.exception("update_slate_status %s 실패 (무시)", result_id)
|
||||
logger.info(
|
||||
"internal/insta/update task=%s status=%s progress=%d",
|
||||
payload.task_id, payload.status, payload.progress,
|
||||
)
|
||||
return {"ok": True}
|
||||
@@ -14,20 +14,11 @@ from pydantic import BaseModel
|
||||
from .config import (
|
||||
CORS_ALLOW_ORIGINS, NAVER_CLIENT_ID, ANTHROPIC_API_KEY,
|
||||
INSTA_DATA_PATH, DB_PATH, DEFAULT_CATEGORY_SEEDS, KEYWORDS_PER_CATEGORY,
|
||||
INSTA_DEFAULT_THEME,
|
||||
)
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from . import db, news_collector, keyword_extractor, card_writer, trend_collector
|
||||
from .internal_router import router as internal_router
|
||||
from . import db, news_collector, keyword_extractor, card_writer, card_renderer, trend_collector
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379")
|
||||
redis_client = aioredis.from_url(REDIS_URL, decode_responses=False)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(internal_router)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -39,16 +30,11 @@ app.add_middleware(
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def on_startup():
|
||||
def on_startup():
|
||||
os.makedirs(INSTA_DATA_PATH, exist_ok=True)
|
||||
db.init_db()
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def on_shutdown():
|
||||
pass
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"ok": True}
|
||||
@@ -159,20 +145,12 @@ async def _bg_create_slate(task_id: str, keyword: str, category: str, keyword_id
|
||||
try:
|
||||
db.update_task(task_id, "processing", 30, "카피 생성 중")
|
||||
sid = card_writer.write_slate(keyword=keyword, category=category)
|
||||
db.update_task(task_id, "processing", 70, "카드 렌더 중")
|
||||
await card_renderer.render_slate(sid)
|
||||
db.update_slate_status(sid, "rendered")
|
||||
if keyword_id:
|
||||
db.mark_keyword_used(keyword_id)
|
||||
# Redis 큐에 push — Windows insta-render worker가 BLPOP 후 렌더
|
||||
from datetime import datetime, timezone, timedelta
|
||||
kst = timezone(timedelta(hours=9))
|
||||
payload = {
|
||||
"task_id": task_id,
|
||||
"kind": "insta",
|
||||
"params": {"slate_id": sid, "theme": INSTA_DEFAULT_THEME},
|
||||
"submitted_at": datetime.now(kst).isoformat(),
|
||||
}
|
||||
await redis_client.rpush("queue:insta-render", json.dumps(payload))
|
||||
# 사용자는 GET /api/insta/tasks/{task_id}로 폴링 — worker가 webhook으로 status update
|
||||
db.update_task(task_id, "processing", 70, "Redis 큐 푸시 → Windows worker 대기 중", result_id=sid)
|
||||
db.update_task(task_id, "succeeded", 100, "완료", result_id=sid)
|
||||
except Exception as e:
|
||||
logger.exception("create slate failed")
|
||||
db.update_task(task_id, "failed", 0, "", error=str(e))
|
||||
@@ -206,20 +184,13 @@ def get_slate(slate_id: int):
|
||||
|
||||
|
||||
async def _bg_render(task_id: str, slate_id: int):
|
||||
"""Redis 큐에 push. 실 렌더는 Windows insta-render worker."""
|
||||
try:
|
||||
from datetime import datetime, timezone, timedelta
|
||||
kst = timezone(timedelta(hours=9))
|
||||
payload = {
|
||||
"task_id": task_id,
|
||||
"kind": "insta",
|
||||
"params": {"slate_id": slate_id, "theme": INSTA_DEFAULT_THEME},
|
||||
"submitted_at": datetime.now(kst).isoformat(),
|
||||
}
|
||||
await redis_client.rpush("queue:insta-render", json.dumps(payload))
|
||||
db.update_task(task_id, "processing", 30, "Redis 큐 푸시 → Windows worker 대기 중")
|
||||
db.update_task(task_id, "processing", 30, "재렌더 중")
|
||||
await card_renderer.render_slate(slate_id)
|
||||
db.update_slate_status(slate_id, "rendered")
|
||||
db.update_task(task_id, "succeeded", 100, "완료", result_id=slate_id)
|
||||
except Exception as e:
|
||||
logger.exception("queue push failed")
|
||||
logger.exception("render failed")
|
||||
db.update_task(task_id, "failed", 0, "", error=str(e))
|
||||
|
||||
|
||||
|
||||
@@ -1,788 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Hedgy Card News – {{ page_no }}/10</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
background: #d0d0d0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
font-family: 'Noto Sans KR', sans-serif;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
width: 1080px;
|
||||
height: 1350px;
|
||||
overflow: hidden;
|
||||
border-radius: 48px;
|
||||
background-size: cover;
|
||||
background-position: center center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
/* ── shared overlay layer ── */
|
||||
.mask {
|
||||
position: absolute;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
PAGE 1 insta_card_start.png
|
||||
bg: #f2f2f0 (light warm white)
|
||||
═══════════════════════════════════════════ */
|
||||
.p1-headline-mask {
|
||||
top: 222px; left: 48px;
|
||||
width: 580px; height: 150px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p1-headline-text {
|
||||
position: absolute;
|
||||
top: 222px; left: 48px;
|
||||
width: 580px; height: 150px;
|
||||
padding: 8px;
|
||||
font-size: 108px;
|
||||
font-weight: 900;
|
||||
color: #1e2235;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.p1-body-mask {
|
||||
top: 400px; left: 48px;
|
||||
width: 460px; height: 120px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p1-body-text {
|
||||
position: absolute;
|
||||
top: 400px; left: 48px;
|
||||
width: 460px; height: 120px;
|
||||
padding: 8px;
|
||||
font-size: 34px;
|
||||
font-weight: 500;
|
||||
color: #4a4e5e;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
}
|
||||
.p1-cta-mask {
|
||||
top: 562px; left: 48px;
|
||||
width: 260px; height: 76px;
|
||||
background: #2f6ef7;
|
||||
border-radius: 38px;
|
||||
padding: 8px;
|
||||
}
|
||||
.p1-cta-text {
|
||||
position: absolute;
|
||||
top: 562px; left: 48px;
|
||||
width: 260px; height: 76px;
|
||||
border-radius: 38px;
|
||||
padding: 8px 24px;
|
||||
font-size: 34px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
PAGE 2 insta_card_keyword.png
|
||||
bg: #3a3fdb (blue gradient)
|
||||
═══════════════════════════════════════════ */
|
||||
.p2-headline-mask {
|
||||
top: 148px; left: 56px;
|
||||
width: 880px; height: 200px;
|
||||
background: #3a3fdb;
|
||||
padding: 8px;
|
||||
}
|
||||
.p2-headline-text {
|
||||
position: absolute;
|
||||
top: 148px; left: 56px;
|
||||
width: 880px; height: 200px;
|
||||
padding: 8px;
|
||||
font-size: 88px;
|
||||
font-weight: 900;
|
||||
color: #ffffff;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.p2-body-mask {
|
||||
top: 370px; left: 56px;
|
||||
width: 880px; height: 80px;
|
||||
background: #3a3fdb;
|
||||
padding: 8px;
|
||||
}
|
||||
.p2-body-text {
|
||||
position: absolute;
|
||||
top: 370px; left: 56px;
|
||||
width: 880px; height: 80px;
|
||||
padding: 8px;
|
||||
font-size: 38px;
|
||||
font-weight: 500;
|
||||
color: #e0e4ff;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
PAGE 3 insta_card_highlight.png
|
||||
bg: #3a3fdb
|
||||
═══════════════════════════════════════════ */
|
||||
.p3-headline-mask {
|
||||
top: 148px; left: 56px;
|
||||
width: 880px; height: 260px;
|
||||
background: #3a3fdb;
|
||||
padding: 8px;
|
||||
}
|
||||
.p3-headline-text {
|
||||
position: absolute;
|
||||
top: 148px; left: 56px;
|
||||
width: 880px; height: 260px;
|
||||
padding: 8px;
|
||||
font-size: 88px;
|
||||
font-weight: 900;
|
||||
color: #ffffff;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
}
|
||||
.p3-body-mask {
|
||||
top: 430px; left: 56px;
|
||||
width: 880px; height: 80px;
|
||||
background: #3a3fdb;
|
||||
padding: 8px;
|
||||
}
|
||||
.p3-body-text {
|
||||
position: absolute;
|
||||
top: 430px; left: 56px;
|
||||
width: 880px; height: 80px;
|
||||
padding: 8px;
|
||||
font-size: 38px;
|
||||
font-weight: 500;
|
||||
color: #e0e4ff;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
PAGE 4 insta_card_observation.png
|
||||
bg: #f2f2f0
|
||||
═══════════════════════════════════════════ */
|
||||
.p4-label-mask {
|
||||
top: 72px; left: 64px;
|
||||
width: 200px; height: 52px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p4-label-text {
|
||||
position: absolute;
|
||||
top: 72px; left: 64px;
|
||||
width: 200px; height: 52px;
|
||||
padding: 8px;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #2f6ef7;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.p4-headline-mask {
|
||||
top: 148px; left: 56px;
|
||||
width: 700px; height: 110px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p4-headline-text {
|
||||
position: absolute;
|
||||
top: 148px; left: 56px;
|
||||
width: 700px; height: 110px;
|
||||
padding: 8px;
|
||||
font-size: 72px;
|
||||
font-weight: 900;
|
||||
color: #1e2235;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.p4-body-mask {
|
||||
top: 290px; left: 56px;
|
||||
width: 700px; height: 180px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p4-body-text {
|
||||
position: absolute;
|
||||
top: 290px; left: 56px;
|
||||
width: 700px; height: 180px;
|
||||
padding: 8px;
|
||||
font-size: 36px;
|
||||
font-weight: 400;
|
||||
color: #3a3e50;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
PAGE 5 insta_card_memo.png
|
||||
bg: #f2f2f0
|
||||
═══════════════════════════════════════════ */
|
||||
.p5-label-mask {
|
||||
top: 72px; left: 64px;
|
||||
width: 200px; height: 52px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p5-label-text {
|
||||
position: absolute;
|
||||
top: 72px; left: 64px;
|
||||
width: 200px; height: 52px;
|
||||
padding: 8px;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #2f6ef7;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.p5-headline-mask {
|
||||
top: 160px; left: 56px;
|
||||
width: 700px; height: 110px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p5-headline-text {
|
||||
position: absolute;
|
||||
top: 160px; left: 56px;
|
||||
width: 700px; height: 110px;
|
||||
padding: 8px;
|
||||
font-size: 70px;
|
||||
font-weight: 900;
|
||||
color: #1e2235;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.p5-body-mask {
|
||||
top: 308px; left: 56px;
|
||||
width: 700px; height: 180px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p5-body-text {
|
||||
position: absolute;
|
||||
top: 308px; left: 56px;
|
||||
width: 700px; height: 180px;
|
||||
padding: 8px;
|
||||
font-size: 34px;
|
||||
font-weight: 400;
|
||||
color: #3a3e50;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
PAGE 6 insta_card_oneline.png
|
||||
bg: #f5f4f2
|
||||
═══════════════════════════════════════════ */
|
||||
.p6-headline-mask {
|
||||
top: 188px; left: 96px;
|
||||
width: 820px; height: 240px;
|
||||
background: #f5f4f2;
|
||||
padding: 8px;
|
||||
}
|
||||
.p6-headline-text {
|
||||
position: absolute;
|
||||
top: 188px; left: 96px;
|
||||
width: 820px; height: 240px;
|
||||
padding: 8px;
|
||||
font-size: 68px;
|
||||
font-weight: 900;
|
||||
color: #1e2235;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
}
|
||||
.p6-body-mask {
|
||||
top: 448px; left: 96px;
|
||||
width: 620px; height: 120px;
|
||||
background: #f5f4f2;
|
||||
padding: 8px;
|
||||
}
|
||||
.p6-body-text {
|
||||
position: absolute;
|
||||
top: 448px; left: 96px;
|
||||
width: 620px; height: 120px;
|
||||
padding: 8px;
|
||||
font-size: 34px;
|
||||
font-weight: 400;
|
||||
color: #5a5e70;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
PAGE 7 insta_card_checklist.png
|
||||
bg: #f5f4f2
|
||||
═══════════════════════════════════════════ */
|
||||
.p7-headline-mask {
|
||||
top: 110px; left: 56px;
|
||||
width: 740px; height: 110px;
|
||||
background: #f5f4f2;
|
||||
padding: 8px;
|
||||
}
|
||||
.p7-headline-text {
|
||||
position: absolute;
|
||||
top: 110px; left: 56px;
|
||||
width: 740px; height: 110px;
|
||||
padding: 8px;
|
||||
font-size: 74px;
|
||||
font-weight: 900;
|
||||
color: #1e2235;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
/* checklist items – 4 rows */
|
||||
.p7-item1-mask { top: 258px; left: 164px; width: 720px; height: 80px; background: #f5f4f2; padding: 8px; }
|
||||
.p7-item1-text { position: absolute; top: 258px; left: 164px; width: 720px; height: 80px; padding: 8px; font-size: 40px; font-weight: 500; color: #2a2d3e; word-wrap: break-word; overflow: hidden; display: flex; align-items: center; }
|
||||
.p7-item2-mask { top: 388px; left: 164px; width: 720px; height: 80px; background: #f5f4f2; padding: 8px; }
|
||||
.p7-item2-text { position: absolute; top: 388px; left: 164px; width: 720px; height: 80px; padding: 8px; font-size: 40px; font-weight: 500; color: #2a2d3e; word-wrap: break-word; overflow: hidden; display: flex; align-items: center; }
|
||||
.p7-item3-mask { top: 518px; left: 164px; width: 720px; height: 80px; background: #f5f4f2; padding: 8px; }
|
||||
.p7-item3-text { position: absolute; top: 518px; left: 164px; width: 720px; height: 80px; padding: 8px; font-size: 40px; font-weight: 500; color: #2a2d3e; word-wrap: break-word; overflow: hidden; display: flex; align-items: center; }
|
||||
.p7-item4-mask { top: 648px; left: 164px; width: 720px; height: 80px; background: #f5f4f2; padding: 8px; }
|
||||
.p7-item4-text { position: absolute; top: 648px; left: 164px; width: 720px; height: 80px; padding: 8px; font-size: 40px; font-weight: 500; color: #2a2d3e; word-wrap: break-word; overflow: hidden; display: flex; align-items: center; }
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
PAGE 8 insta_card_study.png
|
||||
bg: #f2f2f0
|
||||
═══════════════════════════════════════════ */
|
||||
.p8-label-mask {
|
||||
top: 72px; left: 64px;
|
||||
width: 200px; height: 52px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p8-label-text {
|
||||
position: absolute;
|
||||
top: 72px; left: 64px;
|
||||
width: 200px; height: 52px;
|
||||
padding: 8px;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #2f6ef7;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.p8-headline-mask {
|
||||
top: 160px; left: 56px;
|
||||
width: 700px; height: 110px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p8-headline-text {
|
||||
position: absolute;
|
||||
top: 160px; left: 56px;
|
||||
width: 700px; height: 110px;
|
||||
padding: 8px;
|
||||
font-size: 72px;
|
||||
font-weight: 900;
|
||||
color: #1e2235;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.p8-body-mask {
|
||||
top: 306px; left: 56px;
|
||||
width: 700px; height: 180px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p8-body-text {
|
||||
position: absolute;
|
||||
top: 306px; left: 56px;
|
||||
width: 700px; height: 180px;
|
||||
padding: 8px;
|
||||
font-size: 34px;
|
||||
font-weight: 400;
|
||||
color: #3a3e50;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
PAGE 9 insta_card_cta.png
|
||||
bg: #f5f4f2
|
||||
═══════════════════════════════════════════ */
|
||||
.p9-headline-mask {
|
||||
top: 182px; left: 56px;
|
||||
width: 970px; height: 120px;
|
||||
background: #f5f4f2;
|
||||
padding: 8px;
|
||||
}
|
||||
.p9-headline-text {
|
||||
position: absolute;
|
||||
top: 182px; left: 56px;
|
||||
width: 970px; height: 120px;
|
||||
padding: 8px;
|
||||
font-size: 82px;
|
||||
font-weight: 900;
|
||||
color: #1e2235;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
.p9-cta-mask {
|
||||
top: 332px; left: 180px;
|
||||
width: 720px; height: 88px;
|
||||
background: #2244cc;
|
||||
border-radius: 44px;
|
||||
padding: 8px;
|
||||
}
|
||||
.p9-cta-text {
|
||||
position: absolute;
|
||||
top: 332px; left: 180px;
|
||||
width: 720px; height: 88px;
|
||||
border-radius: 44px;
|
||||
padding: 8px;
|
||||
font-size: 42px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.p9-body-mask {
|
||||
top: 980px; left: 56px;
|
||||
width: 860px; height: 60px;
|
||||
background: #f5f4f2;
|
||||
padding: 8px;
|
||||
}
|
||||
.p9-body-text {
|
||||
position: absolute;
|
||||
top: 980px; left: 56px;
|
||||
width: 860px; height: 60px;
|
||||
padding: 8px;
|
||||
font-size: 30px;
|
||||
font-weight: 400;
|
||||
color: #5a5e70;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
PAGE 10 insta_card_finish.png
|
||||
bg: #f2f2f0
|
||||
═══════════════════════════════════════════ */
|
||||
.p10-label-mask {
|
||||
top: 72px; left: 64px;
|
||||
width: 200px; height: 52px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p10-label-text {
|
||||
position: absolute;
|
||||
top: 72px; left: 64px;
|
||||
width: 200px; height: 52px;
|
||||
padding: 8px;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #2f6ef7;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.p10-headline-mask {
|
||||
top: 155px; left: 56px;
|
||||
width: 700px; height: 110px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p10-headline-text {
|
||||
position: absolute;
|
||||
top: 155px; left: 56px;
|
||||
width: 700px; height: 110px;
|
||||
padding: 8px;
|
||||
font-size: 72px;
|
||||
font-weight: 900;
|
||||
color: #1e2235;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.p10-body-mask {
|
||||
top: 302px; left: 56px;
|
||||
width: 680px; height: 180px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p10-body-text {
|
||||
position: absolute;
|
||||
top: 302px; left: 56px;
|
||||
width: 680px; height: 180px;
|
||||
padding: 8px;
|
||||
font-size: 34px;
|
||||
font-weight: 400;
|
||||
color: #3a3e50;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* checklist icon (page 7) */
|
||||
.check-icon {
|
||||
position: absolute;
|
||||
width: 76px; height: 76px;
|
||||
background: #3366ee;
|
||||
border-radius: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.check-icon svg { width: 44px; height: 44px; }
|
||||
|
||||
/* quote mark (page 2 & 3) */
|
||||
.quote-mark {
|
||||
position: absolute;
|
||||
font-size: 100px;
|
||||
font-weight: 900;
|
||||
color: #ffffff;
|
||||
line-height: 1;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
/* left bar (page 6) */
|
||||
.left-bar {
|
||||
position: absolute;
|
||||
top: 196px; left: 64px;
|
||||
width: 10px; height: 232px;
|
||||
background: #7c5ce0;
|
||||
border-radius: 5px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
{% if page_no == 1 %}
|
||||
<!-- ══════════════════════════════════════
|
||||
PAGE 1 · COVER · insta_card_start.png
|
||||
══════════════════════════════════════ -->
|
||||
<div class="card" style="background-image: url('pages/insta_card_start.png');">
|
||||
<!-- headline mask + text -->
|
||||
<div class="mask p1-headline-mask"></div>
|
||||
<div class="mask p1-headline-text">{{ headline }}</div>
|
||||
<!-- body mask + text -->
|
||||
<div class="mask p1-body-mask"></div>
|
||||
<div class="mask p1-body-text">{{ body }}</div>
|
||||
<!-- cta mask + text -->
|
||||
<div class="mask p1-cta-mask"></div>
|
||||
<div class="mask p1-cta-text">{{ cta }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if page_no == 2 %}
|
||||
<!-- ══════════════════════════════════════
|
||||
PAGE 2 · insta_card_keyword.png
|
||||
══════════════════════════════════════ -->
|
||||
<div class="card" style="background-image: url('pages/insta_card_keyword.png');">
|
||||
<!-- quote mark mask -->
|
||||
<div class="mask" style="top:60px;left:48px;width:120px;height:100px;background:#3a3fdb;padding:8px;"></div>
|
||||
<div class="quote-mark" style="top:52px;left:50px;">"</div>
|
||||
<!-- headline -->
|
||||
<div class="mask p2-headline-mask"></div>
|
||||
<div class="mask p2-headline-text">{{ headline }}</div>
|
||||
<!-- body -->
|
||||
<div class="mask p2-body-mask"></div>
|
||||
<div class="mask p2-body-text">{{ body }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if page_no == 3 %}
|
||||
<!-- ══════════════════════════════════════
|
||||
PAGE 3 · insta_card_highlight.png
|
||||
══════════════════════════════════════ -->
|
||||
<div class="card" style="background-image: url('pages/insta_card_highlight.png');">
|
||||
<!-- quote mark mask -->
|
||||
<div class="mask" style="top:60px;left:48px;width:120px;height:100px;background:#3a3fdb;padding:8px;"></div>
|
||||
<div class="quote-mark" style="top:52px;left:50px;">"</div>
|
||||
<!-- headline -->
|
||||
<div class="mask p3-headline-mask"></div>
|
||||
<div class="mask p3-headline-text">{{ headline }}</div>
|
||||
<!-- body -->
|
||||
<div class="mask p3-body-mask"></div>
|
||||
<div class="mask p3-body-text">{{ body }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if page_no == 4 %}
|
||||
<!-- ══════════════════════════════════════
|
||||
PAGE 4 · insta_card_observation.png
|
||||
══════════════════════════════════════ -->
|
||||
<div class="card" style="background-image: url('pages/insta_card_observation.png');">
|
||||
<!-- day label -->
|
||||
<div class="mask p4-label-mask"></div>
|
||||
<div class="mask p4-label-text">{{ label }}</div>
|
||||
<!-- headline -->
|
||||
<div class="mask p4-headline-mask"></div>
|
||||
<div class="mask p4-headline-text">{{ headline }}</div>
|
||||
<!-- body -->
|
||||
<div class="mask p4-body-mask"></div>
|
||||
<div class="mask p4-body-text">{{ body }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if page_no == 5 %}
|
||||
<!-- ══════════════════════════════════════
|
||||
PAGE 5 · insta_card_memo.png
|
||||
══════════════════════════════════════ -->
|
||||
<div class="card" style="background-image: url('pages/insta_card_memo.png');">
|
||||
<!-- day label -->
|
||||
<div class="mask p5-label-mask"></div>
|
||||
<div class="mask p5-label-text">{{ label }}</div>
|
||||
<!-- headline -->
|
||||
<div class="mask p5-headline-mask"></div>
|
||||
<div class="mask p5-headline-text">{{ headline }}</div>
|
||||
<!-- body -->
|
||||
<div class="mask p5-body-mask"></div>
|
||||
<div class="mask p5-body-text">{{ body }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if page_no == 6 %}
|
||||
<!-- ══════════════════════════════════════
|
||||
PAGE 6 · insta_card_oneline.png
|
||||
══════════════════════════════════════ -->
|
||||
<div class="card" style="background-image: url('pages/insta_card_oneline.png');">
|
||||
<!-- purple left bar -->
|
||||
<div class="left-bar"></div>
|
||||
<!-- headline -->
|
||||
<div class="mask p6-headline-mask"></div>
|
||||
<div class="mask p6-headline-text">{{ headline }}</div>
|
||||
<!-- body -->
|
||||
<div class="mask p6-body-mask"></div>
|
||||
<div class="mask p6-body-text">{{ body }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if page_no == 7 %}
|
||||
<!-- ══════════════════════════════════════
|
||||
PAGE 7 · insta_card_checklist.png
|
||||
══════════════════════════════════════ -->
|
||||
<div class="card" style="background-image: url('pages/insta_card_checklist.png');">
|
||||
<!-- section title -->
|
||||
<div class="mask p7-headline-mask"></div>
|
||||
<div class="mask p7-headline-text">{{ headline }}</div>
|
||||
|
||||
<!-- check icons -->
|
||||
<div class="check-icon" style="top:252px;left:56px;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</div>
|
||||
<div class="check-icon" style="top:382px;left:56px;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</div>
|
||||
<div class="check-icon" style="top:512px;left:56px;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</div>
|
||||
<div class="check-icon" style="top:642px;left:56px;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</div>
|
||||
|
||||
<!-- checklist items -->
|
||||
<div class="mask p7-item1-mask"></div>
|
||||
<div class="mask p7-item1-text">{{ item1 }}</div>
|
||||
<div class="mask p7-item2-mask"></div>
|
||||
<div class="mask p7-item2-text">{{ item2 }}</div>
|
||||
<div class="mask p7-item3-mask"></div>
|
||||
<div class="mask p7-item3-text">{{ item3 }}</div>
|
||||
<div class="mask p7-item4-mask"></div>
|
||||
<div class="mask p7-item4-text">{{ item4 }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if page_no == 8 %}
|
||||
<!-- ══════════════════════════════════════
|
||||
PAGE 8 · insta_card_study.png
|
||||
══════════════════════════════════════ -->
|
||||
<div class="card" style="background-image: url('pages/insta_card_study.png');">
|
||||
<!-- day label -->
|
||||
<div class="mask p8-label-mask"></div>
|
||||
<div class="mask p8-label-text">{{ label }}</div>
|
||||
<!-- headline -->
|
||||
<div class="mask p8-headline-mask"></div>
|
||||
<div class="mask p8-headline-text">{{ headline }}</div>
|
||||
<!-- body -->
|
||||
<div class="mask p8-body-mask"></div>
|
||||
<div class="mask p8-body-text">{{ body }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if page_no == 9 %}
|
||||
<!-- ══════════════════════════════════════
|
||||
PAGE 9 · insta_card_cta.png
|
||||
══════════════════════════════════════ -->
|
||||
<div class="card" style="background-image: url('pages/insta_card_cta.png');">
|
||||
<!-- headline -->
|
||||
<div class="mask p9-headline-mask"></div>
|
||||
<div class="mask p9-headline-text">{{ headline }}</div>
|
||||
<!-- cta button -->
|
||||
<div class="mask p9-cta-mask"></div>
|
||||
<div class="mask p9-cta-text">{{ cta }}</div>
|
||||
<!-- body / next episode teaser -->
|
||||
<div class="mask p9-body-mask"></div>
|
||||
<div class="mask p9-body-text">{{ body }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if page_no == 10 %}
|
||||
<!-- ══════════════════════════════════════
|
||||
PAGE 10 · insta_card_finish.png
|
||||
══════════════════════════════════════ -->
|
||||
<div class="card" style="background-image: url('pages/insta_card_finish.png');">
|
||||
<!-- day label -->
|
||||
<div class="mask p10-label-mask"></div>
|
||||
<div class="mask p10-label-text">{{ label }}</div>
|
||||
<!-- headline -->
|
||||
<div class="mask p10-headline-mask"></div>
|
||||
<div class="mask p10-headline-text">{{ headline }}</div>
|
||||
<!-- body -->
|
||||
<div class="mask p10-body-mask"></div>
|
||||
<div class="mask p10-body-text">{{ body }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"insta_card_start.png": 1,
|
||||
"insta_card_keyword.png": 2,
|
||||
"insta_card_highlight.png": 3,
|
||||
"insta_card_observation.png": 4,
|
||||
"insta_card_memo.png": 5,
|
||||
"insta_card_oneline.png": 6,
|
||||
"insta_card_checklist.png": 7,
|
||||
"insta_card_study.png": 8,
|
||||
"insta_card_cta.png": 9,
|
||||
"insta_card_finish.png": 10
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1010 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.3 MiB |
@@ -4,7 +4,6 @@ requests==2.32.3
|
||||
httpx>=0.27
|
||||
anthropic==0.52.0
|
||||
jinja2>=3.1.4
|
||||
Pillow>=10
|
||||
playwright==1.48.0
|
||||
pytest>=8.0
|
||||
pytest-asyncio>=0.24
|
||||
redis>=5.0
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
"""verify_internal_key dependency — Windows webhook 인증."""
|
||||
import os
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from app.auth import verify_internal_key
|
||||
|
||||
|
||||
def test_valid_key_passes(monkeypatch):
|
||||
monkeypatch.setenv("INTERNAL_API_KEY", "secret123")
|
||||
# dependency가 raise 안 하면 통과
|
||||
verify_internal_key(x_internal_key="secret123")
|
||||
|
||||
|
||||
def test_invalid_key_raises_401(monkeypatch):
|
||||
monkeypatch.setenv("INTERNAL_API_KEY", "secret123")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
verify_internal_key(x_internal_key="wrong")
|
||||
assert exc.value.status_code == 401
|
||||
|
||||
|
||||
def test_missing_env_key_raises_401(monkeypatch):
|
||||
monkeypatch.delenv("INTERNAL_API_KEY", raising=False)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
verify_internal_key(x_internal_key="any")
|
||||
assert exc.value.status_code == 401
|
||||
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))
|
||||
@@ -1,176 +0,0 @@
|
||||
"""design_importer 회귀 테스트."""
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app import design_importer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_theme(tmp_path):
|
||||
"""templates/<theme>/pages/ 구조를 가진 임시 디렉토리."""
|
||||
pages = tmp_path / "minimal" / "pages"
|
||||
pages.mkdir(parents=True)
|
||||
return tmp_path / "minimal"
|
||||
|
||||
|
||||
def _touch(pages_dir: Path, names: list[str]):
|
||||
for n in names:
|
||||
(pages_dir / n).write_bytes(b"") # 매핑 테스트는 dimension 검증 안 함
|
||||
|
||||
|
||||
def test_auto_page_mapping_with_cover_and_cta(tmp_theme):
|
||||
"""cover 키워드 → 1, cta 키워드 → 10, 나머지는 알파벳 순 2~9."""
|
||||
_touch(tmp_theme / "pages", [
|
||||
"insta_card_start.png", # start → page 1 (cover priority)
|
||||
"insta_card_keyword.png",
|
||||
"insta_card_highlight.png",
|
||||
"insta_card_observation.png",
|
||||
"insta_card_memo.png",
|
||||
"insta_card_oneline.png",
|
||||
"insta_card_checklist.png",
|
||||
"insta_card_study.png",
|
||||
"insta_card_cta.png", # cta → page 10
|
||||
"insta_card_finish.png", # finish은 cta가 이미 채워 본문 풀로
|
||||
])
|
||||
mapping = design_importer._resolve_page_mapping(tmp_theme / "pages")
|
||||
assert mapping["insta_card_start.png"] == 1
|
||||
assert mapping["insta_card_cta.png"] == 10
|
||||
# 본문 풀 (남은 8장)은 알파벳 정렬: checklist, finish, highlight, keyword, memo, observation, oneline, study
|
||||
body_pages = {p: n for n, p in mapping.items() if 2 <= p <= 9}
|
||||
assert body_pages[2] == "insta_card_checklist.png"
|
||||
assert body_pages[3] == "insta_card_finish.png"
|
||||
assert body_pages[9] == "insta_card_study.png"
|
||||
assert set(mapping.values()) == set(range(1, 11))
|
||||
|
||||
|
||||
def test_explicit_order_json_overrides_auto_mapping(tmp_theme):
|
||||
"""_order.json이 있으면 자동 매핑보다 우선."""
|
||||
pages = tmp_theme / "pages"
|
||||
_touch(pages, [
|
||||
"insta_card_start.png",
|
||||
"insta_card_cta.png",
|
||||
"insta_card_finish.png",
|
||||
] + [f"insta_card_body{i}.png" for i in range(1, 8)])
|
||||
(pages / "_order.json").write_text(json.dumps({
|
||||
"insta_card_start.png": 1,
|
||||
"insta_card_finish.png": 10, # cta 대신 finish를 page 10으로
|
||||
"insta_card_cta.png": 5, # cta를 본문 한가운데로 강제
|
||||
"insta_card_body1.png": 2,
|
||||
"insta_card_body2.png": 3,
|
||||
"insta_card_body3.png": 4,
|
||||
"insta_card_body4.png": 6,
|
||||
"insta_card_body5.png": 7,
|
||||
"insta_card_body6.png": 8,
|
||||
"insta_card_body7.png": 9,
|
||||
}), encoding="utf-8")
|
||||
mapping = design_importer._resolve_page_mapping(pages)
|
||||
assert mapping["insta_card_finish.png"] == 10
|
||||
assert mapping["insta_card_cta.png"] == 5
|
||||
assert mapping["insta_card_start.png"] == 1
|
||||
|
||||
|
||||
def test_validates_exactly_ten_pngs(tmp_theme):
|
||||
"""PNG가 정확히 10장이 아니면 ValueError."""
|
||||
_touch(tmp_theme / "pages", [f"x{i}.png" for i in range(5)]) # 5장
|
||||
with pytest.raises(ValueError, match="10"):
|
||||
design_importer._resolve_page_mapping(tmp_theme / "pages")
|
||||
|
||||
|
||||
def _make_png(path: Path, size: tuple[int, int]) -> None:
|
||||
"""size 픽셀의 단색 PNG를 생성."""
|
||||
from PIL import Image
|
||||
Image.new("RGB", size, color=(200, 200, 200)).save(path, format="PNG")
|
||||
|
||||
|
||||
def test_validate_images_accepts_higher_resolution_4_5_ratio(tmp_theme):
|
||||
"""1080x1350 외에도 같은 4:5 비율이면 통과 (예: 1122x1402, 디자인 도구 export 흔한 사이즈)."""
|
||||
pages = tmp_theme / "pages"
|
||||
for i in range(10):
|
||||
_make_png(pages / f"insta_card_{i:02d}.png", (1122, 1402))
|
||||
design_importer._validate_images(pages) # 예외 없으면 통과
|
||||
|
||||
|
||||
def test_validate_images_accepts_1080x1350(tmp_theme):
|
||||
pages = tmp_theme / "pages"
|
||||
for i in range(10):
|
||||
_make_png(pages / f"insta_card_{i:02d}.png", (1080, 1350))
|
||||
# 예외 없이 통과해야 함
|
||||
design_importer._validate_images(pages)
|
||||
|
||||
|
||||
def test_validate_images_rejects_wrong_dimensions(tmp_theme):
|
||||
pages = tmp_theme / "pages"
|
||||
for i in range(10):
|
||||
size = (800, 800) if i == 5 else (1080, 1350)
|
||||
_make_png(pages / f"insta_card_{i:02d}.png", size)
|
||||
with pytest.raises(ValueError, match="1080x1350"):
|
||||
design_importer._validate_images(pages)
|
||||
|
||||
|
||||
def test_import_design_theme_writes_html_via_mocked_vision(tmp_theme, monkeypatch):
|
||||
"""Vision mock이 정상 HTML 반환 시 card.html.j2 파일이 저장되고 결과 dict 반환."""
|
||||
pages = tmp_theme / "pages"
|
||||
names = [
|
||||
"insta_card_start.png",
|
||||
"insta_card_cta.png",
|
||||
] + [f"insta_card_body{i}.png" for i in range(8)]
|
||||
for n in names:
|
||||
_make_png(pages / n, (1080, 1350))
|
||||
|
||||
fake_html = """<!DOCTYPE html><html><body>
|
||||
{% if page_no == 1 %}<div class="cover">{{ headline }}</div>{% endif %}
|
||||
{% if page_no >= 2 and page_no <= 9 %}<div class="body">{{ headline }}<p>{{ body }}</p></div>{% endif %}
|
||||
{% if page_no == 10 %}<div class="cta">{{ headline }}<p>{{ cta }}</p></div>{% endif %}
|
||||
</body></html>"""
|
||||
|
||||
def fake_vision_call(images_with_pages, theme_name):
|
||||
return {"html": fake_html, "tokens": 12345, "summary": "test summary"}
|
||||
|
||||
monkeypatch.setattr(design_importer, "_call_vision", fake_vision_call)
|
||||
|
||||
result = design_importer.import_design_theme(str(tmp_theme))
|
||||
assert result["theme_name"] == "minimal"
|
||||
assert "card.html.j2" in result["html_path"]
|
||||
assert (tmp_theme / "card.html.j2").exists()
|
||||
assert (tmp_theme / "card.html.j2").read_text(encoding="utf-8") == fake_html
|
||||
assert "insta_card_start.png" in result["page_mapping"]
|
||||
assert result["tokens_used"] == 12345
|
||||
|
||||
|
||||
def test_import_design_theme_raises_on_jinja_parse_failure(tmp_theme, monkeypatch):
|
||||
"""Vision이 깨진 Jinja 반환 시 ValueError + .error.txt 보존."""
|
||||
pages = tmp_theme / "pages"
|
||||
for i in range(10):
|
||||
_make_png(pages / f"insta_card_{i:02d}.png", (1080, 1350))
|
||||
|
||||
broken_html = "<div>{% if page_no == 1 unclosed"
|
||||
|
||||
monkeypatch.setattr(design_importer, "_call_vision",
|
||||
lambda imgs, name: {"html": broken_html, "tokens": 100, "summary": ""})
|
||||
|
||||
with pytest.raises(ValueError, match="Jinja"):
|
||||
design_importer.import_design_theme(str(tmp_theme))
|
||||
assert (tmp_theme / "card.html.j2.error.txt").exists()
|
||||
|
||||
|
||||
def test_import_design_theme_backs_up_existing_html(tmp_theme, monkeypatch):
|
||||
"""기존 card.html.j2가 있으면 .bak.YYYYMMDD-HHMMSS로 백업 후 새로 작성."""
|
||||
pages = tmp_theme / "pages"
|
||||
for i in range(10):
|
||||
_make_png(pages / f"insta_card_{i:02d}.png", (1080, 1350))
|
||||
(tmp_theme / "card.html.j2").write_text("OLD HTML", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(design_importer, "_call_vision",
|
||||
lambda imgs, name: {"html": "<div>{{ headline }}</div>", "tokens": 50, "summary": ""})
|
||||
|
||||
design_importer.import_design_theme(str(tmp_theme))
|
||||
# .bak.* 파일이 생성되었어야 함
|
||||
backups = list(tmp_theme.glob("card.html.j2.bak.*"))
|
||||
assert len(backups) == 1
|
||||
assert backups[0].read_text(encoding="utf-8") == "OLD HTML"
|
||||
# 새 파일은 새 내용
|
||||
assert "headline" in (tmp_theme / "card.html.j2").read_text(encoding="utf-8")
|
||||
@@ -1,80 +0,0 @@
|
||||
"""POST /api/internal/insta/update — Windows worker webhook."""
|
||||
import os
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from app.internal_router import router
|
||||
from app import db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _set_key(monkeypatch):
|
||||
monkeypatch.setenv("INTERNAL_API_KEY", "test-secret")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, monkeypatch):
|
||||
# SQLite in-memory test
|
||||
monkeypatch.setenv("INSTA_DATA_PATH", str(tmp_path))
|
||||
db.init_db()
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _make_task():
|
||||
return db.create_task("slate_render", {"slate_id": 42})
|
||||
|
||||
|
||||
def test_update_with_valid_key_updates_db(client):
|
||||
tid = _make_task()
|
||||
r = client.post(
|
||||
"/api/internal/insta/update",
|
||||
headers={"X-Internal-Key": "test-secret"},
|
||||
json={"task_id": tid, "status": "processing", "progress": 30},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
task = db.get_task(tid)
|
||||
assert task["status"] == "processing"
|
||||
assert task["progress"] == 30
|
||||
|
||||
|
||||
def test_update_with_invalid_key_returns_401(client):
|
||||
tid = _make_task()
|
||||
r = client.post(
|
||||
"/api/internal/insta/update",
|
||||
headers={"X-Internal-Key": "wrong"},
|
||||
json={"task_id": tid, "status": "processing", "progress": 30},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_update_succeeded_sets_result_path(client):
|
||||
tid = _make_task()
|
||||
r = client.post(
|
||||
"/api/internal/insta/update",
|
||||
headers={"X-Internal-Key": "test-secret"},
|
||||
json={
|
||||
"task_id": tid,
|
||||
"status": "succeeded",
|
||||
"progress": 100,
|
||||
"result_path": "/media/insta/42/01.png",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
task = db.get_task(tid)
|
||||
assert task["status"] == "succeeded"
|
||||
assert task["result_id"] is not None # slate_id from input_data
|
||||
|
||||
|
||||
def test_update_failed_records_error(client):
|
||||
tid = _make_task()
|
||||
r = client.post(
|
||||
"/api/internal/insta/update",
|
||||
headers={"X-Internal-Key": "test-secret"},
|
||||
json={"task_id": tid, "status": "failed", "progress": 0, "error": "Chromium crashed"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
task = db.get_task(tid)
|
||||
assert task["status"] == "failed"
|
||||
assert "Chromium" in (task.get("error") or "")
|
||||
@@ -58,11 +58,7 @@ def test_keywords_listing(client):
|
||||
|
||||
|
||||
def test_create_slate_kicks_background_task(client, monkeypatch):
|
||||
"""Plan-B-Insta SP-4: 슬레이트 생성 후 Redis push → task status=processing (Windows worker 대기).
|
||||
|
||||
card_renderer는 NAS에서 제거됨. write_slate → Redis rpush 경로만 검증.
|
||||
"""
|
||||
from app import main, card_writer
|
||||
from app import main, card_writer, card_renderer
|
||||
|
||||
def fake_write(keyword, category, articles=None):
|
||||
return db_module.add_card_slate({
|
||||
@@ -72,25 +68,24 @@ def test_create_slate_kicks_background_task(client, monkeypatch):
|
||||
"cta_copy": {"headline": "C", "body": "B", "cta": "F"},
|
||||
})
|
||||
|
||||
async def fake_rpush(queue, payload):
|
||||
pass # Redis 없이도 테스트 통과
|
||||
async def fake_render(slate_id, template="default/card.html.j2"):
|
||||
for i in range(1, 11):
|
||||
db_module.add_card_asset(slate_id, i, f"/tmp/{slate_id}_{i}.png", "h")
|
||||
return [f"/tmp/{slate_id}_{i}.png" for i in range(1, 11)]
|
||||
|
||||
monkeypatch.setattr(card_writer, "write_slate", fake_write)
|
||||
monkeypatch.setattr(main.redis_client, "rpush", fake_rpush)
|
||||
monkeypatch.setattr(card_renderer, "render_slate", fake_render)
|
||||
|
||||
resp = client.post("/api/insta/slates", json={"keyword": "K", "category": "economy"})
|
||||
assert resp.status_code == 200
|
||||
task_id = resp.json()["task_id"]
|
||||
# 잠시 대기 후 폴링 — background task가 완료될 때까지
|
||||
import time
|
||||
# poll task
|
||||
for _ in range(20):
|
||||
st = client.get(f"/api/insta/tasks/{task_id}").json()
|
||||
if st["status"] != "pending":
|
||||
if st["status"] in ("succeeded", "failed"):
|
||||
break
|
||||
time.sleep(0.1)
|
||||
# Redis push 후 task는 processing 상태 (Windows worker가 rendered로 전환)
|
||||
assert st["status"] == "processing"
|
||||
assert st["result_id"] is not None # slate_id가 result_id에 기록됨
|
||||
assert st["status"] == "succeeded"
|
||||
slate_id = st["result_id"]
|
||||
detail = client.get(f"/api/insta/slates/{slate_id}").json()
|
||||
assert detail["keyword"] == "K"
|
||||
assert detail["status"] == "rendered"
|
||||
assert len(detail["assets"]) == 10
|
||||
|
||||
@@ -15,7 +15,7 @@ ENV PYTHONUNBUFFERED=1
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
ARG APP_VERSION=dev
|
||||
ENV APP_VERSION=$APP_VERSION
|
||||
|
||||
@@ -83,8 +83,7 @@ def on_startup():
|
||||
def _run_simulation_job():
|
||||
run_simulation(n_candidates=20000, top_k=100, best_n=20)
|
||||
|
||||
# stock 08:00 cron과 분리하기 위해 minute=5 → 30 (CHECK_POINT FU-B)
|
||||
scheduler.add_job(_run_simulation_job, "cron", hour="0,4,8,12,16,20", minute=30)
|
||||
scheduler.add_job(_run_simulation_job, "cron", hour="0,4,8,12,16,20", minute=5)
|
||||
|
||||
# 3. 토요일 오전 9시 — 다음 회차 공략 리포트 자동 캐싱
|
||||
def _save_weekly_report_job():
|
||||
|
||||
@@ -15,4 +15,4 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -37,13 +37,6 @@ server {
|
||||
}
|
||||
|
||||
# music videos — Nginx가 직접 비디오 파일 서빙
|
||||
location ^~ /media/insta/ {
|
||||
alias /data/insta_cards/;
|
||||
expires 1h;
|
||||
add_header Cache-Control "public";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location ^~ /media/videos/ {
|
||||
alias /data/videos/;
|
||||
|
||||
@@ -190,26 +183,6 @@ server {
|
||||
proxy_pass http://$insta_backend$request_uri;
|
||||
}
|
||||
|
||||
# Plan-B-Insta — Windows worker → NAS internal webhook (3-layer 차단)
|
||||
# Layer 1·2: nginx IP 화이트리스트 (LAN + Tailscale)
|
||||
# Layer 3: X-Internal-Key (FastAPI dependency)
|
||||
location /api/internal/insta/ {
|
||||
allow 192.168.45.0/24; # LAN 화이트리스트
|
||||
allow 100.64.0.0/10; # Tailscale CGNAT
|
||||
allow 127.0.0.1; # NAS 내부
|
||||
deny all;
|
||||
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
set $insta_internal_backend insta-lab:8000;
|
||||
|
||||
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-Internal-Key $http_x_internal_key;
|
||||
proxy_pass http://$insta_internal_backend$request_uri;
|
||||
}
|
||||
|
||||
# portfolio API (Stock) — trailing slash 유무 모두 매칭
|
||||
location /api/portfolio {
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -15,4 +15,4 @@ ENV PYTHONUNBUFFERED=1
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -7,4 +7,4 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -7,4 +7,4 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import os
|
||||
import logging
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import BackgroundTasks, FastAPI, Query, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -27,19 +26,10 @@ scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
||||
|
||||
|
||||
def scheduled_collect():
|
||||
"""매일 09:15 — 수집 + 정리 (병렬) → 매칭 → 알림 push.
|
||||
|
||||
collect_all과 delete_old_completed_announcements는 서로 다른 데이터
|
||||
영역을 건드리므로 thread 둘로 병렬화. 매칭은 두 작업 완료 후 순차
|
||||
실행 (DB 일관성). CHECK_POINT 중기-8 — env이 BackgroundScheduler+
|
||||
동기 함수 조합이라 asyncio.gather 대신 ThreadPoolExecutor 사용.
|
||||
"""
|
||||
"""매일 09:00 — 수집 + 정리 + 매칭 + 알림 push"""
|
||||
logger.info("스케줄 수집 시작")
|
||||
with ThreadPoolExecutor(max_workers=2) as ex:
|
||||
collect_future = ex.submit(collect_all)
|
||||
delete_future = ex.submit(delete_old_completed_announcements, 90)
|
||||
collect_future.result()
|
||||
deleted = delete_future.result()
|
||||
collect_all()
|
||||
deleted = delete_old_completed_announcements(grace_days=90)
|
||||
if deleted:
|
||||
logger.info("정리: %d건 삭제", deleted)
|
||||
run_matching()
|
||||
@@ -58,8 +48,7 @@ def scheduled_status_update():
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
# 09:00 cron 스태거링 — agent-office 09:00/05/10 이후 (CHECK_POINT FU-A)
|
||||
scheduler.add_job(scheduled_collect, "cron", hour=9, minute=15, id="collect")
|
||||
scheduler.add_job(scheduled_collect, "cron", hour=9, minute=0, id="collect")
|
||||
scheduler.add_job(scheduled_status_update, "cron", hour=0, minute=0, id="status_update")
|
||||
scheduler.start()
|
||||
logger.info("realestate-lab 시작")
|
||||
|
||||
@@ -18,10 +18,8 @@ flock -n 200 || { echo "Deploy already running, skipping"; exit 0; }
|
||||
BUILD_TARGETS="lotto travel-proxy stock music-lab insta-lab realestate-lab agent-office personal packs-lab frontend"
|
||||
# 컨테이너 이름 (고아 정리용 — blog-lab은 폐기 대상으로 정리 리스트에 유지)
|
||||
CONTAINER_NAMES="lotto stock music-lab insta-lab blog-lab realestate-lab agent-office personal packs-lab travel-proxy frontend"
|
||||
# Infra 서비스 (image-based, 영속 데이터 보존을 위해 stop/rm 없이 up만)
|
||||
INFRA_SERVICES="redis"
|
||||
# 헬스체크 대상
|
||||
HEALTH_ENDPOINTS="lotto stock travel-proxy music-lab insta-lab realestate-lab agent-office personal packs-lab redis"
|
||||
HEALTH_ENDPOINTS="lotto stock travel-proxy music-lab insta-lab realestate-lab agent-office personal packs-lab"
|
||||
# data 디렉토리 (packs-lab은 별도 media/packs 사용)
|
||||
DATA_DIRS="music stock insta realestate agent-office personal"
|
||||
|
||||
@@ -105,9 +103,6 @@ done
|
||||
docker compose up -d --build $BUILD_TARGETS
|
||||
docker exec frontend nginx -s reload 2>/dev/null || true
|
||||
|
||||
# 4) Infra 서비스 보장 (이미 떠 있으면 no-op, 없으면 시작 — 영속 데이터 보존)
|
||||
docker compose up -d $INFRA_SERVICES
|
||||
|
||||
# ── 배포 후 헬스체크 ──
|
||||
# Docker compose의 healthcheck 블록이 이미 모든 컨테이너에 정의되어 있으므로
|
||||
# `docker inspect`로 컨테이너 health state를 직접 조회. 이 방식은
|
||||
|
||||
@@ -6,4 +6,4 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
"""Project-level pytest conftest.
|
||||
|
||||
SP-A2: autouse fixture that resets all webai_cache TTLCaches between tests
|
||||
so screener/portfolio/news cache state does not leak across test cases.
|
||||
"""
|
||||
import pytest
|
||||
from app import webai_cache
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_webai_cache():
|
||||
webai_cache.PORTFOLIO_CACHE.clear()
|
||||
webai_cache.NEWS_CACHE.clear()
|
||||
webai_cache.SCREENER_CACHE.clear()
|
||||
yield
|
||||
@@ -25,7 +25,6 @@ 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
|
||||
from . import webai_cache
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@@ -419,16 +418,8 @@ def _augment_portfolio_with_pnl_pct(raw: dict) -> dict:
|
||||
|
||||
@app.get("/api/webai/portfolio", dependencies=[Depends(verify_webai_key)])
|
||||
def get_webai_portfolio():
|
||||
"""web-ai 전용 portfolio (인증 필수, pnl_pct 비율 필드 추가).
|
||||
|
||||
SP-A2 server-side TTLCache 적용. V1+V2 동시 호출도 NAS에서 1회 계산.
|
||||
"""
|
||||
cached = webai_cache.cache_get_portfolio()
|
||||
if cached is not None:
|
||||
return cached
|
||||
result = _augment_portfolio_with_pnl_pct(get_portfolio())
|
||||
webai_cache.cache_set_portfolio(result)
|
||||
return result
|
||||
"""web-ai 전용 portfolio (인증 필수, pnl_pct 비율 필드 추가)."""
|
||||
return _augment_portfolio_with_pnl_pct(get_portfolio())
|
||||
|
||||
|
||||
def _fetch_news_sentiment_dump(date: str | None) -> dict:
|
||||
@@ -475,16 +466,8 @@ def _fetch_news_sentiment_dump(date: str | None) -> dict:
|
||||
|
||||
@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.
|
||||
|
||||
SP-A2 server-side TTLCache 적용. date 파라미터별로 별도 슬롯.
|
||||
"""
|
||||
cached = webai_cache.cache_get_news(date)
|
||||
if cached is not None:
|
||||
return cached
|
||||
result = _fetch_news_sentiment_dump(date)
|
||||
webai_cache.cache_set_news(date, result)
|
||||
return result
|
||||
"""web-ai 전용 news sentiment 일별 dump."""
|
||||
return _fetch_news_sentiment_dump(date)
|
||||
|
||||
|
||||
@app.post("/api/portfolio", status_code=201)
|
||||
|
||||
@@ -12,7 +12,6 @@ from fastapi import APIRouter, HTTPException
|
||||
|
||||
from . import schemas
|
||||
from .registry import NODE_REGISTRY, GATE_REGISTRY
|
||||
from .. import webai_cache
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/stock/screener")
|
||||
@@ -174,12 +173,6 @@ def _persist_run(conn, asof, mode, weights, node_params, gate_params, top_n,
|
||||
@router.post("/run", response_model=schemas.RunResponse)
|
||||
def post_run(body: schemas.RunRequest):
|
||||
from .registry import NODE_REGISTRY as _NR, GATE_REGISTRY as _GR
|
||||
# SP-A2 — preview 모드는 web-ai/frontend 폴링이라 캐시 적용.
|
||||
# auto 모드는 실제 운영 트리거(휴장일 게이트 등)라 캐시 미적용.
|
||||
if body.mode == "preview":
|
||||
cached = webai_cache.cache_get_screener(body.mode, body.top_n, body.weights)
|
||||
if cached is not None:
|
||||
return cached
|
||||
started_at = dt.datetime.utcnow().isoformat()
|
||||
with _conn() as c:
|
||||
asof = _resolve_asof(body.asof, c)
|
||||
@@ -238,7 +231,7 @@ def post_run(body: schemas.RunRequest):
|
||||
top_n=top_n, rows=result.rows, run_id=run_id,
|
||||
)
|
||||
|
||||
response = schemas.RunResponse(
|
||||
return schemas.RunResponse(
|
||||
asof=asof.isoformat(), mode=body.mode, status="success",
|
||||
run_id=run_id, survivors_count=result.survivors_count,
|
||||
weights=weights, top_n=top_n,
|
||||
@@ -246,10 +239,6 @@ def post_run(body: schemas.RunRequest):
|
||||
telegram_payload=schemas.TelegramPayload(**payload),
|
||||
warnings=result.warnings,
|
||||
)
|
||||
# SP-A2 — preview 모드 결과 캐시 저장.
|
||||
if body.mode == "preview":
|
||||
webai_cache.cache_set_screener(body.mode, body.top_n, body.weights, response)
|
||||
return response
|
||||
|
||||
|
||||
# ---------- /snapshot/refresh ----------
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
"""SP-A2 — webai_cache module의 cache hit/miss + key 분기 검증."""
|
||||
import time
|
||||
import pytest
|
||||
from app.webai_cache import (
|
||||
PORTFOLIO_CACHE, NEWS_CACHE, SCREENER_CACHE,
|
||||
cache_get_portfolio, cache_set_portfolio,
|
||||
cache_get_news, cache_set_news,
|
||||
cache_get_screener, cache_set_screener,
|
||||
_screener_key,
|
||||
)
|
||||
|
||||
|
||||
def _clear_all():
|
||||
PORTFOLIO_CACHE.clear()
|
||||
NEWS_CACHE.clear()
|
||||
SCREENER_CACHE.clear()
|
||||
|
||||
|
||||
def test_portfolio_cache_miss_then_hit():
|
||||
_clear_all()
|
||||
assert cache_get_portfolio() is None
|
||||
cache_set_portfolio({"holdings": [], "cash": 0})
|
||||
assert cache_get_portfolio() == {"holdings": [], "cash": 0}
|
||||
|
||||
|
||||
def test_news_cache_key_by_date():
|
||||
"""date가 다르면 별도 캐시 슬롯."""
|
||||
_clear_all()
|
||||
cache_set_news("2026-05-18", {"count": 5})
|
||||
cache_set_news("2026-05-17", {"count": 3})
|
||||
assert cache_get_news("2026-05-18") == {"count": 5}
|
||||
assert cache_get_news("2026-05-17") == {"count": 3}
|
||||
assert cache_get_news("2026-05-16") is None # not cached
|
||||
|
||||
|
||||
def test_news_cache_latest_key_normalized():
|
||||
"""date=None은 'latest' 키로 정규화되어 동일 슬롯."""
|
||||
_clear_all()
|
||||
cache_set_news(None, {"count": 9})
|
||||
assert cache_get_news(None) == {"count": 9}
|
||||
|
||||
|
||||
def test_screener_key_includes_mode_and_top_n():
|
||||
"""screener key는 mode + top_n + weights hash로 분기."""
|
||||
k_preview = _screener_key("preview", 20, None)
|
||||
k_preview_w = _screener_key("preview", 20, {"news": 0.3})
|
||||
k_auto = _screener_key("auto", 20, None)
|
||||
assert k_preview != k_preview_w
|
||||
assert k_preview != k_auto
|
||||
|
||||
|
||||
def test_screener_cache_roundtrip():
|
||||
_clear_all()
|
||||
payload = {"asof": "2026-05-18", "survivors_count": 17}
|
||||
cache_set_screener("preview", 20, None, payload)
|
||||
assert cache_get_screener("preview", 20, None) == payload
|
||||
assert cache_get_screener("preview", 20, {"news": 0.3}) is None
|
||||
|
||||
|
||||
def test_ttl_expiry_portfolio():
|
||||
"""짧은 ttl로 만료 확인 — 직접 시간 조작 대신 TTLCache 내부 동작 신뢰."""
|
||||
from cachetools import TTLCache
|
||||
short = TTLCache(maxsize=1, ttl=0.1) # 0.1초
|
||||
short["result"] = "x"
|
||||
assert short.get("result") == "x"
|
||||
time.sleep(0.2)
|
||||
assert short.get("result") is None
|
||||
@@ -1,68 +0,0 @@
|
||||
"""SP-A2 — NAS stock의 /api/webai/* 엔드포인트 in-memory TTLCache.
|
||||
|
||||
web-ai 측 캐시(stock_client._TTL)가 miss됐을 때도 NAS에서 같은 데이터를
|
||||
KIS·LLM 재호출 없이 즉시 반환하기 위한 2-layer 캐시의 server 측.
|
||||
V1+V2가 동시 호출해도 NAS는 1회만 계산.
|
||||
|
||||
TTL 정책 (spec §10 SP-A2):
|
||||
- portfolio: 120s (web-ai TTL 180s 보다 짧게 — 변경 감지 가능)
|
||||
- news: 600s (sentiment는 일 단위)
|
||||
- screener: 180s
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
from cachetools import TTLCache
|
||||
|
||||
|
||||
PORTFOLIO_CACHE: TTLCache = TTLCache(maxsize=1, ttl=120.0)
|
||||
NEWS_CACHE: TTLCache = TTLCache(maxsize=10, ttl=600.0)
|
||||
SCREENER_CACHE: TTLCache = TTLCache(maxsize=10, ttl=180.0)
|
||||
|
||||
|
||||
# ----- portfolio -----
|
||||
|
||||
def cache_get_portfolio() -> Optional[Any]:
|
||||
return PORTFOLIO_CACHE.get("result")
|
||||
|
||||
|
||||
def cache_set_portfolio(value: Any) -> None:
|
||||
PORTFOLIO_CACHE["result"] = value
|
||||
|
||||
|
||||
# ----- news-sentiment -----
|
||||
|
||||
def _news_key(date: Optional[str]) -> str:
|
||||
return date if date else "latest"
|
||||
|
||||
|
||||
def cache_get_news(date: Optional[str]) -> Optional[Any]:
|
||||
return NEWS_CACHE.get(_news_key(date))
|
||||
|
||||
|
||||
def cache_set_news(date: Optional[str], value: Any) -> None:
|
||||
NEWS_CACHE[_news_key(date)] = value
|
||||
|
||||
|
||||
# ----- screener -----
|
||||
|
||||
def _screener_key(mode: str, top_n: int, weights: Optional[dict]) -> str:
|
||||
"""mode + top_n + weights canonical hash. weights 객체 동등성을 키로."""
|
||||
if weights is None:
|
||||
w_repr = "none"
|
||||
else:
|
||||
# canonical: sorted keys → md5 hex (긴 weights도 짧은 키로)
|
||||
canon = json.dumps(weights, sort_keys=True, ensure_ascii=False)
|
||||
w_repr = hashlib.md5(canon.encode("utf-8")).hexdigest()[:12]
|
||||
return f"{mode}:{top_n}:{w_repr}"
|
||||
|
||||
|
||||
def cache_get_screener(mode: str, top_n: int, weights: Optional[dict]) -> Optional[Any]:
|
||||
return SCREENER_CACHE.get(_screener_key(mode, top_n, weights))
|
||||
|
||||
|
||||
def cache_set_screener(mode: str, top_n: int, weights: Optional[dict], value: Any) -> None:
|
||||
SCREENER_CACHE[_screener_key(mode, top_n, weights)] = value
|
||||
@@ -11,5 +11,4 @@ finance-datareader==0.9.110
|
||||
lxml==6.1.0
|
||||
pytest==8.3.2
|
||||
pytest-asyncio==0.24.0
|
||||
cachetools>=5.3
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ EXPOSE 8000
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
ARG APP_VERSION=dev
|
||||
ENV APP_VERSION=$APP_VERSION
|
||||
|
||||