52 Commits

Author SHA1 Message Date
e6ff234031 refactor(insta-lab): remove Playwright + slim Dockerfile (SP-4)
NAS에서 더 이상 카드 렌더 안 함 → Windows insta-render 워커로 완전 이전.
- card_renderer.py를 1줄 deprecation stub로 교체
- main.py의 import card_renderer 제거 + startup/shutdown hook 정리
- requirements.txt에서 playwright 삭제
- Dockerfile에서 Chromium 30+ dep 라인 + playwright install 제거 → image ~50% 감소
- test_card_renderer.py 폐기 (Windows 측 test_worker.py가 대체)
- test_main.py의 create_slate 테스트를 Redis-push 플로우에 맞게 업데이트

Plan-B-Insta Phase 3 완료.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 02:21:02 +09:00
912cd18e48 feat(insta-lab): cutover to Redis push, Playwright 렌더 호출 제거 (SP-4)
_bg_create_slate, _bg_render의 await card_renderer.render_slate(...)
호출을 Redis RPUSH queue:insta-render 로 전환.

NAS는 task_id 발급 + 큐 푸시 + 30~70% 진행률 보고만. Windows insta-render
워커가 BLPOP → 렌더 → webhook으로 succeeded 보고 시 NAS가
update_slate_status('rendered') 트리거.

Plan-B-Insta Phase 3 (cutover).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 02:18:12 +09:00
a06cc424ca chore(compose): insta-lab REDIS_URL + INTERNAL_API_KEY env + depends_on redis
박재오: NAS .env에 INTERNAL_API_KEY=$(openssl rand -hex 32) 추가 필요.
같은 값을 Windows insta-render .env에 보관 (대칭).

Plan-B-Insta Phase 1 완료.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 02:01:23 +09:00
e87c43a7a4 feat(insta-lab): wire internal_router + Redis client (SP-4 prep)
main.py에 internal_router include + 모듈 레벨 redis client.
requirements.txt에 redis>=5.0 추가 (playwright 제거는 Task 12에서).

Plan-B-Insta Phase 1 마무리. Task 11에서 _bg_render를 Redis push로 전환.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:59:55 +09:00
0c12c3527f feat(insta-lab): internal webhook /api/internal/insta/update (SP-4)
Windows insta-render worker가 작업 진행률·완료·실패를 보고할 수신부.
X-Internal-Key 인증 필수. 4건의 단위 테스트로 status·error·result_path 검증.

Plan-B-Insta Phase 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:57:17 +09:00
5ed9d265f6 feat(insta-lab): verify_internal_key auth for Windows webhook (SP-4)
X-Internal-Key 헤더 검증 dependency. .env의 INTERNAL_API_KEY와 비교.
미설정 시 401 (fail-safe). Plan-B-Insta Phase 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:51:38 +09:00
24229d00ae docs(plan): Plan-B-Insta — insta-render Windows worker + NAS 분할
16 task, 5 phase. NAS insta-lab의 Playwright Chromium 100% Windows로 이전.

Phase 1 (NAS 수신부): verify_internal_key + /api/internal/insta/update
  + main.py에 redis client + docker-compose env (Task 1-4)
Phase 2 (Windows worker 신설): web-ai/services/insta-render Docker
  컨테이너 (Dockerfile, requirements, card_renderer, worker, main, tests)
  (Task 5-10)
Phase 3 (NAS cutover): _bg_render·_bg_create_slate를 Redis push로
  + card_renderer.py stub + Dockerfile 슬림화 (Task 11-13)
Phase 4 (nginx 3-layer 차단): /api/internal/* IP 화이트리스트 (Task 14)
Phase 5 (end-to-end 검증): 폴링 + PNG 생성 확인 (Task 15-16)

NAS Redis + WSL2 Docker + SMB mount (Plan-B-Base) prerequisite 완료.
다음 plan은 Plan-B-Music (Suno+MusicGen), Plan-B-Video (외부 API gateway).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:47:41 +09:00
43f8b111ad chore(deploy): retrigger deployer with new deploy.sh to start redis
Previous push synced new deploy.sh to /runtime/scripts but the deploy
that came with that push had already started under the old script —
so redis (INFRA_SERVICES) was not brought up. This empty commit
forces the deployer to run the new script.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:50:33 +09:00
a9f38e1248 fix(deploy): bring up infra services (redis) via separate up -d step
Previous deploy.sh only started services listed in BUILD_TARGETS, so the
newly-added redis service never came up after the SP-1 commit pushed to
NAS. Split image-based infra (redis) into INFRA_SERVICES and call
'docker compose up -d $INFRA_SERVICES' after the BUILD_TARGETS rebuild.

stop/rm is intentionally skipped for INFRA_SERVICES so AOF data
(/runtime/redis-data) survives each deploy cycle. Future infra services
(prometheus, grafana, ...) can join the same list.

Also add redis to HEALTH_ENDPOINTS so deployer's docker-inspect health
check waits for redis to report healthy before declaring DEPLOY_OK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:47:51 +09:00
87651c9449 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>
2026-05-18 23:44:00 +09:00
a1a37ead9e docs(plan): Plan-B-Base — NAS Redis + Windows WSL2/Docker/Tailscale/SMB
분산 아키텍처 base 인프라 셋업. 8 task:
- Task 1-2: NAS docker-compose redis 서비스 추가 + 검증
- Task 3-5: Windows AI WSL2 + Docker Engine + Tailscale 설치
- Task 6-7: NAS SMB 자격증명·마운트 (/etc/fstab 자동화)
- Task 8: 통합 검증 (redis PING, /mnt/nas 양방향 R/W, docker hello-world)

SP-2 작업은 박재오 Windows AI 머신 192.168.45.59에서 직접 실행 필요.
Claude는 SP-1만 직접 처리, SP-2는 명령어·검증 가이드 제공.

후속 Plan-B-Insta/Music/Video/Infra의 prerequisite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:07:43 +09:00
978aa14f8b 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>
2026-05-18 21:47:23 +09:00
030365bed0 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>
2026-05-18 21:43:24 +09:00
8c5bfa453f 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>
2026-05-18 21:41:25 +09:00
11d86450c3 docs(plan): Track A cache hardening (SP-A1 + SP-A2)
web-ai stock_client TTL 증가 (60/300/60 → 180/600/300) + NAS stock
TTLCache 도입 (cachetools, webai_cache 모듈, 3 endpoint 적용).
2-layer cache로 V2 재시작 시점부터 NAS 인바운드 호출 70% 감소 예상.

8개 task, TDD 적용 (회귀 테스트 3건 + cache 단위 테스트 6건).
~40분 작업.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:30:43 +09:00
90f6af6ab3 docs(arch): NAS↔Windows 분산 아키텍처 통합 design spec
박재오 7결정 + Obsidian 3개 문서(7결정 통합/API 부하/역할 분담)를
실행 가능한 형태로 정리.

12개 SP 분할 (Track A Quick Win 2건 + Track B Infrastructure 10건),
의존성 그래프, 시간대 조건부 우선순위(평일 비휴장일만 트레이딩 HIGH),
Windows Render Worker 통합 패턴 (인스타·음악·영상 셋이 같은 구조),
Redis 큐 컨벤션, SMB direct write + NAS internal webhook,
X-WebAI-Key / X-Internal-Key 분리, 3-layer 차단(IP 화이트리스트 +
Tailscale + 헤더), Suno+영상 API 키 Windows 이전 명세.

첫 plan 대상: Track A (SP-A1 web-ai 캐시 TTL + SP-A2 NAS stock
TTLCache, ~40분 작업, V2 재시작 시 NAS 인바운드 70% 감소).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:24:37 +09:00
83113ab50c docs(check-point): mark #10 already-applied, #11 denied, #12 deferred
#10 NAS LLM 호출 → Windows AI 통일 — 확인 결과 이미 적용. NAS .env가
LLM_PROVIDER=claude + OLLAMA_URL=192.168.45.59:11435. NAS Celeron에서
LLM 추론 안 함. 코드 변경 불필요.

#11 컨테이너 리소스 제한 (cpus 0.5 등) — 박재오 진행 금지. J4025 2C
환경에서 오히려 throughput 손해라는 판단.

#12 NAS 하드웨어 업그레이드 — 박재오 보류 결정.

또한 web-ai V1(:8000)+V2(:8001)+launcher 총 4개 process 종료. NAS API
polling 부담 즉각 감소.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 11:00:04 +09:00
20514193e8 perf(infra): NAS CPU 중기 2건 + 1건 보류 (CHECK_POINT 🟡)
#6 insta-lab Chromium Browser Pool — Playwright/Chromium 인스턴스를
모듈 레벨에서 보관하고 매 슬레이트마다 reuse. 카드 10장 렌더의
launch 비용 (~3초/회)이 사라짐. startup/shutdown lifecycle hook 추가.
crashed/disconnected 시 lazy 재초기화.

#8 realestate-lab 수집 병렬화 — collect_all과 delete_old_completed가
서로 다른 데이터 영역이라 ThreadPoolExecutor(2)로 병렬. asyncio.gather
대신 thread executor를 쓴 이유는 BackgroundScheduler+동기 함수 환경
에서 자연스럽고 추가 의존성 없기 때문. 매칭은 일관성 유지로 순차.

#7 stock async — 보류. 재진단 결과 stock은 BackgroundScheduler 사용
중이라 main loop 블로킹 없음. fetch 4회는 network I/O wait가
대부분이라 to_thread도 의미 없음. 진짜 효과를 보려면 AsyncIOScheduler
전환 + aiohttp 병렬이라 큰 리팩토링. 박재오 판단 대기.

CHECK_POINT.md 갱신.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 10:42:43 +09:00
7a470aad44 perf(infra): NAS CPU 폭주 5건 일괄 fix (CHECK_POINT 🔴 즉시)
J4025 Celeron 2C/2.0GHz에서 oversaturation을 일으키던 5개 패턴 해소.

1) 09:00 cron 스태거링 — agent-office insta_trends 09:00 / lotto 09:05 /
   youtube 09:10, realestate-lab collect 09:15. 동시 실행 4개가 직렬
   분산되어 1분 단위로 분산됨.
2) lotto Monte Carlo 08:05 → 08:30 — stock 08:00 cron과 25분 분리.
3) insta-lab card_renderer.render_slate를 asyncio.Semaphore(1)로 감쌈.
   동시 슬레이트 렌더 요청이 와도 Chromium 인스턴스 1개만 직렬 launch.
4) docker-compose healthcheck interval 30s → 60s (9 백엔드 + frontend
   총 10개). 30초마다 동시 healthcheck로 인한 CPU 잡음 절반으로.
5) 9개 백엔드 Dockerfile CMD에 --workers 1 명시. 기본값 의존 제거.

CHECK_POINT.md 갱신 — 즉시 5건 체크 + 변경 이력 한 줄.
적용 효과 검증: NAS 재기동 후 `docker stats` 비교.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 10:31:02 +09:00
de8adaeadd refactor(agent-office): drop the random idle→break→idle cycle
The pixel-office game UI is gone, so simulating coffee-break /
nap / walk states no longer serves any purpose. Remove:
- scheduler's _check_idle_breaks job (no more 60s idle scan)
- BaseAgent.check_idle_break() and _break_until field
- 'break' from VALID_STATES and from transition() branches
- IDLE_BREAK_THRESHOLD / BREAK_DURATION_MIN / BREAK_DURATION_MAX
  config knobs
- 'idle/break' guard in each agent's on_schedule (now just 'idle')

Agents now sit in 'idle' between scheduled jobs and explicit
commands. Display reads 'Idle' instead of churning between idle
and break.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:44:50 +09:00
5cde24115b feat(insta-lab): minimal 테마 card.html.j2 추가 (host repo 영속화)
NAS docker exec로 design_importer minimal 실행한 결과를 컨테이너에서 docker cp로
추출 → host repo에 영속화. 이전엔 컨테이너 ephemeral state라 다음 webhook rebuild에
소실되면서 렌더러가 default 폴백 → 사용자가 본 카드는 minimal 무관.

검증:
- 25,158 bytes, UTF-8 no BOM, <!DOCTYPE 시작
- Jinja parse OK
- background-image 10건, _order.json 순서 일치 (1=start … 10=finish)
- page_no == 분기 10건, 각 페이지 사용자 PNG 정확히 매핑
- Jinja 변수: headline(10), body(9), cta(2), label(4), page_no(1)
2026-05-18 08:03:29 +09:00
318190c93f docs(insta-lab): design_importer는 로컬 실행 권장 — NAS docker exec 시 결과 소실 함정
docker-compose의 insta-lab volume mount는 /app/data만이라 /app/app/templates는
컨테이너 ephemeral state. NAS docker exec로 design_importer 돌리면 card.html.j2가
컨테이너 안에만 생성되고 다음 webhook rebuild에 소실됨 → 렌더러가 default 폴백.

- CLAUDE.md: "실행 위치 — 로컬 권장" 경고 + 로컬 셋업 흐름 + 응급 hotfix docker cp 패턴
- design_importer.py module docstring 동일 내용 반영

PNG 사이즈 1080×1350 → 4:5 비율 권장으로 문서 일치 (이전 검증 완화 반영).
2026-05-18 07:29:55 +09:00
c8684280af feat(insta-lab): minimal theme page_mapping을 _order.json으로 명시
기본 매핑(start→1, cta→10, 나머지 알파벳)으로는 finish.png가 page 3에
배정되는 문제 해결. 카드뉴스 자연스러운 흐름으로 명시:

1. start (인트로)
2. keyword (오늘의 키워드)
3. highlight (핵심 하이라이트)
4. observation (관찰)
5. memo (메모)
6. oneline (한 줄 정리)
7. checklist (체크리스트)
8. study (심화)
9. cta (액션 유도)
10. finish (마감)

다음 design_importer 실행 시 이 매핑이 우선 적용됨.
2026-05-18 00:55:22 +09:00
6895e2f8dc fix(insta-lab): design_importer dimension 검증을 4:5 비율로 완화
운영에서 사용자 디자인이 1122x1402로 작성됨. 1080x1350과 정확히 같은
4:5 종횡비지만 절대 사이즈만 다르므로 정확한 사이즈 강제는 과도.

- 검증: 종횡비 4:5 (±2% tolerance). 1080x1350·1122x1402 등 동일 비율
  높은 해상도 모두 통과.
- Vision은 base64로 원본 분석 (사이즈 무관).
- Playwright는 background-size: cover로 1080x1350 컨테이너에 자동 fit.
- 비율이 깨지면 (예: 1024x1024 정사각) 여전히 reject.

test_validate_images_accepts_higher_resolution_4_5_ratio 신규 (1 case).
2026-05-18 00:42:30 +09:00
34619dc70b fix(insta-lab): add Pillow to requirements.txt (design_importer 의존)
design_importer.py가 1080x1350 이미지 검증을 위해 `from PIL import Image`
사용. 운영 컨테이너에서 ModuleNotFoundError: No module named 'PIL' 발생.

card_renderer는 Playwright만 쓰므로 기존 requirements에 PIL이 없었음.
local pytest는 dev 환경에 Pillow가 이미 설치돼 있어 PASS — 운영 검증
구멍.

Pillow>=10 추가 → 다음 webhook 빌드 시 pip 설치.
2026-05-18 00:33:21 +09:00
47cdc43aa5 Merge pull request 'feat/insta-design-importer' (#7) from feat/insta-design-importer into main
Reviewed-on: https://gitea.gahusb.synology.me/gahusb/web-page-backend/pulls/7
2026-05-18 00:28:52 +09:00
2270072fe5 docs(claude-md): insta-lab section에 design_importer + INSTA_DEFAULT_THEME 항목 2026-05-18 00:21:24 +09:00
15f24dc890 feat(insta-lab): INSTA_DEFAULT_THEME env 통합 (config + main + compose) 2026-05-18 00:19:47 +09:00
2915f2b697 feat(insta-lab): card_renderer theme 폴백 가드 (HTML 없으면 default) 2026-05-18 00:18:44 +09:00
7640a2b4a8 feat(insta-lab): design_importer CLI entrypoint (python -m app.design_importer) 2026-05-18 00:16:34 +09:00
427522bd1a feat(insta-lab): import_design_theme — Vision 호출 + Jinja sanity + 백업 저장 2026-05-18 00:14:59 +09:00
0bddc5c607 feat(insta-lab): design_importer image dimension 검증 (1080x1350) 2026-05-18 00:10:44 +09:00
54c677f75a feat(insta-lab): design_importer page mapping (자동 + _order.json override) 2026-05-18 00:10:02 +09:00
01bb837525 docs(insta-lab): design_importer — placeholder 텍스트 마스킹 요구 추가
사용자 디자인 PNG에 placeholder 텍스트가 이미 박혀있는 경우 대응.
Vision system prompt에 두 layer 요구:
(a) 마스킹 박스: placeholder 영역 좌표 + 주변 배경색으로 덮음
(b) 동적 텍스트 layer: 동일 좌표에 새 카피, 원본 폰트 스타일 모방
+ overflow:hidden으로 긴 카피가 박스 밖 새지 않게.

spec 4-3 + plan Task 3 step 3 동시 패치.
2026-05-17 20:54:00 +09:00
8ceb0af736 docs(insta-lab): design_importer implementation plan (8 TDD tasks)
페이지 매핑 → 이미지 검증 → Vision 호출 → Jinja sanity → 백업 저장 →
CLI → card_renderer 폴백 → env/compose/CLAUDE.md 통합. Vision은
모든 테스트에서 mock, 실제 호출은 운영 NAS에서 수동 (~$0.05/import).
2026-05-17 20:52:28 +09:00
ecf1f643b2 docs(insta-lab): design_importer spec — 파일명 매핑 충돌 처리 명시 (셀프 리뷰) 2026-05-17 20:47:26 +09:00
077d411f83 docs(insta-lab): design_importer spec — Claude Vision으로 이미지 → Jinja HTML 자동 생성
사용자가 만든 카드 디자인 이미지 10장을 Claude Sonnet Vision으로 분석해
페이지별 텍스트 영역·색·레이아웃 모방한 단일 card.html.j2 자동 생성.

핵심 결정:
- CLI 진입점 (MVP) — API endpoint는 후속
- env INSTA_DEFAULT_THEME 단일 theme — 슬레이트별 선택은 후속
- 파일명 자동 매핑 (cover→1, cta→10, 나머지 알파벳) + _order.json override
- card_renderer 폴백 가드 (theme HTML 없으면 default로)
2026-05-17 20:46:41 +09:00
6674755800 feat(insta-lab): 'minimal' design theme — 10 cards 2026-05-17 20:40:50 +09:00
d919c75ea7 docs(env): align PACK_HOST_DIR with CLAUDE.md (F5) 2026-05-17 20:40:50 +09:00
3a71c91eeb fix(stock,docs): portfolio total_buy 수량 곱산 + insta-trends spec 변경 이력 (F4 + F6)
[F4] /api/portfolio 응답의 summary.total_buy가 종목별 단가 × 수량의 합이
되도록 fix. 기존 인라인 코드가 purchase_price를 수량 미곱산으로 단순
누적해 명세(qty 100 · avg 72000 → 7,200,000)와 어긋났음. API_SPEC.md에
purchase_price 필드 의미 + total_buy 계산식 명시. test 3건 (단가 곱산,
avg_price 폴백, 다종목 합산).

[F6] insta-trends spec/plan 상단에 "google_trends → youtube_trending"
변경 이력 추가. Google Trends endpoint 폐기로 source 교체된 이력이
본문 검색 시 혼란 주는 문제 차단. 사유 cross-ref:
feedback_external_data_sources.md
2026-05-17 20:40:50 +09:00
9d0e9aa8aa Merge pull request 'feat/post-migration-cleanup' (#6) from feat/post-migration-cleanup into main
Reviewed-on: https://gitea.gahusb.synology.me/gahusb/web-page-backend/pulls/6
2026-05-17 14:27:37 +09:00
d9c39a0206 docs(readme,status): CLAUDE.md 기준으로 동기화 (CODE_REVIEW F7)
README.md / STATUS.md가 blog-lab을 운영 중인 18700 포트 컨테이너로
설명하고 insta-lab/personal/packs-lab을 누락했던 문제 정리. CLAUDE.md를
source of truth로 다음을 갱신:

- 컨테이너 표 (11개로 정합화)
- 디렉토리 구조 (insta-lab/personal/packs-lab 추가)
- 빠른 시작 URL 표
- blog-lab 섹션 → insta-lab 파이프라인 설명
- agent-office 표 (InstaAgent + YouTubeResearcher 반영)
- 스케줄러 잡 목록 (09:00 Insta trends, 09:30 Insta extract, 16:30 screener 등)
- DB 표 (insta.db + personal.db + Supabase pack_files 추가)
- .env 예시 (YOUTUBE_DATA_API_KEY, ADMIN_API_KEY, INSTA_LAB_URL 등)
- STATUS 최근 작업: 2026-05-15~17 인스타 + 보안 fix 이력
2026-05-17 14:23:07 +09:00
0f73b6b07d chore(cleanup): post-migration tidying (CODE_REVIEW F8 + 정리 대상)
- stock/app/test_scraper.py 삭제 — 미존재 함수 fetch_overseas_news를
  import하는 untracked 임시 스크립트. 보존 가치 없음 (F8).
- blog-lab/ 디렉토리 잔재 (__pycache__만 남음) 완전 제거. 서비스는
  feat/insta-agent 머지에서 이미 폐기됨.
- .gitignore에 .superpowers/ (스킬 캐시·세션 메타)와 CODE_REVIEW.md
  (임시 리뷰 노트) 추가 — git status 노이즈 차단.
2026-05-17 14:19:13 +09:00
faffca0967 Merge pull request 'feat/security-hardening' (#5) from feat/security-hardening into main
Reviewed-on: https://gitea.gahusb.synology.me/gahusb/web-page-backend/pulls/5
2026-05-17 14:00:03 +09:00
49c5c57be5 docs(env): add ALLOW_UNAUTHENTICATED_ADMIN guidance for F2 2026-05-17 13:58:24 +09:00
6053e69afc fix(stock): admin API auth hardening — ADMIN_API_KEY 빈 값 시 503 거부 (CODE_REVIEW F2)
운영 .env에 ADMIN_API_KEY가 누락되면 verify_admin이 무조건 통과해서
/api/trade/balance, /api/trade/order 인증이 무력화되던 문제 차단.

- ADMIN_API_KEY 설정 + 올바른 키 → 통과 (기존 동작)
- ADMIN_API_KEY 설정 + 잘못된 키 → 401 (기존 동작)
- ADMIN_API_KEY 미설정 + ALLOW_UNAUTHENTICATED_ADMIN=true → 통과 (dev mode)
- ADMIN_API_KEY 미설정 + dev flag 없음 → 503 (신규, 운영 보호)

.env.example에 신규 ALLOW_UNAUTHENTICATED_ADMIN=false 안내 추가.
stock/pytest.ini 신규 (pythonpath=. 설정으로 tests 모듈 import 가능).
test_admin_auth.py 4 케이스 (RED → GREEN 검증, regression 포함).
2026-05-17 13:53:50 +09:00
1e5e1bcdff fix(packs-lab): sign-link path traversal — startswith → relative_to (CODE_REVIEW F1)
str(abs_path).startswith(str(PACK_HOST_DIR))는 trailing slash가 없어
sibling 경로(/foo/packs ↔ /foo/packs_evil)를 통과시켜 DSM API에 잘못된
호스트 경로를 전달할 수 있었음. Path.relative_to 기반으로 컴포넌트 단위
엄격 검증으로 교체. test_sign_link_rejects_sibling_path 회귀 테스트
추가 (RED → GREEN 검증).
2026-05-17 13:50:22 +09:00
64fbbb7958 fix(insta-lab): replace Google Trends with YouTube Data API (Google API 폐기 대응)
Google이 비공식 trends endpoint 두 가지(/trends/.../rss + /trends/api/dailytrends)
모두 404로 폐기 (NAS에서 직접 호출 시 확정). 대안으로 YouTube Data API v3
mostPopular(regionCode=KR, 50개)로 source 교체:

- source 이름: google_trends → youtube_trending
- 키워드: 영상 제목 정제 (대괄호·이모지 제거, 60자 limit)
- API 키: YOUTUBE_DATA_API_KEY (agent-office와 공유, .env 그대로 활용)
- 키 미설정 시 graceful skip
- docker-compose insta-lab에 환경변수 추가
- 테스트 9/9 pass (기존 6 + youtube 3 신규)
2026-05-17 11:54:31 +09:00
cfbb72051f fix(insta-lab): Google Trends — RSS endpoint도 404 폐기, dailytrends JSON API로 교체
Google이 /trends/trendingsearches/daily/rss?geo=KR도 404로 폐기 (직전
fix에서 RSS로 교체했으나 NAS에서 실제 호출 시 404 확인). 대안으로 비공식
/trends/api/dailytrends?hl=ko&tz=-540&geo=KR&ns=15 JSON API로 교체.
응답 앞 `)]}'` XSSI 보호 prefix는 정규식으로 자르고 JSON 파싱.
중복 키워드 제거 + 등장 순서 보존.
2026-05-17 09:30:40 +09:00
bf5897fc85 fix(insta-lab): trend_collector — Google Trends RSS + seed placeholder filter
(1) pytrends 4.x가 Google API 변경으로 trending_searches(pn='south_korea')
가 404 반환 → daily trending searches RSS endpoint를 requests로 직접 호출
하도록 교체. pytrends 의존성 제거.

(2) category_seeds 프롬프트 템플릿에 placeholder ('...', 'TBD' 등) 또는
2자 미만 값이 들어가면 NAVER가 400 Bad Request 반환 → _seeds_for에
_is_valid_seed 가드 추가, 모두 invalid면 DEFAULT_CATEGORY_SEEDS 폴백.

테스트 8/8 PASS (기존 6 + placeholder/fallback 2 신규).
2026-05-17 09:21:38 +09:00
ad6c744f2c fix(deploy): increase docker/buildkit/pip timeouts for NAS slow build
webhook 자동 배포가 pip install (pytrends 추가 후 75s+)에서 buildkit
context deadline exceeded로 실패하던 이슈 대응. scripts/deploy.sh
상단에 COMPOSE_HTTP_TIMEOUT/DOCKER_CLIENT_TIMEOUT/BUILDKIT_STEP_LOG_MAX_SIZE
10분 환경변수 설정 + insta-lab Dockerfile의 pip install에 --timeout 600
--retries 5 추가. NAS Celeron J4025 환경 영구 대응.
2026-05-17 09:03:20 +09:00
aad9bfbe8b Merge pull request 'feat/insta-trends' (#4) from feat/insta-trends into main
Reviewed-on: https://gitea.gahusb.synology.me/gahusb/web-page-backend/pulls/4
2026-05-17 08:52:49 +09:00
72 changed files with 7593 additions and 373 deletions

View File

@@ -51,9 +51,14 @@ PGID=1000
# Windows AI Server (NAS 입장에서 바라본 Windows PC IP)
WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
# Admin API Key (trade/order 등 민감 엔드포인트 보호, 미설정 시 인증 비활성화)
# Admin API Key — /api/trade/* 등 민감 엔드포인트 보호.
# 운영 .env에는 반드시 값을 채워야 함. 빈 값이면 503 응답으로 거부됨 (CODE_REVIEW F2).
ADMIN_API_KEY=
# 개발 모드: 위 ADMIN_API_KEY 비워둔 채로 trade/admin 엔드포인트 호출 허용.
# 운영 환경에서는 절대 true로 두지 말 것. 기본 false (보호 활성).
ALLOW_UNAUTHENTICATED_ADMIN=false
# Anthropic API Key (AI Coach 프록시 + 뉴스 요약 Claude provider)
ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=claude-haiku-4-5-20251001
@@ -119,5 +124,6 @@ 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=/volume1/docker/webpage/media/packs
# 운영 NAS는 반드시 /volume1/docker/webpage/media/packs 같은 절대경로 설정.
# 미설정 시 PACK_DATA_PATH로 fallback (로컬 개발용).
PACK_HOST_DIR=/docker/webpage/media/packs

8
.gitignore vendored
View File

@@ -66,3 +66,11 @@ temp/
# Git worktrees
.worktrees/
################################
# Local working files
################################
# Superpowers 스킬 캐시·세션 메타
.superpowers/
# 임시 코드 리뷰 노트 (작업 끝나면 폐기 또는 docs/로 이동)
CODE_REVIEW.md

209
CHECK_POINT.md Normal file
View File

@@ -0,0 +1,209 @@
# 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건. 진단 커맨드.

View File

@@ -467,6 +467,7 @@ 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 튜닝
**카테고리 시드 키워드**
@@ -482,6 +483,31 @@ 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
View File

@@ -1,7 +1,7 @@
# web-backend
Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
로또 분석, 주식 포트폴리오, AI 음악 생성, 블로그 마케팅, 부동산 청약, AI 에이전트 오피스, 여행 앨범 하나의 Docker Compose 스택으로 운영한다.
로또 분석, 주식 포트폴리오, AI 음악 생성, 인스타 카드 피드, 부동산 청약, AI 에이전트 오피스, 여행 앨범, 개인 서비스(포트폴리오·블로그·투두), NAS 자료 다운로드 자동화를 하나의 Docker Compose 스택으로 운영한다.
---
@@ -9,33 +9,37 @@ Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
```
┌──────────────────────────────────────────────────────────────────────┐
lotto-frontend (Nginx:8080) │
│ frontend (Nginx:8080)
│ ├── 정적 SPA 서빙 (React + Vite) │
│ └── API 리버스 프록시 │
│ ├── /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 직접 서빙, 생성 오디오)
│ ├── /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 직접 서빙, 미디어) │
│ ├── /media/travel/… (nginx 직접 서빙, 사진/썸네일) │
│ └── /webhook → deployer:9000 │
│ └── /webhook → deployer:9000
└──────────────────────────────────────────────────────────────────────┘
```
| 컨테이너 | 포트 | 역할 |
|---------|------|------|
| `lotto-backend` | 18000 | 로또 데이터 수집·분석·추천 + 블로그·투두 API |
| `lotto` | 18000 | 로또 데이터 수집·분석·추천 API |
| `stock` | 18500 | 주식 뉴스·AI 요약·KIS 실계좌·포트폴리오·자산 추적 |
| `music-lab` | 18600 | AI 음악 생성 (Suno + 로컬 MusicGen 듀얼 프로바이더) |
| `blog-lab` | 18700 | 블로그 마케팅 수익화 (키워드→글 생성→리뷰→발행) |
| `realestate-lab` | 18800 | 청약 공고 자동 수집·프로필 매칭 |
| `music-lab` | 18600 | AI 음악 생성 (Suno + 로컬 MusicGen 듀얼 프로바이더) + YouTube 수익화 |
| `insta-lab` | 18700 | 인스타 카드 피드 자동 생성 (뉴스→키워드→10페이지 카드, Playwright) |
| `realestate-lab` | 18800 | 청약 공고 자동 수집·5티어 매칭·신규 매칭 push |
| `agent-office` | 18900 | AI 에이전트 가상 오피스 (WebSocket + 텔레그램 봇) |
| `personal` | 18850 | 개인 서비스 — 포트폴리오·블로그·투두 통합 |
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (DSM 공유 링크 + 5GB 청크 업로드) |
| `travel-proxy` | 19000 | 여행 사진 API + 온디맨드 썸네일 |
| `lotto-frontend` | 8080 | SPA 서빙 + 리버스 프록시 |
| `frontend` | 8080 | SPA 서빙 + 리버스 프록시 |
| `webpage-deployer` | 19010 | Gitea Webhook → 자동 배포 |
---
@@ -44,12 +48,14 @@ Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
```
web-backend/
├── backend/ # lotto-backend (로또·블로그·투두)
├── stock/ # 주식·포트폴리오
├── music-lab/ # AI 음악 생성
├── blog-lab/ # 블로그 마케팅 파이프라인
├── realestate-lab/ # 청약 자동 수집·매칭
├── lotto/ # 로또 추천·통계·시뮬레이션
├── stock/ # 주식·포트폴리오·KIS 연동
├── music-lab/ # AI 음악 생성 + YouTube 수익화
├── insta-lab/ # 인스타 카드 피드 자동 생성 (Playwright)
├── realestate-lab/ # 청약 자동 수집·5티어 매칭
├── agent-office/ # AI 에이전트 오피스 (WS + 텔레그램)
├── personal/ # 포트폴리오·블로그·투두 통합
├── packs-lab/ # NAS 자료 다운로드 자동화 (HMAC + Supabase)
├── travel-proxy/ # 여행 사진 + 썸네일
├── deployer/ # Gitea Webhook 수신 → 자동 배포
├── nginx/default.conf # 리버스 프록시 + SPA + 캐시
@@ -74,12 +80,14 @@ curl http://localhost:18500/health
| 서비스 | 로컬 URL |
|--------|----------|
| Frontend + API | http://localhost:8080 |
| lotto-backend | http://localhost:18000 |
| lotto | http://localhost:18000 |
| stock | http://localhost:18500 |
| music-lab | http://localhost:18600 |
| blog-lab | http://localhost:18700 |
| insta-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 |
---
@@ -123,20 +131,23 @@ curl http://localhost:18500/health
- **라이브러리**: 생성 파일은 `/app/data/music/`에 저장되고 Nginx가 `/media/music/`으로 직접 서빙
- **가사 도구**: 저장·편집·타임스탬프 기반 가라오케 동기
### 4. blog-lab (`/api/blog-marketing/`)
### 4. insta-lab (`/api/insta/`)
블로그 마케팅 수익화 4단계 파이프라인 (`draft → marketed → reviewed → published`).
인스타그램 카드 피드 자동 생성 — 뉴스 모니터링 → 키워드 추출 → 10페이지 카드 카피·PNG 렌더 → 텔레그램 푸시 → 사용자 수동 업로드.
```
리서치(Naver Search + 상위 블로그 본문 크롤링)
작가(AI 초안 생성)
마케터(전환율 강화 + 브랜드 링크 삽입)
평가자(6기준×10점, 42/60 통과 시 published)
NAVER 뉴스 + YouTube 인기 (외부 트렌드)
카테고리별 빈도 + Claude Haiku 정제 → 트렌딩 키워드
사용자가 키워드 선택
Claude Sonnet으로 10페이지 카피 추론 (커버 1 + 본문 8 + CTA 1)
→ Jinja2 + Playwright 1080×1350 PNG 10장 렌더
→ 텔레그램 미디어 그룹 + 추천 캡션·해시태그
```
- **AI 엔진**: Claude API (`claude-sonnet-4-20250514`)
- **키워드 분석**: 네이버 검색(블로그+쇼핑) API + 경쟁도/기회 점수
- **수익 추적**: 포스트별 월간 클릭/구매/수익 기록
- **AI 엔진**: Claude Sonnet (카피) + Claude Haiku (키워드 분류)
- **데이터 소스**: NAVER 뉴스 검색 + YouTube Data API v3 mostPopular(KR)
- **카테고리 가중치**: 사용자가 economy/psychology/celebrity 등 카테고리별 가중치 설정 → 자동 추출 비율에 반영
- **카드 디자인**: `insta-lab/app/templates/default/card.html.j2` — 사용자가 자유 수정 (Tailwind 등)
- **프롬프트 템플릿**: DB에 저장 → 코드 배포 없이 수정 가능
### 5. realestate-lab (`/api/realestate/`)
@@ -152,7 +163,7 @@ curl http://localhost:18500/health
AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 4명의 에이전트가 실제 작업을 수행한다.
- **아키텍처**: stock / music-lab / blog-lab / realestate-lab 기존 API를 서비스 프록시로 호출 (직접 DB 접근 없음)
- **아키텍처**: stock / music-lab / insta-lab / realestate-lab 기존 API를 서비스 프록시로 호출 (직접 DB 접근 없음)
- **FSM 상태**: `idle → working → waiting(승인 대기) → reporting → break`
- **실시간 동기화**: WebSocket `/api/agent-office/ws` (init, agent_state, task_complete, command_result)
- **텔레그램 연동**: 양방향 알림 + 인라인 키보드 승인
@@ -165,22 +176,28 @@ AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 4명의 에
|---------|--------|-----|----------|
| 📈 **주식 트레이더** (`stock`) | 08:00 매일 | — | 뉴스 요약 (LLM) → 텔레그램 아침 브리핑, 종목 알람 등록 |
| 🎵 **음악 프로듀서** (`music`) | 수동 트리거 | ✅ 작곡 | 프롬프트 수신 → 승인 → Suno API 작곡 → 트랙 푸시 |
| ✍️ **블로그 마케** (`blog`) | 10:00 매일 | ✅ 발행 | 트렌드 키워드 1개 선택 → 리서치→작가→마케터→평가 자동 실행 → 점수·본문을 텔레그램 승인 요청 → 승인 시 `published` 전환, 거절 시 재생성 |
| 🏢 **청약 애널리스트** (`realestate`) | 09:15 매일 | — | realestate-lab 수집 트리거 → 신규 매칭 상위 5건 + 대시보드 요약을 텔레그램 리포트 (읽음 처리 자동) |
| 🎴 **인스타 큐레이** (`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 |
#### 에이전트별 명령
**Stock**`fetch_news`, `list_alerts`, `add_alert`, `test_telegram`
**Music**`compose` (승인 필요), `credits`
**Blog**`research {keyword}`, `add_trend_keyword`, `list_trend_keywords`
**Insta**`extract`, `render <keyword_id>`, `collect_trends`
**Realestate**`fetch_matches`, `dashboard`
**YouTube**`research {countries: [...]}`
#### 스케줄러 잡
- 07:00 월요일 — Lotto: AI 큐레이터 브리핑 (5세트 + 내러티브)
- 07:30 — Stock: 뉴스 요약
- 09:15 — Realestate: 매칭 리포트
- 10:00 — Blog: 자동 파이프라인 (리서치→생성→리뷰→승인 대기)
- 08:00 평일 — Stock: AI 뉴스 sentiment 분석
- 09:00 — YouTube: 한국 트렌딩 수집
- 09:00 — Insta: 외부 트렌드 수집 (NAVER 인기 + YouTube mostPopular)
- 09:30 — Insta: 키워드 추출 (가중치 적용) + 텔레그램 후보 푸시
- 15:40 평일 — Stock: 총 자산 스냅샷
- 16:30 평일 — Stock: 스크리너 실행
- 60초 interval — 유휴 에이전트 휴식 체크
### 7. travel-proxy (`/api/travel/`)
@@ -265,13 +282,15 @@ git push → Gitea → X-Gitea-Signature (HMAC SHA256)
| DB | 소유 서비스 | 주요 테이블 |
|----|------------|-----------|
| `lotto.db` | lotto-backend | draws, recommendations, simulation_runs/candidates, best_picks, purchase_history, strategy_performance/weights, weekly_reports, lotto_briefings, todos, blog_posts |
| `lotto.db` | lotto | draws, recommendations, simulation_runs/candidates, best_picks, purchase_history, strategy_performance/weights, weekly_reports, lotto_briefings |
| `stock.db` | stock | articles, portfolio, broker_cash, asset_snapshots, sell_history |
| `music.db` | music-lab | music_tasks, music_library (provider, lyrics, image_url, suno_id, file_hash, cover_images, wav_url, video_url, stem_urls) |
| `blog_marketing.db` | blog-lab | keyword_analyses, blog_posts, brand_links, commissions, generation_tasks, prompt_templates |
| `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 |
| `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 |
---
@@ -292,33 +311,50 @@ PGID=1000
WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
WEBHOOK_SECRET=your_secret_here
# LLM (stock, blog-lab, agent-office 공통)
# LLM (stock, insta-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
# blog-lab
# insta-lab + agent-office (NAVER 검색 + YouTube Data API 공유)
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
BLOG_LAB_URL=http://blog-lab:8000
INSTA_LAB_URL=http://insta-lab:8000
REALESTATE_LAB_URL=http://realestate-lab:8000
# personal (포트폴리오 편집 인증)
PORTFOLIO_EDIT_PASSWORD=
```
---

View File

@@ -1,40 +1,42 @@
# web-backend — 구현 현황 & 로드맵
> 최종 갱신: 2026-05-07
> 최종 갱신: 2026-05-17
> 자세한 서비스·환경변수·DB 표는 [CLAUDE.md](./CLAUDE.md), 설계는 `docs/superpowers/specs/`, 실행 계획은 `docs/superpowers/plans/` 참조.
---
## 1. 서비스 구현 현황
### 1-1. 운영 중인 컨테이너 (10개)
### 1-1. 운영 중인 컨테이너 (11개)
| 서비스 | 포트 | 상태 | 핵심 기능 |
|--------|------|------|-----------|
| `lotto-backend` | 18000 | ✅ | 로또 추천·통계·리포트·구매내역 + 블로그·투두 |
| `stock` | 18500 | ✅ | 주식 뉴스·지수·트레이딩·포트폴리오·자산 스냅샷 |
| `lotto` | 18000 | ✅ | 로또 추천·통계·리포트·구매내역·AI 큐레이터 |
| `stock` | 18500 | ✅ | 주식 뉴스·지수·트레이딩·포트폴리오·자산 스냅샷·스크리너 |
| `music-lab` | 18600 | ✅ | Suno + MusicGen + YouTube 수익화 + 컴파일 |
| `blog-lab` | 18700 | ✅ | 블로그 마케팅 수익화 파이프라인 |
| `realestate-lab` | 18800 | ✅ | 청약 수집·5티어 매칭·매칭 알림 |
| `agent-office` | 18900 | ✅ | AI 에이전트 (WebSocket + 텔레그램 + YouTubeResearcher) |
| `packs-lab` | 18950 | ✅ | NAS 자료 다운로드 자동화 (HMAC + Supabase) — 2026-05-05 |
| `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) |
| `travel-proxy` | 19000 | ✅ | 여행 사진 API + 썸네일 + 지역 관리 |
| `nginx` | 8080 | ✅ | SPA + 리버스 프록시 (5GB body limit) |
| `webpage-deployer` | 19010 | ✅ | Gitea Webhook 자동 배포 |
| `frontend` (nginx) | 8080 | ✅ | SPA + 리버스 프록시 (5GB body limit, 인스타 라우팅 포함) |
| `webpage-deployer` | 19010 | ✅ | Gitea Webhook 자동 배포 (BUILDKIT timeout 600s, healthcheck via docker inspect) |
### 1-2. 최근 큰 작업 (2026-04 ~ 05)
### 1-2. 최근 큰 작업 (2026-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축 점수·알림 대상 카운트) |
| 2026-04-28 | realestate-lab | targeting enhancement (5티어 매칭·5축 점수·알림 대상 카운트, realestate-lab push → agent-office RealestateAgent) |
| 2026-04-27 | personal | personal 서비스 분리 마이그레이션 (블로그·투두·포트폴리오 인증) |
| 2026-04-27 | agent-office | v2 — youtube_researcher (YouTube API + pytrends + Billboard) + 알림 |
| 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) |
| 2026-04-15 | lotto | AI 큐레이터 (Claude 기반 주간 브리핑 자동 생성) |
### 1-3. 인프라 / DX

View File

@@ -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"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

View File

@@ -1,12 +1,9 @@
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", "break")
VALID_STATES = ("idle", "working", "waiting", "reporting")
class BaseAgent:
agent_id: str = ""
@@ -14,7 +11,6 @@ class BaseAgent:
state: str = "idle"
state_detail: str = ""
_idle_since: float = 0.0
_break_until: float = 0.0
_ws_manager = None
def __init__(self):
@@ -32,9 +28,6 @@ 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})")
@@ -48,19 +41,6 @@ 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

View File

@@ -46,7 +46,7 @@ class InstaAgent(BaseAgent):
async def on_schedule(self) -> None:
"""09:30 매일: 뉴스 수집 → 키워드 추출 → 텔레그램 후보 푸시.
custom_config.auto_select=True면 카테고리당 1위 키워드 자동 슬레이트 생성."""
if self.state not in ("idle", "break"):
if self.state != "idle":
return
config = get_agent_config(self.agent_id) or {}
custom = config.get("custom_config", {}) or {}

View File

@@ -8,7 +8,7 @@ class LottoAgent(BaseAgent):
display_name = "로또 큐레이터"
async def on_schedule(self) -> None:
if self.state not in ("idle", "break"):
if self.state != "idle":
return
await self._run(source="auto")

View File

@@ -44,7 +44,7 @@ class StockAgent(BaseAgent):
display_name = "주식 트레이더"
async def on_schedule(self) -> None:
if self.state not in ("idle", "break"):
if self.state != "idle":
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 not in ("idle", "break"):
if self.state != "idle":
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 not in ("idle", "break"):
if self.state != "idle":
return
task_id = create_task(self.agent_id, "ai_news_sentiment", {})

View File

@@ -26,11 +26,6 @@ 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")

View File

@@ -5,10 +5,6 @@ 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:
@@ -74,10 +70,10 @@ def init_scheduler():
id="stock_ai_news_sentiment",
)
scheduler.add_job(_run_insta_schedule, "cron", hour=9, minute=30, id="insta_pipeline")
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")
# 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(_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()

View File

@@ -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: 30s
interval: 60s
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: 30s
interval: 60s
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: 30s
interval: 60s
timeout: 5s
retries: 3
@@ -100,14 +100,20 @@ services:
- ANTHROPIC_MODEL_SONNET=${ANTHROPIC_MODEL_SONNET:-claude-sonnet-4-6}
- NAVER_CLIENT_ID=${NAVER_CLIENT_ID:-}
- NAVER_CLIENT_SECRET=${NAVER_CLIENT_SECRET:-}
- 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: 30s
interval: 60s
timeout: 5s
retries: 3
@@ -127,7 +133,7 @@ services:
- ${RUNTIME_PATH}/data/realestate:/app/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
interval: 60s
timeout: 5s
retries: 3
@@ -168,7 +174,7 @@ services:
- realestate-lab
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
interval: 60s
timeout: 5s
retries: 3
@@ -187,7 +193,7 @@ services:
- ${RUNTIME_PATH:-.}/data/personal:/app/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
interval: 60s
timeout: 5s
retries: 3
@@ -214,7 +220,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: 30s
interval: 60s
timeout: 5s
retries: 3
@@ -237,7 +243,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: 30s
interval: 60s
timeout: 5s
retries: 3
@@ -268,7 +274,7 @@ services:
- "host.docker.internal:host-gateway"
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:80/"]
interval: 30s
interval: 60s
timeout: 5s
retries: 3
@@ -288,3 +294,18 @@ 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

View File

@@ -2,6 +2,10 @@
> **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.
## ⚠️ 변경 이력
- **2026-05-17**: 본문에 `google_trends` source로 기재된 모든 task와 코드 블록은 **실제 구현에서 `youtube_trending`으로 교체됨**. Google Trends 비공식 endpoint(RSS + dailytrends JSON 양쪽) 모두 404 폐기 확인. YouTube Data API v3 mostPopular로 source 대체 + pytrends 의존성 제거. 운영 코드는 현재 `youtube_trending` 사용 중. 이 plan을 다시 실행할 일이 있으면 본문의 `google_trends` 단어를 `youtube_trending`으로 읽어달라. 자세한 사유와 교체 체크리스트는 `feedback_external_data_sources.md`.
**Goal:** Add a "Trends" tab to the Insta page that pulls external trends from NAVER popular + Google Trends, lets the user set category weights, and feeds those weights back into the daily keyword extraction pipeline.
**Architecture:** New `trend_collector` module in insta-lab (NAVER `news.json` 인기순 + `pytrends` Google Trends + Claude Haiku 카테고리 분류 + in-memory 캐시). Existing `trending_keywords` table gets a `source` column; new `account_preferences` table stores category weights. `keyword_extractor` gains a `extract_with_weights()` variant. InstaAgent runs a new 09:00 cron for trend collection and applies weights at 09:30 extraction. web-ui splits InstaCards into Cards/Trends tabs and adds 3 panels (AccountFocus / ExternalTrends / PreferenceImpact).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,635 @@
# 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)에 의존.

View File

@@ -0,0 +1,656 @@
# 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 작성 가능 (이미 명세 충분).

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,10 @@
상태: 사용자 승인 대기 → writing-plans 진입 예정
연관 문서: `2026-05-15-insta-agent-design.md` (insta-lab 기본 설계)
## ⚠️ 변경 이력
- **2026-05-17**: 본문에 `google_trends` source로 기재된 모든 항목은 **실제 구현에서 `youtube_trending`으로 교체됨**. Google Trends 비공식 endpoint 두 가지(`trendingsearches/daily/rss?geo=KR`, `/trends/api/dailytrends?...`)가 모두 404로 폐기되어 운영 호출이 빈 결과로 끝나는 문제 확인 → YouTube Data API v3 `videos.list?chart=mostPopular&regionCode=KR`로 source 대체. 이후 spec 본문을 읽을 때는 `google_trends``youtube_trending`, "Google Trends" → "YouTube 인기"로 치환 해석. 사유와 source 교체 시 동시 갱신 체크리스트: `feedback_external_data_sources.md`.
---
## 1. 목적·배경

View File

@@ -0,0 +1,294 @@
# 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 디자인 반영 확인

View File

@@ -0,0 +1,584 @@
# 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:3004:30 + 주말·휴장일 = 골든타임
---
## 3. 시간대별 우선순위 모드
| 모드 | 조건 | signal_v2 | task-watcher 정책 |
|------|------|-----------|------------------|
| 🔴 트레이딩 | 평일 비휴장일 07:0016:30 | NSSM HIGH, polling 활성 | 박재오 활동 감지 시 `queue:paused` SET |
| 🟡 일반 | 평일 16:3023:30 (NXT) | NSSM HIGH 유지 (5분 폴링 가벼움) | 박재오 활동 감지 시 SET |
| 🟢 자유 | 주말·휴장일 + 평일 23:3004: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`

View File

@@ -3,23 +3,15 @@ ENV PYTHONUNBUFFERED=1
WORKDIR /app
# Korean fonts + Chromium runtime deps (Debian 12 / bookworm)
# `playwright install --with-deps`를 쓰지 않는 이유: 그 명령은 Ubuntu 패키지명을
# 사용해 Debian에서 ttf-ubuntu-font-family / ttf-unifont 등 없는 패키지를 시도
# → apt 실패. 대신 Chromium이 실제 필요로 하는 라이브러리만 명시 설치.
# Korean fonts (insta-lab가 자체 텍스트 처리는 안 하지만 향후 thumbnail 생성 등 위해 유지)
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 .
RUN pip install --no-cache-dir -r requirements.txt
RUN playwright install chromium
RUN pip install --no-cache-dir --timeout 600 --retries 5 -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

17
insta-lab/app/auth.py Normal file
View File

@@ -0,0 +1,17 @@
"""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")

View File

@@ -1,100 +1,7 @@
"""Jinja → HTML → Playwright headless screenshot."""
"""DEPRECATED 2026-05-19 — NAS에서 카드 렌더 안 함. Windows insta-render 워커로 이전됨.
import asyncio
import hashlib
import json
import logging
import os
import tempfile
from typing import List
기존 render_slate, init_browser, shutdown_browser는 모두 web-ai/services/insta-render/card_renderer.py로 이식.
NAS insta-lab은 Redis push (queue:insta-render)만 담당.
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
이 파일은 임포트 호환성 위해서만 존재. 새 코드는 이 모듈을 import하지 말 것.
"""

View File

@@ -2,6 +2,7 @@ import os
NAVER_CLIENT_ID = os.getenv("NAVER_CLIENT_ID", "")
NAVER_CLIENT_SECRET = os.getenv("NAVER_CLIENT_SECRET", "")
YOUTUBE_DATA_API_KEY = os.getenv("YOUTUBE_DATA_API_KEY", "")
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
ANTHROPIC_MODEL_HAIKU = os.getenv("ANTHROPIC_MODEL_HAIKU", "claude-haiku-4-5-20251001")
ANTHROPIC_MODEL_SONNET = os.getenv("ANTHROPIC_MODEL_SONNET", "claude-sonnet-4-6")
@@ -10,6 +11,7 @@ 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"

View File

@@ -0,0 +1,322 @@
"""사용자 디자인 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()

View File

@@ -0,0 +1,69 @@
"""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}

View File

@@ -14,11 +14,20 @@ 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,
)
from . import db, news_collector, keyword_extractor, card_writer, card_renderer, trend_collector
import redis.asyncio as aioredis
from . import db, news_collector, keyword_extractor, card_writer, trend_collector
from .internal_router import router as internal_router
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,
@@ -30,11 +39,16 @@ app.add_middleware(
@app.on_event("startup")
def on_startup():
async 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}
@@ -145,12 +159,20 @@ 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)
db.update_task(task_id, "succeeded", 100, "완료", result_id=sid)
# 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)
except Exception as e:
logger.exception("create slate failed")
db.update_task(task_id, "failed", 0, "", error=str(e))
@@ -184,13 +206,20 @@ def get_slate(slate_id: int):
async def _bg_render(task_id: str, slate_id: int):
"""Redis 큐에 push. 실 렌더는 Windows insta-render worker."""
try:
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)
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 대기 중")
except Exception as e:
logger.exception("render failed")
logger.exception("queue push failed")
db.update_task(task_id, "failed", 0, "", error=str(e))
@@ -265,7 +294,7 @@ async def _bg_collect_trends(task_id: str, categories: list[str]):
try:
db.update_task(task_id, "processing", 10, "외부 트렌드 수집 중")
result = trend_collector.collect_all(categories)
msg = f"naver:{result['naver_popular']}, google:{result['google_trends']}"
msg = f"naver:{result['naver_popular']}, youtube:{result['youtube_trending']}"
db.update_task(task_id, "succeeded", 100, msg, result_id=sum(result.values()))
except Exception as e:
logger.exception("trends collect failed")

View File

@@ -0,0 +1,788 @@
<!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>

View File

@@ -0,0 +1,12 @@
{
"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
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1010 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -1,6 +1,10 @@
"""외부 트렌드 수집 — NAVER 인기 + Google Trends + LLM 카테고리 분류.
"""외부 트렌드 수집 — NAVER 인기 + YouTube 인기 영상 + LLM 카테고리 분류.
Phase B Task 3: Google Trends integration via pytrends + Anthropic Haiku 분류 캐시 (24h TTL).
NAVER: 카테고리별 시드 키워드로 인기 검색 → 빈도 상위 추출.
YouTube: Google Trends 비공식 endpoint(RSS / dailytrends JSON)가 모두 404 폐기되어
대체로 YouTube Data API v3 (`videos.list?chart=mostPopular&regionCode=KR`) 사용.
무료 일일 quota 10000, 한국 region 지원, 인기 영상 50개 제목에서 트렌드 추출.
LLM 분류 결과는 24h in-memory 캐시.
"""
import json
@@ -11,11 +15,10 @@ from typing import Any, Dict, List, Optional
import requests
from anthropic import Anthropic
from pytrends.request import TrendReq
from .config import (
NAVER_CLIENT_ID, NAVER_CLIENT_SECRET, DEFAULT_CATEGORY_SEEDS,
ANTHROPIC_API_KEY, ANTHROPIC_MODEL_HAIKU,
ANTHROPIC_API_KEY, ANTHROPIC_MODEL_HAIKU, YOUTUBE_DATA_API_KEY,
)
from . import db
from .news_collector import _clean
@@ -29,16 +32,46 @@ _NAVER_HEADERS = {
"X-Naver-Client-Secret": NAVER_CLIENT_SECRET,
}
YOUTUBE_TRENDING_URL = "https://www.googleapis.com/youtube/v3/videos"
# YouTube 제목 정제: 대괄호·이모지·과도한 길이 제거 후 카드 주제로 적합한 키워드 형태
_TITLE_BRACKET_RE = re.compile(r"[\[【「『\(][^\]】」』\)]{0,30}[\]】」』\)]")
_EMOJI_RE = re.compile(
r"["
r"\U0001F300-\U0001FAFF" # symbols & pictographs, etc.
r"\U00002600-\U000027BF" # misc symbols, dingbats
r"\U0001F1E6-\U0001F1FF" # regional indicator
r"]"
)
_TITLE_MAX_LEN = 60
_PLACEHOLDER_SEEDS = {"...", "", "tbd", "todo", "placeholder", "example"}
def _is_valid_seed(s: str) -> bool:
"""프롬프트 템플릿에 placeholder/빈 값이 들어가 NAVER에 400을 유발하는 일을 막는 가드."""
if not s:
return False
s = s.strip()
if len(s) < 2:
return False
if s.lower() in _PLACEHOLDER_SEEDS:
return False
return True
def _seeds_for(category: str) -> List[str]:
"""category_seeds 프롬프트 템플릿이 있으면 사용, 없거나 모두 invalid면 config DEFAULT 폴백."""
pt = db.get_prompt_template("category_seeds")
if pt and pt.get("template"):
try:
data = json.loads(pt["template"])
if category in data:
return list(data[category])
except Exception:
pass
filtered = [s for s in (data[category] or []) if _is_valid_seed(s)]
if filtered:
return filtered
logger.warning("category_seeds[%s]에 유효한 시드 없음 → DEFAULT 폴백", category)
except Exception as e:
logger.warning("category_seeds JSON 파싱 실패 → DEFAULT 폴백: %s", e)
return list(DEFAULT_CATEGORY_SEEDS.get(category, []))
@@ -142,36 +175,70 @@ def classify_keyword(keyword: str) -> str:
return cat
# ── Google Trends ─────────────────────────────────────────────────────────────
# ── YouTube Trending ──────────────────────────────────────────────────────────
# YouTube Data API v3 videos.list?chart=mostPopular&regionCode=KR
# 한국 인기 영상 50개 제목에서 카드 주제로 적합한 키워드 추출.
def fetch_google_trends() -> List[Dict[str, Any]]:
"""pytrends 한국 daily trending searches. 실패 시 빈 리스트."""
def _clean_yt_title(title: str) -> str:
"""[공식]·【속보】·🔥 등 제거 후 60자 이내로 자른다."""
if not title:
return ""
cleaned = _TITLE_BRACKET_RE.sub("", title)
cleaned = _EMOJI_RE.sub("", cleaned)
cleaned = re.sub(r"\s+", " ", cleaned).strip()
return cleaned[:_TITLE_MAX_LEN]
def fetch_youtube_trending() -> List[Dict[str, Any]]:
"""YouTube Data API v3 mostPopular (한국, 50개). API 키 없거나 호출 실패 시 빈 리스트."""
if not YOUTUBE_DATA_API_KEY:
logger.info("YOUTUBE_DATA_API_KEY 미설정 — youtube_trending skip")
return []
try:
pytrends = TrendReq(hl="ko-KR", tz=540)
df = pytrends.trending_searches(pn="south_korea")
resp = requests.get(
YOUTUBE_TRENDING_URL,
params={
"part": "snippet",
"chart": "mostPopular",
"regionCode": "KR",
"maxResults": 50,
"key": YOUTUBE_DATA_API_KEY,
},
timeout=15,
)
resp.raise_for_status()
videos = resp.json().get("items", []) or []
except Exception as e:
logger.warning("Google Trends fetch failed: %s", e)
logger.warning("YouTube trending fetch failed: %s", e)
return []
items: List[Dict[str, Any]] = []
for idx, row in df.iterrows():
kw = str(row.iloc[0]).strip()
if not kw:
seen = set()
total = max(1, len(videos))
for idx, v in enumerate(videos):
title = (v.get("snippet") or {}).get("title", "")
kw = _clean_yt_title(title)
if not kw or kw in seen:
continue
cat = classify_keyword(kw)
rank_score = round(max(0.0, 1.0 - (idx / max(1, len(df)))), 4)
seen.add(kw)
try:
cat = classify_keyword(kw)
except Exception as e:
logger.warning("classify_keyword(%s) 실패: %s", kw, e)
cat = "uncategorized"
rank_score = round(max(0.0, 1.0 - (idx / total)), 4)
items.append({
"keyword": kw,
"category": cat,
"source": "google_trends",
"source": "youtube_trending",
"score": rank_score,
"articles_count": 0,
})
return items
def collect_google_trends() -> int:
items = fetch_google_trends()
def collect_youtube_trending() -> int:
items = fetch_youtube_trending()
for it in items:
db.add_external_trend(it)
return len(items)
@@ -179,5 +246,5 @@ def collect_google_trends() -> int:
def collect_all(categories: List[str]) -> Dict[str, int]:
naver_n = collect_naver_popular_for(categories)
google_n = collect_google_trends()
return {"naver_popular": naver_n, "google_trends": google_n}
yt_n = collect_youtube_trending()
return {"naver_popular": naver_n, "youtube_trending": yt_n}

View File

@@ -4,7 +4,7 @@ requests==2.32.3
httpx>=0.27
anthropic==0.52.0
jinja2>=3.1.4
playwright==1.48.0
Pillow>=10
pytest>=8.0
pytest-asyncio>=0.24
pytrends>=4.9
redis>=5.0

View File

@@ -0,0 +1,25 @@
"""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

View File

@@ -1,48 +0,0 @@
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))

View File

@@ -0,0 +1,176 @@
"""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")

View File

@@ -0,0 +1,80 @@
"""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 "")

View File

@@ -58,7 +58,11 @@ def test_keywords_listing(client):
def test_create_slate_kicks_background_task(client, monkeypatch):
from app import main, card_writer, card_renderer
"""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
def fake_write(keyword, category, articles=None):
return db_module.add_card_slate({
@@ -68,24 +72,25 @@ def test_create_slate_kicks_background_task(client, monkeypatch):
"cta_copy": {"headline": "C", "body": "B", "cta": "F"},
})
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)]
async def fake_rpush(queue, payload):
pass # Redis 없이도 테스트 통과
monkeypatch.setattr(card_writer, "write_slate", fake_write)
monkeypatch.setattr(card_renderer, "render_slate", fake_render)
monkeypatch.setattr(main.redis_client, "rpush", fake_rpush)
resp = client.post("/api/insta/slates", json={"keyword": "K", "category": "economy"})
assert resp.status_code == 200
task_id = resp.json()["task_id"]
# poll task
# 잠시 대기 후 폴링 — background task가 완료될 때까지
import time
for _ in range(20):
st = client.get(f"/api/insta/tasks/{task_id}").json()
if st["status"] in ("succeeded", "failed"):
if st["status"] != "pending":
break
assert st["status"] == "succeeded"
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에 기록됨
slate_id = st["result_id"]
detail = client.get(f"/api/insta/slates/{slate_id}").json()
assert detail["status"] == "rendered"
assert len(detail["assets"]) == 10
assert detail["keyword"] == "K"

View File

@@ -59,7 +59,7 @@ def test_collect_trends_kicks_background(client, monkeypatch):
def fake_collect_all(cats):
captured["called"] = True
return {"naver_popular": 3, "google_trends": 2}
return {"naver_popular": 3, "youtube_trending": 2}
monkeypatch.setattr(trend_collector, "collect_all", fake_collect_all)
resp = client.post("/api/insta/trends/collect", json={})

View File

@@ -77,45 +77,84 @@ def test_classify_keyword_with_cache(monkeypatch):
assert calls["n"] == 1
def test_fetch_google_trends_parses_and_classifies(tmp_db, monkeypatch):
class FakePyTrends:
def __init__(self, *_a, **_kw):
pass
def test_fetch_youtube_trending_parses_and_cleans_titles(tmp_db, monkeypatch):
"""YouTube Data API mostPopular 응답 → 제목 정제 + 분류."""
monkeypatch.setattr(trend_collector, "YOUTUBE_DATA_API_KEY", "fake_key")
payload = {
"items": [
{"snippet": {"title": "[속보] 기준금리 인상 단행 🔥"}},
{"snippet": {"title": "(공식) BTS 컴백 무대 🎤"}},
{"snippet": {"title": "스트레스 관리 5가지 방법"}},
# 중복 제목 — 중복 제거 확인
{"snippet": {"title": "[속보] 기준금리 인상 단행 🔥"}},
]
}
fake_resp = MagicMock()
fake_resp.json.return_value = payload
fake_resp.raise_for_status.return_value = None
monkeypatch.setattr(trend_collector.requests, "get", lambda *a, **kw: fake_resp)
monkeypatch.setattr(
trend_collector, "classify_keyword",
lambda kw: ("economy" if "금리" in kw else
"celebrity" if "BTS" in kw else
"psychology" if "스트레스" in kw else "uncategorized"),
)
def trending_searches(self, pn="south_korea"):
import pandas as pd
return pd.DataFrame({"0": ["기준금리", "BTS 컴백", "스트레스 관리"]})
trends = trend_collector.fetch_youtube_trending()
keywords = [t["keyword"] for t in trends]
assert "기준금리 인상 단행" in keywords # 대괄호·이모지 제거
assert "BTS 컴백 무대" in keywords # 괄호 제거
assert "스트레스 관리 5가지 방법" in keywords # 그대로
assert len(trends) == 3 # 중복 제거됨
assert all(t["source"] == "youtube_trending" for t in trends)
monkeypatch.setattr(trend_collector, "TrendReq", FakePyTrends)
monkeypatch.setattr(trend_collector, "classify_keyword",
lambda kw: {"기준금리": "economy", "BTS 컴백": "celebrity",
"스트레스 관리": "psychology"}.get(kw, "uncategorized"))
trends = trend_collector.fetch_google_trends()
by_kw = {t["keyword"]: t for t in trends}
assert by_kw["기준금리"]["category"] == "economy"
assert by_kw["BTS 컴백"]["category"] == "celebrity"
assert by_kw["스트레스 관리"]["category"] == "psychology"
assert all(t["source"] == "google_trends" for t in trends)
def test_fetch_youtube_trending_no_api_key_returns_empty(monkeypatch):
monkeypatch.setattr(trend_collector, "YOUTUBE_DATA_API_KEY", "")
out = trend_collector.fetch_youtube_trending()
assert out == []
def test_fetch_youtube_trending_graceful_on_api_failure(monkeypatch):
monkeypatch.setattr(trend_collector, "YOUTUBE_DATA_API_KEY", "fake_key")
fake_resp = MagicMock()
fake_resp.raise_for_status.side_effect = RuntimeError("quota exceeded")
monkeypatch.setattr(trend_collector.requests, "get", lambda *a, **kw: fake_resp)
out = trend_collector.fetch_youtube_trending()
assert out == []
def test_collect_all_invokes_both_sources(tmp_db, monkeypatch):
monkeypatch.setattr(trend_collector, "collect_naver_popular_for",
lambda cats: 5)
monkeypatch.setattr(trend_collector, "collect_google_trends",
monkeypatch.setattr(trend_collector, "collect_youtube_trending",
lambda: 3)
out = trend_collector.collect_all(["economy"])
assert out == {"naver_popular": 5, "google_trends": 3}
assert out == {"naver_popular": 5, "youtube_trending": 3}
def test_fetch_google_trends_graceful_on_pytrends_failure(monkeypatch):
class FakePyTrends:
def __init__(self, *_a, **_kw):
pass
def test_seeds_for_filters_placeholder(tmp_db, monkeypatch):
"""category_seeds 템플릿에 placeholder '...'가 들어가도 DEFAULT 폴백."""
from app import db as db_module
db_module.upsert_prompt_template(
"category_seeds",
'{"economy": ["...", "", "a", "real_keyword"]}',
"test",
)
out = trend_collector._seeds_for("economy")
# '...', '…', 'a'(2자 미만)는 필터링되고 'real_keyword'만 남음
assert out == ["real_keyword"]
def trending_searches(self, pn="south_korea"):
raise RuntimeError("rate limited")
monkeypatch.setattr(trend_collector, "TrendReq", FakePyTrends)
out = trend_collector.fetch_google_trends()
assert out == []
def test_seeds_for_falls_back_when_all_invalid(tmp_db, monkeypatch):
"""모든 시드가 invalid면 DEFAULT_CATEGORY_SEEDS 폴백."""
from app import db as db_module
db_module.upsert_prompt_template(
"category_seeds",
'{"economy": ["...", "TBD", ""]}',
"test",
)
out = trend_collector._seeds_for("economy")
# DEFAULT_CATEGORY_SEEDS["economy"] 가 반환되어야 함
from app.config import DEFAULT_CATEGORY_SEEDS
assert out == list(DEFAULT_CATEGORY_SEEDS["economy"])

View File

@@ -15,7 +15,7 @@ ENV PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
ARG APP_VERSION=dev
ENV APP_VERSION=$APP_VERSION

View File

@@ -83,7 +83,8 @@ def on_startup():
def _run_simulation_job():
run_simulation(n_candidates=20000, top_k=100, best_n=20)
scheduler.add_job(_run_simulation_job, "cron", hour="0,4,8,12,16,20", minute=5)
# 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)
# 3. 토요일 오전 9시 — 다음 회차 공략 리포트 자동 캐싱
def _save_weekly_report_job():

View File

@@ -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"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

View File

@@ -15,4 +15,4 @@ ENV PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

View File

@@ -133,8 +133,12 @@ async def sign_link(
# 경로 안전: PACK_HOST_DIR(NAS 호스트 절대경로) 하위인지 확인.
# file_path는 upload 라우트가 Supabase에 저장한 호스트경로 그대로 전달되어 DSM API에 사용됨.
# str.startswith는 '/foo/packs' 와 '/foo/packs_evil' 같은 sibling 경로를 통과시키므로
# Path.relative_to로 엄격하게 컴포넌트 단위 검증한다 (CODE_REVIEW F1).
abs_path = Path(payload.file_path).resolve()
if not str(abs_path).startswith(str(PACK_HOST_DIR)):
try:
abs_path.relative_to(PACK_HOST_DIR.resolve())
except ValueError:
raise HTTPException(status_code=400, detail="허용된 경로 외부")
try:

View File

@@ -60,6 +60,29 @@ def test_sign_link_path_outside_base():
assert r.status_code == 400
def test_sign_link_rejects_sibling_path():
"""PACK_HOST_DIR='/foo/packs' 일 때 '/foo/packs_evil/x.mp4' 같이 prefix만
통과하는 sibling 경로는 거부해야 한다 (CODE_REVIEW F1, path traversal 변형).
기존 str.startswith 방식은 trailing slash가 없어 sibling 경로를 통과시킴.
relative_to 기반 검증으로 교체되어야 통과한다.
"""
import json as _json
from pathlib import Path
base_resolved = Path("/foo/packs").resolve()
# base의 자식이 아닌 sibling 경로 (예: /foo/packs_evil/...)
sibling_posix = (base_resolved.parent / f"{base_resolved.name}_evil" / "x.mp4").as_posix()
with patch("app.routes.PACK_HOST_DIR", base_resolved):
body = _json.dumps(
{"file_path": sibling_posix, "expires_in_seconds": 14400}
).encode()
r = client.post("/api/packs/sign-link", content=body, headers=_signed(body))
assert r.status_code == 400, (
f"sibling 경로 '{sibling_posix}'가 허용됨 (status={r.status_code}) "
f"— path traversal 가능성"
)
def test_upload_invalid_token():
r = client.post(
"/api/packs/upload",

View File

@@ -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"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

View File

@@ -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"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

View File

@@ -1,6 +1,7 @@
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
@@ -26,10 +27,19 @@ scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
def scheduled_collect():
"""매일 09:00 — 수집 + 정리 + 매칭 + 알림 push"""
"""매일 09:15 — 수집 + 정리 (병렬) → 매칭 알림 push.
collect_all과 delete_old_completed_announcements는 서로 다른 데이터
영역을 건드리므로 thread 둘로 병렬화. 매칭은 두 작업 완료 후 순차
실행 (DB 일관성). CHECK_POINT 중기-8 — env이 BackgroundScheduler+
동기 함수 조합이라 asyncio.gather 대신 ThreadPoolExecutor 사용.
"""
logger.info("스케줄 수집 시작")
collect_all()
deleted = delete_old_completed_announcements(grace_days=90)
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()
if deleted:
logger.info("정리: %d건 삭제", deleted)
run_matching()
@@ -48,7 +58,8 @@ def scheduled_status_update():
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
scheduler.add_job(scheduled_collect, "cron", hour=9, minute=0, id="collect")
# 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_status_update, "cron", hour=0, minute=0, id="status_update")
scheduler.start()
logger.info("realestate-lab 시작")

View File

@@ -1,6 +1,14 @@
#!/bin/bash
set -euo pipefail
# ── docker / compose / buildkit timeout 늘리기 ──
# NAS Celeron J4025에서 pip install·chromium 다운로드 등 무거운 RUN step이
# 기본 timeout(2분)에 걸려 webhook 자동 배포가 "DeadlineExceeded"로 끝나는 일이
# 있어 10분으로 상향. 호스트 셸 + deployer 컨테이너 둘 다에 적용됨.
export COMPOSE_HTTP_TIMEOUT=600
export DOCKER_CLIENT_TIMEOUT=600
export BUILDKIT_STEP_LOG_MAX_SIZE=-1
# ── 동시 배포 방지 (flock) ──
exec 200>/tmp/deploy.lock
flock -n 200 || { echo "Deploy already running, skipping"; exit 0; }
@@ -10,8 +18,10 @@ 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"
HEALTH_ENDPOINTS="lotto stock travel-proxy music-lab insta-lab realestate-lab agent-office personal packs-lab redis"
# data 디렉토리 (packs-lab은 별도 media/packs 사용)
DATA_DIRS="music stock insta realestate agent-office personal"
@@ -95,6 +105,9 @@ 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를 직접 조회. 이 방식은

View File

@@ -142,6 +142,7 @@ KB증권·삼성증권 등 Open API 미제공 증권사용.
"name": "삼성전자",
"quantity": 100,
"avg_price": 72000,
"purchase_price": 72000,
"current_price": 74500,
"price_session": "NXT_AFTER",
"price_as_of": "2026-05-11T19:21:40+09:00",
@@ -159,6 +160,10 @@ KB증권·삼성증권 등 Open API 미제공 증권사용.
}
```
> **`purchase_price` 필드**: 종목별 매입 단가(1주당). 사용자가 수동 등록한 매입가가
> 평균단가(`avg_price`)와 다를 때 표시용으로 분리한다. 미설정 시 `avg_price`로 폴백.
> `summary.total_buy = SUM(purchase_price × quantity)` (CODE_REVIEW F4에서 명세 정합화).
> **주의**: 현재가 조회에 실패한 종목은 `current_price`, `eval_amount`, `profit_amount`, `profit_rate` 가 `null`로 반환됩니다.
> 프론트에서 `null` 체크 후 `"조회 실패"` 등으로 표시해 주세요.

View File

@@ -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"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

15
stock/app/conftest.py Normal file
View File

@@ -0,0 +1,15 @@
"""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

View File

@@ -25,6 +25,7 @@ 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()
@@ -47,13 +48,30 @@ scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
# Windows AI Server URL (NAS .env에서 설정)
WINDOWS_AI_SERVER_URL = os.getenv("WINDOWS_AI_SERVER_URL", "http://192.168.0.5:8000")
# Admin API Key 인증
# Admin API Key 인증 — /api/trade/* 보호 (CODE_REVIEW F2)
# 빈 키 + 명시적 dev flag 없으면 503으로 거부. 운영 .env에 ADMIN_API_KEY 누락 시
# 무인증 통과되던 버그 차단.
ADMIN_API_KEY = os.getenv("ADMIN_API_KEY", "")
def verify_admin(x_admin_key: str = Header(None)):
"""admin/trade 엔드포인트 보호용 API 키 검증"""
"""admin/trade 엔드포인트 보호용 API 키 검증.
- ADMIN_API_KEY 설정됨 + 키 일치 → 통과
- ADMIN_API_KEY 설정됨 + 키 불일치 → 401 Unauthorized
- ADMIN_API_KEY 미설정 + ALLOW_UNAUTHENTICATED_ADMIN=true → 통과 (개발 모드)
- ADMIN_API_KEY 미설정 + dev flag 없음 → 503 (보호 강화, 운영 .env 누락 차단)
"""
if not ADMIN_API_KEY:
return # 키 미설정 시 인증 비활성화 (개발 환경)
if os.getenv("ALLOW_UNAUTHENTICATED_ADMIN", "false").lower() == "true":
return # 개발 환경 명시적 허용
raise HTTPException(
status_code=503,
detail=(
"admin endpoint protected — ADMIN_API_KEY not configured. "
"Set ADMIN_API_KEY in .env, or set ALLOW_UNAUTHENTICATED_ADMIN=true "
"for development only."
),
)
if x_admin_key != ADMIN_API_KEY:
raise HTTPException(status_code=401, detail="Unauthorized")
@@ -337,11 +355,11 @@ def get_portfolio():
price_session = detail["session"] if detail else None
price_as_of = detail["as_of"] if detail else None
# avg_price: 평균단가 — 손익(평가금액 - 매입원가) 계산 기준
# purchase_price: 매입가 — 총 매입 금액 표시 기준 (없으면 avg_price로 폴백)
# purchase_price: 매입 단가(1주당) — 없으면 avg_price로 폴백 (CODE_REVIEW F4)
purchase_price = item.get("purchase_price") if item.get("purchase_price") is not None else item["avg_price"]
cost_basis = item["avg_price"] * item["quantity"]
# 총 매입 금액 표시는 종목별 매입가의 단순 합계 (수량 미곱산)
buy_amount = purchase_price
# 총 매입 금액 = 단가 × 보유 수량. API_SPEC.md 예시(qty 100·avg 72000 → 7,200,000)와 일치
buy_amount = purchase_price * item["quantity"]
eval_amount = current_price * item["quantity"] if current_price is not None else None
profit_amount = (eval_amount - cost_basis) if eval_amount is not None else None
profit_rate = round((profit_amount / cost_basis) * 100, 2) if (profit_amount is not None and cost_basis) else None
@@ -401,8 +419,16 @@ 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 비율 필드 추가)."""
return _augment_portfolio_with_pnl_pct(get_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
def _fetch_news_sentiment_dump(date: str | None) -> dict:
@@ -449,8 +475,16 @@ 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."""
return _fetch_news_sentiment_dump(date)
"""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
@app.post("/api/portfolio", status_code=201)

View File

@@ -12,6 +12,7 @@ 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")
@@ -173,6 +174,12 @@ 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)
@@ -231,7 +238,7 @@ def post_run(body: schemas.RunRequest):
top_n=top_n, rows=result.rows, run_id=run_id,
)
return schemas.RunResponse(
response = 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,
@@ -239,6 +246,10 @@ 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 ----------

View File

@@ -0,0 +1,67 @@
"""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

68
stock/app/webai_cache.py Normal file
View File

@@ -0,0 +1,68 @@
"""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

3
stock/pytest.ini Normal file
View File

@@ -0,0 +1,3 @@
[pytest]
pythonpath = .
asyncio_mode = auto

View File

@@ -11,4 +11,5 @@ finance-datareader==0.9.110
lxml==6.1.0
pytest==8.3.2
pytest-asyncio==0.24.0
cachetools>=5.3

View File

@@ -0,0 +1,43 @@
"""verify_admin 보안 강화 회귀 테스트 (CODE_REVIEW F2).
운영 .env에서 ADMIN_API_KEY가 누락되면 /api/trade/balance, /api/trade/order
인증이 무력화되는 버그를 막기 위한 가드.
"""
import os
from unittest.mock import patch
import pytest
from fastapi import HTTPException
from app import main as stock_main
def test_verify_admin_rejects_when_key_missing_and_no_dev_flag(monkeypatch):
"""ADMIN_API_KEY 미설정 + ALLOW_UNAUTHENTICATED_ADMIN 미설정 → 503."""
monkeypatch.setattr(stock_main, "ADMIN_API_KEY", "")
monkeypatch.delenv("ALLOW_UNAUTHENTICATED_ADMIN", raising=False)
with pytest.raises(HTTPException) as exc_info:
stock_main.verify_admin(x_admin_key=None)
assert exc_info.value.status_code == 503
assert "ADMIN_API_KEY" in exc_info.value.detail
def test_verify_admin_allows_when_key_missing_with_dev_flag(monkeypatch):
"""ADMIN_API_KEY 미설정 + ALLOW_UNAUTHENTICATED_ADMIN=true → 통과 (개발 모드)."""
monkeypatch.setattr(stock_main, "ADMIN_API_KEY", "")
monkeypatch.setenv("ALLOW_UNAUTHENTICATED_ADMIN", "true")
stock_main.verify_admin(x_admin_key=None) # 예외 없으면 통과
def test_verify_admin_rejects_wrong_key(monkeypatch):
"""ADMIN_API_KEY 설정 + 잘못된 키 → 401 (regression)."""
monkeypatch.setattr(stock_main, "ADMIN_API_KEY", "secret123")
with pytest.raises(HTTPException) as exc_info:
stock_main.verify_admin(x_admin_key="wrong")
assert exc_info.value.status_code == 401
def test_verify_admin_allows_correct_key(monkeypatch):
"""ADMIN_API_KEY 설정 + 올바른 키 → 통과 (regression)."""
monkeypatch.setattr(stock_main, "ADMIN_API_KEY", "secret123")
stock_main.verify_admin(x_admin_key="secret123") # 예외 없으면 통과

View File

@@ -0,0 +1,77 @@
"""포트폴리오 /api/portfolio 응답의 total_buy 계산 회귀 테스트 (CODE_REVIEW F4).
purchase_price는 종목별 단가(1주당) 의미. total_buy = SUM(purchase_price × quantity).
purchase_price가 없으면 avg_price로 폴백 후 동일하게 수량 곱산.
"""
from unittest.mock import patch
from fastapi.testclient import TestClient
from app.main import app
def _fake_db_setup(monkeypatch, items, cash=None):
from app import main as stock_main
monkeypatch.setattr(stock_main, "get_all_portfolio", lambda: items)
monkeypatch.setattr(stock_main, "get_all_broker_cash", lambda: cash or [])
def test_portfolio_total_buy_uses_purchase_price_times_quantity(monkeypatch):
"""purchase_price 설정 시: total_buy = purchase_price × quantity 의 합."""
items = [
{"id": 1, "broker": "KB", "ticker": "005930", "name": "삼성전자",
"quantity": 100, "avg_price": 72000, "purchase_price": 70000},
]
fake_prices = {"005930": {"price": 74500, "session": "REGULAR", "as_of": "2026-05-17T10:00:00+09:00"}}
_fake_db_setup(monkeypatch, items)
from app import main as stock_main
monkeypatch.setattr(stock_main, "get_current_prices_detail", lambda t: fake_prices)
client = TestClient(app)
resp = client.get("/api/portfolio")
assert resp.status_code == 200
data = resp.json()
# purchase_price=70000 × quantity=100 = 7,000,000
assert data["summary"]["total_buy"] == 7_000_000
def test_portfolio_total_buy_falls_back_to_avg_price_with_quantity(monkeypatch):
"""purchase_price 미설정 시: avg_price 폴백 + 수량 곱산. API_SPEC 예시와 일치."""
items = [
{"id": 1, "broker": "KB", "ticker": "005930", "name": "삼성전자",
"quantity": 100, "avg_price": 72000, "purchase_price": None},
]
fake_prices = {"005930": {"price": 74500, "session": "REGULAR", "as_of": "2026-05-17T10:00:00+09:00"}}
_fake_db_setup(monkeypatch, items)
from app import main as stock_main
monkeypatch.setattr(stock_main, "get_current_prices_detail", lambda t: fake_prices)
client = TestClient(app)
resp = client.get("/api/portfolio")
assert resp.status_code == 200
data = resp.json()
# avg_price=72000 × quantity=100 = 7,200,000 (API_SPEC.md 예시와 일치)
assert data["summary"]["total_buy"] == 7_200_000
def test_portfolio_total_buy_sums_multiple_holdings(monkeypatch):
"""여러 종목 합산도 단가 × 수량 합."""
items = [
{"id": 1, "broker": "KB", "ticker": "005930", "name": "삼성전자",
"quantity": 100, "avg_price": 70000, "purchase_price": 70000},
{"id": 2, "broker": "NH", "ticker": "000660", "name": "SK하이닉스",
"quantity": 50, "avg_price": 130000, "purchase_price": 130000},
]
fake_prices = {
"005930": {"price": 74500, "session": "REGULAR", "as_of": "2026-05-17T10:00:00+09:00"},
"000660": {"price": 140000, "session": "REGULAR", "as_of": "2026-05-17T10:00:00+09:00"},
}
_fake_db_setup(monkeypatch, items)
from app import main as stock_main
monkeypatch.setattr(stock_main, "get_current_prices_detail", lambda t: fake_prices)
client = TestClient(app)
resp = client.get("/api/portfolio")
data = resp.json()
# 70000*100 + 130000*50 = 7,000,000 + 6,500,000 = 13,500,000
assert data["summary"]["total_buy"] == 13_500_000

View File

@@ -19,7 +19,7 @@ EXPOSE 8000
ENV PYTHONUNBUFFERED=1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
ARG APP_VERSION=dev
ENV APP_VERSION=$APP_VERSION