425 Commits

Author SHA1 Message Date
ce6c8d8f7d docs(CLAUDE): 카탈로그 슬림화(966→484) + 서비스별 메모리 분담 + stale 수정
포트/nginx/API 엔드포인트 목록·cross-cutting 규칙만 CLAUDE.md에 유지. DB 스키마 세부·스케줄러·env·운영 히스토리는 service_<name>.md 메모리로 이관(§0 규칙 명시).

코드 대조로 발견한 stale 수정: insta 렌더는 Windows 워커(card_renderer.py DEPRECATED), lotto v3 backtest API 추가, music-lab 워커 위임, internal webhook X-Internal-Key 2중, /media video↔videos 구분 등.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 01:48:15 +09:00
0d1b04d322 fix(insta-lab): webhook이 렌더 PNG를 card_assets로 등록 (cutover 누락 복구)
2026-05-19 cutover(렌더를 Windows insta-render 워커로 이관)에서 card_assets 등록 단계가 새 설계에 누락됨. 구 card_renderer.render_slate가 NAS DB에 등록하던 것을, webhook은 task/slate status만 갱신하도록 만들어 card_assets가 영구 빈 상태 → /assets 404, /package 409, get_slate assets=0.

insta_update가 succeeded 시 워커 출력 디렉토리를 스캔해 실제 PNG만 card_assets에 등록(_register_rendered_assets). CARDS_DIR/{id}, INSTA_DATA_PATH/{id} 두 후보를 순서대로 스캔 → 경로 정합 전환기에도 견고. 신규 테스트 2건(등록 성공 / 파일 없으면 미등록).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 01:17:54 +09:00
8b6b251225 merge: 인스타 카드뉴스 품질 고도화 + zip 패키지 (Phase 1-5)
모던 미니멀 디자인 시스템 템플릿 + 카피 글자수 가이드 + zip 패키지 다운로드 API.
(렌더 견고화·템플릿 authoritative는 web-ai repo)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:39:35 +09:00
1efe3d3a48 test(insta-lab): package 404/409 테스트 + 전체파일누락 409 가드
- /package 엔드포인트: asset DB 레코드는 있지만 모든 PNG 파일이
  디스크에 없는 경우 written=0 체크 후 HTTPException(409) 반환
- test_package_unknown_slate_404: 존재하지 않는 slate_id → 404 검증
- test_package_no_assets_409: asset 없는 slate → 409 검증 (기존 guard)
- test_package_no_assets_409: 파일 없는 asset만 있는 경우 → 409 검증 (신규 guard)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:01:39 +09:00
3a9d6e986e feat(insta-lab): 슬레이트 zip 패키지 다운로드 API (10 PNG + caption.txt)
GET /api/insta/slates/{slate_id}/package 엔드포인트 추가.
렌더된 card_assets PNG들 + suggested_caption + hashtags를
단일 zip으로 번들해 StreamingResponse 반환.
hashtags JSON 문자열/리스트 방어 파싱 포함.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:58:32 +09:00
bb0280274e feat(insta-lab): card_writer 프롬프트에 글자수 가이드(오버플로우 예방)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:56:21 +09:00
cd9a73254b polish(insta-lab): 템플릿 동기화 (CSS | safe + cover clamp)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:50:29 +09:00
332525a6f0 feat(insta-lab): default 템플릿 디자인 시스템 동기화(참조용)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:46:23 +09:00
11f591e3d4 docs(plan): 인스타 카드뉴스 고도화 구현 plan (6 Phase, 3 repo, TDD)
Phase 1 디자인시스템 템플릿(web-ai+insta-lab) → 2 렌더 견고화(fonts.ready+
PNG검증) → 3 카피 글자수 가이드 → 4 zip 패키지 API → 5 web-ui 버튼 → 6 검증.
템플릿 sync open-item 해결(web-ai templates/ authoritative).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:20:30 +09:00
8788763b3d docs(spec): 인스타 카드뉴스 품질 고도화 + 업로드 친화 패키지
모던 미니멀 디자인 시스템 템플릿으로 카드 품질 격상 + 렌더 견고화
(fonts.ready 대기·1080x1350 정확·오버플로우 clamp로 known-issue 해결)
+ zip 패키지 다운로드(업로드 친화, 반자동). Graph API 미사용.
2 repo: insta-lab(템플릿/카피/zip/web-ui) + web-ai(렌더 워커).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:50:37 +09:00
b89e92440a merge: nginx CVE-2026-9256 대응 (1.30.2 상향) 2026-06-01 17:35:40 +09:00
5ad0adf719 fix(security): nginx CVE-2026-9256 추가 대응 — 1.30.1 → 1.30.2
CVE-2026-9256(nginx-poolslip, ngx_http_rewrite_module 힙 오버플로우)는
영향 범위가 ~1.31.0으로 넓어 1.30.1은 여전히 취약, stable은 1.30.2+에서 수정.
1.30.2-alpine로 상향해 CVE-2026-42945 + CVE-2026-9256 둘 다 커버.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:35:39 +09:00
d98cd9afbe merge: nginx CVE-2026-42945 패치 버전 고정 2026-06-01 17:33:13 +09:00
4e846a2d5f fix(security): nginx CVE-2026-42945 대응 — 패치 버전 고정
미고정 nginx:alpine → nginx:1.30.1-alpine (NGINX Rift, ngx_http_rewrite_module
힙 오버플로우 CVSS 9.2, 1.30.1/1.31.0에서 수정). 현재 default.conf엔 rewrite
디렉티브가 없어 실 익스플로잇 경로는 미도달이나 defense-in-depth로 패치 stable 고정.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:33:13 +09:00
5d9be51dba merge: 주식 보유종목 인텔리전스 (Phase 1-5)
스크리너 엔진을 보유종목에 restrict + 매도/리스크 룰 + 이슈 감지
(급변·거래량·외인·뉴스감성) + 포트 건강 → 매일 advisory 브리핑.
EOD(16:50)+아침(08:30) cron. KIS 실주문 미사용.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:56:00 +09:00
cd4fb27d5a fix(agent-office): EOD 16:50 stagger(부분일봉 방지)·idle가드 문서화·proxy/import 정리
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:28:12 +09:00
b94b5973d6 feat(agent-office): StockAgent holdings EOD(16:40)+브리핑(08:30) cron
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:22:04 +09:00
f54ade2c0d feat(agent-office): 보유종목 브리핑 텔레그램 포매터
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:21:58 +09:00
2cbc830004 feat(agent-office): stock holdings run/brief 프록시
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:21:54 +09:00
d0c057358a test(stock): Phase 4 회귀 (momentum_loss·멱등·non-KRX 경로)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:18:58 +09:00
7d7064ae93 feat(stock): holdings intel API (intel/history/run)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:12:28 +09:00
789785fe3a feat(stock): compute_and_store + build_holdings_brief
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:11:45 +09:00
c3a3055060 test(stock): Phase 3 커버리지 보강 (volume Z경로·외인매도·severity경계·빈포트)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:09:05 +09:00
3056e8d35f feat(stock): portfolio_health (집중도·현금·손익)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:03:21 +09:00
4ed3794f71 feat(stock): news_issues (감성 기반 악재 flag)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:02:45 +09:00
241c24943f feat(stock): market_events (급변·거래량Z·외인순매도)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:02:10 +09:00
c756b20c77 fix(stock): Phase 2 결정엔진 견고화 (빈노드 제외·cur=0 손절·params기본값·NaN MA·테스트)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:00:02 +09:00
fba6dbf1fd feat(stock): decide_action 매트릭스 (sell>trim>add>hold)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:48:52 +09:00
b13c088739 feat(stock): exit_rules (손절·MA이탈·익절·클라이맥스)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:48:37 +09:00
116b2540c2 feat(stock): technical_posture (스크리너 노드 보유종목 적용)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:48:01 +09:00
62169ad33f refactor(stock): Phase 1 리뷰 반영 (public get_krx_tickers·타입·limit명명·테스트)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:45:19 +09:00
0ef7d414b7 feat(stock): get_holdings (현재가·손익·KRX판별)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:37:01 +09:00
885d52d8f5 feat(stock): holdings_signals 테이블 + CRUD
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:36:27 +09:00
e3088f7cc6 docs(plan): 주식 보유종목 인텔리전스 구현 plan (7 Phase, TDD)
Phase 1 데이터모델+get_holdings → 2 기술분석·매도룰·decide_action →
3 이슈(market_events·news·portfolio_health) → 4 compute+brief+API →
5 agent-office EOD·아침브리핑 → 6 web-ui 탭 → 7 검증. 장중 가드는 후속.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:33:55 +09:00
2996cf16d1 docs(spec): 주식 보유종목 인텔리전스 설계
스크리너 엔진을 보유종목에 restrict 적용 + 신규 매도/리스크 룰 +
이슈 감지(급변·거래량·외인·뉴스 LLM) + 포트 건강 → 매일 advisory 브리핑.
EOD 일봉 + 장중 경량 가드, KIS 실주문 미사용. 기존 screener/snapshot/
news_sentiment/portfolio 재활용, 신규 데이터소스 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:25:21 +09:00
03ee5ce147 merge: 로또 자가학습 백테스트 & 캘리브레이션 (Phase 1-5)
forward 가상구매(6 engine_w + 6 random_null + coverage) + winner 캘리브레이션
+ evolver lift 학습신호(best-vs-best, ε게이팅) + 일요 회고 텔레그램.
null-model 베이스라인으로 무작위 대비 우위를 정직하게 측정.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:04:40 +09:00
11212c4afd fix(agent-office): 일요 회고 견고화 (dead import 제거·send 가드·부분 payload 방어)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:02:01 +09:00
1b8548a73f feat(agent-office): LottoAgent 일 09:00 sunday_review cron
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:53:01 +09:00
c4ba7e81e6 feat(agent-office): 일요 회고 텔레그램 포매터
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:51:34 +09:00
e8270c5a63 feat(agent-office): lotto backtest review/run-forward 프록시
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:51:29 +09:00
4063f29cd3 fix(lotto): 학습 게이트 정직화 (engine-best vs random-best 6trial·명시적 gated·정체성 일관)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:47:52 +09:00
03056a4747 feat(lotto): evaluate_weekly 학습 신호를 forward lift로 승격
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:33:27 +09:00
8e7b4adabd feat(lotto): select_winner_by_lift + ε-게이팅
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:32:37 +09:00
add433233a fix(lotto): Phase 3 리뷰 반영 (run-forward 백그라운드·review 404·track_record distinct·테스트 보강)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:30:10 +09:00
74f385c7bd feat(lotto): 새 회차 동기화 시 forward+calibration 자동 실행
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:20:59 +09:00
3bc4f423db feat(lotto): backtest API 라우터 + main 등록
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:20:32 +09:00
a425bb8809 feat(lotto): track_record + build_review_payload 집계
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:19:05 +09:00
850638ae58 fix(lotto): Phase 2 리뷰 반영 (engine_w 회차주 기준·누출제거·N+1제거·테스트 보강)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:17:09 +09:00
94a94e260c feat(lotto): run_forward_purchase 3전략 구매·채점·저장
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:07:26 +09:00
c196da4902 feat(lotto): calibrate_winner + backfill (멱등·청크)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:06:00 +09:00
aaba4fbc46 feat(lotto): calibrate_winner_compute 당첨조합 역분석+percentile
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:05:06 +09:00
9f897ea4a0 feat(lotto): point_in_time_draws 헬퍼
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:04:35 +09:00
77efa9b653 refactor(lotto): Phase 1 코드리뷰 반영 (로컬 RNG·write-once·가드·테스트 보강)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:02:16 +09:00
8dbb1abaeb feat(lotto): 티켓 생성 3전략 (engine_w/random_null/coverage)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 16:50:28 +09:00
41ad56e3ef feat(lotto): grade_tickets 매칭 채점 + 등수 매핑
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 16:49:49 +09:00
bb0e771a4a feat(lotto): backtest_runs/winner_calibration 테이블 + CRUD
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 16:49:03 +09:00
160fc27279 docs(plan): 로또 자가학습 백테스트 구현 plan (7 Phase, TDD)
Phase 1 데이터모델+구매/채점 → 2 캘리브레이션+forward+백필 →
3 API+스케줄러 → 4 evolver lift 학습신호 → 5 agent-office 일요회고 →
6 web-ui 자율학습 탭 → 7 통합검증. 각 task TDD bite-sized + 멱등.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 16:44:21 +09:00
f3f6cccd33 docs(spec): 로또 자가학습 백테스트 & 캘리브레이션 설계
3종 스마트 에이전트 고도화 중 로또 1번. forward 가상구매(수천 장/회차)
+ winner 캘리브레이션(역대 백필) + 일요 회고 브리핑 + weight_evolver
학습 신호 강화(W-무관 결함 수정). null-model 베이스라인 내장으로
무작위 대비 우위를 정직하게 측정. NAS-first, Windows WSL 이전 가능 설계.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 16:37:25 +09:00
2bfbd1dd93 feat(P3): 5개 서비스 비즈니스 이벤트 logger.info 보강 2026-05-28 22:38:43 +09:00
c5c260aefc feat(agent-office/scheduler): 매일 03:00 agent_logs 90일 retention cleanup 2026-05-28 08:39:33 +09:00
378f5210d4 feat(P2): stock/music-lab/insta-lab/realestate-lab access_log 적용 + AGENT_CONTAINER_MAP 4개 매핑 2026-05-28 08:38:36 +09:00
cfbb3c24b8 fix(lotto): _shared mount target 을 패키지 경로로 변경
마운트 `${RUNTIME_PATH}/_shared:/shared:ro` 가 컨테이너 안에서
`/shared/access_log.py` 만 노출하여 PYTHONPATH=/shared 에서 `from _shared.access_log import ...` 패키지 import 실패 (ModuleNotFoundError: No module named '_shared'). mount target 을 `/shared/_shared` 로 변경하여 `/shared/_shared/__init__.py` 가 패키지로 인식되게 함.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:29:03 +09:00
c7214b8896 fix(deploy): _shared 마운트 NAS 배포 실패 fix
deployer 컨테이너 안에서 docker compose 가 실행될 때 './_shared' 상대
경로가 deployer 내부 path '/runtime/_shared' 로 resolve 되어 host docker
daemon 이 mount source 를 찾지 못하는 문제. 추가로 deploy-nas.sh 의 SERVICES
화이트리스트에 _shared 미등재라 rsync sync 자체에서 빠져 host 에 디렉토리
가 생성되지 않음.

- scripts/deploy-nas.sh: SERVICES 에 _shared 추가
- docker-compose.yml: lotto volume 을 ./_shared → \${RUNTIME_PATH}/_shared 로 변경
- docs/superpowers/plans/...: Phase 2 task 11-14 의 docker-compose 패턴
  동일 적용 (replace_all)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 05:09:17 +09:00
4224333219 refactor(agent-office/base): transition의 State 자동 로그 제거
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 02:47:41 +09:00
5613497367 feat(agent-office): /agents/{id}/logs 엔드포인트가 service /logs/recent 와 merge
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 02:46:44 +09:00
b25abea80a feat(agent-office/db): get_logs에서 State: 자동 로그 제외 + delete_old_logs(90일) 2026-05-28 02:45:10 +09:00
ed30790f22 feat(agent-office): fetch_service_logs 추가 (path_prefix 정규식 필터)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 02:42:23 +09:00
1d723764b4 feat(agent-office): AGENT_CONTAINER_MAP 상수 추가 (Phase 1 lotto) 2026-05-28 02:40:18 +09:00
c0c4422c7c build(lotto): PYTHONPATH=_shared + json-file logging 추가 (Phase 1 PoC) 2026-05-28 02:38:47 +09:00
fe4d3912a5 feat(lotto): _shared/access_log install (Phase 1 PoC) 2026-05-28 02:37:19 +09:00
f461f05ac0 feat(_shared): access_log 공용 모듈 추가 (ring buffer + middleware + /logs/recent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 02:31:30 +09:00
dfd3b1bb17 docs(agent-office): docker logs 통합 타임라인 구현 계획
21개 task로 분해된 3-phase 실행 계획. Phase 1 (PoC, lotto 단일),
Phase 2 (4개 서비스 확장 + cleanup 스케줄러), Phase 3 (비즈니스 이벤트
보강). TDD 기반 task 구성, 모든 step 에 실제 코드/명령 포함.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 01:44:43 +09:00
809eec9b15 docs(agent-office): docker logs 통합 타임라인 설계 spec
agent-office LogTab에 각 서비스의 docker 로그(액세스 + 비즈니스 이벤트)를
통합 타임라인으로 노출하는 아키텍처를 정의. 5개 서비스 공용 _shared/access_log
모듈, ring buffer 기반 /logs/recent 엔드포인트, agent-office 측 merge 로직,
3단계 phase 분리 포함.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 01:23:21 +09:00
512ed59dcd fix(nginx): /api/tarot/ proxy_read/send_timeout 300s→600s
3-card Claude 해석 응답이 truncation reroll 발생 시 90초+ 걸려 DSM Reverse
Proxy 또는 docker nginx에서 504 가능성. 600s 안전 마진 (saju/music과 동일
수준).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:19:24 +09:00
4ee4a1ae7d docs(plan): 호령 사주 UI v2 리디자인 — Phase 1~6 implementation plan
spec(commit fd40777)을 30+ task / 130+ step으로 분해. Phase 1: shell+토큰+공통
컴포넌트, Phase 2~5: home/saju/today/match 라우트 교체, Phase 6: v1 cleanup +
QA. 각 task는 test/run/implement/verify/commit 5-step 또는 시각 검증 step 포함.
self-review 통과 (spec 12절 모두 cover, placeholder/type inconsistency 없음).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 23:35:41 +09:00
fd40777177 docs(spec): 호령 사주 UI v2 리디자인 — 디자인 시스템 + 4 라우트 동시 교체
백호 사주도사 프로토타입(JSX 11파일 + styles.css)을 web-ui로 옮기는 작업의 spec.
주요 결정: (1) 사주 4 라우트 동시 리디자인 + /saju/me placeholder 신설 (2)
useViewportMode 1024px 분기로 모바일/데스크탑 컴포넌트 분리 (3) BottomNav 5항목
+ DesktopHeader 도입 (4) v1 components 12개 + SajuNav + Saju.css 전체 교체.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 23:24:22 +09:00
be9165efd2 fix(tarot-lab): max_tokens 1400→2800 + stop_reason 검사로 응답 truncation 처리
3-card spread 해석 응답이 1400 토큰 한계에서 잘려 JSON "Unterminated string" 파싱 실패가 reroll 2회 모두 발생하던 버그 수정.

- max_tokens 1400 → 2800 (saju-lab 2400 기준 + interactions 마진)
- stop_reason == "max_tokens" 검사 → 신규 TarotTruncated 예외로 truncation 명시화
- reroll feedback에 "각 카드 1~2문장으로 축약" 안내 추가 → 모델이 다음 응답 길이 조절
- truncation 시나리오 테스트 2개 추가 (1차 잘림→성공, 2회 모두 잘림→TarotError)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 22:55:28 +09:00
99dca8df64 fix(nginx): /api/image/ public gateway + /media/image/ 정적 서빙 추가
VideoStudio T5에서 누락된 image-lab의 외부 진입점. /api/internal/image/
(worker webhook 차단)만 있고 public /api/image/ 라우팅이 빠져 있어
catch-all /api/로 빠지면서 외부 호출이 잘못된 lab으로 가서 404.
video-lab 패턴(line 83)을 그대로 따라 image-lab gateway + image-render
결과 SMB 파일 정적 서빙 두 블록 추가.

memory: reference_nas_url_routing.md — 신규 lab 추가 시 nginx 7번째 등재 위치 영구 기록

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 22:24:10 +09:00
03e1dc1dbb feat(saju-lab): /interpret 응답에 fortune_scores + lucky + monthly_flow 포함 2026-05-26 08:08:14 +09:00
f57c790437 feat(saju-lab): db.py — saju_records 3 컬럼 추가 (fortune_scores/lucky/monthly_flow) + 4 마이그레이션 테스트 2026-05-26 08:05:41 +09:00
030367da6c feat(saju-lab): monthly_flow.py — 12개월 운세 흐름 (4 tests)
월간(月干)과 월지(月支)의 일간 관계를 이용한 12개월 운세 점수 계산:
- 월간 상생(生) 관계: +5~10점
- 월간 상극(剋) 관계: -8점
- 월지 육합(六合) 관계: +10점
- 월지 육충(六衝) 관계: -12점
- 월지 상생/상극: ±4점

점수 범위 0~100, 5단계 레이블 (정체/도전/변동/안정/성장)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 08:02:35 +09:00
429e3448e5 feat(saju-lab): lucky.py — 럭키 컬러/숫자/방향 + 행운/위험 알림 (6 tests) 2026-05-26 08:00:37 +09:00
579e7387be feat(saju-lab): fortune_scores.py — 4 카테고리 점수 + overall (6 tests) 2026-05-26 07:58:02 +09:00
8ef0ba81f2 docs(plan): saju-lab UI v1 — 호령 사주 페이지 구현 plan
- Phase A 백엔드 확장 (Task 1-5): fortune_scores + lucky + monthly_flow + DB 마이그레이션 + 응답 확장
- Phase B 캐릭터 자산 (Task 6): horyung.png + saju_color_sheet.png에서 6 PNG 추출 (PIL)
- Phase C 프론트 구축 (Task 7-16): CSS 격리 + 컴포넌트 11개 + 3 페이지 + e2e 검증
- TDD + 빈번한 commit + 시안 1:1 매칭 목표

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 07:54:13 +09:00
afb4175bd5 docs(spec): saju-lab UI v1 — 호령 사주 페이지 설계
- 시안 4종(메인/오늘운세/궁합/사주풀이) + 호령 캐릭터 시트 + 컬러시트 기반
- v1 범위: 메인 + 사주풀이 + 오늘운세 (궁합은 v2 placeholder)
- 백엔드 확장: fortune_scores + lucky + monthly_flow 산출
- 입력 흐름: reading_id URL 공유 + useSajuReading 캐시
- 데스크탑 우선 + 태블릿 반응형
- CSS .saju-page scope로 격리 + Pretendard + Noto Serif KR 폰트

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 02:51:34 +09:00
af836df1ac feat(healthcheck): tarot-lab(18250) + saju-lab(18300) 헬스체크 추가
deploy.sh/deploy-nas.sh/docker-compose.yml/nginx의 5위치는 이미 동기화됐으나
scripts/healthcheck.sh가 누락되어 있어 추가.

- 7. Tarot Lab: /health + /api/tarot/readings
- 8. Saju Lab: /health + /api/saju/readings
- 10. Frontend (nginx): /api/tarot/, /api/saju/ proxy 검증 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:02:02 +09:00
8123f758a8 feat(deploy): saju-lab 컨테이너 + nginx + 5위치 동기화 2026-05-25 20:29:34 +09:00
8ec3abb800 feat(saju-lab): main.py + routers (saju 6 + compat 5) + route tests
Phase 2 백엔드 마지막 task — FastAPI app · 11 endpoint · 10 route tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 20:28:08 +09:00
6d752acbe1 feat(saju-lab): interpret/pipeline.py — Claude 호출 + reroll 1회 (8 tests) 2026-05-25 20:23:38 +09:00
f995f8739f feat(saju-lab): interpret/prompt + schema — 12항목 + 궁합 SYSTEM_PROMPT (8 tests) 2026-05-25 20:21:07 +09:00
cad65dc869 feat(saju-lab): config + Pydantic 모델 + db.py CRUD (saju + compat) — 10 tests 2026-05-25 20:18:19 +09:00
f4f518fc80 feat(saju-lab): compatibility.py — 두 사주 궁합 점수 + breakdown
- saju-web/app/compatibility/result/page.tsx의 calculateCompatibility() 1:1 매핑
- 알고리즘: base 50 ± (일간 오행 same/produce/overcome) ± (일지 6합/3합/충), clamp [0,100]
- breakdown: day_master_element + branch_interaction (delta + relation/flags + description)
- 17 unit tests passed (헬퍼 9개 + 통합 8개), 438/438 total

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 20:14:47 +09:00
db1f69c7a5 feat(saju-lab): daeun.py — 대운 8개 계산 (30/30 reference) 2026-05-25 20:09:46 +09:00
ebfade655a feat(saju-lab): analysis.py — 오행/신강신약/용신/세운 (30/30 reference)
- calculate_detailed_element_balance: 가중치 오행 점수 (천간 1.0, 본기 1.0, 중기 0.5, 여기 0.3)
- calculate_element_score: 오행 비율 (%) — JS Math.round 호환
- analyze_day_master_strength: 신강/신약/중화 + score + reasons
  (월령 득령/실령, 통근, 투출, 오행 비율 4단계 평가)
- estimate_yongshin: 용신/희신/기신 + explanation
  (신강 → 식상/재성/관살 중 약한 2개, 신약 → 인성/비겁 중 약한 것, 중화 → 5행 중 약한 2개)
- calculate_seun: 올해 세운 + 4주 지지와 충/합 매핑
- perform_full_analysis: 위 5종 + branch_interactions + shinsal + gongmang + hidden_stems 통합

saju-web/lib/ai-interpretation.ts 와 1:1 매핑, 30 reference fixture 모두 통과 (180/180).
전체 saju-lab 테스트 389/389 passed.

JS Math.round 와 Python round() 의 banker-rounding 차이 보정을 위해 _js_round 헬퍼 추가.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 20:00:36 +09:00
234ccfe857 feat(saju-lab): shinsal.py — 지장간/신살/공망/지지 상호작용 2026-05-25 19:54:21 +09:00
3f0b7bcd74 feat(saju-lab): core.py — 60갑자 + 십성 + 십이운성 + calculate_saju (30/30 reference pass) 2026-05-25 19:48:28 +09:00
f91a74237b feat(saju-lab): lunar.py — 음력↔양력 변환 (sxtwl) 2026-05-25 19:39:06 +09:00
95243a7f1f fix(saju-lab): reference fixture 재생성 (solarlunar gzMonth 기반 + xfail 제거)
이전 fixture는 saju-web `getCurrentSolarTerm` 루프 버그로 30 케이스 모두 month branch='丑'으로 고정되어 검증 불가했음. saju-web을 임시 패치(solarlunar.gzMonth 직접 사용)하여 재생성한 뒤 패치는 되돌렸음. 결과: 12개 unique month branch + sxtwl과 49 테스트 전부 PASS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 19:35:49 +09:00
07b5c32f2f feat(saju-lab): solar_terms.py — sxtwl 기반 24절기 + 月支 매핑 2026-05-25 19:28:33 +09:00
4ddcd75453 feat(saju-lab): calculator/constants.py — 천간/지지/오행/지장간 상수 2026-05-25 19:20:34 +09:00
018459db88 feat(saju-lab): reference fixture 30 케이스 (TS 엔진 결과 추출) 2026-05-25 19:18:22 +09:00
42182014f0 feat(saju-lab): 스캐폴딩 — Dockerfile + requirements + 디렉토리 구조 2026-05-25 18:58:41 +09:00
03edfb04aa refactor(agent-office): tarot 모듈 제거 (tarot-lab으로 cutover 완료)
- DELETE: app/tarot/ 디렉토리 (pipeline, prompt, schema 모듈)
- DELETE: app/routers/tarot.py (FastAPI 라우터)
- DELETE: 4개 tarot 테스트 파일 (test_tarot_*.py)
- MODIFY: app/main.py — tarot 라우터 import + register 제거
- MODIFY: app/models.py — 5개 Tarot* 클래스 제거
- MODIFY: app/config.py — 4개 TAROT_* 환경변수 제거
- MODIFY: app/db.py — 6개 tarot_readings CRUD 함수 제거

KEEP:
- tarot_readings CREATE TABLE 블록 (DB 호환성)
- CREATE INDEX ... tarot_readings 인덱스 2개
- scripts/migrate_tarot_to_lab.py (cutover 마이그레이션)
- tests/test_migrate_tarot.py (마이그레이션 테스트)

테스트: 88 pass (migrate_tarot tests 포함)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 18:54:12 +09:00
8b0c12b595 feat(deploy): tarot-lab 5위치(SERVICES/BUILD/CONTAINER/HEALTH/DATA) 동기화 2026-05-25 18:43:34 +09:00
e52e47fe3b feat(nginx): /api/tarot/ → tarot-lab:8000 라우팅 추가 2026-05-25 18:42:33 +09:00
8d25a1467a feat(docker-compose): tarot-lab 컨테이너 추가 (18250 포트) 2026-05-25 18:41:08 +09:00
901d3535ee feat(agent-office): tarot_readings 1회성 마이그레이션 스크립트 (3 테스트) 2026-05-25 18:35:36 +09:00
91caddb4b2 feat(tarot-lab): main.py + 5 라우트 테스트 (총 21 tests 통과) 2026-05-25 18:32:37 +09:00
abdfcbb144 feat(tarot-lab): pipeline.py 이관 + 6 테스트 통과 2026-05-25 18:30:00 +09:00
a94c73b134 feat(tarot-lab): prompt.py + schema.py 이관 + 검증 테스트 6건
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 18:26:08 +09:00
387d2465b0 feat(tarot-lab): db.py CRUD 5 + init_db (테스트 4건 통과) 2026-05-25 18:23:44 +09:00
4073370e1b feat(tarot-lab): config + Pydantic 모델 5개 추출 2026-05-25 18:20:30 +09:00
1775f7dd2d feat(tarot-lab): 스캐폴딩 — Dockerfile + requirements + pytest 2026-05-25 18:18:37 +09:00
677d05fc31 docs(plan): saju-lab 신설 + tarot-lab 분리 구현 plan
- Phase 1 (Task 1~12): tarot 코드 복사 + 모듈 평탄화 + DB 마이그레이션 + agent-office cutover + web-ui URL 변경
- Phase 2 (Task 13~29): saju 계산 엔진 TS→Python 포팅 (reference fixture 30) + Claude 12항목 해석 + DB + 라우터 + UI 시안 후 진행
- 총 29 task, 각 5~10 step bite-sized, TDD + 빈번한 commit

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 17:58:11 +09:00
d87ad2421d docs(spec): saju-lab 신설 + tarot-lab 분리 마이그레이션 설계
- saju-web (Next.js+Supabase+OpenAI) → saju-lab (Python FastAPI+SQLite+Claude)
- agent-office 내 tarot 모듈 → 독립 tarot-lab 컨테이너 분리
- Phase 1 tarot 분리 (DB 마이그레이션 스크립트 + cutover)
- Phase 2 saju 신설 (TS→Python 계산엔진 포팅 + 사주/궁합 v1)
- 포트: tarot-lab 18250, saju-lab 18300
- API: /api/tarot/*, /api/saju/* 완전 이전

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 17:23:12 +09:00
20691b5057 fix(tarot): Claude 응답 시간 단축 + nginx timeout 정리
504 Gateway Timeout 근본 원인은 DSM Reverse Proxy의 60s 기본 timeout
(agent-office는 200 OK 정상 응답했으나 client 도달 전 DSM이 끊음).
사용자 측 DSM Reverse Proxy timeout 늘리기 별도 필요.

코드 측 대응:
- pipeline.py max_tokens 2048 → 1400 (응답 시간 단축, 3-card spread 충분)
- pipeline.py에 latency_ms·tokens 로그 출력 (모니터링)
- nginx /api/agent-office/에 proxy_send_timeout 300s, proxy_connect_timeout 60s
  추가 (proxy_read_timeout은 WebSocket 위해 86400s 유지)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:43:27 +09:00
3bf87a93fb feat(agent-office): /api/agent-office/tarot 5 endpoint (T6)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:23:52 +09:00
4623c68d4e feat(agent-office): Tarot Claude 파이프라인 + reroll 1회 (T5)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:18:42 +09:00
f79dc87d75 feat(agent-office): Tarot 응답 스키마 검증 (T4)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:13:38 +09:00
d4302acb6a feat(agent-office): Tarot SYSTEM_PROMPT + user message builder (T3)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:11:12 +09:00
b7fd98c8c7 feat(agent-office): Tarot Pydantic 모델 + config 추가 (T2)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:07:48 +09:00
0b29283043 feat(agent-office): tarot_readings 테이블 + CRUD (T1)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:03:47 +09:00
9dba1e74b0 docs(plan): tarot-lab v1 implementation plan
18 task — agent-office 6 (DB·모델·프롬프트·스키마·파이프라인·라우터) + web-ui 11 (자산·카드·hooks·컴포넌트·CSS·페이지·라우팅) + 통합 검증 1.
TDD per task — 실패 테스트 → 구현 → 통과 → 커밋 워크플로우.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 23:59:13 +09:00
4c9fe11fc9 docs(spec): tarot-lab v1 — 카드 시각 효과(글로우/플립)는 v2로 분리
사용자 피드백: 카드 이미지 자산 도착 후 보강 — hover glow, 3D 뒤집기 애니메이션, sparkle particles.
v1은 hover lift + fade-in 등 미니멀 모션만.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 23:47:09 +09:00
a356a5895f docs(spec): tarot-lab v1 design
랜딩(/tarot) + 오늘의 카드 + 3장 스프레드 + 히스토리 4 페이지.
agent-office 확장으로 tarot_readings 테이블 + interpret/save/list/patch/delete 5 endpoint.
Claude Sonnet 4.6 + evidence·interactions 기반 근거 해석 프롬프트.
켈틱 10장·카드 이미지 정식 매핑·텔레그램 push는 v2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 23:44:42 +09:00
2e042e18c5 fix(image-lab): env 변수를 다른 -lab과 동일하게 정렬 (TZ + :- defaults) 2026-05-23 11:51:38 +09:00
83e74ad1f4 fix(image-lab): volume mount을 video-lab과 동일한 ${RUNTIME_PATH}/data/image로 통일 2026-05-23 11:48:24 +09:00
b70caddff1 feat(image-lab): Dockerfile + compose entry + scripts 6위치 + nginx 차단
Task 5 of Video Studio backend plan. Wires image-lab Python code (T1-T4)
into NAS Docker infrastructure on port 18802.

- image-lab/Dockerfile (python:3.12-slim + uvicorn)
- image-lab/requirements.txt (fastapi, redis, httpx)
- image-lab/env.example (INTERNAL_API_KEY, IMAGE_DATA_DIR, REDIS_URL, CORS)
- docker-compose.yml: image-lab service block (port 18802, redis depends_on,
  healthcheck, volume ${RUNTIME_PATH}/image-data:/app/data) + frontend
  depends_on entry
- scripts/deploy-nas.sh: SERVICES += image-lab
- scripts/deploy.sh: BUILD_TARGETS/CONTAINER_NAMES/HEALTH_ENDPOINTS += image-lab,
  DATA_DIRS += image
- nginx/default.conf: /api/internal/image/ 3-layer block (IP allowlist +
  deny all + X-Internal-Key forward) mirroring /api/internal/video/

Plan-B-Video lesson: 6-location registration enforced per
feedback_nas_deploy_paths.md rule 3 to avoid 'transferring dockerfile: 2B'
deploy failure.

Tests: image-lab pytest 11 passed (no regression).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:46:45 +09:00
d6e34973a4 feat(image-lab): generate/tasks/providers 엔드포인트 (video-lab 복제) 2026-05-23 11:41:47 +09:00
7007c90665 feat(image-lab): /api/internal/image/update webhook (video-lab 복제) 2026-05-23 11:37:33 +09:00
ca7a502514 feat(image-lab): verify_internal_key (video-lab 복제) 2026-05-23 11:34:03 +09:00
dc471ecc60 feat(image-lab): image_tasks 테이블 + CRUD (video-lab 복제) 2026-05-23 11:31:02 +09:00
e91715bf2c docs(plan): video-studio Plan 1 — image-render 포트 18714(task-watcher 충돌 회피) + scripts 6위치 등재 step 추가 2026-05-23 11:28:21 +09:00
1e4c1b42b7 fix(insta-lab): 프롬프트 템플릿 GET이 미저장 시 코드 기본값 반환
slate_writer/category_seeds가 DB에 없으면 404 대신 생성 파이프라인이
실제 폴백하는 코드 기본값(card_writer.DEFAULT_PROMPT,
DEFAULT_CATEGORY_SEEDS)을 is_default=true로 반환. 편집 UI가 마스터
프롬프트를 표시·수정 가능. 미지정 이름은 여전히 404. 테스트 4건.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 02:50:33 +09:00
0190a6c206 feat(agent-office): 인스타 큐레이터 후보를 중복 제거 + 신뢰도 0.7+ 필터
_dedup_and_filter_keywords: score>=0.7만 남기고 동일 keyword 중복 제거
(최고 score 유지) 후 내림차순. _push_keyword_candidates가 이 필터를 거쳐
"확실한 것만" 전송, 후보 없으면 안내 메시지. 헬퍼 테스트 5건.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 02:50:33 +09:00
6ef4160da2 fix(stock): AI 뉴스 호재/악재 명확히 구분
(1) 부호 게이트: top_pos는 score>0, top_neg는 score<0만 분류해 양수(호재)
종목이 악재란에 채워지는 문제 제거. 중립(0)은 양쪽 모두 제외.
(2) 프롬프트: reason을 score 부호와 같은 방향 근거만 쓰도록 명시 —
호재 평가에 악재 내용, 악재 평가에 호재 내용 혼입 금지.
부호 게이트 회귀 테스트 2건 추가.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 02:50:18 +09:00
078c9f008a fix(agent-office): /agents/{id}/tasks response에 tasks/items 양쪽 키 유지 (backward compat) 2026-05-23 02:12:50 +09:00
918151bda8 feat(agent-office): GET /agents/{id}/tasks에 task_type/days 필터 추가 2026-05-23 02:11:28 +09:00
2ce6721c35 fix(tests): fresh_db fixture가 매 test마다 db.DB_PATH 재패치 (cross-file isolation)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 02:08:01 +09:00
c5303151c0 feat(lotto-agent): sync_evolver_activity 매일 09:30 cron + 멱등 가드 + 3 테스트
- LottoAgent.sync_evolver_activity(): lotto-lab evolver status polling → agent_office.db task+log 미러링
- UTC 날짜 기준 멱등 guard (get_tasks_by_agent_date_kind 활용)
- 일요일(dow=6) → 5 clamp (lotto-lab trials는 0~5)
- 월요일 6-trial 완성 시 evolver_generate task 추가 생성
- scheduler.py: lotto_evolver_activity_sync cron 09:30 등록
- tests: creates_apply_task / idempotent / no_picks_no_task 3종

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 02:06:30 +09:00
ee61405ff1 feat(lotto-agent): run_weekly_evolution_report task_id wrap
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 01:59:56 +09:00
fef5f7a835 feat(lotto-agent): run_daily_digest task_id wrap
daily_digest에 create_task/update_task_status/add_log task_id wrap 적용.
test_run_daily_digest_creates_task 추가 (75 passed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 01:57:40 +09:00
e47ccdb762 feat(lotto-agent): run_signal_check task_id wrap + 단위 테스트
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 01:55:20 +09:00
4b6996b0f7 feat(lotto-agent): get_agent_tasks 필터 + get_tasks_by_agent_date_kind 멱등 guard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 01:52:05 +09:00
0f65aa53e4 docs(plan): Lotto Evolver UI + 활동 가시화 구현 plan (12 tasks)
Why: spec (2026-05-23-lotto-evolver-ui-design.md)을 12개 atomic task로
분해. Phase 1-2 web-backend (task_id wrap + sync cron + API 확장),
Phase 3 web-ui (Evolver 페이지 + 5 컴포넌트 + 라우터), Phase 4 배포 검증.
TDD red→green→commit + 멱등 guard 패턴.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 01:45:47 +09:00
ea3485cde6 docs(spec): Lotto Evolver UI + 에이전트 활동 가시화 (v2.1)
Why: v2 텔레그램 메시지의 /lotto/evolver 링크가 404 → 페이지 신설.
+ LottoAgent 활동(signal/digest/evolution/curate)이 agent_tasks에
누락된 거 보강. 모든 활동을 한 timeline에서 추적 가능.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 01:31:56 +09:00
d6366a38f3 fix(stock): 원달러 환율 등락 방향 판별 수정
네이버 환율 HTML에 .blind span이 "미국 USD"/"원"/"상승" 3개라
select_one(".blind")이 첫 번째 "미국 USD"를 잡아 방향 추출 실패 →
direction="" + 부호 없는 change_value → 프론트가 항상 상승으로 표시.
해외 지수와 동일하게 .head_info의 point_up/point_dn 클래스로 판별,
직속 .blind 텍스트(상승/하락)를 fallback으로 사용.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 01:17:12 +09:00
0f8c71c552 fix(lotto-evolver): previous_base diff + 일요일 cron skip + idempotent evaluate
- weight_evolver.evaluate_weekly: save_base_history 직전에 current_base를
  previous_base로 캡처해 return dict에 포함 → formatter가 진짜 diff 표시 가능
- evaluate_weekly: same effective_from row 이미 존재 시 save skip + idempotent
  return (토 22:00 lotto cron과 agent-office 22:15 재호출 중복 row 방지)
- main._run_weight_evolver_daily: 일요일(weekday=6) 도 skip — 토요일 trial을
  INSERT OR REPLACE로 덮어쓰는 문제 방지
- telegram_lotto._format_evolution_report: eval_result.previous_base 우선
  사용 (없으면 current_base 폴백) → diff 자기 자신 비교 버그 수정
- test_lotto_evolution_format: previous_base 키 추가 + 새 diff 검증 테스트

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 03:35:20 +09:00
1401c5703d docs(CLAUDE): lotto-lab weight_evolver API/스케줄러/테이블 추가
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 03:27:41 +09:00
92329f6fd5 feat(lotto-evolver): LottoAgent.run_weekly_evolution_report + 토 22:15 cron
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 03:24:18 +09:00
d0047c2b9d feat(lotto-evolver): 텔레그램 주간 evolution report 포맷 + 발송
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 03:21:23 +09:00
088944499c feat(lotto-evolver): service_proxy.lotto_evolver_status/evaluate helpers 2026-05-22 03:17:50 +09:00
a9fdbf8a93 feat(weight-evolver): evolver API 5종 (status/history/trials/generate-now/evaluate-now)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 03:15:57 +09:00
f46851d481 feat(weight-evolver): cron 3종 등록 (월 generate+apply / 일 apply / 토 evaluate)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 03:14:23 +09:00
11b3700959 feat(weight-evolver): run_simulation이 active W를 score_combination에 전달 2026-05-22 03:12:24 +09:00
1db8a0063d fix(weight-evolver): draws 테이블 컬럼명 n1..n6 사용 (drw_num1..6 X) + datetime import 정렬
evaluate_weekly()에서 당첨번호 참조 시 존재하지 않는 drw_num1..6 컬럼을
실제 테이블 컬럼명 n1..n6으로 수정. datetime/timedelta/timezone import를
파일 중간(line 128)에서 상단 stdlib imports 섹션으로 이동.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 03:11:37 +09:00
f017a61c79 feat(weight-evolver): DB 통합 진입점 (generate_weekly/apply_today/evaluate_weekly)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 03:08:56 +09:00
1694823129 feat(analyzer): score_combination에 weights 파라미터 추가 (None=기존 fixed)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 03:06:26 +09:00
a4614ebeae feat(weight-evolver): lotto.db에 weight_trials/auto_picks/weight_base_history + CRUD 2026-05-22 03:03:51 +09:00
875e750f77 feat(weight-evolver): 순수 함수 (clamp/perturb/Dirichlet/score/base-rule)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 02:59:38 +09:00
9cb40fb4e5 test(weight-evolver): 순수 함수 + base update rule 단위 테스트 2026-05-22 02:56:06 +09:00
383f48c71e feat(stock): GET /api/stock/holidays endpoint (SP-10 task-watcher용)
holidays.json(list) 노출. task-watcher가 휴장일 판정에 조회.
인증 불필요. 주말은 task-watcher가 weekday로 별도 판정.
Plan-B-Infra Phase 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 01:40:44 +09:00
6be74737c2 docs(plan): Lotto Weight Evolver 구현 plan (13 tasks, Phase 1-4 + 배포)
Why: spec (2026-05-22-lotto-weight-evolver-design.md)을 13개 atomic
task로 분해. TDD red→green→commit 패턴. analyzer.score_combination
기존 fixed 가중치 보존+동적 W 옵션 추가. v1 시그널 자동 cascade.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 01:38:23 +09:00
3106716e70 docs(plan): Plan-B-Infra — NSSM 자동 시작(SP-9) + task-watcher(SP-10)
SP-9 NSSM 안내(ai_trade HIGH + wsl_docker NORMAL) + SP-10 task-watcher
WSL2 컨테이너(시간대 큐 토글). 박재오 결정: idle 감지 생략 — 시간대만.

8 task: NAS holidays endpoint(1) → task-watcher mode/watcher/main/compose(2-5)
→ NSSM 안내 문서(6) → 박재오 빌드+검증(7) → 메모리(8).

spec 정정: signal_v2→ai_trade, Ubuntu-22.04→24.04, web-ai-services→web-ai/services.
완료 시 spec 12 SP 전부 완료.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 01:37:36 +09:00
a126155948 docs(spec): Lotto Weight Evolver — 자율 학습 루프 설계 (v2)
Why: v1 능동 모니터링 위에 매주 6가지 가중치 시도+토요일 회고+
winner 기반 base 갱신 루프를 lotto-lab에 추가. 5종 시뮬 점수
가중치를 사람 없이 자가 학습.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 01:12:12 +09:00
f509339cbb fix(lotto-signals): draw_no 모든 source에 전달 (drift baseline 회차 가드 활성화)
light/sim source에서도 current_draw_no를 항상 fetch해 drift/confidence
메트릭의 회차 단위 중복 push 가드가 올바르게 동작하도록 수정.
lotto_latest_draw() 헬퍼를 service_proxy에 추가하고 run_signal_check에서
source에 무관하게 최신 회차를 먼저 조회; deep_check는 curate_weekly
반환값을 우선 사용.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 08:24:02 +09:00
e72a52a950 feat(lotto): /api/lotto/best에 5종 점수 array 노출 (agent-office sim_consensus 입력)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 08:21:48 +09:00
eecaefc26d docs(CLAUDE): agent-office 로또 능동 시그널 API/스케줄러/env 추가
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 03:21:51 +09:00
b3c0683364 feat(lotto-signals): GET signals/baselines + POST signal-check endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 03:20:08 +09:00
17321d948e feat(lotto-signals): urgent 텔레그램 발송 + throttle/cap + daily digest 발송 + baseline_mu/sigma 노출
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 03:13:29 +09:00
8552cbc184 feat(lotto-signals): 텔레그램 urgent/digest 메시지 포맷 2026-05-20 03:07:30 +09:00
b1c786e59d feat(lotto-signals): scheduler cron 4종 등록 (light/sim/deep/digest)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 03:04:14 +09:00
b885d02ac4 fix(tests): test_lotto_signal_runner DB_PATH 패치 (import order 안전)
db.DB_PATH = _TMP를 from app import db 직후에 주입해
타 테스트 파일이 app.db를 먼저 import해 DB_PATH가 동결된 경우에도
올바른 임시 경로를 사용하도록 수정.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 03:02:40 +09:00
b35fab777e feat(lotto-signals): LottoAgent.run_signal_check/run_daily_digest (텔레그램 X)
Phase 2: on_command에 signal_check/light_check/sim_check/deep_check/daily_digest 액션 추가.
run_signal_check는 lotto_signals DB INSERT만, run_daily_digest는 24h 발화 카운트 반환.
텔레그램 발송은 Task 9 (Phase 3)에서 추가 예정.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 02:54:09 +09:00
43081bea0e feat(lotto-signals): config env vars 7종 추가 (window/임계치/digest/throttle) 2026-05-20 02:51:28 +09:00
bebe5797e7 feat(lotto-signals): signal_runner orchestrator + service_proxy GET helpers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 02:48:12 +09:00
9e1001b935 feat(lotto-signals): lotto_signals/lotto_baselines 테이블 + CRUD
agent-office DB에 lotto_signals, lotto_baselines 테이블 추가 및
insert/mark/query/upsert CRUD 헬퍼 함수 구현 (throttle, z-score, baseline 관리)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 02:43:27 +09:00
e5465ad136 fix(lotto-signals): pstdev→stdev (ddof=1 sample) + z=None contract 문서화
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 02:41:09 +09:00
21d46d95dd feat(lotto-signals): 메트릭 함수·adaptive baseline 순수함수 구현
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 02:38:33 +09:00
ac4a574ef2 test(lotto-signals): floating-point 임계치 보정 + import 정리 + decide_fire 분리
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 02:36:32 +09:00
c985d2c605 test(lotto-signals): 메트릭 함수·adaptive baseline 단위 테스트
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 02:32:10 +09:00
b4e873b5b0 docs(plan): LottoAgent 능동성 확장 구현 plan (12 tasks, Phase 1-3)
Why: spec (2026-05-20-lotto-active-agent-design.md)을 12개 atomic task
(TDD: 테스트→fail→구현→pass→commit)로 분해. 24h 가동 검증 task 포함.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 02:26:49 +09:00
6c5e93f64e docs(spec): LottoAgent 능동성 확장 설계 (능동 시그널·일일 요약)
Why: 매주 1회 무조건 큐레이션만 있는 현 구조를 다중 트리거+적응형
시그널 모니터링으로 확장. 좋은 수치(z≥1.5) 일 때만 텔레그램 보고.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 02:07:39 +09:00
6b7eb5a9c1 fix(deploy): register video-lab in deploy scripts (5 locations)
Plan-B-Video T6 가 docker-compose + nginx 만 등재했고 scripts/* 누락 →
deploy-nas.sh rsync가 video-lab/을 sync 안 함 →
NAS target 빈 context → docker buildkit "transferring dockerfile: 2B" →
"failed to read dockerfile: no such file or directory"

Fix:
- deploy-nas.sh SERVICES: + video-lab (rsync 대상)
- deploy.sh BUILD_TARGETS: + video-lab (docker compose build/up)
- deploy.sh CONTAINER_NAMES: + video-lab (orphan cleanup)
- deploy.sh HEALTH_ENDPOINTS: + video-lab (post-deploy health wait)
- deploy.sh DATA_DIRS: + video (단수형, /data/video volume mount)

memory feedback_nas_deploy_paths.md "depends_on 9개 lab 등재 완료" 패턴
정확히 이 케이스 경고했으나 plan T6에서 적용 누락. 메모리 → 10개 lab 갱신 예정.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:59:51 +09:00
4b28ef3afa feat(nginx): /api/internal/video/ 3-layer 차단 (SP-8)
LAN(192.168.45.0/24) + Tailscale(100.64.0.0/10) + 127.0.0.1 allow.
deny all. X-Internal-Key forward → video-lab:8000.
insta/music 블록과 동일 패턴.
Plan-B-Video Phase 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:33:37 +09:00
211aff1e45 docs(plan): Plan-B-Video port 18800 → 18801 (realestate-lab 충돌)
T6 implementer가 발견: realestate-lab이 이미 18800 점유.
video-lab 포트를 18801로 정정. plan 18 occurrence 일괄 변경.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:32:56 +09:00
37ca8e594e feat(video-lab): docker-compose entry + nginx routing (SP-8)
video-lab service: port 18801, REDIS_URL/INTERNAL_API_KEY env,
depends_on redis, /app/data volume mount.
nginx: /api/video/ proxy + /media/video/ direct serve alias.
frontend depends_on + volume mount 추가.
Plan-B-Video Phase 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:31:37 +09:00
c9a094969d feat(video-lab): main.py — FastAPI + redis client + 2 endpoint (SP-8)
POST /api/video/generate (provider validation + Redis push + task_id 반환).
GET /api/video/tasks/{id} (DB 조회).
GET /api/video/providers (4 provider 메타).
SUPPORTED_PROVIDERS = sora/veo/kling/seedance.
Plan-B-Video Phase 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:30:21 +09:00
e8dbf8092a feat(video-lab): /api/internal/video/update endpoint + tests (SP-8)
UpdatePayload schema (task_id/status/progress/message/video_url/error).
404 if task not found. insta/music-lab과 동일 패턴 + video_url 필드.
Plan-B-Video Phase 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:29:05 +09:00
21cf0114f4 feat(video-lab): verify_internal_key + tests (SP-8)
X-Internal-Key 검증 dependency. insta-lab/music-lab 동일 패턴.
Plan-B-Video Phase 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:27:38 +09:00
20f83cee33 feat(video-lab): app/db.py — video_tasks 테이블 + CRUD (SP-8)
WAL + busy_timeout 표준 fix. create_task / update_task / get_task.
provider 컬럼 추가(Sora/Veo/Kling/Seedance 구분). video_url 필드.
Plan-B-Video Phase 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:26:19 +09:00
1e77123394 feat(video-lab): Dockerfile + requirements + app package skeleton (SP-8)
NAS video-lab 신설. python:3.12-alpine 기반. redis>=5.0 의존성.
영상 외부 호출 없음(gateway만) — 외부 API 의존 없음.
Plan-B-Video Phase 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:24:50 +09:00
fbd8d26ec6 docs(plan): Plan-B-Video — video-lab 신설 + 4 provider Windows worker
SP-7 + SP-8 — 4 video provider (Sora 2, Veo 3.1, Kling via PiAPI, Seedance 2.0)
Windows video-render로 분산. NAS video-lab 신설 (port 18800).
spec §10 SP-7 갱신: 6 provider(Runway/Pika/Luma 포함) → 4 provider 축소
(박재오 2026-05-19 결정 — 실사용 provider만).

17 task: NAS video-lab 신설(1~6) → nginx 차단(7) → Windows video-render(8~14)
→ NAS push + 박재오 빌드(15) → Kling end-to-end(16) → 메모리 기록(17).

부록: 4 provider API 키 발급 가이드 (Sora/Veo/Kling/Seedance).
Plan-B-Music 3가지 함정 (WSL2 mirror + Redis chown + .env NAS_BASE_URL) 모두 사전 인지.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:22:20 +09:00
6f505b8cb1 feat(nginx): /api/internal/music/ 3-layer 차단 (SP-6)
LAN(192.168.45.0/24) + Tailscale(100.64.0.0/10) + 127.0.0.1 allow.
deny all. X-Internal-Key forward → music-lab:8000.
insta 블록과 동일 패턴.
Plan-B-Music Phase 4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 05:24:41 +09:00
e1722e3963 refactor(music-lab): suno_provider/local_provider → stub (SP-6)
기존 13+1 외부 API 호출 함수는 web-ai/services/music-render/providers로 이식.
NAS는 SUNO_MODELS (정적 데이터)만 잔존. SUNO_API_KEY = "" sentinel.
Plan-B-Music Phase 3 (cutover 4/4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 05:22:05 +09:00
b1e28aa725 refactor(music-lab): batch_generator _generate_one_track → Redis push (SP-6)
기존 직접 run_suno_generation 호출 + asyncio.to_thread를
Redis push (queue:music-render, job_type=suno_generation) +
task 상태 polling 패턴으로 변경. 결과는 task_id로 music_library 조회.
Plan-B-Music Phase 3 (cutover 3/4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 05:19:41 +09:00
532b794c11 refactor(music-lab): sync helpers → Windows HTTP forward + cleanup (SP-6)
/api/music/{lyrics, credits, timestamped-lyrics, style-boost}
모두 sync_forward 모듈로 위임 → Windows :18711/api/music-render/sync/*.
SUNO_API_KEY가 NAS에 없으므로 직접 호출 불가.
run_*, generate_*, get_* import 제거 (Windows로 이전됨).
SUNO_MODELS만 잔존 (정적 데이터).

추가 cleanup (T11 reviewer 지적):
- _push_render_job의 datetime import를 모듈 상위로
- 11 endpoint의 unused BackgroundTasks 매개변수 제거

generate_batch: SUNO_API_KEY 체크를 os.getenv()로 전환 + 테스트 monkeypatch 갱신.

Plan-B-Music Phase 3 (cutover 2/4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 05:16:15 +09:00
e7f6edf7c5 refactor(music-lab): 13 background_tasks → Redis push (SP-6)
generate, extend, vocal-removal, cover-image, wav, stem-split,
upload-cover, upload-extend, add-vocals, add-instrumental, video
모두 _push_render_job 헬퍼로 queue:music-render에 push.
job_type 디스크리미네이터로 Windows worker가 분기.
Plan-B-Music Phase 3 (cutover 1/4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 05:10:20 +09:00
42cf39d0da feat(music-lab): wire redis client + internal_router + compose env (SP-6)
main.py에 redis.asyncio client 추가 + internal_router include.
docker-compose의 music-lab에 REDIS_URL/INTERNAL_API_KEY/MUSIC_RENDER_URL.
SUNO_API_KEY 라인 제거 (spec §9 — Windows로 이전).
Plan-B-Music Phase 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 03:16:54 +09:00
74196396c5 fix(music-lab): track guard if payload.track is not None: (T1 follow-up)
Code review found: empty dict `{}` was falsy and would silently skip
add_track. Use explicit None check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 03:14:41 +09:00
4393ba706b feat(music-lab): verify_internal_key + /api/internal/music/update (SP-6)
X-Internal-Key 헤더 검증 dependency (insta-lab 동일 패턴).
Windows music-render webhook 수신 endpoint — update_task + 옵션 add_track.
Plan-B-Music Phase 1 (수신부).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 03:10:05 +09:00
714224a9b4 docs(plan): Plan-B-Music — music-render Windows worker + NAS 분할
SP-5 + SP-6 — 모든 Suno(13) + MusicGen(1) 외부 호출 + sync helpers(4)를
Windows music-render로 이전. NAS music-lab은 Redis push(async) +
httpx forward(sync)만. SUNO_API_KEY는 Windows .env 단독 보유 (spec §9).

17 task: NAS 수신부(1-2) → Windows worker(3-10) → NAS cutover(11-14) →
nginx 차단 + end-to-end 검증(15-17).

박재오 결정: 모든 Suno + MusicGen 일괄 이전 (Plan-B-Insta 패턴 + sync forward 추가).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 03:02:48 +09:00
ea93dc522b fix(insta): wire /media/insta nginx alias + frontend insta_cards mount (Plan-B-Insta)
End-to-end 검증 중 발견된 2 가지 인프라 누락 보완:

1) frontend 컨테이너에 /data/insta_cards 마운트 추가 (NAS의 실저장 위치는
   data/insta/insta_cards/<slate_id>/ 로 기존 insta-lab 컨테이너가 사용)
2) nginx /media/insta/ location → /data/insta_cards/ alias

이로써 Windows insta-render worker가 result_path "/media/insta/<id>/01.png"
로 보낸 URL이 NAS frontend nginx에서 정상 서빙됨.

Plan-B-Insta Phase 5 (검증) — T15 end-to-end 디버깅 fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 02:36:44 +09:00
408b6a3df7 feat(nginx): 3-layer block for /api/internal/insta/ (SP-4)
Layer 1·2: IP 화이트리스트 (192.168.45.0/24 LAN + 100.64.0.0/10 Tailscale).
Layer 3: X-Internal-Key 헤더 (FastAPI dependency, 별도 검증).

외부에서 직접 호출 시 403 (nginx deny), LAN에서 키 없으면 401 (FastAPI).
Windows insta-render만 호출 가능.

Plan-B-Insta Phase 4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 02:25:40 +09:00
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: #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: #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: #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: #4
2026-05-17 08:52:49 +09:00
42bd53ee7b feat(insta): _bg_extract uses preferences + 09:00 trends_collect cron 2026-05-16 17:58:52 +09:00
86694ae4fe feat(agent-office): InstaAgent collect_trends action + preferences-aware on_schedule 2026-05-16 17:57:44 +09:00
41225b3337 feat(insta-lab): main.py — trends + preferences endpoints
- POST /api/insta/trends/collect — background trend collection via trend_collector.collect_all
- GET /api/insta/trends — list external trends with source/category/days filters
- GET /api/insta/preferences — return category weights (defaults seeded on init_db)
- PUT /api/insta/preferences — upsert category weights
- Modified GET /api/insta/keywords to accept source= filter (source present → list_trends, else existing list_trending_keywords, backward compatible)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 17:54:09 +09:00
6bb5c2fb40 feat(insta-lab): keyword_extractor.extract_with_weights for category proportions 2026-05-16 17:51:16 +09:00
bd1773e29e feat(insta-lab): trend_collector adds Google Trends + LLM category classification 2026-05-16 17:48:26 +09:00
685320f3cf feat(insta-lab): trend_collector with NAVER popular fetcher 2026-05-16 17:47:17 +09:00
b3982c8f72 feat(insta-lab): db migration — trending_keywords.source + account_preferences + CRUD
- Idempotent ALTER TABLE adds source column (default 'manual') + idx_tk_source index
- New account_preferences table seeded with economy/psychology/celebrity at weight=1.0
- add_trending_keyword now accepts optional source param
- New helpers: add_external_trend, list_trends, get_preferences, upsert_preferences
- test_db updated: six→seven tables; test_preferences_crud.py (7 new tests, all pass)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 17:44:01 +09:00
002c0893f8 chore(insta-lab): add pytrends>=4.9 dependency 2026-05-16 17:41:30 +09:00
d6081ba2d3 docs(insta-trends): implementation plan (10 TDD-grouped tasks)
trend_collector NAVER+Google+LLM 분류, db migration + preferences CRUD,
extract_with_weights, 4 endpoints + keywords source 필터, InstaAgent
collect_trends action + preferences-aware schedule, web-ui 탭 + 3 패널,
스모크 매트릭스.
2026-05-16 17:39:19 +09:00
10cb3ae1df docs(insta-trends): 셀프 리뷰 보강 — LLM 분류 캐시 위치, days 쿼리 의미 명시 2026-05-16 17:31:22 +09:00
e3348da642 docs(insta-trends): 외부 트렌드 + 카테고리 가중치 설계
NAVER 인기 + Google Trends 두 source 수집, account_preferences로 카테고리
가중치 모델, 가중치 기반 키워드 추출 알고리즘, Insta 페이지 Cards/Trends
탭 분리.
2026-05-16 17:30:45 +09:00
088bbaa097 fix(deploy): use docker inspect for healthcheck (호스트/컨테이너 둘 다 동작)
기존 curl http://lotto:8000/health은 deployer 컨테이너 내부에서만
Docker DNS가 'lotto'를 해석. 호스트 셸에서 sudo bash로 직접 실행 시
DNS 해석 실패해 모든 서비스가 HEALTH_FAIL로 오판정. docker inspect로
이미 정의된 compose healthcheck 결과를 직접 조회하도록 변경. starting
상태는 최대 60초 대기 후 최종 판정.
2026-05-16 02:11:38 +09:00
be322557ee fix(insta-lab): pin to bookworm + manual Chromium deps (drop --with-deps)
python:3.12-slim이 trixie(Debian 13)로 옮겨가면서 Playwright 1.48의
--with-deps가 ttf-ubuntu-font-family / ttf-unifont 등 ubuntu20.04
fallback 패키지를 시도하다 apt 실패 → Docker build exit 100.

해결: python:3.12-slim-bookworm 명시(Debian 12, Playwright 공식 지원)
+ Chromium 런타임 라이브러리 직접 apt 설치 + --with-deps 제거.
2026-05-16 01:58:53 +09:00
70438caa1f fix(scripts): blog-lab → insta-lab in deploy/healthcheck service lists
배포 스크립트 hardcoded 서비스 리스트가 blog-lab을 참조해 머지 후
첫 webhook 배포가 rsync(/repo/blog-lab 없음) + docker compose
(서비스 미정의) 양쪽에서 실패. SERVICES/BUILD_TARGETS/HEALTH_ENDPOINTS/
DATA_DIRS를 insta-lab 기준으로 갱신. CONTAINER_NAMES는 blog-lab 고아
정리용으로 유지(다음번 docker rm -f가 안전 실행).
2026-05-16 01:51:45 +09:00
e16029ebdb Merge pull request 'feat/insta-agent' (#3) from feat/insta-agent into main
Reviewed-on: #3
2026-05-16 01:43:21 +09:00
cefc3119c0 docs(claude-md): replace blog-lab references with insta-lab 2026-05-16 00:53:58 +09:00
5485d4858a chore: remove blog-lab service and BlogAgent (replaced by insta-lab) 2026-05-16 00:52:05 +09:00
fbd963db86 feat(agent-office): telegram render_<id> callback dispatches to InstaAgent 2026-05-16 00:49:30 +09:00
9095423026 feat(agent-office): register InstaAgent + 09:30 cron job 2026-05-16 00:47:28 +09:00
6eb24090ed feat(agent-office): InstaAgent — daily extract + keyword push + media group render 2026-05-16 00:47:24 +09:00
8cb5a01431 feat(agent-office): replace blog_* proxy with insta_* helpers 2026-05-16 00:47:16 +09:00
8a4a8790ca chore(agent-office): swap BLOG_LAB_URL for INSTA_LAB_URL 2026-05-16 00:47:12 +09:00
2200748122 chore(nginx): replace /api/blog-marketing with /api/insta 2026-05-16 00:40:41 +09:00
7bc0a7cd77 chore(compose): replace blog-lab service with insta-lab 2026-05-16 00:40:26 +09:00
b84efd730b feat(insta-lab): main.py FastAPI endpoints + BackgroundTasks
13 REST endpoints covering health, status, news, keywords, slates,
tasks, and prompt templates. All background functions are async def
so FastAPI awaits them without asyncio.run conflicts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 00:38:34 +09:00
11bd223612 feat(insta-lab): card_renderer with Jinja + Playwright (1080x1350) 2026-05-16 00:35:55 +09:00
c3a5d7210f feat(insta-lab): card_writer with Claude 10-page JSON generator 2026-05-16 00:31:34 +09:00
07c4459085 feat(insta-lab): keyword_extractor with frequency + Claude refinement 2026-05-16 00:30:38 +09:00
c057304981 feat(insta-lab): news_collector with NAVER news.json + dedupe 2026-05-16 00:27:13 +09:00
d1245d040c feat(insta-lab): db.py with 6 tables + CRUD 2026-05-16 00:26:28 +09:00
34ca407ca2 feat(insta-lab): anchor templates/default/ directory with .gitkeep 2026-05-16 00:22:42 +09:00
b1ef778fc5 feat(insta-lab): project scaffold (Dockerfile, requirements, config)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 00:20:49 +09:00
30706e2eb6 docs(insta-agent): add implementation plan (18 TDD tasks)
scaffold → db → news_collector → keyword_extractor → card_writer →
card_renderer → main.py FastAPI → docker-compose/nginx 교체 →
agent-office service_proxy/InstaAgent/registry/scheduler/webhook
콜백 → blog-lab 폐기 → CLAUDE.md → 스모크 테스트.
2026-05-15 08:58:15 +09:00
6062445c12 fix(stock-webai): final review notes — env default + 1-time auth error log
(1) docker-compose: ${WEBAI_API_KEY} → ${WEBAI_API_KEY:-} matches
project convention, avoids "variable not set" warning when NAS .env
lacks the key during initial deploy.

(2) auth.py: ERROR log when WEBAI_API_KEY env unset fires only on
first miss, then silent (module-level _WEBAI_AUTH_WARNED flag).
Flag resets when env becomes configured, so future regressions log
again. Eliminates log spam under web-ai polling (~3/min).

All 102 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:56:03 +09:00
13da2226c3 feat(nginx-webai): /api/webai/ location with rate limit + X-WebAI-Key forward
limit_req_zone webai:5m rate=60r/m, burst=20 nodelay, return 429 on
limit hit. Proxies to stock:8000 with X-Real-IP, X-Forwarded-For,
and X-WebAI-Key headers preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:48:14 +09:00
1e377e1559 chore(stock-webai): pass WEBAI_API_KEY env to stock container
Required by /api/webai/* endpoints. Operator must set WEBAI_API_KEY
in NAS /volume1/docker/webpage/.env before deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:46:52 +09:00
eb75d692f5 test(stock-webai): edge cases — 401 no leak, 503 env missing, unknown date
Verifies auth failure responses contain no portfolio/sentiment data,
503 when WEBAI_API_KEY env unset (existing endpoints unaffected),
news-sentiment unknown date returns empty result.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:44:17 +09:00
6c25866487 docs(insta-agent): 셀프 리뷰 수정 — 6테이블 표기 일치, auto_select 설정 위치 명확화 2026-05-15 08:42:38 +09:00
6ac7469f26 docs(insta-agent): blog-lab 폐기 및 insta-lab 설계 (1080x1350 카드 피드)
뉴스 수집 → 키워드 추출 → 10페이지 카드 카피·PNG 생성 → 텔레그램 푸시 →
사용자 수동 인스타 업로드 파이프라인. blog-lab 디렉토리·DB 폐기, 포트
18700 재활용, agents/blog.py → agents/insta.py, Playwright 기반 카드 렌더.
2026-05-15 08:42:03 +09:00
d1b2b6a4ba feat(stock-webai): /api/webai/news-sentiment daily dump
JOINs news_sentiment with krx_master for name fallback. Sorted by
score DESC. Date param defaults to latest. Empty table returns
{date: null, count: 0, items: []}. 4 integration tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:40:49 +09:00
2abfa5cb23 feat(stock-webai): /api/webai/portfolio + pnl_pct augment
Reuses get_portfolio() and adds pnl_pct (ratio, profit_rate/100) to
each holding plus total_pnl_pct to summary. 4 integration tests pass.
verify_webai_key dependency enforced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:36:27 +09:00
227e294bd3 feat(stock-webai): add X-WebAI-Key auth dependency + tests
verify_webai_key FastAPI dependency: 401 on missing/wrong key,
503 when WEBAI_API_KEY env unset. 4 unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:33:07 +09:00
ace0339d33 refactor: rename stock-lab → stock (graduation)
- git mv stock-lab/ → stock/
- docker-compose.yml: 서비스 키 + container_name + build.context +
  frontend.depends_on + agent-office STOCK_LAB_URL → STOCK_URL
- agent-office/app: config.py, service_proxy.py, agents/stock.py, tests/
  STOCK_LAB_URL → STOCK_URL
- nginx/default.conf: proxy_pass http://stock-labhttp://stock (3 lines)
- CLAUDE.md / README.md / STATUS.md / scripts/ 문구 갱신
- stock/ 내부 자기 참조 갱신

lab 네이밍 정책 (feedback_lab_naming.md) graduation.
API URL / Python import / DB 파일명 변경 없음.
2026-05-15 01:45:44 +09:00
8812bd870a docs(ai_news): mark scraper.py deprecated (Phase 1 transition) 2026-05-14 02:13:30 +09:00
b3fac4f442 feat(ai_news): router forwards mapping stats to telegram 2026-05-14 02:13:06 +09:00
19aed304cb feat(ai_news): telegram includes article mapping stats line 2026-05-14 02:12:17 +09:00
bbe5221e57 feat(ai_news): pipeline uses articles_source (replaces Naver scraper) 2026-05-14 02:09:41 +09:00
ec0ccf649e feat(ai_news): include summary + pub_date in LLM prompt 2026-05-14 02:07:01 +09:00
84d90f6e1c feat(ai_news): articles_source module (substring ticker matching) 2026-05-14 02:04:32 +09:00
ddfe0ca3eb feat(ai_news): add news_sentiment.source column with migration 2026-05-14 02:00:38 +09:00
943f676414 fix(ai_news): set weight=0 and add Spearman IC validation harness
검증 전 gradient 차단 + IC 측정 인프라.

- schema.py: DEFAULT_WEIGHTS["ai_news"] 0.8 → 0.0
  + 1회성 migration: 기존 운영 row 의 0.8 값 자동 reset
  (사용자가 명시 조정한 다른 값은 그대로 유지)
- ai_news/validation.py: compute_ic() — 일자별 score_raw × forward
  return Spearman 상관, ic_mean/ic_std/ic_per_day 반환, verdict 분류
  (skip/weak/strong)
- router.py: GET /api/stock/screener/ai-news/ic?days=30&horizon=1
- 단위 테스트 5개: empty DB, strong +IC, random ≈0 IC, min_news_count
  필터, horizon=5

배경: adversarial review 결과 — ai_news 가중치 0.8 이 검증 없이 출시됨.
4주+ 데이터 누적 후 IC > 0.05 확인 전까지 데이터 수집은 계속하되
가중합 영향만 차단. 운영 DB row 의 0.8 → 0.0 자동 reset 도 같은 의도.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:06:02 +09:00
06162b1e6e feat(ai_news): show stock name (ticker) in telegram top 5/5 2026-05-14 00:36:10 +09:00
c3659eb6c5 fix(ai_news): assistant prefill + temperature=0 + system prompt to force JSON 2026-05-14 00:26:48 +09:00
16941d76e8 fix(ai_news): escape MarkdownV2 reserved chars in score (+, -, .) 2026-05-14 00:17:53 +09:00
9f91dae1a4 feat(agent-office): add run_ai_news command for manual trigger 2026-05-13 23:59:30 +09:00
2a552d3cc8 test(screener): update node count test to 8 (ai_news added) 2026-05-13 23:52:54 +09:00
f37b21a408 fix(agent-office): on_ai_news_schedule — graceful fail on missing telegram_text 2026-05-13 23:48:59 +09:00
df7a8d985e feat(agent-office): cron mon-fri 08:00 ai_news sentiment job 2026-05-13 23:46:37 +09:00
c5d0c84183 feat(agent-office): on_ai_news_schedule (cron handler + telegram dispatch) 2026-05-13 23:46:17 +09:00
53a78a1062 feat(agent-office): refresh_ai_news_sentiment service helper 2026-05-13 23:45:51 +09:00
ca8bcb3fed feat(screener): POST /snapshot/refresh-news-sentiment with telegram_text 2026-05-13 23:44:38 +09:00
4b4f91c052 feat(screener): register ai_news in NODE_REGISTRY 2026-05-13 23:41:21 +09:00
6c3a84b8ec feat(screener): ScreenContext.news_sentiment field + load query 2026-05-13 23:41:01 +09:00
2ff2645240 feat(screener): AiNewsSentiment ScoreNode (percentile_rank + min_news_count) 2026-05-13 23:39:42 +09:00
f2143b3889 feat(screener): ai_news telegram message builder (MarkdownV2 + cost line) 2026-05-13 23:38:07 +09:00
810cc76d40 feat(screener): ai_news pipeline (top-100 parallel, fail-soft, upsert) 2026-05-13 23:36:03 +09:00
0a91f43c46 feat(screener): ai_news Claude Haiku analyzer (-10~+10 + clamp + JSON-fail soft) 2026-05-13 23:33:20 +09:00
3d321f2b4b chore(stock-lab): add pytest + pytest-asyncio to requirements 2026-05-13 23:30:47 +09:00
6ba29599aa feat(screener): ai_news scraper (naver finance ticker news) 2026-05-13 23:29:52 +09:00
658ed13571 feat(screener): add news_sentiment table + ai_news defaults + migration 2026-05-13 23:26:38 +09:00
15ee3c3301 fix(compose): frontend.depends_on 누락된 6개 lab 추가
lotto, stock-lab, agent-office, personal, packs-lab, travel-proxy 가
누락되어 있어 한 컨테이너 다운 시 nginx upstream resolve 실패 위험.
이번 사이클에 lotto httpx 사고로 명시화된 risk 를 해소.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 22:12:07 +09:00
2b5009f864 fix(sqlite): WAL + busy_timeout 120s standardized across all labs
8개 lab의 _conn() 함수에 표준 동시성 패턴 통일:
- timeout=120.0 (connection 획득)
- PRAGMA journal_mode=WAL (reader/writer 분리)
- PRAGMA busy_timeout=120000 (트랜잭션 충돌 시 120초 대기)

stock-lab/screener/router.py 의 검증된 패턴(d9b6122) 을 lotto, stock-lab(메인),
music-lab, blog-lab, realestate-lab, agent-office, personal, travel-proxy 로 확산.
기존 'database is locked' 오류 윈도우를 흡수.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 22:12:01 +09:00
d9b612253a fix(stock-lab): snapshot flow 범위 100종목 + busy_timeout 2분 (writer 충돌 완화)
자동 잡 16:30 KST 실패 원인:
- agent-office httpx timeout 180s
- 그러나 snapshot/refresh의 flow 스크래핑(500종목 × 0.2-0.5s) = 100~250s
- 180s 초과 시 client timeout → 서버 background 처리 계속
- 곧 /run 호출 → snapshot의 long write transaction과 INSERT 충돌
- WAL은 reader/writer 분리만, writer 두 명은 직렬 → busy_timeout 30s 초과 lock

Fix:
- DEFAULT_FLOW_TOP_N 500 → 100 (시총 상위 100종목 × 0.2s = ~20s)
- busy_timeout 30s → 120s (snapshot write 시간보다 충분히 김)
- connect timeout 30s → 120s

외국인 매수 시그널은 대형주에서 의미 큼. 상위 100종목으로 충분.
더 많은 커버리지 필요 시 별도 cron으로 snapshot/refresh와 /run 시간 분리.
2026-05-13 19:56:30 +09:00
db4322006d fix(stock-lab): screener DB connection WAL 모드 + busy_timeout 30s
snapshot/refresh 직후 /run mode=auto가 'database is locked'으로 500
실패하던 증상 fix. SQLite 기본 rollback journal 모드 + busy_timeout=0
조합에서 long write transaction과 read가 겹치면 즉시 OperationalError.

PRAGMA journal_mode=WAL: reader가 writer를 block 안 함
PRAGMA busy_timeout=30000: 30초 대기 후 timeout (즉시 실패 X)
sqlite3.connect timeout=30: connection 획득 자체에도 대기 적용

agent-office 자동 잡 16:30 KST 흐름 안정화.
2026-05-13 16:50:25 +09:00
a05e6ba8ca feat(stock-lab): 텔레그램 노드 풀 라벨 + 원 단위 표기
- 아이콘(👤외/🆙고/...) 제거하고 풀 한글 라벨로 변경
  (외국인/거래량급증/20일모멘텀/52주신고가/RS레이팅/이평선정배열/VCP수축)
- 가격은 "103,917원" 형태로 원 단위 명시
- 활성 노드 없을 때 fallback 문구
- 테스트도 새 포맷으로 갱신 + 원 단위 검증 신규 케이스
2026-05-13 07:52:17 +09:00
4a333434ac Merge feature/stock-screener-board: Stock Screener Board MVP (backend + agent-office)
stock-lab:
- pykrx→FDR/네이버 데이터 전환 (KRX 인증 회피)
- 스키마 7테이블 + 디폴트 시드
- snapshot.py (FDR 마스터·일봉 + 네이버 외국인 수급, 시총 상위 500종목)
- ScreenContext, ScoreNode/GateNode 추상, percentile_rank
- 게이트 1 (HygieneGate) + 점수 노드 7 (ForeignBuy/VolumeSurge/Momentum20/
  High52WProximity/RsRating/MaAlignment/VcpLite)
- Screener 엔진 + combine + position_sizer (ATR Wilder) + telegram 빌더
- FastAPI 라우터: /nodes, /settings, /run (preview/manual_save/auto),
  /snapshot/refresh, /runs (리스트·상세), 공휴일·주말 skipped_holiday

agent-office:
- StockAgent.on_screener_schedule + run_screener 명령
- 평일 16:30 KST APScheduler cron (Asia/Seoul)
- service_proxy 헬퍼, send_raw parse_mode 확장 (MarkdownV2 지원)
- 5 신규 테스트, 38 회귀 통과
2026-05-13 07:23:43 +09:00
119ac88e1e feat(agent-office): stock screener 평일 16:30 KST 자동 잡 + 텔레그램 전송
- StockAgent.on_screener_schedule: snapshot/refresh → screener/run(mode=auto)
  → telegram_payload(MarkdownV2) 발송. skipped_holiday는 무발신,
  실패 시 운영자 HTML 알림.
- service_proxy: refresh_screener_snapshot, run_stock_screener 추가
  (각각 180s timeout, STOCK_LAB_URL 기존 env 재사용).
- telegram.messaging.send_raw: parse_mode 파라미터 추가
  (기본 HTML 유지, MarkdownV2 페이로드 직접 전달용).
- scheduler: cron day_of_week=mon-fri hour=16 minute=30 id=stock_screener
  (Asia/Seoul TZ).
- on_command 'run_screener' 수동 트리거 추가.
- tests: 성공/휴일/스냅샷실패/run실패/이상status 5케이스.
2026-05-12 14:54:24 +09:00
c4cb18a25c feat(stock-lab): /run mode=auto 공휴일·주말 skipped_holiday 처리 2026-05-12 13:49:45 +09:00
50e811c5dd feat(stock-lab): /snapshot/refresh + /runs 리스트·상세 라우터 2026-05-12 13:47:16 +09:00
5ec7c2461b feat(stock-lab): /run 엔드포인트 — preview/manual_save/auto 모드 매트릭스 2026-05-12 13:44:21 +09:00
5f0fed7f13 feat(stock-lab): /nodes + /settings 라우터 + main.py include
- screener/router.py: APIRouter prefix=/api/stock/screener
  - GET /nodes: NODE_REGISTRY + GATE_REGISTRY 메타 노출 (7 score + 1 gate)
  - GET /settings: screener_settings 싱글톤 row 조회
  - PUT /settings: 가중치/노드/게이트 파라미터 round-trip
- main.py: screener_router include (FastAPI 생성 직후)
- db.py: STOCK_DB_PATH 환경변수 지원 (테스트 격리, 기본값 /app/data/stock.db 유지)
- test_screener_router.py: 3 tests (nodes list, settings GET, PUT round-trip)
2026-05-12 13:41:24 +09:00
070f2de3f1 feat(stock-lab): screener Pydantic 스키마 2026-05-12 13:37:23 +09:00
01ebd2e7d9 feat(stock-lab): telegram.py 메시지 빌더 (Top10 + 아이콘 + 페이지 링크) 2026-05-12 09:34:53 +09:00
7db9869722 feat(stock-lab): Screener 엔진 + combine + ScreenerResult + 노드 레지스트리
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:29:10 +09:00
97cb38ca7f feat(stock-lab): position_sizer — ATR Wilder + entry/stop/target 2026-05-12 09:25:49 +09:00
90c408aa77 feat(stock-lab): VcpLite 노드 — 변동성 수축률 백분위 2026-05-12 09:07:59 +09:00
55f2fa9cff feat(stock-lab): MaAlignment 노드 — 이평선 정배열 5조건 룰 점수 2026-05-12 09:06:45 +09:00
3ded781059 feat(stock-lab): RsRating 노드 — IBD 가중 시장초과수익 백분위 2026-05-12 09:02:28 +09:00
4eaeea9833 feat(stock-lab): High52WProximity 노드 — 신고가 대비 근접도 룰 점수 2026-05-12 08:59:55 +09:00
9709e5b019 feat(stock-lab): Momentum20 노드 — N일 수익률 백분위 2026-05-12 08:58:43 +09:00
94d6a39ce8 feat(stock-lab): VolumeSurge 노드 — log(최근/평균) 거래량 급증 2026-05-12 08:54:47 +09:00
804fdcba26 feat(stock-lab): ForeignBuy 노드 — 외국인 N일 누적 순매수 강도 2026-05-12 08:19:44 +09:00
204cee67d6 fix(lotto): grade_weekly_review import용 httpx 의존성 추가
운영 사이트 nginx emerg 'host not found in upstream lotto'의 진짜
원인은 lotto 컨테이너 자체가 ModuleNotFoundError: httpx로 시작 실패한
것이었음. grade_weekly_review.py가 httpx를 import하는데 requirements
에서 누락. 재빌드 시 컨테이너 정상 부팅 가능.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 08:03:34 +09:00
779e78405e feat(stock-lab): HygieneGate — 위생 필터 (시총/거래대금/우선주/관리종목) 2026-05-12 07:59:32 +09:00
16a651f670 feat(stock-lab): ScoreNode/GateNode 추상 + percentile_rank 유틸 2026-05-12 07:52:01 +09:00
e508b7dc35 feat(stock-lab): ScreenContext.load/restrict + 합성 픽스쳐 2026-05-12 07:49:15 +09:00
6c5481971b feat(stock-lab): FDR 종목 마스터+일봉 + naver 외국인 수급 (snapshot) 2026-05-12 07:41:40 +09:00
d7e235c008 feat(stock-lab): screener 스키마 7테이블 + 디폴트 설정 시드 2026-05-12 04:10:36 +09:00
8707d322e4 chore(stock-lab): FDR/네이버 데이터 의존성 + screener 패키지 골격 2026-05-12 04:07:52 +09:00
b4dd21e67a feat(packs-lab): chunked resumable upload (offset-based) 추가
기존 single-shot POST /upload는 그대로 유지하고, 5GB+ 안정성을 위한
chunk upload 5-endpoint를 추가했다.

- POST /upload/init — mint-token jti consume + 세션 디렉토리 생성
- PUT /upload/{sid}/chunk?offset=N — offset 매칭 후 .part 파일 append
  · 불일치 시 409 + X-Current-Offset 헤더로 재개 지점 통보
- GET /upload/{sid}/status — 현재 written / expected_size 조회
- POST /upload/{sid}/complete — atomic rename + Supabase INSERT
- DELETE /upload/{sid} — 세션 중단 + 부분파일 정리

auth.py: verify_upload_token_no_consume() 추가 — chunk/complete/abort/status
는 동일 mint-token을 재사용해야 하므로 jti consume 없이 시그니처+만료만 검증.

models.py: InitUploadResponse, ChunkUploadResponse 추가.

세션 state: PACK_BASE_DIR/.uploads/{jti}/meta.json + data.part (파일시스템
영속, 단일 컨테이너 가정).

chunk 크기 상한: PACK_CHUNK_MAX_SIZE env (기본 64MB).

tests: chunk upload 시나리오 8종 — full-flow / offset mismatch / status /
abort / wrong token / incomplete complete / filename collision / host path
저장. 전체 37 테스트 pass.

CLAUDE.md: packs-lab API 표에 chunk 5-endpoint + 사용 패턴 보강.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 02:36:20 +09:00
448dbd5f48 feat(packs-lab): DSM 호출 retry/backoff + 업로드 cleanup 보강
- dsm_client.py: _request_with_retry()로 5xx·transport·timeout만 지수백오프
  재시도 (DSM_MAX_RETRIES, DSM_BACKOFF_SEC env). DSM error code 응답 본문 로깅.
- routes.py: upload 핸들러를 try/finally로 감싸 부분파일 정리 보장, Supabase
  INSERT 호출 자체에 try/except 추가해 네트워크 예외도 cleanup.
- test_dsm_client.py: retry 시나리오 4종 추가 (5xx→성공/소진/transport
  error/4xx no-retry). 전체 29 테스트 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 02:31:39 +09:00
a826e00399 feat(stock): NXT 시간외 거래가를 정규장 마감 후 자동 연결
네이버 모바일 주식 API의 overMarketPriceInfo를 인식해 NXT 프리/애프터마켓
운영 중이면 overPrice를 current_price로 자동 전환. 포트폴리오 응답에
price_session(REGULAR/NXT_PRE/NXT_AFTER/CLOSED)과 price_as_of 메타 동봉.

이전엔 closePrice만 사용해 15:30 이후 NXT 거래가 진행 중이어도 평가금액이
동결됐음. 이제 가격이 자연스럽게 이어짐. _select_price_from_response는
순수 함수로 분리, unittest 8케이스로 회귀 방지.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:32:10 +09:00
134e628e5e Merge feature/lotto-curator-evolution: Lotto Curator Evolution
16 commits across Phase A-E + H:
- weekly_review 테이블 + grade_weekly_review 잡 (일 03:00 KST)
- review/bulk/briefing 4계층 라우터
- 큐레이터 4계층 스키마 + retrospective + N=30
- 텔레그램 큐레이션·당첨 알림 + lotto_agent 월 09:00 KST
- 1주차 운영 점검 체크리스트

자세한 컨셉/계획: web-ui/docs/superpowers/{specs,plans}/2026-05-11-*.md
2026-05-11 09:38:31 +09:00
ce3a734e81 docs(lotto): 1주차 운영 점검 체크리스트 2026-05-11 09:08:05 +09:00
fb81c51dc8 feat(curator): 큐레이션 후 텔레그램 자동 푸시 + cron 09:00 변경 2026-05-11 08:55:12 +09:00
715e1598ce feat(agent-office): /api/agent-office/notify/lotto-prize 웹훅 2026-05-11 08:54:19 +09:00
57a4a72ff1 feat(curator): 텔레그램 큐레이션·당첨 알림 포맷터 2026-05-11 08:53:10 +09:00
e14278ec69 feat(curator): pipeline 4계층 직렬화 + retrospective 컨텍스트 + N=30 2026-05-11 08:51:07 +09:00
ff3134b838 feat(curator): build_retrospective + lotto review service proxy 2026-05-11 08:49:58 +09:00
95c5dc4217 feat(curator): SYSTEM_PROMPT 회고 + 4계층 규칙 2026-05-11 08:48:06 +09:00
9fb1c37eae feat(curator): 4계층 picks + tier_rationale + narrative.retrospective 스키마 2026-05-11 08:46:50 +09:00
3bd819b5e2 feat(lotto): briefing API 4계층 picks + tier_rationale 수용 2026-05-11 08:45:21 +09:00
b936233e7c feat(lotto): POST /api/lotto/purchase/bulk — 결정카드 원클릭 기록 2026-05-11 08:42:27 +09:00
4f85496fe5 feat(lotto): review 라우터 — latest/history/by-draw 2026-05-11 08:39:01 +09:00
2a2209a86c feat(lotto): 일 03:00 KST 채점 잡 APScheduler 등록 2026-05-11 08:37:08 +09:00
30bc627ae7 feat(lotto): grade_weekly_review 통합 잡 — 큐레이터 자기평가 + 패턴 갭 2026-05-11 08:33:51 +09:00
d972ea66c3 feat(lotto): 채점 보조 함수 — 일치 수·패턴 요약·델타 2026-05-11 08:29:46 +09:00
66165ebb88 feat(lotto): lotto_briefings.picks 4계층 객체로 마이그레이션 + tier_rationale 컬럼 2026-05-11 08:25:23 +09:00
5621cc7687 feat(lotto): weekly_review 테이블 + CRUD 헬퍼 2026-05-11 08:21:44 +09:00
fb54998def fix(deployer): deploy.sh 4 화이트리스트에 packs-lab 추가 + media/packs 자동 생성
deployer가 webhook 받을 때 packs-lab을 자동 rebuild·재시작·헬스체크 안 하던
근본 원인 — deploy.sh의 BUILD_TARGETS / CONTAINER_NAMES / HEALTH_ENDPOINTS
3개 화이트리스트에서 packs-lab 누락. SERVICES 화이트리스트(deploy-nas.sh)는
rsync 동기화용이라 별도이며 거기엔 이전에 추가했지만 빌드 트리거는 deploy.sh가
담당.

Fix:
- BUILD_TARGETS, CONTAINER_NAMES, HEALTH_ENDPOINTS에 packs-lab 추가
- media/packs 디렉토리 자동 mkdir + chown (admin이 수동 생성하던 절차 제거)
- DATA_DIRS는 path 다르니(data/X 아닌 media/packs) 제외

이번 push 자체는 옛 deploy.sh로 처리되지만 새 deploy.sh가 RUNTIME에 sync된 후
다음 push부터 packs-lab이 자동 빌드·헬스체크된다.
2026-05-11 04:07:02 +09:00
b792cdb8d5 docs(packs-lab): 운영 검증 결과 반영 — DSM API path 형식 + DSM_VERIFY_SSL 명시
5/11 운영 첫 호출 검증 중 발견된 사항을 spec/CLAUDE.md에 반영:

1. DSM API path 형식 차이: Synology DSM은 일반 사용자 권한일 때
   /<shared_folder>/... 형식만 인식, /volume1/... 거부 (error 408).
   PACK_HOST_DIR 운영 예시값 /docker/webpage/media/packs로 변경.

2. DSM_VERIFY_SSL env 명시: LAN IP + self-signed cert 환경에서 SSL 검증
   끄기 위한 환경변수. .env.example 7+3 path로 갱신.

3. DSM 사용자 권한 가이드: File Station + Sharing 둘 다 ON 필요.

4. NAS 디렉토리 준비 명령에서 호스트 OS path와 DSM API path 차이 명시.

운영 검증: HTTP 200 + DSM 공유 URL (gofile.me/...) 발급 확인.
2026-05-11 04:02:36 +09:00
1d4bff31c4 feat(packs-lab): DSM_VERIFY_SSL env — LAN IP + self-signed cert 환경 대응
운영 NAS에서 DSM_HOST=https://192.168.x.x:5001 같은 LAN IP 사용 시
DSM의 self-signed 인증서가 IP 주소에 매칭되지 않아 SSL 검증 실패
(SSL: CERTIFICATE_VERIFY_FAILED — IP address mismatch).

LAN 내부 통신이라 verify=False 허용 가능. 환경변수로 토글:
- DSM_VERIFY_SSL=true (default) — 도메인 + 정상 cert 환경
- DSM_VERIFY_SSL=false — LAN IP + self-signed 환경

dsm_client.py가 환경변수 읽어 httpx.AsyncClient(verify=...)에 전달.
docker-compose.yml + .env.example + CLAUDE.md에 신규 env 명시.
회귀 25/25 passing.
2026-05-11 03:31:15 +09:00
e31bf549a8 docs(spec/plan): packs-lab spec/plan 복구 + PACK_HOST_DIR/평면구조/SERVICES 화이트리스트 반영
dc92c3d에서 "완료된 spec/plan 제거"로 함께 정리됐던 두 파일을 복구하고,
이후 적용된 운영 변경사항을 반영해 문서-구현 추적성 회복:

- PACK_HOST_DIR 환경변수 도입 (NAS 호스트 절대경로, DSM·Supabase에 노출)
- 평면 저장 구조 (PACK_BASE_DIR/{filename}, tier 디렉토리 분기 제거 — tier는 filename 규칙으로)
- scripts/deploy-nas.sh의 SERVICES 화이트리스트에 packs-lab 추가 (누락 시 NAS 컨테이너 미등장)
- .env.example 환경변수 6+3 path (DSM 3 / HMAC / Supabase 2 / TTL / DATA_PATH / BASE_DIR / HOST_DIR)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 03:03:00 +09:00
aec0fdcd31 fix(packs-lab): tier 디렉토리 제거(평면 구조) + deployer SERVICES에 packs-lab 추가
문제 1: deploy-nas.sh의 SERVICES 화이트리스트에 packs-lab이 빠져 있어
NAS 운영 디렉토리에 소스 sync가 안 됐고 docker compose가 packs-lab을
빌드 못해 컨테이너가 안 떠 있었다.

문제 2: routes.py가 PACK_BASE_DIR/{tier}/{filename} 트리 구조로 저장 →
사용자 요청에 따라 평면 구조(PACK_BASE_DIR/{filename})로 변경. tier 구분은
filename 규칙(prefix 등)으로 admin이 관리.

- scripts/deploy-nas.sh: SERVICES에 packs-lab 추가 (10개 → 11개)
- routes.py: tier 디렉토리 제거 (target = PACK_BASE_DIR / filename, host_path = PACK_HOST_DIR / filename)
- tests: tier 분기 사용처 평면 구조로 보정 (size_mismatch / host_path_check)
- 25/25 passing

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:54:25 +09:00
f1f1dc98a6 fix(packs-lab): PACK_HOST_DIR 도입 — sign-link 시 DSM이 NAS 호스트경로 받도록
이전: upload가 컨테이너 경로(/app/data/packs/...)를 Supabase에 저장 →
sign-link 시 그 경로를 DSM에 전달 → DSM은 NAS 호스트 절대경로
(/volume1/.../media/packs/...) 기준이라 파일을 찾지 못함.

수정:
- routes.py: PACK_HOST_DIR 신규 (env, fallback=PACK_BASE_DIR)
  - upload 시 host_path = PACK_HOST_DIR/{tier}/{filename}을 Supabase에 INSERT
  - sign-link 시 PACK_HOST_DIR 기준 경로 검증
- docker-compose: PACK_HOST_DIR env 주입 (default=PACK_DATA_PATH)
- .env.example + CLAUDE.md: 환경변수 의미 분리 명시
- tests: 호스트경로 저장 검증 신규 (test_upload_stores_host_path_not_container_path)
- 25/25 passing

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:47:26 +09:00
8b5cb2c16a feat(music-lab): 랜덤 풀에 7개 장르 추가 + GET /api/music/genres 2026-05-10 23:53:35 +09:00
77b8d05ad7 feat(music-lab): 배치 음악 생성 endpoint + 자동 compile·video 파이프라인 오케스트레이터
- batch_generator.py: 장르별 N트랙 순차 Suno 생성 → 자동 compile → 자동 video pipeline
- main.py: POST/GET /api/music/generate-batch, GET /api/music/generate-batch/{id} 추가
- tests: 10개 endpoint 테스트 (검증·필터·404)
2026-05-10 18:57:23 +09:00
f0cb06268e feat(music-lab): music_batch_jobs 테이블 + 장르별 랜덤 풀 2026-05-10 18:52:07 +09:00
f074cbec2d docs: 배치 음악 생성 + 자동 영상 파이프라인 spec + plan 2026-05-10 18:49:16 +09:00
84548a326e feat(music-lab): cover 16:9 landscape 생성 + 메타데이터 프로페셔널화
- cover.py: DALL·E 3 → 1792x1024, gpt-image-1 → 1536x1024 (모델별 자동),
  prompt에 'cinematic landscape composition' 명시. OPENAI_IMAGE_SIZE env로 override 가능.
- metadata.py: prompt를 list+join 패턴으로 재구성 (인접 문자열/+ 충돌 해결)
  + lofi 채널 카피라이터 페르소나 부여. description 5-7섹션 구조 명시:
  후크/분위기/사용시나리오/챕터/시청권장/콜투액션/해시태그.
  mix vs single 분기 + tags 가이드 + 출력 JSON schema 명시.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:38:53 +09:00
5f5010ded4 fix(music-lab): video encoder timeout을 duration에 비례 (긴 mix 인코딩 지원) 2026-05-10 17:10:27 +09:00
755dea63f4 fix(music-lab): cache-buster query 제거 + DALL·E prompt에 background_keyword 활용
1. video.py _container_to_nas, orchestrator.py _local_path에서 path 변환 전 ?쿼리 strip
   — 이전 commit 20c5268의 cache-buster ?v=...가 Windows path로 그대로 전달되어 input_validation 실패하던 문제 픽스
2. cover.py _generate_with_dalle가 background_keyword를 prompt에 포함
   — 사용자가 PipelineStartModal에서 '배경 키워드' 입력 시 처음부터 원하는 분위기 cover 생성
2026-05-10 16:12:21 +09:00
20c5268def fix(music-lab): pipeline media URL에 cache-buster — regen 시 브라우저/텔레그램 캐시 우회 2026-05-10 15:50:42 +09:00
dc3f9cb6a9 fix(music-lab): compile job status='done'도 ready로 인식 (production convention) 2026-05-10 15:28:08 +09:00
262366bc1e test(music-lab): compile_job 기반 happy path 통합 테스트 2026-05-09 13:27:47 +09:00
5fc914cd8f feat(music-lab): POST /pipeline에 compile_job_id + visual_style/background 옵션 2026-05-09 13:20:38 +09:00
8f859274c4 feat(music-lab): video.py — Windows에 style/background_mode/tracks 전달 + orchestrator 파라미터 wiring 2026-05-09 13:17:49 +09:00
a347da075c feat(music-lab): metadata tracks 옵션 + YouTube 챕터 자동 형식 2026-05-09 13:15:30 +09:00
e754fb30f5 feat(music-lab): background.py — Pexels Video API + orchestrator video_loop 분기 2026-05-09 13:13:42 +09:00
f0c0c18beb feat(music-lab): cover.py Pexels 이미지 검색 분기 (image_source=pexels) 2026-05-09 13:10:49 +09:00
d11023decb feat(music-lab): orchestrator _resolve_input — track/compile_job 통합 입력 2026-05-09 13:08:53 +09:00
70a256bbe4 feat(music-lab): video_pipelines 4 컬럼 추가 + compile_jobs JOIN
- _add_column_if_missing 헬퍼 추가 (idempotent ALTER TABLE)
- video_pipelines에 compile_job_id, visual_style, background_mode, background_keyword 컬럼 추가
- track_id를 nullable로 변경 (compile_job_id 입력 모드 지원)
- create_pipeline에 compile_job_id XOR track_id 검증 추가
- get_pipeline / list_pipelines에 compile_jobs LEFT JOIN — compile_title 노출

Task 1 of 17: Essential Mix pipeline DB migration

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:04:23 +09:00
ebbfa6299a docs(plan): Essential Mix 파이프라인 — 17 task 구현 계획
DB 마이그레이션 → orchestrator _resolve_input → cover Pexels 분기 →
background.py 신규 → metadata tracks → video.py 파라미터 확장 →
main.py compile_job_id → Windows essential filter (showfreqs+ring+drawtext) →
server.py schema → 통합 테스트 → 배포 → 프론트(api.js, CompileTab,
PipelineStartModal, PipelineCard+DetailModal, SetupTab) → 프론트 푸시 → E2E.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:44:02 +09:00
d4fb485931 docs(spec): Essential Mix 파이프라인 설계
1시간+ mix 영상(컴파일 → 파이프라인) + essential 시각 스타일(배경 사진 + 중앙 방사형 막대 + 곡명 자막) + 진행 탭 산출물 미리보기 모달.

핵심 결정:
- 입력: track_id XOR compile_job_id
- 시각: single (기존) / essential (신규, default)
- 배경: static(사진) / video_loop(Pexels 영상)
- 배경 소스: AI 기본 + Pexels 폴백
- Mix 메타: 트랙 리스트 자동 챕터화 (YouTube 자동 인식)
- UX: PipelineCard mini 미리보기 + 클릭 시 상세 모달

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:55:24 +09:00
b6dffb4d42 chore(infra): GPU 영상 인코더 env 추가 (WINDOWS_VIDEO_ENCODER_URL, NAS_VIDEOS_ROOT, NAS_MUSIC_ROOT) 2026-05-09 02:03:26 +09:00
240bd38541 feat(music-lab): 영상 인코딩을 Windows GPU 서버로 오프로드
- pipeline/video.py 재작성: subprocess.run 제거, httpx로 Windows /encode_video 호출
- Windows 서버 다운 시 즉시 VideoGenerationError (NAS 로컬 폴백 X — 의도적 결정)
- /app/data/* → /volume1/docker/webpage/data/* 경로 변환 (_container_to_nas)
- 테스트는 respx mock 기반으로 교체 (6개)
2026-05-09 02:01:34 +09:00
bb0b0dff25 docs: GPU 영상 인코딩 오프로드 spec + plan
NAS 저성능 CPU(J4025) ffmpeg 5분 타임아웃 → Windows PC RTX 5070 Ti NVENC로
오프로드. 같은 music_ai 서버에 /encode_video endpoint 추가, NAS는 다운 시
즉시 실패 (로컬 폴백 X). LAN 무인증.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:52:34 +09:00
47e5315487 fix(music-lab): ffmpeg 인코딩 가속 + 타임아웃 확장 (저성능 CPU 대응)
- preset fast → ultrafast (5-10x 가속, J4025 같은 저성능 CPU에서 5분 내 완료)
- tune stillimage 추가 (정적 배경 + 파형 오버레이에 최적)
- threads 0 — 모든 CPU 코어 활용
- VIDEO_TIMEOUT_S 300 → 600 (안전 마진)
- subprocess.TimeoutExpired 캐치하여 명확한 에러 메시지
2026-05-09 01:01:03 +09:00
97b15cb985 fix(pipeline): premature state update + reject 재생성 알림
버그1: /feedback approve가 bg task 시작 전에 state를 next_pending으로 set →
polling이 빈 video_url로 알림 발송. bg task의 run_step이 state를 set하도록
일임 — 이중 update 제거.

버그2: reject 후 같은 *_pending 상태로 재생성됐을 때 dedupe에 막혀 알림이
안 감. dedupe 키에 feedback_count_per_step[step]을 포함 — 재생성마다
count가 증가하므로 키가 달라져 재알림 동작.
2026-05-08 23:08:24 +09:00
6d416aab78 fix(music-lab): pipeline 동기 작업을 asyncio.to_thread로 — 이벤트 루프 블로킹 해결
video.generate/thumb.generate/youtube.upload_video는 동기 함수로 ffmpeg subprocess(최대 5분)와
google-api-python-client(최대 10분)를 호출함. async run_step에서 직접 호출하면 이벤트 루프가
블로킹돼 후속 요청이 504로 타임아웃되고 텔레그램 폴링도 끊김.

asyncio.to_thread로 감싸 스레드 풀에서 실행 — 이벤트 루프 자유.
2026-05-08 22:57:33 +09:00
2c13e7cc85 fix(music-lab): pipeline 오디오 경로 + ffmpeg 에러 가시성
- orchestrator._run_video: track.file_path 우선 사용 (audio_url 변환 불필요)
- _local_path: /media/music/ → /app/data/ (마운트가 /app/data 직접이라 music 서브디렉토리 없음)
- video.py/thumb.py: stderr truncation [-800:]/[-500:] — 진짜 에러 보이게
2026-05-08 22:50:13 +09:00
4f67cd02fa fix(music-lab): pipeline 응답에 track_title 포함 (LEFT JOIN music_library) 2026-05-07 17:43:55 +09:00
868906b8c6 test(music-lab): 풀 파이프라인 통합 테스트 (mock) 2026-05-07 17:37:15 +09:00
bd97cc1e97 chore(infra): pipeline env (OPENAI/YOUTUBE_OAUTH_*) + 폰트
music-lab 서비스에 OpenAI/YouTube OAuth 환경변수 + Claude 모델 변수 추가.
agent-office에도 CLAUDE_HAIKU_MODEL/CLAUDE_SONNET_MODEL 노출.
Alpine 기반 music-lab 이미지에 ttf-dejavu + fontconfig 설치하고
PIL이 참조하는 Debian 스타일 경로(/usr/share/fonts/truetype/dejavu)로
심볼릭 링크 생성하여 썸네일/그라디언트 폰트 로딩 보장.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:33:44 +09:00
7552ce4263 feat(agent-office): youtube_publisher 에이전트 + 30s 폴링
- YoutubePublisherAgent: 음악 파이프라인의 *_pending 상태를 폴링하여
  텔레그램 단일 채널로 단계별 검토 요청 발송, reply 수신 시 의도 분류
  후 music-lab에 feedback POST
- service_proxy: pipeline list/get/feedback/telegram-msg/lookup-by-msg
  헬퍼 5종 추가 (MUSIC_LAB_URL 사용)
- scheduler: 30초 interval로 poll_state_changes 실행
- telegram webhook: reply_to_message 가 파이프라인 메시지면
  youtube_publisher 로 라우팅 (슬래시 명령보다 우선)
- 테스트 4종 추가 (4 PASS)
2026-05-07 17:20:21 +09:00
17034ea6ea feat(agent-office): 텔레그램 자연어 의도 분류 2026-05-07 17:15:24 +09:00
fe60c8d330 feat(music-lab): pipeline 오케스트레이터 + 14 엔드포인트 2026-05-07 17:11:29 +09:00
4755e34c14 feat(music-lab): YouTube OAuth + resumable 업로드 2026-05-07 17:05:12 +09:00
ad1c721ba8 feat(music-lab): pipeline 4축 AI 검토 + 휴리스틱 폴백 2026-05-07 17:01:17 +09:00
1c705b0ef3 feat(music-lab): pipeline 메타데이터 LLM 생성 + 폴백 2026-05-07 16:58:03 +09:00
68dec2e53d feat(music-lab): pipeline 영상·썸네일 생성 2026-05-07 16:53:57 +09:00
e33a2310af feat(music-lab): AI 커버 생성 + 그라데이션 폴백
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:50:28 +09:00
fceca88db4 feat(music-lab): pipeline 상태 머신 2026-05-07 16:40:31 +09:00
d66a321982 feat(music-lab): pipeline 5개 DB 테이블 + 헬퍼
YouTube 음악 파이프라인 Task 1 — 신규 5개 테이블과 헬퍼 함수 추가.

테이블:
- video_pipelines: 파이프라인 단위 상태 머신 + 메타/리뷰 JSON
- pipeline_jobs: 단계별 비동기 작업 상태/시간
- pipeline_feedback: 텔레그램 피드백 이력
- youtube_oauth_tokens: 채널 OAuth refresh/access 토큰
- youtube_setup: 단일 행 설정 (메타 템플릿/커버 프롬프트/리뷰 가중치/임계값/비주얼/공개정책)

헬퍼:
- create_pipeline / get_pipeline / update_pipeline_state / list_pipelines
- increment_feedback_count / record_feedback / get_feedback_history
- create_pipeline_job / update_pipeline_job / list_pipeline_jobs
- get_youtube_setup / update_youtube_setup
- upsert_oauth_token / get_oauth_token / delete_oauth_token

테스트:
- tests/test_pipeline_db.py: 7개 테스트 (생성/상태/피드백/잡/셋업/OAuth)
- tests/conftest.py: freezegun 기반 freezer fixture 추가
- requirements.txt: freezegun>=1.4 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:37:11 +09:00
e03d074222 docs(plan): Music YouTube 파이프라인 구현 계획 — 16 task
스펙 2026-05-07-music-youtube-pipeline-design.md를 16개 task로 분해.
TDD 패턴: 각 task = 실패 테스트 → 구현 → 통과 → 커밋.

태스크 흐름:
1. DB 5개 테이블 + 헬퍼
2. 상태 머신
3. Storage + 커버 (DALL·E + 폴백)
4. 영상/썸네일 (FFmpeg)
5. 메타데이터 (Claude Haiku)
6. AI 검토 4축 (Claude Sonnet + 휴리스틱)
7. YouTube OAuth + 업로드
8. 오케스트레이터 + 13 엔드포인트
9. agent-office 자연어 의도 분류
10. youtube_publisher 에이전트 + 30s 폴링
11. web-ui api.js 헬퍼
12. SetupTab
13. PipelineTab + 카드
14. YoutubeTab 6 서브탭 + Library 트리거
15. docker-compose env + nginx
16. 통합 테스트 + 수동 E2E

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:23:46 +09:00
2eeb98a723 docs(spec): Music YouTube 파이프라인 단계별 승인 자동화 설계
트랙 → 영상 → 발행까지 단계별 텔레그램 승인 워크플로 설계.
- 6단계 진행 바: 커버/영상/썸네/메타/AI검토/발행
- 자연어 의도 분류 (화이트리스트 + LLM 폴백)
- 반려 시 사용자 피드백 반영 재생성 (5회 한도)
- AI 최종 검토 4축 가중평균 (메타/정책/시청/트렌드)
- music-lab 5개 신규 테이블 + 12개 엔드포인트
- agent-office youtube_publisher 에이전트 + scheduler 폴링
- web-ui SetupTab + PipelineTab 신규 + Library 트리거

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:13:29 +09:00
657ffdc55f fix(agent-office): 아침 브리핑 직전 뉴스 스크랩 트리거 — 어제 뉴스 송출 방지
기존: stock-lab cron 스크랩(08:00)이 stock 에이전트 브리핑(07:30)보다 늦어
어제 8시 스크랩 결과만 DB에 있어 어제 뉴스가 요약·전송되었음.

수정: 에이전트 on_schedule에서 summarize 직전 /api/stock/scrap을 호출해
DB를 오늘 새벽 뉴스로 갱신한 뒤 요약. 스크랩 실패 시 경고 로그만 남기고
이전 데이터로 진행(브리핑 자체가 무산되는 것은 방지).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:31:55 +09:00
f54da7d46a chore(harness): 프로젝트 settings.json — git/docker/pytest allowlist + 민감파일 deny
체크인되는 프로젝트 권한 설정. read-only 명령(status/diff/logs/ps 등)을
사전 승인하여 권한 프롬프트 감소. .env / *.pem / *.key / lotto.db / stock.db
deny로 비밀·DB 직접 읽기 차단.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:06:12 +09:00
dc92c3d42d docs: 완료된 spec/plan 제거 + lotto 프리미엄 로드맵 보존
운영 중인 기능에 대한 design/plan 문서 일괄 삭제(20개 spec + 14개 plan).
미구현 pet-lab만 보존. lotto-premium-roadmap.md 신규 추가
(Phase 3 구독 모델 미구현 — STATUS.md에서 참조).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:06:04 +09:00
450 changed files with 95083 additions and 36771 deletions

41
.claude/settings.json Normal file
View File

@@ -0,0 +1,41 @@
{
"permissions": {
"allow": [
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(git show:*)",
"Bash(git branch:*)",
"Bash(git stash list:*)",
"Bash(git remote -v)",
"Bash(docker ps:*)",
"Bash(docker logs:*)",
"Bash(docker compose ps:*)",
"Bash(docker compose logs:*)",
"Bash(docker compose config:*)",
"Bash(docker images:*)",
"Bash(pytest:*)",
"Bash(python -m pytest:*)",
"Bash(python -V)",
"Bash(python -c:*)",
"Bash(pip list:*)",
"Bash(pip show:*)",
"Bash(pip freeze:*)",
"Bash(uvicorn --version)",
"Bash(ls:*)",
"Bash(cat docker-compose.yml)"
],
"deny": [
"Read(.env)",
"Read(.env.*)",
"Read(**/.env)",
"Read(**/.env.*)",
"Read(**/credentials*)",
"Read(**/secrets*)",
"Read(**/*.pem)",
"Read(**/*.key)",
"Read(**/lotto.db)",
"Read(**/stock.db)"
]
}
}

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
@@ -99,6 +104,8 @@ YOUTUBE_DATA_API_KEY=
DSM_HOST=https://gahusb.synology.me:5001
DSM_USER=
DSM_PASS=
# LAN IP로 DSM 접근 시 self-signed cert가 IP에 매칭 안 되어 검증 실패. 그 경우 false 설정 (LAN 내부 통신이라 허용 가능). 도메인 + 정상 cert면 true 유지.
DSM_VERIFY_SSL=true
# Vercel SaaS ↔ backend HMAC 시크릿 (양쪽 동일 값)
BACKEND_HMAC_SECRET=
@@ -115,3 +122,8 @@ PACK_DATA_PATH=./data/packs
# 컨테이너 내부 PACK_BASE_DIR (routes.py가 파일 저장 시 사용. docker-compose volume의 컨테이너 측 경로와 반드시 일치)
PACK_BASE_DIR=/app/data/packs
# DSM·Supabase에 노출되는 NAS 호스트 절대경로 (PACK_DATA_PATH와 같은 디렉토리를 호스트 시점에서 가리킴).
# 운영 NAS는 반드시 /volume1/docker/webpage/media/packs 같은 절대경로 설정.
# 미설정 시 PACK_DATA_PATH로 fallback (로컬 개발용).
PACK_HOST_DIR=/docker/webpage/media/packs

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건. 진단 커맨드.

829
CLAUDE.md
View File

@@ -1,15 +1,31 @@
# CLAUDE.md — web-backend 프로젝트 가이드
> Claude Code가 이 프로젝트를 작업할 때 참조하는 설정 및 구조 문서.
> Claude Code가 이 프로젝트를 작업할 때 참조하는 **안정적 카탈로그**.
> 포트·nginx 라우팅·서비스별 API 엔드포인트 목록·공통 규칙만 담는다.
> **DB 스키마 세부·스케줄러 잡·환경변수 세부·최근 기능 히스토리는 서비스별 메모리(`service_<name>.md`)가 authoritative** — 9번 섹션 각 서비스 끝의 메모리 포인터 참조.
---
## 0. 메모리 구조 규칙 (하네스 엔지니어링)
이 모노레포는 **서비스당 1개 메모리 파일**(`memory/service_<name>.md`)로 운영 상태를 관리한다.
- **CLAUDE.md (이 파일, 항상 로딩)** = 변하지 않는 지도: 포트, nginx 라우팅, 서비스 한 줄 역할, API 엔드포인트 목록, cross-cutting 규칙.
- **`service_<name>.md` (관련 시 recall)** = 휘발성 상세: DB 테이블+컬럼, 스케줄러 cron, 환경변수, provider/큐 흐름, 비자명한 함정, 최근 기능 작업 히스토리.
**작업 시작 전**: 해당 서비스의 `service_<name>.md`를 먼저 읽어 최신 운영 상태·함정을 확인할 것. 14개 서비스 전부 메모리 파일이 있다(`MEMORY.md` 인덱스 참조).
**변경 후**: DB 스키마/스케줄러/운영 흐름이 바뀌면 CLAUDE.md가 아니라 해당 서비스 메모리를 갱신할 것.
---
## 1. 프로젝트 개요
Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
- **서비스**: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, personal, packs-lab, deployer (10개)
- **서비스 14개**: lotto, stock, music-lab, video-lab, image-lab, insta-lab, realestate-lab, agent-office, tarot-lab, saju-lab, personal, packs-lab, travel-proxy, deployer
- **공유 인프라**: `_shared/access_log` 모듈 (5개 서비스 공유), `redis` (music/video/image/insta-lab 큐 공유)
- **렌더/생성 위임**: music/video/image/insta의 무거운 생성·렌더는 **Windows AI 워커**(`web-ai` 별도 레포)가 담당. NAS 서비스는 Redis 큐 push + 결과 webhook 수신만 한다.
- **프론트엔드**: 별도 레포 (React + Vite SPA), 빌드 산출물만 NAS에 배포
- **인프라**: Docker Compose (10컨테이너) + Nginx(리버스 프록시) + Gitea Webhook 자동 배포
- **인프라**: Docker Compose (16+ 컨테이너) + Nginx(리버스 프록시) + Gitea Webhook 자동 배포
---
@@ -22,7 +38,7 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
| 메모리 | 18 GB |
| Docker | Synology Container Manager |
| Git 서버 | Gitea (self-hosted, NAS 내부) |
| AI 서버 | Windows PC (192.168.45.59:8000) — NVIDIA RTX 5070 Ti (16GB VRAM) + Ollama |
| AI 서버 | Windows PC (192.168.45.59) — NVIDIA RTX 5070 Ti (16GB VRAM) + Ollama. 상세 → `infra_windows_ai.md` 메모리 |
---
@@ -31,22 +47,20 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
```
/volume1
├── docker/webpage/ # 운영 런타임 (Docker Compose 실행 위치)
│ ├── lotto/ # lotto 소스 (rsync 동기화)
│ ├── stock-lab/ # stock-lab 소스 (rsync 동기화)
│ ├── travel-proxy/ # travel-proxy 소스 (rsync 동기화)
│ ├── deployer/ # deployer 소스 (rsync 동기화)
│ ├── <service>/ # 각 서비스 소스 (rsync 동기화)
│ ├── nginx/default.conf # Nginx 설정
│ ├── scripts/deploy.sh # Webhook 트리거 배포 스크립트
│ ├── docker-compose.yml
│ ├── .env # 운영 환경변수
── data/lotto.db # SQLite DB
│ └── data/music/ # 생성된 오디오 파일 (music-lab)
── data/ # SQLite DB + 생성 미디어 (*.db, music/, video/, image/, insta_cards/ 등)
├── workspace/web-page-backend/ # Git 레포 클론 위치 (REPO_PATH)
└── web/images/webPage/travel/ # 원본 여행 사진 (RO 마운트)
```
배포 흐름·런타임 함정 상세 → `service_deployer.md`, `feedback_nas_deploy_runtime.md` 메모리.
---
## 4. Docker 서비스 & 포트
@@ -54,14 +68,19 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
| 컨테이너 | 포트 | 역할 |
|---------|------|------|
| `lotto` | 18000 | 로또 데이터 수집·분석·추천 API |
| `stock-lab` | 18500 | 주식 뉴스·AI 분석·KIS API 연동 |
| `music-lab` | 18600 | AI 음악 생성·라이브러리 관리 API |
| `blog-lab` | 18700 | 블로그 마케팅 수익화 API |
| `stock` | 18500 | 주식 뉴스·AI 분석·KIS API 연동 + 보유종목 인텔리전스 |
| `music-lab` | 18600 | AI 음악 생성 게이트웨이 (Suno/MusicGen 호출은 Windows 워커, NAS는 Redis push) |
| `video-lab` | 18801 | 동영상 생성 게이트웨이 (sora/veo/kling/seedance, Redis 큐) |
| `image-lab` | 18802 | 이미지 생성 게이트웨이 (gpt_image/nano_banana/flux, Redis 큐) |
| `insta-lab` | 18700 | 인스타 카드 피드 자동 생성 (렌더는 Windows insta-render 워커) |
| `realestate-lab` | 18800 | 부동산 청약 자동 수집·매칭 API |
| `agent-office` | 18900 | AI 에이전트 오피스 (실시간 WebSocket + 텔레그램 연동) |
| `tarot-lab` | 18250 | 타로 카드 해석 (Claude Sonnet 3-card, agent-office에서 분리) |
| `saju-lab` | 18300 | 사주 분석 + 궁합 (Claude Sonnet, TS→Python 포팅, lunar↔solar 내장) |
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (DSM 공유 링크 + 5GB 업로드, Vercel SaaS와 HMAC 통신) |
| `personal` | 18850 | 개인 서비스 (포트폴리오·블로그·투두 통합) |
| `travel-proxy` | 19000 | 여행 사진 API + 썸네일 생성 |
| `redis` | 6379 | 비동기 큐 (music/video/image/insta-lab 공유) |
| `frontend` (nginx) | 8080 | 정적 SPA 서빙 + API 리버스 프록시 |
| `webpage-deployer` | 19010 | Gitea Webhook 수신 → 자동 배포 |
@@ -71,26 +90,41 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
| 경로 | 프록시 대상 | 비고 |
|------|------------|------|
| `/api/` | `lotto:8000` | lotto API (기본) |
| `/api/travel/` | `travel-proxy:8000` | travel API |
| `/api/stock/` | `stock-lab:8000` | stock API |
| `/api/trade/` | `stock-lab:8000` | KIS 실계좌 API |
| `/api/portfolio` | `stock-lab:8000` | trailing slash 유무 모두 매칭 |
| `/api/music/` | `music-lab:8000` | AI 음악 생성·라이브러리 API |
| `/api/blog-marketing/` | `blog-lab:8000` | 블로그 마케팅 수익화 API |
| `/api/` | `lotto:8000` | lotto API (catch-all fallback) |
| `/api/travel/` | `travel-proxy:8000` | travel API (proxy_read_timeout 600s) |
| `/api/stock/` | `stock:8000` | stock API |
| `/api/trade/` | `stock:8000` | KIS 실계좌 API |
| `/api/portfolio` | `stock:8000` | trailing slash 유무 모두 매칭 |
| `/api/music/` | `music-lab:8000` | AI 음악 생성·라이브러리 API (660s) |
| `/api/video/` | `video-lab:8000` | 동영상 생성 게이트웨이 (120s) |
| `/api/image/` | `image-lab:8000` | 이미지 생성 게이트웨이 (120s) |
| `/api/insta/` | `insta-lab:8000` | 인스타 카드 자동 생성 API (300s) |
| `/api/realestate/` | `realestate-lab:8000` | 부동산 청약 API |
| `/api/tarot/` | `tarot-lab:8000` | 타로 해석 (proxy_read/send_timeout **600s**, Claude 3-card 응답) |
| `/api/saju/` | `saju-lab:8000` | 사주 분석 (300s) |
| `/api/todos` | `personal:8000` | 투두 API |
| `/api/blog/` | `personal:8000` | 블로그 API |
| `/api/profile/` | `personal:8000` | 포트폴리오 API |
| `/api/agent-office/` | `agent-office:8000` | AI 에이전트 오피스 API + WebSocket |
| `/api/packs/` | `packs-lab:8000` | 5GB 업로드 대응 (`client_max_body_size 5G`, `proxy_request_buffering off`, 1800s timeout) |
| `/api/agent-office/` | `agent-office:8000` | AI 에이전트 오피스 API + WebSocket (86400s) |
| `/api/packs/upload` | `packs-lab:8000` | 5GB multipart 업로드 (`client_max_body_size 5G`, `proxy_request_buffering off`, **1800s** timeout) |
| `/api/packs/` | `packs-lab:8000` | 다운로드/list |
| `/api/internal/insta/` | `insta-lab:8000` | Windows 워커 webhook (nginx IP 화이트리스트 + 앱 `X-Internal-Key`) |
| `/api/internal/music/` | `music-lab:8000` | Windows 워커 webhook (IP 화이트리스트 + `X-Internal-Key`) |
| `/api/internal/video/` | `video-lab:8000` | Windows 워커 webhook (IP 화이트리스트 + `X-Internal-Key`) |
| `/api/internal/image/` | `image-lab:8000` | Windows 워커 webhook (IP 화이트리스트 + `X-Internal-Key`) |
| `/api/webai/` | `stock:8000` | Windows AI 서버 프록시 (rate-limited 60r/m) |
| `/webhook`, `/webhook/` | `deployer:9000` | Gitea Webhook |
| `/media/music/` | `/data/music/` (파일 직접 서빙) | 생성된 오디오 파일 |
| `/media/videos/` | `/data/videos/` (파일 직접 서빙) | YouTube 영상 MP4 |
| `/media/travel/.thumb/` | `/data/thumbs/` (파일 직접 서빙) | 썸네일 캐시 |
| `/media/travel/` | `/data/travel/` (파일 직접 서빙) | 원본 사진 |
| `/assets/` | 정적 파일 (장기 캐시) | Vite 해시 파일 |
| `/` | SPA fallback (`try_files → index.html`) | |
| `/ext/feargreed` | CNN API | 공포탐욕지수 외부 프록시 |
| `/ext/vix`, `/ext/treasury`, `/ext/wti`, `/ext/brent` | Yahoo Finance | 시장 지표 외부 프록시 |
| `/media/music/` | `/data/music/` (파일 직접 서빙) | 생성된 오디오 파일 (30d cache) |
| `/media/video/` | `/data/video/` (파일 직접 서빙) | video-lab 생성 영상 (1d cache). **단수 `video`** |
| `/media/videos/` | `/data/videos/` (파일 직접 서빙) | music-lab 뮤직비디오 (1d). **복수 `videos`** |
| `/media/image/` | `/data/image/` (파일 직접 서빙) | image-lab 생성 이미지 (1d cache) |
| `/media/insta/` | `/data/insta_cards/` (파일 직접 서빙) | 카드 PNG (1h cache) |
| `/media/travel/.thumb/` | `/data/thumbs/` (파일 직접 서빙) | 썸네일 캐시 (30d). nginx miss 시 앱 라우트 폴백 생성 |
| `/media/travel/` | `/data/travel/` (파일 직접 서빙) | 원본 사진 (7d) |
| `/assets/` | 정적 파일 (장기 캐시) | Vite 해시 파일 (1y immutable) |
| `/` | SPA fallback (`try_files → index.html`) | `index.html` no-cache |
---
@@ -103,8 +137,9 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
| DB | SQLite (`/app/data/*.db`) |
| 스케줄러 | APScheduler |
| 컨테이너 | Docker (`python:3.12-slim` 기반) |
| AI 연동 | Ollama (Llama 3.1) — Windows PC (192.168.45.59) |
| AI 연동 | Claude API (Anthropic) + Ollama (Llama 3.1, Windows PC 192.168.45.59) |
| 주식 API | KIS (한국투자증권) Open API |
| 생성 워커 | Windows `web-ai` 레포 (music/video/image/insta 렌더·생성) |
---
@@ -112,13 +147,14 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
```
개발자 git push → Gitea → Webhook (HMAC SHA256 검증)
→ deployer 컨테이너 → /scripts/deploy.sh
→ rsync(REPO→RUNTIME) → docker compose up -d --build
→ deployer 컨테이너 → scripts/deploy.sh (오케스트레이터)
deploy-nas.sh (rsync REPO→RUNTIME) → docker compose up -d --build
```
- **배포 스크립트 위치**: `scripts/deploy-nas.sh` (레포) / `scripts/deploy.sh` (런타임)
- **환경변수 파일**: `.env` (RUNTIME_PATH, REPO_PATH, PHOTO_PATH, PUID, PGID 등)
- **백업**: `.releases/` 디렉토리에 자동 백업
- 배포 스크립트 동기화 함정(6개 hardcoded 위치) → `feedback_deploy_script_sync.md` 메모리
- Webhook 검증·동시배포 락·헬스체크 게이트·`.releases` 백업 상세 → `service_deployer.md` 메모리
- 머지 후 첫 webhook이 깨지는 패턴 → `feedback_nas_deploy_runtime.md` 메모리
- **프론트엔드는 자동 배포 안 됨**: 로컬 Vite 빌드 후 NAS에 수동 업로드
---
@@ -129,47 +165,29 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
docker compose up -d
```
| 서비스 | 로컬 URL |
|--------|----------|
| Frontend + API | http://localhost:8080 |
| Lotto Backend | http://localhost:18000 |
| Travel API | http://localhost:19000 |
| Stock Lab | http://localhost:18500 |
| Blog Lab | http://localhost:18700 |
| Realestate Lab | http://localhost:18800 |
| Packs Lab | http://localhost:18950 |
⚠️ **Docker는 NAS에서만 구동** — 로컬에서 docker 명령어 실행 금지 (`feedback_docker_nas.md`).
| 서비스 | 로컬 URL | | 서비스 | 로컬 URL |
|--------|----------|--|--------|----------|
| Frontend + API | http://localhost:8080 | | Tarot Lab | http://localhost:18250 |
| Lotto | http://localhost:18000 | | Saju Lab | http://localhost:18300 |
| Stock | http://localhost:18500 | | Video Lab | http://localhost:18801 |
| Music Lab | http://localhost:18600 | | Image Lab | http://localhost:18802 |
| Insta Lab | http://localhost:18700 | | Personal | http://localhost:18850 |
| Realestate Lab | http://localhost:18800 | | Agent Office | http://localhost:18900 |
| Packs Lab | http://localhost:18950 | | Travel API | http://localhost:19000 |
| Redis | redis://localhost:6379 | | | |
---
## 9. 서비스별 핵심 정보
## 9. 서비스별 API 엔드포인트
### lotto-lab (lotto/)
- DB: `/app/data/lotto.db`
- 데이터 소스: `smok95.github.io/lotto/results/`
- 파일 구조: `main.py`, `db.py`, `recommender.py`, `collector.py`, `checker.py`, `generator.py`, `analyzer.py`, `utils.py`, `purchase_manager.py`, `strategy_evolver.py`
> 각 서비스의 DB 스키마·스케줄러·환경변수·운영 함정·최근 작업은 **해당 메모리 파일**을 읽을 것.
**lotto.db 테이블**
| 테이블 | 설명 |
|--------|------|
| `draws` | 로또 당첨번호 |
| `recommendations` | 추천 이력 (즐겨찾기·태그·채점 포함) |
| `simulation_runs` | 시뮬레이션 실행 기록 |
| `simulation_candidates` | 시뮬레이션 후보 (점수 5종) |
| `best_picks` | 현재 활성 최적 번호 20개 (`is_active` 플래그로 교체) |
| `purchase_history` | 구매 이력 (실제/가상, 번호, 전략 출처, 결과) |
| `strategy_performance` | 전략별 회차 성과 (EMA 입력 데이터) |
| `strategy_weights` | 메타 전략 가중치 (EMA + Softmax) |
| `weekly_reports` | 주간 공략 리포트 캐시 |
| `lotto_briefings` | AI 큐레이터 주간 브리핑 (5세트 + 내러티브 + 토큰·비용 집계) |
| `todos` | 투두리스트 (UUID PK) — personal 서비스로 이전됨, 레거시 테이블 유지 |
| `blog_posts` | 블로그 글 (tags: JSON 배열) — personal 서비스로 이전됨, 레거시 테이블 유지 |
**스케줄러 job**
- 09:10 / 21:10 매일 — 당첨번호 동기화 + 채점 (`sync_latest``check_results_for_draw`)
- 00:05, 04:05, 08:05, 12:05, 16:05, 20:05 — 몬테카를로 시뮬레이션 (20,000후보 → 상위100 → best_picks 20개 교체)
**lotto-lab API 목록**
### lotto (lotto/)
로또 데이터 수집·분석·추천 + 자율 학습(능동 시그널·가중치 진화·자가학습 백테스트).
- 핵심 파일: `main.py`, `db.py`, `recommender.py`, `collector.py`, `checker.py`, `generator.py`, `analyzer.py`, `purchase_manager.py`, `strategy_evolver.py`, `backtest.py`, `routers/`(curator/briefing/review/backtest)
- 📌 상세(DB 15테이블·스케줄러·v1~v3 자율학습): **`service_lotto.md`**
| 메서드 | 경로 | 설명 |
|--------|------|------|
@@ -181,509 +199,286 @@ docker compose up -d
| GET | `/api/lotto/simulation` | 시뮬레이션 상세 결과 |
| GET | `/api/lotto/recommend` | 통계 기반 추천 |
| GET | `/api/lotto/recommend/heatmap` | 히트맵 기반 추천 |
| GET | `/api/lotto/recommend/batch` | 배치 추천 |
| POST | `/api/lotto/recommend/batch` | 배치 추천 저장 |
| GET/POST | `/api/lotto/recommend/batch` | 배치 추천 (조회/저장) |
| GET | `/api/lotto/recommend/smart` | 전략 진화 기반 메타 추천 |
| GET | `/api/lotto/purchase` | 구매 이력 조회 (is_real, strategy, draw_no, days 필터) |
| POST | `/api/lotto/purchase` | 구매 등록 (실제/가상, 번호, 전략 출처 포함) |
| PUT | `/api/lotto/purchase/{id}` | 구매 이력 수정 |
| DELETE | `/api/lotto/purchase/{id}` | 구매 이력 삭제 |
| GET/POST | `/api/lotto/purchase` | 구매 이력 조회/등록 |
| PUT/DELETE | `/api/lotto/purchase/{id}` | 구매 이력 수정/삭제 |
| GET | `/api/lotto/purchase/stats` | 구매 통계 (전체/실제/가상 + 전략별) |
| GET | `/api/lotto/strategy/weights` | 전략별 가중치 + 성과 + trend |
| GET | `/api/lotto/strategy/performance` | 전략별 회차 성과 이력 (차트용) |
| POST | `/api/lotto/strategy/evolve` | 수동 가중치 재계산 |
| POST | `/api/admin/simulate` | 시뮬레이션 수동 실행 |
| POST | `/api/admin/sync_latest` | 당첨번호 수동 동기화 |
| POST | `/api/admin/simulate`, `/api/admin/sync_latest` | 시뮬레이션·동기화 수동 실행 |
| GET | `/api/history` | 추천 이력 (limit, offset, favorite, tag, sort) |
| PATCH | `/api/history/{id}` | 즐겨찾기·메모·태그 수정 |
| DELETE | `/api/history/{id}` | 삭제 |
| GET | `/api/lotto/curator/candidates` | 큐레이터용 후보 N세트 + 피처 |
| GET | `/api/lotto/curator/context` | 주간 맥락(핫/콜드·직전 회차) |
| GET | `/api/lotto/curator/usage` | 큐레이터 토큰·비용 집계 |
| PATCH/DELETE | `/api/history/{id}` | 즐겨찾기·메모·태그 수정 / 삭제 |
| GET | `/api/lotto/curator/candidates`, `/curator/context`, `/curator/usage` | 큐레이터 후보·맥락·토큰비용 |
| POST | `/api/lotto/briefing` | AI 브리핑 저장 |
| GET | `/api/lotto/briefing/latest` | 최신 브리핑 |
| GET | `/api/lotto/briefing/{draw_no}` | 특정 회차 브리핑 |
| GET | `/api/lotto/briefing` | 브리핑 이력 |
| GET | `/api/lotto/briefing/latest`, `/briefing/{draw_no}`, `/briefing` | 브리핑 조회/이력 |
| GET | `/api/lotto/evolver/status`, `/evolver/history`, `/evolver/trials/{week_start}` | 가중치 진화 상태/이력/주별 trial |
| POST | `/api/lotto/evolver/generate-now`, `/evolver/evaluate-now` | 진화 수동 트리거 |
| GET | `/api/lotto/backtest/track-record` | forward 가상구매 성적 |
| GET | `/api/lotto/backtest/calibration` | winner 캘리브레이션 percentile |
| GET | `/api/lotto/backtest/review/{draw_no}` | 특정 회차 백테스트 리뷰 |
| POST | `/api/lotto/backtest/run-forward` | forward 가상구매 수동 실행 |
| POST | `/api/lotto/backtest/backfill` | 역대 캘리브레이션 백필 (`batch`, `sample_m`) |
### stock-lab (stock-lab/)
### stock (stock/)
주식 뉴스·AI 분석·KIS 연동 + **보유종목 인텔리전스**(advisory 브리핑) + 스크리너.
- 핵심 파일: `main.py`, `db.py`, `scraper.py`, `price_fetcher.py`, `holdings_intel.py`, `screener/`, `holidays.json`
- Windows AI 서버 연동: `WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000`
- KIS API 연동으로 실계좌 잔고·거래 조회
- 뉴스 스크래핑: 네이버 증권 + 해외 사이트
- DB: `/app/data/stock.db` (articles, portfolio, broker_cash, asset_snapshots, sell_history 테이블)
- 파일 구조: `main.py`, `db.py`, `scraper.py`, `price_fetcher.py`, `holidays.json`
**stock-lab API 목록**
- 📌 상세(DB 테이블·스케줄러·보유종목 인텔 아키텍처): **`service_stock.md`**
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/stock/news` | 뉴스 조회 (`limit`, `category` 파라미터) |
| GET | `/api/stock/news` | 뉴스 조회 (`limit`, `category`) |
| GET | `/api/stock/indices` | 주요 지표 실시간 조회 |
| GET | `/api/stock/holidays` | KRX 공휴일 목록 |
| POST | `/api/stock/scrap` | 수동 뉴스 스크랩 트리거 |
| GET | `/api/trade/balance` | 실계좌 잔고 조회 (Windows AI 서버 프록시) |
| POST | `/api/trade/order` | 주식 주문 (Windows AI 서버 프록시) |
| GET | `/api/portfolio` | 포트폴리오 전체 조회 (현재가·손익·예수금 포함) |
| POST | `/api/portfolio` | 종목 추가 |
| PUT | `/api/portfolio/{id}` | 종목 수정 |
| DELETE | `/api/portfolio/{id}` | 종목 삭제 |
| GET | `/api/portfolio/cash` | 예수금 전체 조회 |
| PUT | `/api/portfolio/cash` | 예수금 등록·수정 (upsert) |
| GET | `/api/stock/holdings/intel` | 보유종목 advisory (+ `/history`, `/run`) |
| GET | `/api/trade/balance` | 실계좌 잔고 (Windows AI 프록시) |
| POST | `/api/trade/order` | 주식 주문 (Windows AI 프록시) |
| GET/POST | `/api/portfolio` | 포트폴리오 조회/추가 |
| PUT/DELETE | `/api/portfolio/{id}` | 종목 수정/삭제 |
| GET/PUT | `/api/portfolio/cash` | 예수금 조회/upsert |
| DELETE | `/api/portfolio/cash/{broker}` | 예수금 삭제 |
| POST | `/api/portfolio/snapshot` | 총 자산 스냅샷 수동 저장 |
| GET | `/api/portfolio/snapshot/history` | 스냅샷 이력 조회 (`days=0`: 전체, `days=N`: 최근 N건) |
| GET | `/api/portfolio/sell-history` | 매도 내역 조회 (`broker`, `days` 필터 선택) |
| POST | `/api/portfolio/sell-history` | 매도 기록 저장 (id 포함 레코드 반환) |
| PUT | `/api/portfolio/sell-history/{id}` | 매도 기록 수정 (수정된 레코드 반환) |
| DELETE | `/api/portfolio/sell-history/{id}` | 매도 기록 삭제 |
**매도 히스토리 (`sell_history`)**
- 독립 테이블 — `portfolio` 테이블과 별개로 관리
- `sold_at`: UTC ISO8601 형식 (`new Date().toISOString()`)
- `realized_profit` / `realized_rate`: 프론트 계산값 저장 (백엔드 재계산 무방)
- 응답 정렬: `sold_at DESC` (최신순)
**총 자산 스냅샷 (`asset_snapshots`)**
- 평일 15:40 APScheduler 자동 실행 (`save_daily_snapshot`)
- 공휴일 판별: `holidays.json` (매년 수동 갱신, KRX 기준) → `is_market_open()` 함수
- 같은 날 중복 저장 시 upsert (date UNIQUE 제약)
- 수동 저장: `POST /api/portfolio/snapshot`
- 이력 조회: `GET /api/portfolio/snapshot/history?days=30` (ASC 정렬, 차트용)
**스케줄러 job**
- 08:00 매일 — 뉴스 스크랩 (`run_scraping_job`)
- 15:40 평일 — 총 자산 스냅샷 저장 (`save_daily_snapshot`)
| GET | `/api/portfolio/snapshot/history` | 스냅샷 이력 (`days`) |
| GET/POST | `/api/portfolio/sell-history` | 매도 내역 조회/저장 |
| PUT/DELETE | `/api/portfolio/sell-history/{id}` | 매도 기록 수정/삭제 |
### music-lab (music-lab/)
- 듀얼 프로바이더 음악 생성 서비스 (Suno API + 로컬 MusicGen) + YouTube 영상 제작 + 시장 조사 트렌드
- 생성된 오디오 파일: `/app/data/music/` (Nginx가 `/media/music/`로 직접 서빙)
- 생성된 영상 파일: `/app/data/videos/` (Nginx가 `/media/videos/`로 직접 서빙)
- DB: `/app/data/music.db` (music_tasks, music_library, video_projects, revenue_records, market_trends, trend_reports 테이블)
- 파일 구조: `main.py`, `db.py`, `suno_provider.py`, `local_provider.py`, `video_producer.py`, `market.py`
- 생성 흐름: POST generate (provider 지정) → task_id 반환 → BackgroundTask → 파일 저장 → 라이브러리 자동 등록
**Provider 구조**
- `suno`: Suno REST API (`apicast.suno.ai/v1`) — 보컬·가사·인스트루멘탈 지원
- `local`: Windows AI 서버 (MusicGen) — 인스트루멘탈 전용
**music-lab API 목록**
듀얼 프로바이더 음악 생성(Suno + MusicGen) + YouTube 영상 자동화 파이프라인 + 시장 트렌드.
- ⚠️ **NAS는 게이트웨이** — Suno/MusicGen 호출은 Windows `music-render` 워커가 담당. NAS는 `queue:music-render` Redis push + `/api/internal/music/update` webhook 수신. `suno_provider.py`/`local_provider.py`는 DEPRECATED stub.
- 핵심 파일: `main.py`, `db.py`, `batch_generator.py`, `compiler.py`, `internal_router.py`, `market.py`, `pipeline/`(orchestrator/cover/video/thumb/metadata/review/youtube/state_machine 등)
- 📌 상세(DB 14테이블·env·pipeline state machine·YouTube OAuth): **`service_music.md`**
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/music/providers` | 사용 가능한 프로바이더 목록 |
| GET | `/api/music/models` | Suno 모델 목록 (V4~V5.5) |
| GET | `/api/music/credits` | Suno 크레딧 조회 |
| POST | `/api/music/generate` | 음악 생성 (provider, model, vocal_gender, negative_tags, style_weight, audio_weight) |
| GET | `/api/music/providers`, `/models`, `/credits`, `/genres` | 프로바이더·모델·크레딧·장르 |
| POST | `/api/music/generate` | 음악 생성 (provider/model/vocal 등) |
| GET | `/api/music/status/{task_id}` | 생성 상태 폴링 |
| POST | `/api/music/lyrics` | Suno AI 가사 생성 |
| GET | `/api/music/library` | 라이브러리 전체 조회 |
| POST | `/api/music/library` | 트랙 수동 추가 |
| DELETE | `/api/music/library/{id}` | 트랙 삭제 |
| POST | `/api/music/extend` | 곡 연장 |
| POST | `/api/music/vocal-removal` | 보컬/인스트 분리 (2트랙) |
| POST | `/api/music/cover-image` | 커버 이미지 2장 생성 |
| POST | `/api/music/wav` | WAV 고음질 변환 |
| POST | `/api/music/stem-split` | 12스템 분리 (50cr) |
| GET | `/api/music/timestamped-lyrics` | 타임스탬프 가사 (가라오케) |
| POST | `/api/music/style-boost` | AI 스타일 프롬프트 생성 |
| POST | `/api/music/upload-cover` | 외부 음원 AI Cover |
| POST | `/api/music/upload-extend` | 외부 음원 확장 |
| POST | `/api/music/add-vocals` | 인스트에 AI 보컬 추가 |
| POST | `/api/music/add-instrumental` | 보컬에 AI 반주 추가 |
| POST | `/api/music/video` | 뮤직비디오 MP4 생성 |
| GET | `/api/music/lyrics/library` | 저장된 가사 목록 |
| POST | `/api/music/lyrics/library` | 가사 저장 |
| PUT | `/api/music/lyrics/library/{id}` | 가사 수정 |
| DELETE | `/api/music/lyrics/library/{id}` | 가사 삭제 |
| POST | `/api/music/video-project` | 영상 프로젝트 생성 (track_id, format, target_countries) |
| GET | `/api/music/video-projects` | 영상 프로젝트 목록 |
| GET | `/api/music/video-project/{id}` | 영상 프로젝트 상세 |
| POST | `/api/music/video-project/{id}/render` | FFmpeg 렌더링 시작 (BackgroundTask) |
| GET | `/api/music/video-project/{id}/export` | 내보내기 패키지 (mp4+thumbnail+metadata.json) |
| DELETE | `/api/music/video-project/{id}` | 영상 프로젝트 삭제 |
| GET | `/api/music/revenue/dashboard` | 수익 대시보드 (총수익·조회수·가중평균 RPM) |
| GET | `/api/music/revenue` | 수익 기록 목록 |
| POST | `/api/music/revenue` | 수익 기록 추가 (UNIQUE: yt_video_id+record_month+country) |
| PUT | `/api/music/revenue/{id}` | 수익 기록 수정 |
| DELETE | `/api/music/revenue/{id}` | 수익 기록 삭제 |
| POST | `/api/music/market/ingest` | agent-office 트렌드 수신 + 리포트 생성 |
| GET | `/api/music/market/trends` | 트렌드 조회 (country, genre, source, days=7) |
| GET | `/api/music/market/report/latest` | 최신 트렌드 리포트 |
| GET | `/api/music/market/report` | 트렌드 리포트 목록 (limit=10) |
| GET | `/api/music/market/suggest` | Suno 프롬프트 추천 (limit=5) |
| POST | `/api/music/lyrics`, `/style-boost` | 가사·스타일 프롬프트 생성 |
| GET/POST/DELETE | `/api/music/library` | 라이브러리 조회/추가/삭제 |
| POST | `/api/music/extend`, `/vocal-removal`, `/wav`, `/stem-split`, `/cover-image` | 곡 후처리 |
| POST | `/api/music/upload-cover`, `/upload-extend`, `/add-vocals`, `/add-instrumental` | 외부 음원 가공 |
| GET | `/api/music/timestamped-lyrics` | 타임스탬프 가사 |
| GET/POST/PUT/DELETE | `/api/music/lyrics/library` | 가사 라이브러리 CRUD |
| POST/GET | `/api/music/generate-batch` | 배치 생성 |
| POST/GET | `/api/music/compile` (+ `/compiles/{id}/export`) | 컴파일 |
| POST/GET/DELETE | `/api/music/video-project` (+ `/{id}/render`, `/export`) | 영상 프로젝트 |
| ALL | `/api/music/pipeline` (생성/start/feedback/cancel/publish/telegram-msg/lookup) | YouTube 자동화 파이프라인 |
| GET/PUT | `/api/music/setup` | 파이프라인 설정 |
| GET | `/api/music/youtube/auth-url`, `/callback`, `/status`; POST `/disconnect` | YouTube OAuth |
| GET/POST/PUT/DELETE | `/api/music/revenue` (+ `/dashboard`) | 수익 기록 |
| POST | `/api/music/market/ingest` | 트렌드 수신 + 리포트 |
| GET | `/api/music/market/trends`, `/report/latest`, `/report`, `/suggest` | 트렌드 조회·추천 |
| POST | `/api/internal/music/update` | Windows 워커 결과 webhook |
**환경변수**
- `SUNO_API_KEY`: Suno API 키 (미설정 시 Suno provider 비활성화)
- `MUSIC_AI_SERVER_URL`: 로컬 MusicGen 서버 URL (미설정 시 local provider 비활성화)
- `MUSIC_MEDIA_BASE`: 오디오 파일 공개 URL prefix (기본 `/media/music`)
- `MUSIC_DATA_PATH`: NAS 오디오 파일 저장 경로 (기본 `./data/music`)
- `PEXELS_API_KEY`: Pexels 스톡 이미지 API 키 (미설정 시 슬라이드쇼 Pexels 이미지 비활성화)
- `ANTHROPIC_API_KEY`: Claude Haiku — YouTube 메타데이터 생성 + 시장 인사이트 (미설정 시 폴백 텍스트)
- `VIDEO_DATA_DIR`: 영상 파일 저장 경로 (기본 `/app/data/videos`)
### video-lab (video-lab/)
동영상 생성 게이트웨이 (Redis 비동기 큐 `queue:video-render`). provider: sora/veo/kling/seedance.
- 핵심 파일: `main.py`, `db.py`, `internal_router.py`, `auth.py`
- 흐름: POST generate → task_id + Redis push → Windows 워커 pop → `/api/internal/video/update` webhook → DB 업데이트. 출력 mp4 `/data/video/` → nginx `/media/video/` (1d)
- 📌 상세(`video_tasks` 스키마·큐 payload·`X-Internal-Key`): **`service_video.md`**
**video_projects 테이블**
- format: `visualizer` | `slideshow`
- status: `pending``rendering``done` | `failed`
- target_countries: JSON 배열 (예: `["BR","US"]`)
- render_params: JSON 객체 (FFmpeg 파라미터 캐시)
| 메서드 | 경로 | 설명 |
|--------|------|------|
| POST | `/api/video/generate` | 영상 생성 → task_id + Redis push |
| GET | `/api/video/tasks/{id}` | 생성 상태 폴링 |
| GET | `/api/video/providers` | 지원 provider 목록 |
| POST | `/api/internal/video/update` | Windows 워커 결과 webhook |
**revenue_records 테이블**
- UNIQUE(yt_video_id, record_month, country)
- avg_rpm 계산: 가중평균 `SUM(revenue_usd)/SUM(views)*1000` (단순 AVG 아님)
### image-lab (image-lab/)
이미지 생성 게이트웨이 (Redis 비동기 큐 `queue:image-render`). provider: gpt_image/nano_banana/flux.
- 핵심 파일: `main.py`, `db.py`, `internal_router.py`, `auth.py`
- 흐름: video-lab과 동형. 출력 png/jpg `/data/image/` → nginx `/media/image/` (1d)
- 📌 상세(`image_tasks` 스키마·provider 모델 메타·`X-Internal-Key`): **`service_image.md`**
**market_trends 테이블**
- source: `youtube` | `google_trends` | `billboard`
- metadata: JSON 객체 (원본 API 응답 부분)
- 인덱스: `idx_mt_country_source` ON (country, source, collected_at DESC)
| 메서드 | 경로 | 설명 |
|--------|------|------|
| POST | `/api/image/generate` | 이미지 생성 → task_id + Redis push |
| GET | `/api/image/tasks/{id}` | 생성 상태 폴링 |
| GET | `/api/image/providers` | 지원 provider 목록 |
| POST | `/api/internal/image/update` | Windows 워커 결과 webhook |
**trend_reports 테이블**
- report_date UNIQUE — 같은 날 두 번 ingest 시 upsert
- top_genres: JSON 배열 `[{genre, score, countries}]` (최대 10개, score 내림차순)
- recommended_styles: JSON 배열 `[{genre, suno_prompt, target_countries, reason}]` (최대 5개)
### insta-lab (insta-lab/)
인스타그램 카드 피드 자동 생성 — 뉴스→키워드→10페이지 카드 카피 → 텔레그램 푸시 → 사용자 수동 업로드.
- ⚠️ **렌더는 NAS가 안 함**`card_renderer.py`는 DEPRECATED stub. NAS는 `queue:insta-render` Redis push만, 실제 Jinja→Playwright 렌더는 Windows `insta-render` 워커(web-ai). 워커가 `GET /slates/{id}` fetch → 렌더 → `/api/internal/insta/update` webhook.
- 핵심 파일: `app/main.py`, `config.py`, `db.py`, `news_collector.py`, `keyword_extractor.py`, `card_writer.py`, `internal_router.py`, `trend_collector.py`, `design_importer.py`, `templates/<theme>/card.html.j2`
- 카드 사이즈 1080×1350 (4:5). 디자인 import는 **로컬 Python 실행 필수**(NAS docker exec 시 소실 → `feedback_container_ephemeral_artifacts.md`)
- 📌 상세(DB 스키마·디자인 import·v2 카드뉴스·렌더 아키텍처·미해결 갭): **`service_insta.md`**
**music_library 테이블 (확장 컬럼)**
- `provider`: `suno` | `local` — 생성에 사용된 프로바이더
- `lyrics`: Suno 생성 가사 텍스트
- `image_url`: Suno 생성 커버 이미지 URL
- `suno_id`: Suno 곡 ID (CDN 참조용)
- `file_hash`: MD5 해시 (rename 감지용)
- `cover_images`: JSON 배열 — 커버 이미지 URL 목록
- `wav_url`: WAV 변환 URL
- `video_url`: 뮤직비디오 URL
- `stem_urls`: JSON 객체 — 12스템 URL 맵
**Suno 생성 특이사항**
- 1회 생성 시 2개 변형(variation) 반환 → 둘 다 라이브러리에 저장
- CDN URL(`cdn1.suno.ai`)은 임시 → 반드시 로컬 다운로드 필요
- 가사 섹션 태그: `[Verse]`, `[Chorus]`, `[Bridge]`, `[Instrumental]`
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/insta/status` | 서비스 상태 (NAVER/ANTHROPIC 키 여부) |
| POST | `/api/insta/news/collect` | 뉴스 수집 트리거 |
| GET | `/api/insta/news/articles` | 수집 기사 목록 |
| POST | `/api/insta/keywords/extract` | 키워드 추출 트리거 |
| GET | `/api/insta/keywords` | 트렌딩 키워드 목록 |
| GET/POST | `/api/insta/slates` | 슬레이트 목록/생성 |
| GET/DELETE | `/api/insta/slates/{id}` | 슬레이트 상세/삭제 |
| POST | `/api/insta/slates/{id}/render` | 카드 렌더 재시도 |
| GET | `/api/insta/slates/{id}/assets/{page}` | 카드 PNG 다운로드 (1~10) |
| GET | `/api/insta/slates/{id}/package` | zip 패키지 (10 PNG + caption.txt) |
| GET | `/api/insta/tasks/{task_id}` | BackgroundTask 상태 폴링 |
| GET/PUT | `/api/insta/templates/prompts/{name}` | 프롬프트 템플릿 CRUD |
| POST | `/api/internal/insta/update` | Windows 워커 결과 webhook |
### realestate-lab (realestate-lab/)
- 공공데이터포털 API 연동: 한국부동산원 청약 분양정보 조회 + 자치구 5티어 매칭 + agent-office push 알림
- DB: `/app/data/realestate.db` (announcements, announcement_models, user_profile, match_results, collect_log 테이블)
- 파일 구조: `main.py`, `db.py`, `collector.py`, `matcher.py`, `notifier.py`, `models.py`
**환경변수**
- `DATA_GO_KR_API_KEY`: 공공데이터포털 API 키 (미설정 시 수동 등록만 가능)
- `AGENT_OFFICE_URL`: agent-office 내부 URL (기본 `http://agent-office:8000`) — 신규 매칭 push 대상
- `REALESTATE_NOTIFY_TIMEOUT`: agent-office push timeout 초 (기본 15)
**스케줄러 job (`scheduled_collect` 4단계 흐름)**
- 09:00 매일 — `collect → cleanup → match → notify`
1. `collect_all()` — 모집공고일 30일 윈도우(`RCRIT_PBLANC_DE_FROM`) 사전 좁힘 + 자치구 추출 + status='완료' skip
2. `delete_old_completed_announcements(grace_days=90)``winner_date + 90일` 경과한 완료 공고 정리 (FK CASCADE로 match_results도 삭제)
3. `run_matching()` — 자치구 5티어 가중치 + 자격 곡선 적용
4. `notify_new_matches()``notified_at IS NULL AND match_score >= profile.min_match_score AND profile.notify_enabled`인 매칭을 agent-office로 push
- 00:00 매일 — 상태 갱신 + 재매칭 (`scheduled_status_update`, notifier 미호출)
**매칭 점수 모델 (총 100점)**
- 지역 35점 — 광역 매칭 시 10점 + 자치구 5티어 가중치(S=25 / A=20 / B=15 / C=10 / D=5)
- `preferred_districts`가 모든 티어 비어있으면 광역 매칭만으로 35점 풀 점수 (legacy 호환)
- 주택유형 10점 — `preferred_types`에 매칭 (binary)
- 면적 15점 — `[min_area, max_area]` 범위 안 모델 1개 이상 (binary)
- 가격 15점 — `max_price` 이하 모델 1개 이상 (binary)
- 자격 25점 — `_check_eligible_types()` 결과 1개 이상이면 15점 + 추가당 5점, 최대 +10
- reasons 텍스트 예시: `"자치구 S티어: 강남구 (+25)"`, `"광역 일치: 서울"`, `"선호 지역 일치: 서울"` (legacy)
**user_profile 신규 컬럼 (Task 2026-04-28 마이그레이션)**
- `preferred_districts` TEXT — JSON `{"S":[...], "A":[...], "B":[...], "C":[...], "D":[...]}`. default `'{}'`
- `min_match_score` INTEGER — 알림 임계값. default 70
- `notify_enabled` INTEGER — 알림 ON/OFF. default 1
**announcements / match_results 신규 컬럼**
- `announcements.district` TEXT + `idx_ann_district` 인덱스 — collector가 주소/region_name에서 정규식 파싱
- `match_results.notified_at` TEXT NULL — agent-office push 성공 시 timestamp 기록 (멱등 마킹)
**notifier.py 흐름**
1. `get_profile()``notify_enabled=False`면 skip, `min_match_score` 가져옴
2. `get_unnotified_matches(min_score)` — JOIN으로 announcements 정보 포함 (district, status, receipt 등)
3. `POST {AGENT_OFFICE_URL}/api/agent-office/realestate/notify` body=`{"matches": [...]}`
4. 응답 `{sent_ids: [...]}``mark_matches_notified(sent_ids)` (notified_at = now)
5. RequestException 시 마킹 안 함 → 다음 사이클 재시도
**realestate-lab API 목록**
공공데이터포털 청약 분양정보 수집 + 자치구 5티어 매칭 + agent-office push 알림.
- 핵심 파일: `main.py`, `db.py`, `collector.py`, `matcher.py`, `notifier.py`, `models.py`
- 매칭 100점: 지역35 / 주택유형10 / 면적15 / 가격15 / 자격25
- 📌 상세(DB 스키마·스케줄러 4단계·매칭 모델·notifier 멱등 흐름·env): **`service_realestate.md`**
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/realestate/announcements` | 공고 목록. 응답에 `district`, `match_score`, `match_reasons`, `eligible_types` 포함 |
| GET | `/api/realestate/announcements/{id}` | 공고 상세 (주택형별 + district 포함) |
| POST | `/api/realestate/announcements` | 수동 공고 등록 |
| PUT | `/api/realestate/announcements/{id}` | 공고 수정 |
| PATCH | `/api/realestate/announcements/{id}/bookmark` | 북마크 토글 (텔레그램 인라인 키보드 콜백 대상) |
| DELETE | `/api/realestate/announcements/{id}` | 공고 삭제 |
| DELETE | `/api/realestate/announcements/closed` | status='완료' 공고 일괄 삭제 |
| POST | `/api/realestate/collect` | 수동 수집 트리거 (collect → cleanup → match → notify 전체 흐름) |
| GET | `/api/realestate/collect/status` | 마지막 수집 결과 |
| GET | `/api/realestate/profile` | 내 프로필 조회 (`preferred_districts`, `min_match_score`, `notify_enabled` 포함) |
| PUT | `/api/realestate/profile` | 프로필 수정 (upsert). body에 `preferred_districts: {S:[],...}`, `min_match_score: 0~100`, `notify_enabled: bool` 수용 |
| GET | `/api/realestate/matches` | 매칭 결과 목록 (응답에 `district`, `status` 포함) |
| GET/POST | `/api/realestate/announcements` | 공고 목록(district/match_score 포함)/수동 등록 |
| GET/PUT/DELETE | `/api/realestate/announcements/{id}` | 공고 상세/수정/삭제 |
| PATCH | `/api/realestate/announcements/{id}/bookmark` | 북마크 토글 (텔레그램 콜백 대상) |
| DELETE | `/api/realestate/announcements/closed` | 완료 공고 일괄 삭제 |
| POST/GET | `/api/realestate/collect` (+ `/collect/status`) | 수동 수집(collect→cleanup→match→notify)/상태 |
| GET/PUT | `/api/realestate/profile` | 프로필 조회/수정 (preferred_districts, min_match_score, notify_enabled) |
| GET | `/api/realestate/matches` | 매칭 결과 (district/status 포함) |
| POST | `/api/realestate/matches/refresh` | 매칭 재계산 |
| PATCH | `/api/realestate/matches/{id}/read` | 신규 알림 읽음 처리 |
| GET | `/api/realestate/dashboard` | 요약 (진행중 공고수, 신규 매칭수, 다가오는 일정) |
### travel-proxy (travel-proxy/)
- 원본 사진: `/data/travel/` (RO)
- 썸네일 캐시: `/data/thumbs/` (RW)
- DB: `/data/thumbs/travel.db` (photos, album_covers 테이블)
- 메타: `/data/travel/_meta/region_map.json`, `regions.geojson`
- 지역 오버라이드: `/data/thumbs/region_map_extra.json` (RW, `_regions_meta` 포함)
- 파일 구조: `main.py`, `db.py`, `indexer.py`
- 썸네일: 480×480 리사이징 (Pillow), 동기화 시 사전 생성 + 온디맨드 폴백
- 데이터 흐름: 수동 sync → 폴더 스캔 → SQLite 인덱싱 + 썸네일 일괄 생성
**travel.db 테이블**
| 테이블 | 설명 |
|--------|------|
| `photos` | 사진 인덱스 (album, filename, mtime, has_thumb) |
| `album_covers` | 앨범별 커버 사진 지정 |
**지역 관리 아키텍처**
- `region_map.json` (RO): 원본 지역→앨범 매핑 (`_meta/` 안에 위치)
- `region_map_extra.json` (RW): 사용자 수정분 오버라이드 (앨범 이동, 신규 지역)
- `_regions_meta`: 커스텀 지역의 이름·좌표 저장 (`{ "region_id": { "name": "...", "coordinates": [lng, lat] } }`)
- `regions.geojson` (RO): GeoJSON Polygon 지역 경계
- 커스텀 지역: `GET /api/travel/regions`에서 `region_map`에 있지만 GeoJSON에 없는 지역을 자동 추가 (Point geometry 또는 null)
**travel-proxy API 목록**
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/travel/regions` | 지역 GeoJSON (커스텀 지역 동적 추가 포함) |
| GET | `/api/travel/photos` | 사진 목록 (region, page=1, size=20) |
| POST | `/api/travel/sync` | 폴더 스캔 → DB 동기화 + 썸네일 생성 |
| GET | `/api/travel/albums` | 앨범 목록 + 사진 수 + 커버 + region/regionName |
| PUT | `/api/travel/albums/{album}/cover` | 앨범 커버 지정 |
| PUT | `/api/travel/albums/{album}/region` | 앨범 지역 변경 (region_map_extra 수정) |
| PUT | `/api/travel/regions/{region_id}` | 커스텀 지역 이름/좌표 수정 (지도 핀 표시용) |
### blog-lab (blog-lab/)
- 블로그 마케팅 수익화 서비스 (키워드 분석 → AI 글 생성 → 마케팅 강화 → 품질 리뷰 → 포스팅 → 수익 추적)
- AI 엔진: Claude API (Anthropic, `claude-sonnet-4-20250514`)
- 웹 검색: Naver Search API (블로그 + 쇼핑) + 상위 블로그 본문 크롤링
- DB: `/app/data/blog_marketing.db`
- 파일 구조: `main.py`, `db.py`, `config.py`, `naver_search.py`, `content_generator.py`, `marketer.py`, `quality_reviewer.py`, `web_crawler.py`
**파이프라인**: 리서치(+크롤링) → 작가(초안) → 마케터(링크 삽입) → 평가자(6기준 60점)
**상태 흐름**: `draft``marketed``reviewed``published`
**blog_marketing.db 테이블**
| 테이블 | 설명 |
|--------|------|
| `keyword_analyses` | 키워드 분석 결과 (네이버 검색 데이터 + 경쟁도/기회 점수 + 크롤링 본문) |
| `blog_posts` | 블로그 글 (draft → marketed → reviewed → published) |
| `brand_links` | 브랜드커넥트 제휴 링크 (post_id/keyword_id FK) |
| `commissions` | 포스트별 월간 클릭/구매/수익 |
| `generation_tasks` | 비동기 작업 상태 (research/generate/market/review) |
| `prompt_templates` | AI 프롬프트 템플릿 (DB 저장, 코드 배포 없이 수정 가능) |
**blog-lab API 목록**
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/blog-marketing/status` | 서비스 상태 (API 키 설정 현황) |
| POST | `/api/blog-marketing/research` | 키워드 분석 시작 (+ 상위 블로그 크롤링) |
| GET | `/api/blog-marketing/research/history` | 분석 이력 조회 |
| GET | `/api/blog-marketing/research/{id}` | 분석 상세 조회 |
| DELETE | `/api/blog-marketing/research/{id}` | 분석 삭제 |
| GET | `/api/blog-marketing/task/{task_id}` | 작업 상태 폴링 |
| POST | `/api/blog-marketing/generate` | 작가 단계: AI 글 생성 (크롤링 참고 + 링크 반영) |
| POST | `/api/blog-marketing/market/{post_id}` | 마케터 단계: 전환율 강화 + 링크 삽입 |
| POST | `/api/blog-marketing/review/{post_id}` | 평가자 단계: 품질 리뷰 (6기준 × 10점, 42/60 통과) |
| POST | `/api/blog-marketing/regenerate/{post_id}` | 피드백 기반 재생성 |
| POST | `/api/blog-marketing/links` | 브랜드커넥트 링크 등록 |
| GET | `/api/blog-marketing/links` | 링크 조회 (post_id, keyword_id 필터) |
| PUT | `/api/blog-marketing/links/{id}` | 링크 수정 |
| DELETE | `/api/blog-marketing/links/{id}` | 링크 삭제 |
| GET | `/api/blog-marketing/posts` | 포스트 목록 (status 필터) |
| GET | `/api/blog-marketing/posts/{id}` | 포스트 상세 |
| PUT | `/api/blog-marketing/posts/{id}` | 포스트 수정 |
| DELETE | `/api/blog-marketing/posts/{id}` | 포스트 삭제 |
| POST | `/api/blog-marketing/posts/{id}/publish` | 발행 (네이버 URL 등록) |
| GET | `/api/blog-marketing/commissions` | 수익 내역 조회 |
| POST | `/api/blog-marketing/commissions` | 수익 기록 추가 |
| PUT | `/api/blog-marketing/commissions/{id}` | 수익 기록 수정 |
| DELETE | `/api/blog-marketing/commissions/{id}` | 수익 기록 삭제 |
| GET | `/api/blog-marketing/dashboard` | 대시보드 집계 |
**환경변수**
- `ANTHROPIC_API_KEY`: Claude API 키 (미설정 시 AI 생성 비활성화)
- `NAVER_CLIENT_ID`: 네이버 검색 API 클라이언트 ID
- `NAVER_CLIENT_SECRET`: 네이버 검색 API 시크릿
- `BLOG_DATA_PATH`: SQLite DB 저장 경로 (기본 `./data/blog`)
| PATCH | `/api/realestate/matches/{id}/read` | 신규 알림 읽음 |
| GET | `/api/realestate/dashboard` | 요약 (진행중·신규매칭·일정) |
### agent-office (agent-office/)
- AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 에이전트가 실제 작업 수행
- stock-lab/music-lab/realestate-lab 기존 API를 서비스 프록시로 호출 (직접 DB 접근 없음)
- 실시간 상태 동기화: WebSocket (`/api/agent-office/ws`)
- 텔레그램 봇: 양방향 알림 + 승인 (인라인 키보드)
- 청약 매칭 알림: realestate-lab이 신규 매칭 발견 시 push → `RealestateAgent.on_new_matches()` → 텔레그램 1통(인라인 [🔖 북마크]/[📄 공고] 또는 [전체 보기] 버튼)
- DB: `/app/data/agent_office.db` (agent_config, agent_tasks, agent_logs, telegram_state 테이블)
- 파일 구조: `main.py`, `db.py`, `config.py`, `models.py`, `websocket_manager.py`, `service_proxy.py`, `telegram_bot.py`, `scheduler.py`, `agents/base.py`, `agents/stock.py`, `agents/music.py`, `agents/realestate.py`, `telegram/realestate_message.py`
**에이전트 FSM 상태**: idle → working → waiting (승인 대기) → reporting → break (휴식)
**환경변수**
- `STOCK_LAB_URL`: stock-lab 내부 URL (기본 `http://stock-lab:8000`)
- `MUSIC_LAB_URL`: music-lab 내부 URL (기본 `http://music-lab:8000`)
- `REALESTATE_LAB_URL`: realestate-lab 내부 URL (기본 `http://realestate-lab:8000`) — 북마크 콜백 프록시 대상
- `REALESTATE_DASHBOARD_URL`: 텔레그램 [전체 보기] 버튼 URL (기본 `http://localhost:8080/realestate`)
- `TELEGRAM_BOT_TOKEN`: 텔레그램 봇 토큰 (미설정 시 알림 비활성화)
- `TELEGRAM_CHAT_ID`: 텔레그램 채팅 ID
- `TELEGRAM_WEBHOOK_URL`: 텔레그램 Webhook URL
- `TELEGRAM_WIFE_CHAT_ID`: 아내 chat.id (브리핑 공유 + 대화 허용)
- `ANTHROPIC_API_KEY`: 자연어 대화용 Claude API 키 (미설정 시 대화 비활성)
- `CONVERSATION_MODEL`: 대화 모델 (기본 `claude-haiku-4-5-20251001`)
- `CONVERSATION_HISTORY_LIMIT`: 이력 주입 수 (기본 20)
- `CONVERSATION_RATE_PER_MIN`: 채팅당 분당 최대 메시지 (기본 6)
- `LOTTO_BACKEND_URL`: 기본 `http://lotto:8000`
- `LOTTO_CURATOR_MODEL`: 기본 `claude-sonnet-4-5`
- `YOUTUBE_DATA_API_KEY`: YouTube Data API v3 키 (미설정 시 YouTube trending 수집 skip)
**YouTubeResearchAgent (`agents/youtube.py`)**
- `agent_id = "youtube"` — AGENT_REGISTRY에 등록
- 09:00 매일 `on_schedule()` → 국가별 YouTube 트렌딩 + Google Trends + Billboard Top20 수집 → music-lab push
- `on_command("research", {countries: []})` → 수동 트리거 (백그라운드 asyncio.create_task)
- 수집 소스: `youtube_researcher.py` (fetch_youtube_trending, fetch_google_trends, fetch_billboard_top20)
- DB: `youtube_research_jobs` 테이블에 실행 이력 기록
- 동시실행 방지: `self.state == "working"` 체크 후 거부
- 월요일 08:00 `send_weekly_report()` → music-lab 최신 리포트 → 텔레그램 발송
**텔레그램 자연어 대화 (옵션 B)**
- 슬래시 명령이 아닌 일반 문장을 보내면 Claude Haiku 4.5가 응답
- 프롬프트 캐싱: `system` 블록 + 히스토리 마지막 블록에 `cache_control: ephemeral` → 5분 TTL
- 허용 chat_id 화이트리스트: `TELEGRAM_CHAT_ID`, `TELEGRAM_WIFE_CHAT_ID`
- 평가 지표: `conversation_messages` 테이블에 tokens / cache_read / cache_write / latency 기록
- 조회: `GET /api/agent-office/conversation/stats?days=7`
**스케줄러 job**
- 07:30 매일 — 주식 뉴스 요약 (`stock_news_job`)
- 매주 월요일 07:00 — 로또 큐레이터 브리핑 (`lotto_curate`)
- 60초 간격 — 유휴 에이전트 휴식 체크 (`idle_check_job`)
- ~~09:15 매일 — 청약 매칭 데일리 리포트~~ (Task 2026-04-28에서 폐기. realestate-lab의 push 트리거로 전환)
- 09:00 매일 — YouTube 트렌드 수집 (`youtube_research`) → music-lab `/api/music/market/ingest` push
- 매주 월요일 08:00 — YouTube 주간 리포트 텔레그램 발송 (`youtube_weekly_report`)
**RealestateAgent (`agents/realestate.py`)**
- 진입점: `on_new_matches(matches: list[dict]) -> {sent, sent_ids, message_id}`
- realestate-lab의 push에서 트리거 → `format_realestate_matches()` + `build_match_keyboard()``messaging.send_raw()`
- 1~2건이면 풀 카드 + [🔖 북마크]/[📄 공고 보기] 행씩, 3건 이상이면 묶음 카드 + [📋 전체 보기] 단일 URL 버튼
- 인라인 키보드 콜백 `realestate_bookmark_{id}``webhook.py``_handle_realestate_bookmark``service_proxy.realestate_bookmark_toggle()` → realestate-lab의 `PATCH /announcements/{id}/bookmark`
- 송신 성공 시 sent_ids 반환 → realestate-lab이 match_results.notified_at 마킹 (멱등)
- 실패 시 sent=0/sent_ids=[]/error 반환 → 마킹 안 됨 → 다음 사이클 재시도
- `on_command("fetch_matches")`: 수동 트리거 — service_proxy로 매치 가져와 `on_new_matches` 호출
- `on_schedule`: 폐기 (cron 등록 제거됨)
**agent-office API 목록**
AI 에이전트 가상 오피스 — 기존 서비스 API를 프록시로 호출, 실시간 WebSocket + 텔레그램 봇.
- 핵심 파일: `main.py`, `db.py`, `config.py`, `websocket_manager.py`, `service_proxy.py`, `telegram_bot.py`, `scheduler.py`, `agents/`(stock/music/realestate/youtube/youtube_publisher/lotto/base)
- 에이전트 7종 레지스트리. 명령 API body 필드명 → `reference_agent_office_command_api.md`
- 📌 상세(DB 9테이블·FSM·전체 cron 목록·AGENT_CONTAINER_MAP·텔레그램 캐싱·env): **`service_agent_office.md`**
| 메서드 | 경로 | 설명 |
|--------|------|------|
| WS | `/api/agent-office/ws` | WebSocket (init, agent_state, task_complete, command_result) |
| GET | `/api/agent-office/agents` | 에이전트 목록 |
| GET | `/api/agent-office/agents/{id}` | 에이전트 상세 (설정 + 상태) |
| WS | `/api/agent-office/ws` | WebSocket (init/agent_state/task_complete/command_result) |
| GET | `/api/agent-office/agents` (+ `/{id}`, `/{id}/tasks`, `/{id}/logs`) | 에이전트 목록·상세·이력·로그 |
| PUT | `/api/agent-office/agents/{id}` | 에이전트 설정 수정 |
| GET | `/api/agent-office/agents/{id}/tasks` | 에이전트 작업 이력 |
| GET | `/api/agent-office/agents/{id}/logs` | 에이전트 로그 |
| GET | `/api/agent-office/tasks/pending` | 승인 대기 작업 목록 |
| GET | `/api/agent-office/tasks/{id}` | 작업 상세 |
| POST | `/api/agent-office/command` | 에이전트에 명령 전송 |
| GET | `/api/agent-office/tasks/pending` (+ `/{id}`) | 승인 대기·작업 상세 |
| POST | `/api/agent-office/command` | 에이전트 명령 전송 |
| POST | `/api/agent-office/approve` | 작업 승인/거부 |
| POST | `/api/agent-office/telegram/webhook` | 텔레그램 Webhook 수신 (realestate_bookmark_* 콜백 포함) |
| POST | `/api/agent-office/realestate/notify` | realestate-lab 전용 push 수신 → 텔레그램 송신 |
| GET | `/api/agent-office/states` | 전체 에이전트 상태 조회 |
| GET | `/api/agent-office/conversation/stats` | 텔레그램 자연어 대화 토큰·캐시 통계 (`days` 필터) |
| POST | `/api/agent-office/youtube/research` | YouTube 트렌드 수집 수동 트리거 (body: `{countries: []}`) |
| GET | `/api/agent-office/youtube/research/status` | 마지막 수집 작업 상태 |
| POST | `/api/agent-office/telegram/webhook` | 텔레그램 Webhook (realestate_bookmark_* 콜백 포함) |
| POST | `/api/agent-office/realestate/notify` | realestate-lab 전용 push 수신 → 텔레그램 |
| GET | `/api/agent-office/states` | 전체 에이전트 상태 |
| GET | `/api/agent-office/conversation/stats` | 텔레그램 대화 토큰·캐시 통계 (`days`) |
| POST/GET | `/api/agent-office/youtube/research` (+ `/status`) | YouTube 트렌드 수집 트리거/상태 |
| GET | `/api/agent-office/lotto/signals`, `/lotto/baselines` | 로또 시그널 이력·baseline |
| POST | `/api/agent-office/lotto/signal-check` | 로또 시그널 평가 트리거 (light/sim/deep) |
### tarot-lab (tarot-lab/)
타로 카드 해석 (Claude Sonnet, agent-office에서 2026-05-25 독립).
- 핵심 파일: `app/main.py`, `pipeline.py`, `prompt.py`, `schema.py`, `models.py`, `config.py`, `db.py`
- interpret(해석만, DB 저장 X) ↔ readings(저장) 2단계 분리. nginx 600s
- 📌 상세(`tarot_readings` 스키마·max_tokens/truncation/reroll·env): **`service_tarot.md`**
| 메서드 | 경로 | 설명 |
|--------|------|------|
| POST | `/api/tarot/interpret` | 카드 배치 → AI 해석 (저장 X) |
| POST/GET | `/api/tarot/readings` | 해석 결과 저장 / 기록 목록 (page, favorite, spread_type, category) |
| GET/PATCH/DELETE | `/api/tarot/readings/{id}` | 기록 상세 / 즐겨찾기·note 수정 / 삭제 |
### saju-lab (saju-lab/)
사주 분석 + 궁합 (Claude Sonnet 4.6 + prompt caching, saju-web TS→Python 포팅).
- 핵심 파일: `main.py`, `routers/`(saju+compat), `interpret/`(pipeline+prompt+schema), `calculator/`(core/analysis/daeun/lunar/shinsal/compatibility/fortune_scores/lucky/monthly_flow/solar_terms)
- TS 원본 버그도 동등 재현(`feedback_ts_python_reference_fixture.md`). DSM timeout 300s+
- 📌 상세(DB 스키마·계산엔진·TS 버그·UI v2 schema 매핑): **`service_saju.md`**
| 메서드 | 경로 | 설명 |
|--------|------|------|
| POST | `/api/saju/interpret` | 사주 계산 + AI 해석 + DB 저장 (fortune_scores/lucky/monthly_flow 포함) |
| GET | `/api/saju/readings` (+ `/{id}`) | 사주 기록 목록/상세 |
| PATCH/DELETE | `/api/saju/readings/{id}` | 즐겨찾기·메모 수정 / 삭제 |
| GET | `/api/saju/current-fortune` | 저장된 사주의 현재 연도 세운 (실시간 계산) |
| POST | `/api/saju/compat/interpret` | 두 사람 궁합 계산 + AI 해석 |
| GET | `/api/saju/compat/readings` (+ `/{id}`) | 궁합 기록 목록/상세 |
| PATCH/DELETE | `/api/saju/compat/readings/{id}` | 궁합 즐겨찾기·메모 수정 / 삭제 |
### packs-lab (packs-lab/)
NAS 자료 다운로드 자동화 — DSM 공유링크 발급 + 5GB chunked 업로드. Vercel SaaS와 HMAC 통신.
- 핵심 파일: `app/main.py`, `auth.py`, `dsm_client.py`, `routes.py`, `models.py`
- 외부 DB: Supabase `pack_files`. 경로 3분리(`PACK_DATA_PATH``PACK_BASE_DIR``PACK_HOST_DIR`)
- 📌 상세(HMAC 패턴·chunked contract·운영검증 DSM env·backlog): **`service_packs.md`**
| 메서드 | 경로 | 설명 |
|--------|------|------|
| POST | `/api/packs/sign-link` | HMAC → DSM 4시간 다운로드 URL 발급 |
| POST | `/api/packs/admin/mint-token` | HMAC → 일회성 upload 토큰 (30분 TTL) |
| POST | `/api/packs/upload` | Bearer → single-shot 5GB 저장 + Supabase INSERT |
| POST | `/api/packs/upload/init` | Bearer → chunked 세션 초기화 (jti consume) |
| PUT | `/api/packs/upload/{session_id}/chunk` | 부분파일 append (offset 불일치 시 409 + `X-Current-Offset`) |
| GET | `/api/packs/upload/{session_id}/status` | `{written, expected_size}` (재개용) |
| POST | `/api/packs/upload/{session_id}/complete` | rename + Supabase INSERT |
| DELETE | `/api/packs/upload/{session_id}` | 세션 중단 + 부분파일 정리 |
| GET | `/api/packs/list` | HMAC → 활성 pack_files |
| DELETE | `/api/packs/{file_id}` | HMAC → soft delete |
### personal (personal/)
- 개인 서비스 (포트폴리오 + 블로그 + 투두 통합)
- DB: `/app/data/personal.db` (profile, careers, projects, skills, introductions, todos, blog_posts 테이블)
- 편집 인증: `PORTFOLIO_EDIT_PASSWORD` 환경변수, Bearer 토큰 (24시간 TTL)
- 파일 구조: `main.py`, `db.py`, `models.py`, `auth.py`
**환경변수**
- `PORTFOLIO_EDIT_PASSWORD`: 편집 모드 비밀번호 (미설정 시 편집 불가)
**personal API 목록**
포트폴리오 + 블로그 + 투두 통합. 편집 인증 Bearer 24h TTL (인메모리).
- 핵심 파일: `main.py`, `db.py`, `models.py`, `auth.py`
- ⚠️ `DELETE /api/todos/done``DELETE /api/todos/{id}`보다 **반드시 먼저** 등록 (FastAPI prefix 매칭)
- 📌 상세(DB 7테이블·인증 흐름·라우트 함정): **`service_personal.md`**
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/profile/public` | 공개 데이터 일괄 조회 |
| POST | `/api/profile/auth` | 비밀번호 인증 → 토큰 |
| GET | `/api/profile/profile` | 프로필 조회 (인증) |
| PUT | `/api/profile/profile` | 프로필 수정 (인증) |
| GET | `/api/profile/careers` | 경력 목록 (인증) |
| POST | `/api/profile/careers` | 경력 추가 (인증) |
| PUT | `/api/profile/careers/{id}` | 경력 수정 (인증) |
| DELETE | `/api/profile/careers/{id}` | 경력 삭제 (인증) |
| GET | `/api/profile/projects` | 프로젝트 목록 (인증) |
| POST | `/api/profile/projects` | 프로젝트 추가 (인증) |
| PUT | `/api/profile/projects/{id}` | 프로젝트 수정 (인증) |
| DELETE | `/api/profile/projects/{id}` | 프로젝트 삭제 (인증) |
| GET | `/api/profile/skills` | 기술 목록 (인증) |
| POST | `/api/profile/skills` | 기술 추가 (인증) |
| PUT | `/api/profile/skills/{id}` | 기술 수정 (인증) |
| DELETE | `/api/profile/skills/{id}` | 기술 삭제 (인증) |
| GET | `/api/profile/introductions` | 자기소개 목록 (인증) |
| POST | `/api/profile/introductions` | 자기소개 추가 (인증) |
| PUT | `/api/profile/introductions/{id}` | 자기소개 수정 (인증) |
| DELETE | `/api/profile/introductions/{id}` | 자기소개 삭제 (인증) |
| GET/PUT | `/api/profile/profile` | 프로필 조회/수정 (인증) |
| GET/POST/PUT/DELETE | `/api/profile/careers`, `/projects`, `/skills`, `/introductions` | 각 섹션 CRUD (인증) |
| PATCH | `/api/profile/introductions/{id}/main` | 메인 자기소개 지정 (인증) |
| GET | `/api/todos` | 투두 전체 목록 |
| POST | `/api/todos` | 투두 생성 |
| PUT | `/api/todos/{id}` | 투두 수정 |
| DELETE | `/api/todos/done` | 완료 항목 일괄 삭제 |
| DELETE | `/api/todos/{id}` | 투두 개별 삭제 |
| GET | `/api/blog/posts` | 블로그 글 목록 |
| POST | `/api/blog/posts` | 블로그 글 생성 |
| PUT | `/api/blog/posts/{id}` | 블로그 글 수정 |
| DELETE | `/api/blog/posts/{id}` | 블로그 글 삭제 |
| GET/POST | `/api/todos` | 투두 목록/생성 |
| PUT/DELETE | `/api/todos/{id}` | 투두 수정/삭제 |
| DELETE | `/api/todos/done` | 완료 항목 일괄 삭제 (라우트 순서 주의) |
| GET/POST/PUT/DELETE | `/api/blog/posts` (+ `/{id}`) | 블로그 글 CRUD |
### packs-lab (packs-lab/)
- NAS 자료 다운로드 자동화 — Synology DSM 공유링크 발급 + 5GB 멀티파트 업로드 수신
- Vercel SaaS와 HMAC 인증으로 통신, 사용자 인증은 Vercel이 Supabase로 처리 (본 서비스는 외부 인증 없음)
- DB: 외부 Supabase `pack_files` 테이블 (DDL: `packs-lab/supabase/pack_files.sql`)
- 파일 구조: `app/main.py`, `app/auth.py`, `app/dsm_client.py`, `app/routes.py`, `app/models.py`
- 컨테이너 저장 경로: `PACK_BASE_DIR` env (default `/app/data/packs`). docker-compose volume 마운트와 일치 필수.
**환경변수**
- `DSM_HOST` / `DSM_USER` / `DSM_PASS`: Synology DSM 7.x 인증 (공유 링크 발급용)
- `BACKEND_HMAC_SECRET`: Vercel SaaS와 양쪽 공유 시크릿 (HMAC SHA256)
- `SUPABASE_URL` / `SUPABASE_SERVICE_KEY`: Supabase pack_files 테이블 접근 (service_role, RLS 우회)
- `UPLOAD_TOKEN_TTL_SEC`: admin upload 토큰 TTL (기본 1800초 = 30분)
- `PACK_BASE_DIR`: 컨테이너 내부 저장 경로 (기본 `/app/data/packs`)
- `PACK_DATA_PATH`: 호스트 마운트 경로 (로컬 `./data/packs`, NAS `/volume1/docker/webpage/media/packs`)
**HMAC 인증 패턴**
- Vercel → backend 요청: `X-Timestamp` (UNIX 초) + `X-Signature` (HMAC_SHA256(timestamp + "." + body, secret))
- Replay 방어: 타임스탬프 ±5분 윈도우
- admin browser → backend upload: `Authorization: Bearer <token>` (jti 단발성)
**packs-lab API 목록**
### travel-proxy (travel-proxy/)
여행 사진 API + 썸네일(480×480 Pillow) + 지역 관리.
- 핵심 파일: `main.py`, `db.py`, `indexer.py`. DB `/data/thumbs/travel.db`
- 지역 3중 파일: `region_map.json`(RO 원본) + `region_map_extra.json`(RW 오버라이드: `_regions_meta`/`_removes`/`미분류`) + `regions.geojson`. PUID/PGID 권한 주입
- 📌 상세(DB 스키마·지역 병합·sync 동작·썸네일 폴백): **`service_travel.md`**
| 메서드 | 경로 | 설명 |
|--------|------|------|
| POST | `/api/packs/sign-link` | Vercel HMAC → DSM Sharing.create로 4시간 유효 다운로드 URL 발급 |
| POST | `/api/packs/admin/mint-token` | Vercel HMAC → 일회성 upload 토큰 발급 (기본 30분 TTL) |
| POST | `/api/packs/upload` | Bearer token → multipart 5GB 저장 + Supabase INSERT |
| GET | `/api/packs/list` | Vercel HMAC → 활성 pack_files 목록 (deleted_at IS NULL) |
| DELETE | `/api/packs/{file_id}` | Vercel HMAC → soft delete (DSM 공유는 자동 만료) |
| GET | `/api/travel/regions` | 지역 GeoJSON (커스텀 지역 동적 추가) |
| GET | `/api/travel/photos` | 사진 목록 (region, page, size) |
| POST | `/api/travel/sync` | 폴더 스캔 → DB 동기화 + 썸네일 생성 |
| GET | `/api/travel/albums` | 앨범 목록 + 사진 수 + 커버 + region |
| PUT | `/api/travel/albums/{album}/cover` | 앨범 커버 지정 |
| PUT | `/api/travel/albums/{album}/region` | 앨범 지역 변경 |
| PUT | `/api/travel/regions/{region_id}` | 커스텀 지역 이름/좌표 수정 |
### deployer (deployer/)
- Webhook 검증: `X-Gitea-Signature` (HMAC SHA256, `compare_digest` 사용)
- `WEBHOOK_SECRET` 환경변수로 시크릿 관리
- Webhook 수신 즉시 `{"ok": True}` 응답 후 BackgroundTask로 배포 실행
- 배포 타임아웃: 10분 (`scripts/deploy.sh`)
Gitea Webhook 수신 → 자동 배포. HMAC SHA256 검증(`X-Gitea-Signature` 또는 `X-Hub-Signature-256`).
- 즉시 `{"ok": true}` 응답 후 BackgroundTask 배포. 동시 배포 락(threading.Lock + flock). 10분 타임아웃
- 📌 상세(검증 흐름·배포 스크립트 2단 구조·`.releases` 백업·헬스체크 게이트): **`service_deployer.md`**
| 메서드 | 경로 | 설명 |
|--------|------|------|
| POST | `/webhook` | Gitea Webhook 수신 → HMAC 검증 → 배포 |
---
## 10. 주의사항
## 10. 공유 인프라
- **Nginx trailing slash**: `/api/portfolio`는 trailing slash 없이도 매칭되도록 두 location 블록으로 처리
- **라우트 순서**: `DELETE /api/todos/done``DELETE /api/todos/{id}` 보다 **반드시 먼저** 등록 (personal 서비스, FastAPI prefix 매칭 순서)
- **PUID/PGID**: travel-proxy는 NAS 파일 권한을 위해 PUID/PGID를 환경변수로 주입
- **캐시 전략**: `index.html``no-store`, `assets/`는 1년 장기 캐시(immutable)
- **Frontend 배포**: git push로 자동 배포되지 않음. 로컬 빌드 후 NAS에 수동 업로드
- **.env 파일**: 절대 커밋 금지. `.env.example`만 레포에 포함
- **공휴일 목록**: `stock-lab/app/holidays.json` 매년 수동 갱신 필요 (KRX 기준)
- **Windows AI 서버 IP**: `192.168.45.59` — 공유기 DHCP 고정 예약으로 고정. Tailscale은 Synology에서 TCP 불가(userspace 모드)라 로컬 IP 사용
- **현재가 조회**: 네이버 모바일 API → HTML 파싱 폴백, 3분 TTL 캐시 (`price_fetcher.py`)
- **시뮬레이션 교체 방식**: `best_picks`는 교체형 — 새 시뮬레이션 실행 시 `is_active=0`으로 비활성화 후 신규 입력
### `_shared/access_log.py` 공용 모듈
5개 서비스(lotto, stock, music-lab, insta-lab, realestate-lab)가 공유. agent-office의 `/agents/{id}/logs`가 이를 통해 각 서비스 `/logs/recent`를 수집·병합.
- `install(app)` 단일 진입점 → middleware(요청 계측 + ring buffer maxlen=500) + `BufferLogHandler` + `GET /logs/recent?limit&since&path_prefix`
- docker-compose: `${RUNTIME_PATH}/_shared:/shared/_shared:ro` + `PYTHONPATH=/app:/shared`
- 제외: `/health` `/docs` `/logs/recent` 등 + `OPTIONS`/`HEAD`
### `redis` 컨테이너 (6379)
4개 서비스 비동기 큐 공유. 각 서비스가 `queue:<svc>-render` push → Windows AI 워커 pop → 완료 후 `/api/internal/<svc>/update` webhook → DB 업데이트.
- `queue:music-render`, `queue:video-render`, `queue:image-render`, `queue:insta-render`
---
## 11. 주의사항 (cross-cutting)
- **`.env` 절대 커밋 금지** (`.env.example`만 레포 포함)
- **커밋 경로**: web-ui / web-backend 별도 Git — 각 경로에서만 커밋 (`feedback_commit_repo.md`)
- **Docker는 NAS에서만 구동** (`feedback_docker_nas.md`)
- **Nginx trailing slash**: `/api/portfolio`는 두 location 블록으로 slash 유무 모두 매칭
- **라우트 순서**: personal `DELETE /api/todos/done``/{id}`보다 먼저 등록
- **DB 마이그레이션**: 스키마 변경 시 ALTER TABLE 멱등 패턴 (각 서비스 메모리 참조)
- **공휴일 목록**: `stock/app/holidays.json` 매년 수동 갱신 (KRX 기준)
- **Windows AI 서버 IP**: `192.168.45.59` (DHCP 고정 예약). Tailscale은 Synology userspace 모드라 TCP 불가 → 로컬 IP 사용
- **렌더/생성 워커 분리**: music/video/image/insta 무거운 작업은 Windows `web-ai` 워커. NAS 코드의 `*_provider.py`/`card_renderer.py`가 DEPRECATED stub면 실 로직은 web-ai 쪽이 authoritative
- **Playwright Dockerfile**: bookworm 고정 + 수동 chromium deps, `--with-deps` 금지 (`feedback_playwright_dockerfile.md`)
- **lab 네이밍**: `-lab`은 개발/연구 단계에만, 정식 서비스엔 미사용 (`feedback_lab_naming.md`)

142
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-lab:8000 │
│ ├── /api/portfolio → stock-lab: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 |
| `stock-lab` | 18500 | 주식 뉴스·AI 요약·KIS 실계좌·포트폴리오·자산 추적 |
| `music-lab` | 18600 | AI 음악 생성 (Suno + 로컬 MusicGen 듀얼 프로바이더) |
| `blog-lab` | 18700 | 블로그 마케팅 수익화 (키워드→글 생성→리뷰→발행) |
| `realestate-lab` | 18800 | 청약 공고 자동 수집·프로필 매칭 |
| `lotto` | 18000 | 로또 데이터 수집·분석·추천 API |
| `stock` | 18500 | 주식 뉴스·AI 요약·KIS 실계좌·포트폴리오·자산 추적 |
| `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-lab/ # 주식·포트폴리오
├── 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 |
| stock-lab | http://localhost:18500 |
| 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 |
---
@@ -99,7 +107,7 @@ curl http://localhost:18500/health
- 09:10 / 21:10 — 당첨번호 동기화 + 추천 채점
- 00:05, 04:05, 08:05, 12:05, 16:05, 20:05 — 몬테카를로 시뮬레이션 (후보 20,000 → 상위 100 → best_picks 20쌍 교체)
### 2. stock-lab (`/api/stock/`, `/api/trade/`, `/api/portfolio`)
### 2. stock (`/api/stock/`, `/api/trade/`, `/api/portfolio`)
주식 뉴스 스크래핑 + LLM 요약 + KIS 실계좌 연동 + 포트폴리오·자산 스냅샷.
@@ -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-lab / 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/`)
@@ -224,7 +241,7 @@ Gitea Webhook 수신 → NAS 자동 배포.
| 공동 출현 | 15% | 번호 쌍 동시 출현 빈도 |
| 다양성 | 10% | 연속번호·범위·구간 커버리지 |
### LLM 요약 provider 추상화 (stock-lab)
### LLM 요약 provider 추상화 (stock)
`ai_summarizer.py`는 provider 분리 구조. `summarize_news(articles)` 시그니처는 provider와 무관하게 고정.
@@ -232,7 +249,7 @@ Gitea Webhook 수신 → NAS 자동 배포.
- `_summarize_with_ollama`: Ollama `/api/generate` (타임아웃 180s, qwen3:14b 첫 로드 대응)
- 실패 시 `LLMError` (구 `OllamaError` alias 유지)
### 총 자산 스냅샷 (stock-lab)
### 총 자산 스냅샷 (stock)
평일 15:40 자동 실행 → `holidays.json`으로 공휴일 스킵 → 포트폴리오 현재가 조회 + 예수금 합계 → `asset_snapshots` upsert (date UNIQUE).
@@ -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 |
| `stock.db` | stock-lab | 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 |
| `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), 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-lab, 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_LAB_URL=http://stock-lab:8000
STOCK_URL=http://stock:8000
MUSIC_LAB_URL=http://music-lab:8000
BLOG_LAB_URL=http://blog-lab:8000
INSTA_LAB_URL=http://insta-lab:8000
REALESTATE_LAB_URL=http://realestate-lab:8000
# personal (포트폴리오 편집 인증)
PORTFOLIO_EDIT_PASSWORD=
```
---
@@ -343,7 +379,7 @@ REALESTATE_LAB_URL=http://realestate-lab:8000
- **라우트 순서** — `DELETE /api/todos/done``/api/todos/{id}` 보다 먼저 등록 필수 (FastAPI prefix 매칭)
- **캐시 전략** — `index.html`: no-store / `assets/`: 1년 immutable
- **PUID/PGID** — travel-proxy는 NAS 파일 권한을 위해 환경변수 주입 필수
- **공휴일 목록** — `stock-lab/app/holidays.json` 매년 수동 갱신 (KRX 기준)
- **공휴일 목록** — `stock/app/holidays.json` 매년 수동 갱신 (KRX 기준)
- **Windows AI 서버 IP** — `192.168.45.59` 공유기 DHCP 고정 예약. Synology Tailscale은 userspace 모드라 TCP 불가 → 로컬 IP 사용
- **Suno CDN** — `cdn1.suno.ai` URL은 임시 만료 → 생성 즉시 로컬 다운로드 필수
- **LLM provider 롤백** — Claude API 장애 시 `.env``LLM_PROVIDER=ollama`로 전환 후 `docker compose up -d`

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-lab` | 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

1
_shared/__init__.py Normal file
View File

@@ -0,0 +1 @@
# empty

112
_shared/access_log.py Normal file
View File

@@ -0,0 +1,112 @@
"""각 lab 컨테이너에서 import 하는 공용 액세스/이벤트 로그 모듈.
사용법:
from _shared.access_log import install as install_access_log
install_access_log(app)
"""
from collections import deque
from datetime import datetime
from typing import Optional
import logging
import time
from fastapi import APIRouter, Request
from fastapi.applications import FastAPI
from starlette.middleware.base import BaseHTTPMiddleware
# 컨테이너당 최근 500개를 in-memory 로 유지. 재시작 시 휘발.
_BUFFER: deque = deque(maxlen=500)
EXCLUDED_PATHS = {
"/health", "/healthz", "/ping", "/favicon.ico",
"/docs", "/redoc", "/openapi.json", "/logs/recent",
}
EXCLUDED_PREFIXES = ("/static/",)
EXCLUDED_METHODS = {"OPTIONS", "HEAD"}
def _should_log(request: Request) -> bool:
if request.method in EXCLUDED_METHODS:
return False
path = request.url.path
if path in EXCLUDED_PATHS:
return False
if any(path.startswith(p) for p in EXCLUDED_PREFIXES):
return False
return True
class AccessLogMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
start = time.time()
response = await call_next(request)
if not _should_log(request):
return response
elapsed_ms = int((time.time() - start) * 1000)
status = response.status_code
if status < 400:
level = "info"
elif status < 500:
level = "warning"
else:
level = "error"
_BUFFER.append({
"ts": datetime.utcnow().isoformat() + "Z",
"level": level,
"source": "access",
"method": request.method,
"path": request.url.path,
"status": status,
"ms": elapsed_ms,
"message": f"{request.method} {request.url.path}{status} ({elapsed_ms}ms)",
})
return response
class BufferLogHandler(logging.Handler):
"""root logger 에 부착하면 모든 logger.info/warning/error 가 buffer 에 흐름."""
def emit(self, record: logging.LogRecord) -> None:
try:
_BUFFER.append({
"ts": datetime.utcfromtimestamp(record.created).isoformat() + "Z",
"level": record.levelname.lower(),
"source": "log",
"logger": record.name,
"message": record.getMessage(),
})
except Exception:
# buffer 에 못 넣는다고 서비스가 죽으면 안 됨
pass
router = APIRouter()
@router.get("/logs/recent")
def logs_recent(limit: int = 200, since: Optional[str] = None,
path_prefix: Optional[str] = None):
items = list(_BUFFER)
if since:
items = [x for x in items if x["ts"] > since]
if path_prefix:
items = [
x for x in items
if x["source"] == "log"
or x.get("path", "").startswith(path_prefix)
]
return {"logs": items[-limit:]}
def install(app: FastAPI, logger_root: str = "") -> None:
"""서비스 main.py 에서 호출하는 단일 설치 함수.
- AccessLogMiddleware 등록
- /logs/recent 라우터 등록
- root logger 에 BufferLogHandler 부착 (모든 child logger 자동 전파)
"""
app.add_middleware(AccessLogMiddleware)
app.include_router(router)
root = logging.getLogger(logger_root)
if not any(isinstance(h, BufferLogHandler) for h in root.handlers):
root.addHandler(BufferLogHandler())

View File

@@ -0,0 +1,129 @@
import logging
import time
from fastapi import FastAPI
from fastapi.testclient import TestClient
from _shared.access_log import (
AccessLogMiddleware,
BufferLogHandler,
router as logs_router,
install,
_BUFFER,
)
def _reset_buffer():
_BUFFER.clear()
def test_access_middleware_records_request():
_reset_buffer()
app = FastAPI()
app.add_middleware(AccessLogMiddleware)
@app.get("/api/lotto/recommend")
def recommend():
return {"ok": True}
client = TestClient(app)
client.get("/api/lotto/recommend")
items = [x for x in _BUFFER if x["source"] == "access"]
assert len(items) == 1
assert items[0]["method"] == "GET"
assert items[0]["path"] == "/api/lotto/recommend"
assert items[0]["status"] == 200
assert items[0]["ms"] >= 0
def test_access_middleware_skips_health():
_reset_buffer()
app = FastAPI()
app.add_middleware(AccessLogMiddleware)
@app.get("/health")
def health():
return {"ok": True}
client = TestClient(app)
client.get("/health")
items = [x for x in _BUFFER if x["source"] == "access"]
assert items == []
def test_access_middleware_skips_options():
_reset_buffer()
app = FastAPI()
app.add_middleware(AccessLogMiddleware)
@app.get("/api/lotto/recommend")
def recommend():
return {"ok": True}
client = TestClient(app)
client.options("/api/lotto/recommend")
items = [x for x in _BUFFER if x["source"] == "access"]
assert items == []
def test_buffer_log_handler_captures_logger_info():
_reset_buffer()
root = logging.getLogger("")
handler = BufferLogHandler()
root.addHandler(handler)
try:
lg = logging.getLogger("lotto.test")
lg.setLevel(logging.INFO)
lg.info("뉴스 스크래핑 완료: 국내 12건")
finally:
root.removeHandler(handler)
items = [x for x in _BUFFER if x["source"] == "log"]
assert len(items) == 1
assert items[0]["message"] == "뉴스 스크래핑 완료: 국내 12건"
assert items[0]["level"] == "info"
assert items[0]["logger"] == "lotto.test"
def test_logs_recent_endpoint_returns_recent_items():
_reset_buffer()
app = FastAPI()
install(app)
@app.get("/api/lotto/recommend")
def recommend():
return {"ok": True}
client = TestClient(app)
client.get("/api/lotto/recommend")
client.get("/api/lotto/recommend")
client.get("/health") # 제외되어야 함
resp = client.get("/logs/recent")
assert resp.status_code == 200
logs = resp.json()["logs"]
access_items = [x for x in logs if x["source"] == "access"]
assert len(access_items) == 2
def test_logs_recent_with_since_filter():
_reset_buffer()
app = FastAPI()
install(app)
@app.get("/api/lotto/recommend")
def recommend():
return {"ok": True}
client = TestClient(app)
client.get("/api/lotto/recommend")
time.sleep(0.01)
cursor_resp = client.get("/logs/recent")
cursor_ts = cursor_resp.json()["logs"][-1]["ts"]
client.get("/api/lotto/recommend")
resp = client.get(f"/logs/recent?since={cursor_ts}")
items = [x for x in resp.json()["logs"] if x["source"] == "access"]
assert len(items) == 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,19 +1,21 @@
from .stock import StockAgent
from .music import MusicAgent
from .blog import BlogAgent
from .insta import InstaAgent
from .realestate import RealestateAgent
from .lotto import LottoAgent
from .youtube import YouTubeResearchAgent
from .youtube_publisher import YoutubePublisherAgent
AGENT_REGISTRY = {}
def init_agents():
AGENT_REGISTRY["stock"] = StockAgent()
AGENT_REGISTRY["music"] = MusicAgent()
AGENT_REGISTRY["blog"] = BlogAgent()
AGENT_REGISTRY["insta"] = InstaAgent()
AGENT_REGISTRY["realestate"] = RealestateAgent()
AGENT_REGISTRY["lotto"] = LottoAgent()
AGENT_REGISTRY["youtube"] = YouTubeResearchAgent()
AGENT_REGISTRY["youtube_publisher"] = YoutubePublisherAgent()
def get_agent(agent_id: str):
return AGENT_REGISTRY.get(agent_id)

View File

@@ -1,12 +1,7 @@
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 +9,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,11 +26,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})")
if self._ws_manager:
await self._ws_manager.send_agent_state(self.agent_id, new_state, detail, task_id)
@@ -48,19 +37,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

@@ -1,192 +0,0 @@
import asyncio
from typing import Optional
from .base import BaseAgent
from ..db import (
create_task, update_task_status, approve_task, reject_task,
get_task, get_agent_config, add_log,
)
from .. import service_proxy
from .. import telegram_bot
DEFAULT_TREND_KEYWORDS = [
"다이어트 식단", "재택근무 꿀템", "캠핑 장비 추천",
"홈트레이닝", "제주도 여행", "에어프라이어 레시피",
]
class BlogAgent(BaseAgent):
"""블로그 마케팅 에이전트.
매일 10:00 자동 실행: 키워드 1개 리서치 → 글 생성 → 마케터 → 평가자
→ 평가 점수와 요약을 텔레그램 승인 요청으로 푸시
→ 승인 시 `published` 상태로 전환, 거절 시 재생성
"""
agent_id = "blog"
display_name = "블로그 마케터"
async def on_schedule(self) -> None:
if self.state not in ("idle", "break"):
return
config = get_agent_config(self.agent_id) or {}
custom = config.get("custom_config", {}) or {}
keywords = custom.get("trend_keywords") or DEFAULT_TREND_KEYWORDS
if not keywords:
return
import random
keyword = random.choice(keywords)
task_id = create_task(
self.agent_id,
"auto_blog_pipeline",
{"keyword": keyword},
requires_approval=True,
)
await self.transition("working", f"리서치: {keyword}", task_id)
asyncio.create_task(self._run_pipeline(task_id, keyword))
async def _await_task(self, step: str, task_id: str, timeout_sec: int = 240) -> Optional[int]:
"""blog-lab BackgroundTask 완료 폴링. 완료 시 result_id 반환."""
attempts = max(1, timeout_sec // 5)
for _ in range(attempts):
await asyncio.sleep(5)
status = await service_proxy.blog_task_status(task_id)
s = status.get("status")
if s == "succeeded":
return status.get("result_id")
if s == "failed":
raise Exception(f"{step} failed: {status.get('error')}")
raise Exception(f"{step} timeout ({timeout_sec}s 내 완료되지 않음)")
async def _run_pipeline(self, task_id: str, keyword: str) -> None:
try:
# 1) 리서치
research = await service_proxy.blog_research(keyword)
keyword_id = await self._await_task("research", research.get("task_id"), 180)
if not keyword_id:
raise Exception("research succeeded but result_id missing")
# 2) 작가 단계 (비동기)
await self.transition("working", f"글 생성: {keyword}", task_id)
gen = await service_proxy.blog_generate(keyword_id)
post_id = await self._await_task("generate", gen.get("task_id"), 300)
if not post_id:
raise Exception("generate succeeded but post_id missing")
# 3) 마케터 단계 (비동기)
await self.transition("working", "링크 삽입 중", task_id)
mkt = await service_proxy.blog_market(post_id)
await self._await_task("market", mkt.get("task_id"), 180)
# 4) 평가자 단계 (비동기)
await self.transition("working", "품질 리뷰 중", task_id)
rev = await service_proxy.blog_review(post_id)
await self._await_task("review", rev.get("task_id"), 180)
post_after = await service_proxy.blog_get_post(post_id)
score = post_after.get("review_score")
passed = (score or 0) >= 42
title = post_after.get("title", "(제목 없음)")
excerpt = (post_after.get("body") or "")[:300]
update_task_status(task_id, "pending", {
"keyword": keyword,
"post_id": post_id,
"score": score,
"passed": passed,
"title": title,
})
await self.transition("waiting", f"승인 대기 · {score}/60", task_id)
detail = (
f"키워드: {keyword}\n"
f"제목: {title}\n"
f"평가 점수: {score}/60 ({'통과' if passed else '미통과'})\n\n"
f"{excerpt}..."
)
await telegram_bot.send_approval_request(
self.agent_id, task_id,
"✍️ [블로그 에이전트] 발행 승인 요청", detail,
)
except Exception as e:
add_log(self.agent_id, f"Blog pipeline failed: {e}", "error", task_id)
update_task_status(task_id, "failed", {"error": str(e), "keyword": keyword})
await self.transition("idle", f"오류: {e}")
await telegram_bot.send_task_result(
self.agent_id, "✍️ [블로그 에이전트] 파이프라인 실패",
f"키워드: {keyword}\n오류: {e}",
)
async def on_command(self, command: str, params: dict) -> dict:
if command == "research":
keyword = (params.get("keyword") or "").strip()
if not keyword:
return {"ok": False, "message": "keyword 필수"}
task_id = create_task(
self.agent_id, "auto_blog_pipeline",
{"keyword": keyword}, requires_approval=True,
)
await self.transition("working", f"리서치: {keyword}", task_id)
asyncio.create_task(self._run_pipeline(task_id, keyword))
return {"ok": True, "task_id": task_id, "message": f"파이프라인 시작: {keyword}"}
if command == "add_trend_keyword":
keyword = (params.get("keyword") or "").strip()
if not keyword:
return {"ok": False, "message": "keyword 필수"}
config = get_agent_config(self.agent_id) or {}
custom = config.get("custom_config", {}) or {}
kws = list(custom.get("trend_keywords") or [])
if keyword not in kws:
kws.append(keyword)
from ..db import update_agent_config
update_agent_config(self.agent_id, custom_config={**custom, "trend_keywords": kws})
return {"ok": True, "keywords": kws}
if command == "list_trend_keywords":
config = get_agent_config(self.agent_id) or {}
custom = config.get("custom_config", {}) or {}
return {"ok": True, "keywords": custom.get("trend_keywords") or DEFAULT_TREND_KEYWORDS}
return {"ok": False, "message": f"Unknown command: {command}"}
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
task = get_task(task_id)
if not task:
return
result = task.get("result_data") or {}
post_id = result.get("post_id")
if not approved:
reject_task(task_id)
await self.transition("idle", "발행 거절됨")
await telegram_bot.send_task_result(
self.agent_id, "✍️ [블로그 에이전트] 발행 취소",
f"키워드: {result.get('keyword', '')}\n사용자가 거절했습니다.",
)
return
approve_task(task_id, via="telegram")
await self.transition("reporting", "발행 중...", task_id)
try:
if post_id:
await service_proxy.blog_publish(int(post_id))
update_task_status(task_id, "succeeded", {**result, "published": True})
await telegram_bot.send_task_result(
self.agent_id, "✍️ [블로그 에이전트] 발행 완료",
f"키워드: {result.get('keyword', '')}\n제목: {result.get('title', '')}\n"
f"점수: {result.get('score')}/60",
)
await self.transition("idle", "발행 완료")
except Exception as e:
add_log(self.agent_id, f"Blog publish failed: {e}", "error", task_id)
update_task_status(task_id, "failed", {**result, "publish_error": str(e)})
await self.transition("idle", f"발행 오류: {e}")

View File

@@ -0,0 +1,75 @@
"""텔레그램 사용자 응답 자연어 분류 — 화이트리스트 우선, 모호 시 LLM."""
import os
import json
import logging
import httpx
logger = logging.getLogger("agent-office.classify_intent")
CLAUDE_HAIKU_DEFAULT = "claude-haiku-4-5-20251001"
APPROVE_WORDS = {
"승인", "시작", "진행", "ok", "okay", "agree",
"", "", "좋아", "좋아요", "go", "yes", "y",
}
REJECT_WORDS = {"반려", "거절", "취소", "no", "nope", "n"}
def _get_api_key() -> str:
return os.getenv("ANTHROPIC_API_KEY", "")
def _get_model() -> str:
return os.getenv("CLAUDE_HAIKU_MODEL", CLAUDE_HAIKU_DEFAULT)
def classify(text: str) -> tuple[str, str | None]:
"""returns (intent, feedback) — intent ∈ {approve, reject, unclear}"""
if not text:
return ("unclear", None)
t = text.strip().lower()
if t in APPROVE_WORDS:
return ("approve", None)
if t in REJECT_WORDS:
return ("reject", None)
# 반려 단어로 시작 + 추가 텍스트
for w in REJECT_WORDS:
if t.startswith(w):
rest = text.strip()[len(w):].lstrip(" ,.-:").strip()
if rest:
return ("reject", rest)
# 승인 단어로 시작 (긍정 의도면 추가 텍스트 무시)
for w in APPROVE_WORDS:
if t.startswith(w + " ") or t == w:
return ("approve", None)
return _llm_classify(text)
def _llm_classify(text: str) -> tuple[str, str | None]:
api_key = _get_api_key()
if not api_key:
return ("unclear", None)
prompt = (
"사용자 응답을 분류하세요. JSON으로만 응답.\n"
f'응답: "{text}"\n\n'
'출력: {"intent":"approve|reject|unclear","feedback":"반려면 수정 방향, 아니면 빈 문자열"}'
)
try:
resp = httpx.post(
"https://api.anthropic.com/v1/messages",
headers={"x-api-key": api_key, "anthropic-version": "2023-06-01"},
json={"model": _get_model(), "max_tokens": 200,
"messages": [{"role": "user", "content": prompt}]},
timeout=15,
)
resp.raise_for_status()
text_out = resp.json()["content"][0]["text"]
start = text_out.find("{")
end = text_out.rfind("}") + 1
if start < 0 or end <= start:
return ("unclear", None)
data = json.loads(text_out[start:end])
return (data.get("intent", "unclear"), data.get("feedback") or None)
except (httpx.HTTPError, httpx.TimeoutException, KeyError, ValueError, json.JSONDecodeError) as e:
logger.warning("LLM 분류 실패: %s", e)
return ("unclear", None)

View File

@@ -0,0 +1,194 @@
"""인스타 카드 에이전트 — 매일 09:30 뉴스 수집·키워드 추출 → 텔레그램 후보 푸시.
사용자가 키워드 버튼을 누르면 카드 슬레이트 생성 + 10장 미디어 그룹 발송."""
import asyncio
import json
import logging
from typing import Any, Dict, List, Optional
import httpx
from .base import BaseAgent
from ..db import (
create_task, update_task_status, add_log, get_agent_config,
)
from ..config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID
from .. import service_proxy
from ..telegram import messaging
logger = logging.getLogger(__name__)
# 텔레그램 후보 푸시 시 "확실한 것만" 보내기 위한 최소 신뢰도 (키워드 score 0~1)
KEYWORD_MIN_SCORE = 0.7
def _dedup_and_filter_keywords(
keywords: List[Dict[str, Any]], min_score: float = KEYWORD_MIN_SCORE,
) -> List[Dict[str, Any]]:
"""score >= min_score 인 키워드만 남기고, 동일 keyword 중복 제거(최고 score 유지).
결과는 score 내림차순. 텔레그램 후보 푸시 전 정리용."""
best: Dict[str, Dict[str, Any]] = {}
for k in keywords:
if float(k.get("score", 0)) < min_score:
continue
name = str(k.get("keyword", "")).strip()
if not name:
continue
if name not in best or k["score"] > best[name]["score"]:
best[name] = k
return sorted(best.values(), key=lambda k: -k["score"])
async def _send_media_group(media: List[Dict[str, Any]], caption: str = "") -> Dict[str, Any]:
"""텔레그램 sendMediaGroup. media는 InputMediaPhoto dicts.
각 항목에는 임시 키 '_bytes'로 PNG 바이트가 담겨 있어 attach:// 형식으로 multipart 업로드."""
if not TELEGRAM_BOT_TOKEN:
return {"ok": False, "reason": "TELEGRAM_BOT_TOKEN missing"}
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMediaGroup"
files: Dict[str, tuple] = {}
for i, m in enumerate(media):
attach_key = f"photo{i+1}"
files[attach_key] = (f"{i+1}.png", m["_bytes"], "image/png")
m["media"] = f"attach://{attach_key}"
m.pop("_bytes", None)
if caption and media:
media[0]["caption"] = caption[:1024]
payload = {"chat_id": TELEGRAM_CHAT_ID, "media": json.dumps(media, ensure_ascii=False)}
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(url, data=payload, files=files)
return resp.json()
class InstaAgent(BaseAgent):
agent_id = "insta"
display_name = "인스타 큐레이터"
async def on_schedule(self) -> None:
"""09:30 매일: 뉴스 수집 → 키워드 추출 → 텔레그램 후보 푸시.
custom_config.auto_select=True면 카테고리당 1위 키워드 자동 슬레이트 생성."""
if self.state != "idle":
return
config = get_agent_config(self.agent_id) or {}
custom = config.get("custom_config", {}) or {}
auto_select = bool(custom.get("auto_select", False))
task_id = create_task(self.agent_id, "insta_daily", {"auto_select": auto_select},
requires_approval=False)
await self.transition("working", "뉴스 수집·키워드 추출", task_id)
try:
prefs = await service_proxy.insta_get_preferences()
add_log(self.agent_id, f"insta preferences: {prefs}", "info", task_id)
await self._run_collect_and_extract()
kws = await service_proxy.insta_list_keywords(used=False)
if auto_select:
await self._auto_render(kws)
else:
await self._push_keyword_candidates(kws)
update_task_status(task_id, "succeeded", {"keywords": len(kws)})
await self.transition("idle", "후보 푸시 완료")
except Exception as e:
add_log(self.agent_id, f"insta daily failed: {e}", "error", task_id)
update_task_status(task_id, "failed", {"error": str(e)})
await self.transition("idle", f"오류: {e}")
async def _run_collect_and_extract(self) -> None:
col = await service_proxy.insta_collect()
await self._wait_task(col["task_id"], step="collect", timeout_sec=300)
ext = await service_proxy.insta_extract()
await self._wait_task(ext["task_id"], step="extract", timeout_sec=300)
async def _wait_task(self, task_id: str, step: str, timeout_sec: int = 300) -> Dict[str, Any]:
attempts = max(1, timeout_sec // 5)
for _ in range(attempts):
await asyncio.sleep(5)
st = await service_proxy.insta_task_status(task_id)
if st["status"] == "succeeded":
return st
if st["status"] == "failed":
raise RuntimeError(f"{step} failed: {st.get('error')}")
raise TimeoutError(f"{step} timeout {timeout_sec}s")
async def _push_keyword_candidates(self, keywords: List[Dict[str, Any]]) -> None:
# 중복 제거 + 신뢰도(score) 임계값 이상만 — "확실한 것만" 정리해서 전송
filtered = _dedup_and_filter_keywords(keywords)
if not filtered:
await messaging.send_raw(
f"📰 [인스타 큐레이터] 오늘은 확실한 추천 키워드가 없습니다 (신뢰도 {KEYWORD_MIN_SCORE:.1f}+ 기준)."
)
return
by_cat: Dict[str, List[Dict[str, Any]]] = {}
for k in filtered:
by_cat.setdefault(k["category"], []).append(k)
rows: List[List[Dict[str, Any]]] = []
text_lines = [f"📰 <b>[인스타 큐레이터]</b> 오늘의 키워드 후보 (신뢰도 {KEYWORD_MIN_SCORE:.1f}+)"]
for cat, items in by_cat.items():
text_lines.append(f"\n<b>{cat}</b>")
for k in items[:5]:
text_lines.append(f" · {k['keyword']} (score {k['score']:.2f})")
rows.append([{
"text": f"🎴 {k['keyword']}",
"callback_data": f"render_{k['id']}",
}])
await messaging.send_raw("\n".join(text_lines), reply_markup={"inline_keyboard": rows})
async def _auto_render(self, keywords: List[Dict[str, Any]]) -> None:
by_cat: Dict[str, Dict[str, Any]] = {}
for k in keywords:
cat = k["category"]
if cat not in by_cat or k["score"] > by_cat[cat]["score"]:
by_cat[cat] = k
for kw in by_cat.values():
await self._render_and_push(kw["id"])
async def _render_and_push(self, keyword_id: int) -> None:
kw = await service_proxy.insta_get_keyword(keyword_id)
if not kw:
await messaging.send_raw(f"⚠️ 키워드 {keyword_id} 없음")
return
await messaging.send_raw(f"🎨 카드 생성 중: <b>{kw['keyword']}</b>")
created = await service_proxy.insta_create_slate(
keyword=kw["keyword"], category=kw["category"], keyword_id=kw["id"],
)
st = await self._wait_task(created["task_id"], step="slate", timeout_sec=600)
slate_id = st["result_id"]
slate = await service_proxy.insta_get_slate(slate_id)
media = []
for a in slate["assets"][:10]:
data = await service_proxy.insta_get_asset_bytes(slate_id, a["page_index"])
media.append({"type": "photo", "_bytes": data})
caption = slate.get("suggested_caption", "")
hashtags = " ".join(slate.get("hashtags", []) or [])
full_caption = f"{caption}\n\n{hashtags}".strip()
await _send_media_group(media, caption=full_caption)
async def on_command(self, command: str, params: dict) -> dict:
if command == "extract":
await self._run_collect_and_extract()
kws = await service_proxy.insta_list_keywords(used=False)
await self._push_keyword_candidates(kws)
return {"ok": True, "count": len(kws)}
if command == "render":
kid = int(params.get("keyword_id") or 0)
if not kid:
return {"ok": False, "message": "keyword_id 필수"}
await self._render_and_push(kid)
return {"ok": True}
if command == "collect_trends":
await messaging.send_raw("🌐 외부 트렌드 수집 시작")
created = await service_proxy.insta_collect_trends()
st = await self._wait_task(created["task_id"], step="trends_collect", timeout_sec=300)
await messaging.send_raw(f"✅ 트렌드 수집 완료: {st.get('message', '')}")
return {"ok": True, "result": st}
return {"ok": False, "message": f"Unknown command: {command}"}
async def on_callback(self, action: str, params: dict) -> dict:
if action == "render":
kid = int(params.get("keyword_id") or 0)
if not kid:
return {"ok": False}
await self._render_and_push(kid)
return {"ok": True}
return {"ok": False}
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
return

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")
@@ -17,21 +17,269 @@ class LottoAgent(BaseAgent):
return await self._run(source="manual")
if action == "status":
return {"ok": True, "message": f"{self.state}: {self.state_detail}"}
if action in ("signal_check", "light_check", "sim_check", "deep_check"):
source = action.replace("_check", "") if action != "signal_check" else "light"
return await self.run_signal_check(source=source)
if action == "daily_digest":
return await self.run_daily_digest()
if action == "sunday_review":
return await self.run_sunday_review()
return {"ok": False, "message": f"unknown action: {action}"}
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
pass
async def run_signal_check(self, source: str = "light") -> dict:
"""비-LLM 시그널 평가. task_id wrap 적용."""
from ..curator.signal_runner import run_signal_check
from ..config import (
LOTTO_Z_NORMAL, LOTTO_Z_URGENT,
LOTTO_THROTTLE_HOURS, LOTTO_URGENT_DAILY_MAX,
)
from ..db import (
create_task, update_task_status, add_log,
get_last_signal_notification, get_recent_urgent_count,
mark_signal_notified,
)
from ..notifiers.telegram_lotto import send_urgent_signal
from ..service_proxy import lotto_latest_draw
if self.state not in ("idle", "reporting"):
return {"ok": False, "message": f"busy ({self.state})"}
task_id = create_task("lotto", "signal_check", {"source": source})
try:
curate_result = None
current_draw_no = await lotto_latest_draw()
if source == "deep":
from ..curator.pipeline import curate_weekly
cw = await curate_weekly(source="signal_deep")
curate_result = {"confidence": cw.get("confidence")}
if cw.get("draw_no"):
current_draw_no = cw.get("draw_no")
outcome = await run_signal_check(
source=source,
z_normal=LOTTO_Z_NORMAL,
z_urgent=LOTTO_Z_URGENT,
curate_result=curate_result,
current_draw_no=current_draw_no,
)
# urgent 텔레그램 + throttle (기존 동작 유지)
if outcome["overall_fire"] == "urgent":
if get_recent_urgent_count(hours=24) >= LOTTO_URGENT_DAILY_MAX:
add_log("lotto", "urgent daily cap 도달 → normal로 강등", level="warning", task_id=task_id)
else:
blocked = False
for r in outcome["results"]:
if r["fire_level"] in ("normal", "urgent"):
if get_last_signal_notification(
metric=r["metric"], fire_level=r["fire_level"],
hours=LOTTO_THROTTLE_HOURS,
):
blocked = True
break
if not blocked:
from datetime import datetime, timezone
event = {
"fire_level": "urgent",
"triggered_at": datetime.now(timezone.utc).isoformat(),
"results": outcome["results"],
}
await send_urgent_signal(event)
for r in outcome["results"]:
if r["fire_level"] in ("normal", "urgent"):
mark_signal_notified(r["signal_id"])
add_log("lotto", f"urgent 텔레그램 발송 ({len(outcome['results'])}개 시그널)", task_id=task_id)
fired_metrics = [
r["metric"] for r in outcome["results"]
if r["fire_level"] not in ("noop", "warmup")
]
update_task_status(task_id, "succeeded", result_data={
"source": source,
"overall_fire": outcome["overall_fire"],
"n_results": len(outcome["results"]),
"fired_metrics": fired_metrics,
})
add_log("lotto", f"signal_check({source}) → {outcome['overall_fire']} results={len(outcome['results'])}", task_id=task_id)
return {"ok": True, **outcome}
except Exception as e:
update_task_status(task_id, "failed", result_data={"error": str(e)})
add_log("lotto", f"signal_check 예외: {e}", level="error", task_id=task_id)
return {"ok": False, "message": f"{type(e).__name__}: {e}"}
async def run_daily_digest(self) -> dict:
"""일일 요약 — 지난 24h normal/urgent 발화 텔레그램 1통. task_id wrap."""
from ..db import (
create_task, update_task_status, add_log,
get_recent_lotto_signals, get_signals_history, get_baseline,
)
from ..notifiers.telegram_lotto import send_signal_summary
task_id = create_task("lotto", "daily_digest", {})
try:
sigs = get_recent_lotto_signals(hours=24, min_fire="normal")
total_24h = get_signals_history(days=1)
evaluated = len(total_24h)
trend = {}
try:
cache = get_baseline("drift_weights_cache")
if cache and isinstance(cache["window_values"], list) and len(cache["window_values"]) >= 2:
prev_w = cache["window_values"][-2]
curr_w = cache["window_values"][-1]
trend = {
k: curr_w.get(k, 0.0) - prev_w.get(k, 0.0)
for k in (set(prev_w) | set(curr_w))
}
except Exception as e:
add_log("lotto", f"weights_trend 계산 실패: {e}", level="warning", task_id=task_id)
digest = {
"evaluated": evaluated,
"fired": len(sigs),
"signals": sigs,
"weights_trend": trend,
}
await send_signal_summary(digest)
update_task_status(task_id, "succeeded", result_data={
"evaluated": evaluated,
"fired": len(sigs),
"signals_count": len(sigs),
})
add_log("lotto", f"daily_digest 발송: 평가 {evaluated} / 발화 {len(sigs)}", task_id=task_id)
return {"ok": True, **digest}
except Exception as e:
update_task_status(task_id, "failed", result_data={"error": str(e)})
add_log("lotto", f"daily_digest 예외: {e}", level="error", task_id=task_id)
return {"ok": False, "message": f"{type(e).__name__}: {e}"}
async def run_sunday_review(self) -> dict:
"""일 09:00 — 최신 회차 forward+calibration 보장 후 회고 텔레그램."""
from ..service_proxy import lotto_latest_draw, lotto_backtest_review
from ..notifiers.telegram_lotto import send_sunday_review
from ..db import create_task, update_task_status, add_log
task_id = create_task("lotto", "sunday_review", {})
try:
draw_no = await lotto_latest_draw()
if not draw_no:
update_task_status(task_id, "failed", result_data={"reason": "no_draw"})
return {"ok": False, "message": "no latest draw"}
# forward는 lotto cron이 이미 돌렸을 수 있으나 멱등이라 안전 — review만 호출
payload = await lotto_backtest_review(draw_no)
await send_sunday_review(payload)
update_task_status(task_id, "succeeded", result_data={"draw_no": draw_no})
add_log("lotto", f"sunday_review 발송: #{draw_no}", task_id=task_id)
return {"ok": True, "draw_no": draw_no}
except Exception as e:
update_task_status(task_id, "failed", result_data={"error": str(e)})
add_log("lotto", f"sunday_review 예외: {e}", level="error", task_id=task_id)
return {"ok": False, "message": f"{type(e).__name__}: {e}"}
async def run_weekly_evolution_report(self) -> dict:
"""토 22:15 — lotto-lab evaluate-now 트리거 후 텔레그램 리포트. task_id wrap."""
from ..service_proxy import lotto_evolver_evaluate, lotto_evolver_status
from ..notifiers.telegram_lotto import send_evolution_report
from ..db import create_task, update_task_status, add_log
task_id = create_task("lotto", "weekly_evolution_report", {})
try:
eval_result = await lotto_evolver_evaluate()
status = await lotto_evolver_status()
current_base = status.get("current_base") or [0.2] * 5
await send_evolution_report(eval_result, current_base)
winner = eval_result.get("winner") or {}
update_task_status(task_id, "succeeded", result_data={
"draw_no": eval_result.get("draw_no"),
"update_reason": eval_result.get("update_reason"),
"winner_day_of_week": winner.get("day_of_week"),
"winner_max_correct": winner.get("max_correct"),
})
add_log("lotto", f"weekly_evolution_report 발송: draw={eval_result.get('draw_no')} reason={eval_result.get('update_reason')}", task_id=task_id)
return {"ok": True, **eval_result}
except Exception as e:
update_task_status(task_id, "failed", result_data={"error": str(e)})
add_log("lotto", f"weekly_evolution_report 예외: {e}", level="error", task_id=task_id)
return {"ok": False, "message": f"{type(e).__name__}: {e}"}
async def sync_evolver_activity(self) -> dict:
"""매일 09:30 — lotto-lab evolver 상태 polling → agent_office.db에 task+log 거울. 멱등."""
from datetime import datetime, timezone, timedelta
from ..service_proxy import lotto_evolver_status
from ..db import (
create_task, update_task_status, add_log,
get_tasks_by_agent_date_kind,
)
KST = timezone(timedelta(hours=9))
today_kst = datetime.now(KST).date()
# created_at은 UTC로 저장되므로 idempotency guard는 UTC 날짜 기준
today_utc_iso = datetime.now(timezone.utc).date().isoformat()
dow = today_kst.weekday()
if dow == 6:
dow = 5
try:
status = await lotto_evolver_status()
except Exception as e:
add_log("lotto", f"sync_evolver_activity: lotto-lab status fetch 실패: {e}", level="warning")
return {"ok": False, "reason": "status_fetch_failed", "error": str(e)}
results = {"created": []}
today_trial = next((t for t in status.get("trials", []) if t.get("day_of_week") == dow), None)
if today_trial and today_trial.get("picks"):
if not get_tasks_by_agent_date_kind("lotto", today_utc_iso, "evolver_apply"):
tid = create_task("lotto", "evolver_apply", {
"date": today_utc_iso,
"trial_id": today_trial["id"],
"day_of_week": dow,
"weight": today_trial["weight"],
})
update_task_status(tid, "succeeded", result_data={
"n_picks": len(today_trial["picks"]),
"meta_scores": [p.get("meta_score") for p in today_trial["picks"]],
})
add_log("lotto", f"evolver_apply: 오늘({dow}) W로 {len(today_trial['picks'])}세트 추출", task_id=tid)
results["created"].append("evolver_apply")
if today_kst.weekday() == 0 and len(status.get("trials", [])) == 6:
if not get_tasks_by_agent_date_kind("lotto", today_utc_iso, "evolver_generate"):
tid = create_task("lotto", "evolver_generate", {"week_start": status.get("week_start")})
update_task_status(tid, "succeeded", result_data={
"trials_count": 6,
"candidates_per_source": {"perturb": 4, "dirichlet": 2},
})
add_log("lotto", f"evolver_generate: {status.get('week_start')} 주의 6 trials 생성", task_id=tid)
results["created"].append("evolver_generate")
return {"ok": True, **results}
async def _run(self, source: str) -> dict:
task_id = create_task(self.agent_id, "curate_weekly", {"source": source})
await self.transition("working", "후보 수집 및 AI 큐레이션 중...", task_id)
try:
result = await curate_weekly(source=source)
update_task_status(task_id, "succeeded", result_data=result)
update_task_status(task_id, "succeeded", result_data={
k: v for k, v in result.items() if k != "payload"
})
await self.transition("reporting", f"#{result['draw_no']} 브리핑 저장 완료")
add_log(self.agent_id, f"큐레이션 완료: #{result['draw_no']} conf={result['confidence']}", task_id=task_id)
# 텔레그램 헤드라인 푸시 (실패해도 큐레이션은 성공으로 마감)
try:
from ..notifiers.telegram_lotto import send_curator_briefing
await send_curator_briefing(result["payload"])
except Exception as e:
add_log(self.agent_id, f"텔레그램 알림 실패: {e}", level="warning", task_id=task_id)
await self.transition("idle", "대기 중")
return {"ok": True, **result}
return {"ok": True, **{k: v for k, v in result.items() if k != "payload"}}
except CuratorError as e:
update_task_status(task_id, "failed", result_data={"error": str(e)})
add_log(self.agent_id, f"큐레이션 실패: {e}", level="error", task_id=task_id)

View File

@@ -44,14 +44,23 @@ 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})
await self.transition("working", "AI 뉴스 요약 생성 중...", task_id)
await self.transition("working", "최신 뉴스 수집 중...", task_id)
try:
# AI 요약 호출 (뉴스 수집 + LLM 처리는 stock-lab이 담당)
# stock cron(매일 8:00)이 7:30 브리핑보다 늦게 돌아 어제 뉴스가
# 요약되던 문제 방지 — 요약 직전에 동기 스크랩으로 DB를 갱신한다.
try:
await service_proxy.scrape_stock_news()
except Exception as e:
add_log(self.agent_id, f"뉴스 스크랩 실패 (이전 데이터로 진행): {e}", "warning", task_id)
await self.transition("working", "AI 뉴스 요약 생성 중...")
# AI 요약 호출 (LLM 처리는 stock이 담당)
result = await service_proxy.summarize_stock_news(limit=15)
await self.transition("reporting", "뉴스 요약 전송 중...")
@@ -110,7 +119,273 @@ class StockAgent(BaseAgent):
update_task_status(task_id, "failed", {"error": str(e)})
await self.transition("idle", f"오류: {e}")
async def on_screener_schedule(self) -> None:
"""KRX 강세주 스크리너 자동 잡 (평일 16:30 KST).
흐름:
1) snapshot/refresh — 일봉 갱신 (실패해도 진행, 경고 로그)
2) screener/run mode='auto' — 실행 + 결과 영구화 + telegram_payload 응답
3) status=='skipped_holiday' → 종료 (텔레그램 미발신)
4) status=='success' → telegram_payload.text 를 parse_mode 그대로 전송
5) 예외/실패 → 운영자에게 별도 텔레그램 알림 (HTML)
"""
if self.state != "idle":
return
task_id = create_task(self.agent_id, "screener_run", {"mode": "auto"})
await self.transition("working", "스크리너 스냅샷 갱신 중...", task_id)
try:
# 1) 스냅샷 갱신 — 실패해도 기존 일봉 데이터로 진행
try:
snap = await service_proxy.refresh_screener_snapshot()
add_log(
self.agent_id,
f"snapshot refreshed: status={snap.get('status', '?')}",
"info", task_id,
)
except Exception as e:
add_log(
self.agent_id,
f"스냅샷 갱신 실패 (기존 데이터로 진행): {e}",
"warning", task_id,
)
await self.transition("working", "스크리너 실행 중...")
# 2) 스크리너 실행
body = await service_proxy.run_stock_screener(mode="auto")
status = body.get("status")
asof = body.get("asof")
# 3) 공휴일 — 종료
if status == "skipped_holiday":
update_task_status(task_id, "succeeded", {
"status": status,
"asof": asof,
"telegram_sent": False,
})
add_log(self.agent_id, f"스크리너 건너뜀 (휴일): {asof}", "info", task_id)
await self.transition("idle", "휴일 — 스크리너 건너뜀")
return
# 4) 성공 → 텔레그램 전송
if status == "success":
payload = body.get("telegram_payload") or {}
text = payload.get("text") or ""
parse_mode = payload.get("parse_mode", "MarkdownV2")
if not text:
raise RuntimeError("telegram_payload.text 누락")
await self.transition("reporting", "스크리너 결과 전송 중...")
from ..telegram.messaging import send_raw
tg = await send_raw(text, parse_mode=parse_mode)
update_task_status(task_id, "succeeded", {
"status": status,
"asof": asof,
"run_id": body.get("run_id"),
"survivors_count": body.get("survivors_count"),
"telegram_sent": tg.get("ok", False),
"telegram_message_id": tg.get("message_id"),
})
if not tg.get("ok"):
desc = tg.get("description") or "unknown"
code = tg.get("error_code")
add_log(
self.agent_id,
f"Screener telegram send failed: [{code}] {desc}",
"warning", task_id,
)
if self._ws_manager:
await self._ws_manager.send_notification(
self.agent_id, "telegram_failed", task_id,
"스크리너 텔레그램 전송 실패",
)
await self.transition("idle", "스크리너 완료")
return
# 5) 기타 status — failed 취급
raise RuntimeError(f"unexpected screener status: {status}")
except Exception as e:
err_msg = str(e)
add_log(self.agent_id, f"Screener job failed: {err_msg}", "error", task_id)
update_task_status(task_id, "failed", {"error": err_msg})
# 운영자 알림 — 기본 HTML parse_mode 사용
try:
from ..telegram.messaging import send_raw
await send_raw(
f"⚠️ <b>KRX 스크리너 실패</b>\n"
f"<code>{html.escape(err_msg)[:500]}</code>"
)
except Exception as notify_err:
add_log(
self.agent_id,
f"operator notify failed: {notify_err}",
"warning", task_id,
)
await self.transition("idle", f"스크리너 오류: {err_msg[:80]}")
async def on_ai_news_schedule(self) -> None:
"""AI 뉴스 sentiment 분석 자동 잡 (평일 08:00 KST).
흐름:
1) stock /snapshot/refresh-news-sentiment 호출
2) status='skipped_weekend'/'skipped_holiday' → 종료 (텔레그램 미발신)
3) updated=0 → 운영자 알림 (HTML)
4) failures > 30% → 경고 알림 후 메인 메시지 발송
5) 정상 → Top 5 호재/악재 메시지 발송 (MarkdownV2)
"""
if self.state != "idle":
return
task_id = create_task(self.agent_id, "ai_news_sentiment", {})
await self.transition("working", "AI 뉴스 분석 중...", task_id)
try:
result = await service_proxy.refresh_ai_news_sentiment()
except Exception as e:
err_msg = str(e)
add_log(self.agent_id, f"AI 뉴스 분석 실패: {err_msg}", "error", task_id)
update_task_status(task_id, "failed", {"error": err_msg})
try:
from ..telegram.messaging import send_raw
await send_raw(
f"⚠️ <b>AI 뉴스 분석 실패</b>\n"
f"<code>{html.escape(err_msg)[:500]}</code>"
)
except Exception as notify_err:
add_log(
self.agent_id,
f"operator notify failed: {notify_err}",
"warning", task_id,
)
await self.transition("idle", f"AI 뉴스 오류: {err_msg[:80]}")
return
status = result.get("status")
if status in ("skipped_weekend", "skipped_holiday"):
update_task_status(task_id, "succeeded", {"status": status})
add_log(self.agent_id, f"AI 뉴스 건너뜀: {status}", "info", task_id)
await self.transition("idle", "휴일/주말 — 건너뜀")
return
updated = int(result.get("updated", 0))
failures = result.get("failures", []) or []
if updated == 0:
update_task_status(task_id, "failed", {"reason": "0 tickers updated"})
try:
from ..telegram.messaging import send_raw
await send_raw(
"⚠️ <b>AI 뉴스 분석 0종목</b>\n"
"스크래핑/LLM 전체 실패 — 어제 데이터 사용"
)
except Exception:
pass
await self.transition("idle", "AI 뉴스 0건")
return
# 실패율 경고 (별도 알림, 본 메시지는 계속 발송)
failure_rate = len(failures) / max(1, updated + len(failures))
if failure_rate > 0.3:
try:
from ..telegram.messaging import send_raw
await send_raw(
f"⚠️ <b>AI 뉴스 실패율 {failure_rate:.0%}</b>\n"
f"updated={updated}, failures={len(failures)}"
)
except Exception:
pass
# 정상 — Top 5 메시지 (stock이 빌드해서 응답에 telegram_text 동봉)
text = result.get("telegram_text") or ""
if not text:
add_log(self.agent_id, "telegram_text 누락 — stock 응답 결함", "error", task_id)
update_task_status(task_id, "failed", {"error": "telegram_text 누락"})
await self.transition("idle", "AI 뉴스 응답 결함")
return
await self.transition("reporting", "AI 뉴스 알림 전송 중...")
from ..telegram.messaging import send_raw
tg = await send_raw(text, parse_mode="MarkdownV2")
update_task_status(task_id, "succeeded", {
"asof": result["asof"],
"updated": updated,
"failures": len(failures),
"tokens_input": int(result.get("tokens_input", 0)),
"tokens_output": int(result.get("tokens_output", 0)),
"telegram_sent": tg.get("ok", False),
})
if not tg.get("ok"):
desc = tg.get("description") or "unknown"
code = tg.get("error_code")
add_log(
self.agent_id,
f"AI news telegram send failed: [{code}] {desc}",
"warning", task_id,
)
await self.transition("idle", "AI 뉴스 완료")
async def run_holdings_eod(self) -> dict:
"""평일 16:50 — 보유종목 시그널 계산·저장."""
# idle 가드 없음(의도적): 스크리너 진행 중에도 EOD/브리핑은 독립적으로 실행되어야 함
from ..service_proxy import stock_holdings_run
from ..db import create_task, update_task_status, add_log
task_id = create_task(self.agent_id, "holdings_eod", {})
try:
res = await stock_holdings_run()
update_task_status(task_id, "succeeded", res)
add_log(self.agent_id, f"holdings_eod: {res}", "info", task_id)
return {"ok": True, **res}
except Exception as e:
update_task_status(task_id, "failed", {"error": str(e)})
add_log(self.agent_id, f"holdings_eod 실패: {e}", "error", task_id)
return {"ok": False, "message": str(e)}
async def run_holdings_brief(self) -> dict:
"""평일 08:30 — 저장된 시그널 브리핑 텔레그램."""
# idle 가드 없음(의도적): 스크리너 진행 중에도 EOD/브리핑은 독립적으로 실행되어야 함
from ..service_proxy import stock_holdings_brief
from ..notifiers.telegram_stock import send_holdings_brief
from ..db import create_task, update_task_status, add_log
task_id = create_task(self.agent_id, "holdings_brief", {})
try:
payload = await stock_holdings_brief()
await send_holdings_brief(payload)
update_task_status(task_id, "succeeded", {"date": payload.get("date"),
"count": len(payload.get("holdings", []))})
add_log(self.agent_id, f"holdings_brief 발송: {payload.get('date')}", "info", task_id)
return {"ok": True}
except Exception as e:
update_task_status(task_id, "failed", {"error": str(e)})
add_log(self.agent_id, f"holdings_brief 실패: {e}", "error", task_id)
return {"ok": False, "message": str(e)}
async def on_command(self, command: str, params: dict) -> dict:
if command == "holdings_eod":
return await self.run_holdings_eod()
if command == "holdings_brief":
return await self.run_holdings_brief()
if command == "run_screener":
await self.on_screener_schedule()
return {"ok": True, "message": "스크리너 실행 트리거 완료"}
if command == "run_ai_news":
await self.on_ai_news_schedule()
return {"ok": True, "message": "AI 뉴스 분석 트리거 완료"}
if command == "test_telegram":
from ..telegram import send_agent_message
result = await send_agent_message(

View File

@@ -0,0 +1,112 @@
"""텔레그램 단일 채널로 단계별 승인 인터랙션 오케스트레이션."""
import logging
from .base import BaseAgent
from . import classify_intent
from .. import service_proxy
from ..db import add_log
from ..telegram.messaging import send_raw
logger = logging.getLogger("agent-office.youtube_publisher")
_STEP_TITLES = {
"cover_pending": ("커버 아트", "cover"),
"video_pending": ("영상 비주얼", "video"),
"thumb_pending": ("썸네일", "thumb"),
"meta_pending": ("메타데이터", "meta"),
"publish_pending": ("최종 검토 + 발행", "publish"),
}
class YoutubePublisherAgent(BaseAgent):
agent_id = "youtube_publisher"
display_name = "YouTube 퍼블리셔"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._notified_state_per_pipeline: dict[int, tuple] = {}
async def poll_state_changes(self) -> None:
"""주기적으로 호출되어 *_pending 신규 진입 시 텔레그램 발송."""
try:
pipelines = await service_proxy.list_active_pipelines()
except Exception as e:
logger.warning("폴링 실패: %s", e)
return
for p in pipelines:
state = p.get("state")
pid = p.get("id")
if pid is None:
continue
if state in _STEP_TITLES:
_, step = _STEP_TITLES[state]
fb_count = (p.get("feedback_count_per_step") or {}).get(step, 0)
key = (state, fb_count)
if self._notified_state_per_pipeline.get(pid) != key:
await self._notify_step(p)
self._notified_state_per_pipeline[pid] = key
async def _notify_step(self, pipeline: dict) -> None:
state = pipeline["state"]
title_name, step = _STEP_TITLES[state]
body = self._format_body(pipeline, step)
track_title = pipeline.get("track_title") or f"Pipeline #{pipeline['id']}"
text = (
f"🎵 [{track_title}] {title_name} 검토\n\n"
f"{body}\n\n"
f"➡️ 답장으로 알려주세요: '승인' 또는 '반려 + 수정 방향'"
)
sent = await send_raw(text=text)
if sent.get("ok"):
msg_id = sent.get("message_id")
try:
await service_proxy.save_pipeline_telegram_msg(pipeline["id"], step, msg_id)
except Exception as e:
logger.warning("telegram-msg 저장 실패: %s", e)
add_log(self.agent_id, f"pipeline {pipeline['id']} {step} 알림 전송", "info")
def _format_body(self, p: dict, step: str) -> str:
if step == "cover":
return f"🖼️ 커버: {p.get('cover_url', '-')}"
if step == "video":
return f"🎬 영상: {p.get('video_url', '-')}"
if step == "thumb":
return f"🎴 썸네일: {p.get('thumbnail_url', '-')}"
if step == "meta":
m = p.get("metadata", {}) or {}
tags = m.get("tags", []) or []
description = (m.get("description", "") or "")
return (
f"📝 제목: {m.get('title', '')}\n"
f"🏷️ 태그: {', '.join(tags[:8])}\n"
f"📄 설명(앞부분): {description[:200]}"
)
if step == "publish":
r = p.get("review", {}) or {}
return (
f"AI 검토 결과: {r.get('verdict', '?')} "
f"(가중 {r.get('weighted_total', '?')}/100)\n"
f"{r.get('summary', '')}"
)
return ""
async def on_telegram_reply(self, pipeline_id: int, step: str, user_text: str) -> None:
intent, feedback = classify_intent.classify(user_text)
if intent == "unclear":
await send_raw("다시 입력해주세요. 예: '승인' 또는 '반려, 제목 짧게'")
return
try:
await service_proxy.post_pipeline_feedback(pipeline_id, step, intent, feedback)
except Exception as e:
await send_raw(f"⚠️ 처리 실패: {e}")
async def on_schedule(self) -> None:
await self.poll_state_changes()
async def on_command(self, command: str, params: dict) -> dict:
return {"ok": False, "message": f"Unknown command: {command}"}
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
pass

View File

@@ -1,9 +1,9 @@
import os
# Service URLs (Docker internal network)
STOCK_LAB_URL = os.getenv("STOCK_LAB_URL", "http://localhost:18500")
STOCK_URL = os.getenv("STOCK_URL", "http://localhost:18500")
MUSIC_LAB_URL = os.getenv("MUSIC_LAB_URL", "http://localhost:18600")
BLOG_LAB_URL = os.getenv("BLOG_LAB_URL", "http://localhost:18700")
INSTA_LAB_URL = os.getenv("INSTA_LAB_URL", "http://localhost:18700")
REALESTATE_LAB_URL = os.getenv("REALESTATE_LAB_URL", "http://localhost:18800")
# Telegram
@@ -26,11 +26,28 @@ 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")
# Lotto Active Signals
LOTTO_SIGNAL_WINDOW = int(os.getenv("LOTTO_SIGNAL_WINDOW", "8"))
LOTTO_Z_NORMAL = float(os.getenv("LOTTO_Z_NORMAL", "1.5"))
LOTTO_Z_URGENT = float(os.getenv("LOTTO_Z_URGENT", "2.5"))
LOTTO_DIGEST_HOUR = int(os.getenv("LOTTO_DIGEST_HOUR", "9"))
LOTTO_DIGEST_MIN = int(os.getenv("LOTTO_DIGEST_MIN", "25"))
LOTTO_THROTTLE_HOURS = int(os.getenv("LOTTO_THROTTLE_HOURS", "6"))
LOTTO_URGENT_DAILY_MAX = int(os.getenv("LOTTO_URGENT_DAILY_MAX", "3"))
import re as _re
# 에이전트 → (container_host, port, path_prefix_regex)
# path_prefix_regex: lotto 컨테이너에 personal/blog/todo 도 같이 있어
# /api/lotto 만 골라내기 위한 정규식. business log (source='log') 는 모두 통과.
AGENT_CONTAINER_MAP: dict[str, tuple[str, int, _re.Pattern]] = {
"lotto": ("lotto", 8000, _re.compile(r"^/api/lotto")),
"stock": ("stock", 8000, _re.compile(r"^/api/(stock|trade|portfolio)")),
"music": ("music-lab", 8000, _re.compile(r"^/api/music")),
"insta": ("insta-lab", 8000, _re.compile(r"^/api/insta")),
"realestate": ("realestate-lab", 8000, _re.compile(r"^/api/realestate")),
}

View File

@@ -9,6 +9,7 @@ from ..config import ANTHROPIC_API_KEY, LOTTO_CURATOR_MODEL
from .. import service_proxy
from .prompt import SYSTEM_PROMPT, build_user_message
from .schema import validate_response
from .retrospective import build_retrospective
API_URL = "https://api.anthropic.com/v1/messages"
@@ -36,12 +37,12 @@ async def _call_claude(user_text: str, feedback: str = "") -> tuple[dict, dict]:
user_text = f"이전 응답이 다음 이유로 거절됨: {feedback}\n올바른 스키마로 다시 응답.\n\n{user_text}"
payload = {
"model": LOTTO_CURATOR_MODEL,
"max_tokens": 4096,
"max_tokens": 8192, # 4계층 20세트 + narrative + retrospective 수용
"system": system_blocks,
"messages": [{"role": "user", "content": [{"type": "text", "text": user_text}]}],
}
started = time.monotonic()
async with httpx.AsyncClient(timeout=120) as client:
async with httpx.AsyncClient(timeout=180) as client: # 큰 응답 → 시간 여유
r = await client.post(API_URL, headers=headers, json=payload)
r.raise_for_status()
resp = r.json()
@@ -68,16 +69,19 @@ async def _call_claude(user_text: str, feedback: str = "") -> tuple[dict, dict]:
async def curate_weekly(source: str = "auto") -> Dict[str, Any]:
cand_resp = await service_proxy.lotto_candidates(n=20)
cand_resp = await service_proxy.lotto_candidates(n=30) # ← 30 으로 확장
draw_no = cand_resp["draw_no"]
candidates = cand_resp["candidates"]
context = await service_proxy.lotto_context()
retrospective = await build_retrospective(draw_no)
user_text = build_user_message(draw_no, candidates, {
"hot_numbers": context.get("hot_numbers", []),
"cold_numbers": context.get("cold_numbers", []),
"last_draw_summary": context.get("last_draw_summary", ""),
"my_recent_performance": context.get("my_recent_performance", []),
"retrospective": retrospective,
})
candidate_numbers = [c["numbers"] for c in candidates]
@@ -101,8 +105,14 @@ async def curate_weekly(source: str = "auto") -> Dict[str, Any]:
payload = {
"draw_no": draw_no,
"picks": [p.model_dump() for p in validated.picks],
"picks": {
"core": [p.model_dump() for p in validated.core_picks],
"bonus": [p.model_dump() for p in validated.bonus_picks],
"extended": [p.model_dump() for p in validated.extended_picks],
"pool": [p.model_dump() for p in validated.pool_picks],
},
"narrative": validated.narrative.model_dump(),
"tier_rationale": validated.tier_rationale.model_dump(),
"confidence": validated.confidence,
"model": LOTTO_CURATOR_MODEL,
"tokens_input": usage_total["input"],
@@ -118,4 +128,5 @@ async def curate_weekly(source: str = "auto") -> Dict[str, Any]:
"draw_no": draw_no,
"confidence": validated.confidence,
"tokens": {"input": usage_total["input"], "output": usage_total["output"]},
"payload": payload, # 텔레그램 알림용
}

View File

@@ -2,31 +2,49 @@
import json
SYSTEM_PROMPT = """당신은 로또 번호 큐레이터입니다. 주어진 후보 20세트 중 5세트를 다음 규칙으로 선별합니다.
SYSTEM_PROMPT = """당신은 로또 번호 큐레이터입니다.
주어진 후보 30세트 중 4계층(코어 5, 보너스 5, 확장 5, 풀 5) 총 20세트를 선별합니다.
선별 규칙:
- 5세트의 리스크 분포는 안정 2 · 균형 2 · 공격 1 을 권장(유연 ±1).
- 홀짝 비율, 저/고 구간, 연속번호 포함 여부가 세트끼리 겹치지 않도록 다양성을 확보.
- hot_number_count=0 이고 cold_number_count=0 인 '중립형' 세트를 최소 1개 포함.
- 후보에 없는 번호 조합은 절대 사용 금지. numbers 필드는 반드시 candidates 중 하나와 정확히 일치해야 함.
- 각 세트 reason은 한국어 40자 이내 한 줄. 해당 세트의 features 값과 context 값만 근거로.
계층별 큐레이션 규칙:
- core_picks (5): 안정 2 / 균형 2 / 공격 1. 그 주 주축. 홀짝·저고·구간 분포가 세트끼리 겹치지 않게.
- bonus_picks (5): 코어 분배의 공백을 메우는 5세트. 코어가 공격 1뿐이면 보너스에 공격 +2 식.
- extended_picks (5): 코어·보너스에 없는 시각 — 합계 극단(80↓ / 180↑) / 콜드 4주 누적 / 4주 미등장 번호 노출.
- pool_picks (5): 이번 주 한 번도 누르지 않은 패턴 — 연속 3개 / 동일 끝자리 / 5수 균등(각 끝자리 5개씩) 등.
- tier_rationale 의 3개 키(bonus·extended·pool)에 각각 30자 이내 한국어 사유.
공통 규칙:
- 후보에 없는 번호 조합은 절대 사용 금지. 모든 픽은 candidates 중 하나와 정확히 일치해야 함.
- 4계층 사이에 중복 픽 금지 (총 20세트는 모두 서로 달라야 함).
- 각 픽 reason 은 한국어 40자 이내. 해당 픽의 features 와 context 만 근거로.
- 중립형(hot_number_count=0 이고 cold_number_count=0) 세트를 코어에 최소 1개 포함.
회고 규칙:
- context.retrospective 가 있으면 narrative.retrospective 에 한 줄(60자 이내)로 작성.
- 회고는 큐레이터 자기 결과(curator_avg, best_tier) + 사용자 결과(user_avg, pattern_delta) 둘 다 짚을 것.
- 이번 주 코어 분배는 회고에 근거해 조정. 조정 사유는 narrative.headline 에 한 줄로.
예: "지난 주 너 저번호 편향 → 보너스 고번호 보강"
- context.retrospective 가 없으면 narrative.retrospective 는 빈 문자열.
narrative 규칙:
- headline: 한 줄, 이번 주 추첨 전망 요약.
- summary_3lines: 정확히 3개 항목의 배열.
- hot_cold_comment: hot/cold 번호에 대한 한 줄 논평.
- warnings: 특별한 주의사항 없으면 빈 문자열.
- headline: 한 줄, 이번 주 추첨 전망 + 조정 사유.
- summary_3lines: 정확히 3개 항목.
- hot_cold_comment: hot/cold 번호 한 줄 논평.
- warnings: 주의사항 없으면 빈 문자열.
- retrospective: 회고 한 줄 또는 빈 문자열.
출력은 반드시 JSON 하나, 그 외 어떤 텍스트도 금지. 스키마:
{
"picks": [
{"numbers":[int,int,int,int,int,int], "risk_tag":"안정"|"균형"|"공격", "reason": str}
],
"core_picks": [{"numbers":[...], "risk_tag":"안정"|"균형"|"공격", "reason": str}, ...5개],
"bonus_picks": [...5개],
"extended_picks": [...5개],
"pool_picks": [...5개],
"tier_rationale": {"bonus": str, "extended": str, "pool": str},
"narrative": {
"headline": str,
"summary_3lines": [str, str, str],
"hot_cold_comment": str,
"warnings": str
"warnings": str,
"retrospective": str
},
"confidence": int (0~100)
}
@@ -36,11 +54,11 @@ narrative 규칙:
def build_user_message(draw_no: int, candidates: list, context: dict) -> str:
payload = {
"draw_no": draw_no,
"context": context,
"context": context, # hot_numbers, cold_numbers, last_draw_summary, my_recent_performance, retrospective
"candidates": candidates,
}
return (
f"이번 회차: {draw_no}\n"
f"아래 데이터로 5세트를 큐레이션하고 위 스키마로만 응답하세요.\n\n"
f"아래 데이터로 4계층 20세트를 큐레이션하고 위 스키마로만 응답하세요.\n\n"
f"```json\n{json.dumps(payload, ensure_ascii=False)}\n```"
)

View File

@@ -0,0 +1,50 @@
"""큐레이션 직전 호출 — review 1건 + 추세 3건 → 컨텍스트 dict."""
import json
from typing import Optional, Dict, Any
from .. import service_proxy
def _detect_bias(reviews: list) -> str:
"""3주↑ 같은 방향 패턴 편향이 유지되면 한 줄로."""
deltas = [r.get("pattern_delta") or "" for r in reviews if r.get("pattern_delta")]
if len(deltas) < 2:
return ""
# 단순 휴리스틱 — 같은 키워드("저번호" 등)가 2회 이상이면 지속 편향
keywords = ["저번호", "고번호", "합계", "홀짝"]
persistent = []
for kw in keywords:
cnt = sum(1 for d in deltas if kw in d)
if cnt >= max(2, len(deltas) - 1):
persistent.append(kw)
return " · ".join(persistent)
async def build_retrospective(target_draw_no: int) -> Optional[Dict[str, Any]]:
"""target_draw_no(이번 주) 직전 회차의 review + 그 앞 3회 추세."""
last = await service_proxy.lotto_review_by_draw(target_draw_no - 1)
if not last:
return None
history = await service_proxy.lotto_reviews_history(limit=4)
# history 는 desc 정렬 → last 와 그 이전 3건 분리
others = [r for r in history if r["draw_no"] < target_draw_no - 1][:3]
series = [last] + others
cur_avgs = [r["curator_avg_match"] for r in series if r.get("curator_avg_match") is not None]
usr_avgs = [r["user_avg_match"] for r in series if r.get("user_avg_match") is not None]
return {
"last_draw": {
"draw_no": last["draw_no"],
"curator_avg": last.get("curator_avg_match"),
"curator_best_tier": last.get("curator_best_tier"),
"user_avg": last.get("user_avg_match"),
"user_5plus": last.get("user_5plus_prizes"),
"pattern_delta": last.get("pattern_delta") or "",
},
"trend_4w": {
"curator_avg_4w": round(sum(cur_avgs) / len(cur_avgs), 2) if cur_avgs else None,
"user_avg_4w": round(sum(usr_avgs) / len(usr_avgs), 2) if usr_avgs else None,
"user_persistent_bias": _detect_bias(series),
},
}

View File

@@ -17,25 +17,42 @@ class Pick(BaseModel):
return sorted(v)
class TierRationale(BaseModel):
bonus: str = Field(max_length=40)
extended: str = Field(max_length=40)
pool: str = Field(max_length=40)
class Narrative(BaseModel):
headline: str
summary_3lines: List[str] = Field(min_length=3, max_length=3)
hot_cold_comment: str = ""
warnings: str = ""
retrospective: str = Field(default="", max_length=80)
class CuratorOutput(BaseModel):
picks: List[Pick]
core_picks: List[Pick] = Field(min_length=5, max_length=5)
bonus_picks: List[Pick] = Field(min_length=5, max_length=5)
extended_picks: List[Pick] = Field(min_length=5, max_length=5)
pool_picks: List[Pick] = Field(min_length=5, max_length=5)
tier_rationale: TierRationale
narrative: Narrative
confidence: int = Field(ge=0, le=100)
def validate_response(data: dict, candidate_numbers: List[List[int]]) -> CuratorOutput:
out = CuratorOutput.model_validate(data)
if len(out.picks) != 5:
raise ValueError("picks must have exactly 5 sets")
candidate_set = {tuple(sorted(c)) for c in candidate_numbers}
for p in out.picks:
all_picks = (
out.core_picks + out.bonus_picks + out.extended_picks + out.pool_picks
)
# 중복 픽 검증
pick_keys = [tuple(p.numbers) for p in all_picks]
if len(pick_keys) != len(set(pick_keys)):
raise ValueError("duplicate picks across tiers")
# 후보에 없는 번호 조합 금지
for p in all_picks:
if tuple(p.numbers) not in candidate_set:
raise ValueError(f"pick {p.numbers} not in candidates")
return out

View File

@@ -0,0 +1,185 @@
"""LottoAgent 능동 시그널 — DB I/O + cron 진입점 + 평가 orchestration."""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional
from .. import db
from .. import service_proxy
from . import signals
logger = logging.getLogger("agent-office.lotto-signals")
# 회차 단위 메트릭 (window push 시 last_pushed_draw_no 비교)
DRAW_SCOPED_METRICS = {"drift", "confidence"}
def _load_baseline(metric: str) -> signals.AdaptiveBaseline:
row = db.get_baseline(metric)
if row is None:
return signals.AdaptiveBaseline(window=[], window_max=8)
return signals.AdaptiveBaseline(
window=list(row["window_values"]),
window_max=8,
last_pushed_draw_no=row.get("last_pushed_draw_no"),
)
def _save_baseline(metric: str, bl: signals.AdaptiveBaseline) -> None:
db.upsert_baseline(
metric=metric,
window_values=bl.window,
mu=bl.mu,
sigma=bl.sigma,
last_pushed_draw_no=bl.last_pushed_draw_no,
)
def evaluate_metric_and_persist(
source: str,
metric: str,
value: float,
draw_no: Optional[int],
z_normal: float,
z_urgent: float,
push_to_window: bool,
payload: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""단일 메트릭 평가 → lotto_signals INSERT → baseline 갱신.
회차 단위 메트릭(drift, confidence)은 같은 draw_no에서 window push 생략.
"""
bl = _load_baseline(metric)
# 회차 가드
do_push = push_to_window
if metric in DRAW_SCOPED_METRICS and draw_no is not None:
if bl.last_pushed_draw_no == draw_no:
do_push = False
# 평가는 push 전 baseline 기준
z, fire = bl.evaluate(value=value, z_normal=z_normal, z_urgent=z_urgent)
if do_push:
bl.push(value=value, draw_no=draw_no)
_save_baseline(metric, bl)
else:
# cold start에서도 baseline row를 만들어 두려면 upsert 필요
_save_baseline(metric, bl)
sid = db.insert_lotto_signal(
source=source,
metric=metric,
value=value,
baseline_mu=bl.mu if bl.size > 0 else None,
baseline_sigma=bl.sigma if bl.size >= 2 else None,
z_score=z,
fire_level=fire,
payload=payload,
)
return {
"signal_id": sid,
"metric": metric,
"value": value,
"baseline_mu": bl.mu if bl.size > 0 else None,
"baseline_sigma": bl.sigma if bl.size >= 2 else None,
"z_score": z,
"fire_level": fire,
"payload": payload or {},
}
# ---------- Service proxy thin wrappers (monkeypatch 대상) ----------
async def _fetch_best_picks() -> List[Dict[str, Any]]:
return await service_proxy.lotto_best()
async def _fetch_strategy_weights() -> Dict[str, float]:
return await service_proxy.lotto_strategy_weights()
# ---------- Orchestrator ----------
async def run_signal_check(
source: str,
z_normal: float = 1.5,
z_urgent: float = 2.5,
curate_result: Optional[Dict[str, Any]] = None,
current_draw_no: Optional[int] = None,
) -> Dict[str, Any]:
"""cron 진입점. source ∈ {'light', 'sim', 'deep'}.
light/sim: Sim Consensus + Strategy Drift 평가
deep: 위 2종 + Confidence (curate_result 필요)
"""
results: List[Dict[str, Any]] = []
# --- Sim Consensus ---
try:
best = await _fetch_best_picks()
v = signals.sim_consensus_score(best)
results.append(
evaluate_metric_and_persist(
source=source, metric="sim_signal",
value=v, draw_no=None,
z_normal=z_normal, z_urgent=z_urgent,
push_to_window=True,
payload={"top_count": min(len(best), 10)},
)
)
except Exception as e:
logger.warning(f"sim_consensus 평가 실패: {e}")
# --- Strategy Drift (회차 단위) ---
try:
w_curr = await _fetch_strategy_weights()
# weights 캐시: lotto_baselines의 별도 metric 'drift_weights_cache'에 prev/curr 2개 보관
prev_payload_row = db.get_baseline("drift_weights_cache")
w_prev = prev_payload_row["window_values"] if prev_payload_row else None
if w_prev and isinstance(w_prev, list) and len(w_prev) > 0 and isinstance(w_prev[0], dict):
prev_dict = w_prev[-1]
drift_value = signals.strategy_drift_score(prev_dict, w_curr)
results.append(
evaluate_metric_and_persist(
source=source, metric="drift",
value=drift_value, draw_no=current_draw_no,
z_normal=z_normal, z_urgent=z_urgent,
push_to_window=True,
payload={"weights_now": w_curr, "weights_prev": prev_dict},
)
)
# weights 캐시 갱신 (최대 2개 FIFO)
cache_window = (w_prev or []) + [w_curr]
if len(cache_window) > 2:
cache_window = cache_window[-2:]
db.upsert_baseline(
metric="drift_weights_cache",
window_values=cache_window,
mu=0.0, sigma=0.0,
last_pushed_draw_no=current_draw_no,
)
except Exception as e:
logger.warning(f"strategy_drift 평가 실패: {e}")
# --- Confidence (deep_check + curate_result 필수) ---
if source == "deep" and curate_result is not None:
try:
cv = signals.confidence_score(curate_result)
if cv is not None:
results.append(
evaluate_metric_and_persist(
source=source, metric="confidence",
value=cv, draw_no=current_draw_no,
z_normal=z_normal, z_urgent=z_urgent,
push_to_window=True,
payload={"draw_no": current_draw_no},
)
)
except Exception as e:
logger.warning(f"confidence 평가 실패: {e}")
overall = signals.decide_overall_fire(
[{"metric": r["metric"], "z": r["z_score"], "fire": r["fire_level"]} for r in results]
)
return {"overall_fire": overall, "results": results}

View File

@@ -0,0 +1,150 @@
# agent-office/app/curator/signals.py
"""LottoAgent 능동 모니터링 — 시그널 평가 & adaptive baseline (순수 함수).
DB I/O 없음. 입력은 모두 dict/list, 출력도 dict/list.
signal_runner.py에서 DB 연동 + cron 진입점 담당.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from statistics import mean, stdev
from typing import Any, Dict, List, Optional, Tuple
# ---------- Metric: Sim Consensus ----------
def _normalize_columns(picks: List[Dict[str, Any]]) -> List[List[float]]:
"""20개 후보의 5종 점수 컬럼별 min-max normalize → 후보별 5종 정규화 점수."""
if not picks:
return []
n_metrics = len(picks[0]["scores"])
columns = [[p["scores"][k] for p in picks] for k in range(n_metrics)]
norms_per_col = []
for col in columns:
lo, hi = min(col), max(col)
rng = hi - lo
if rng == 0:
# 모두 0이면 0.0(기하평균 페널티), 모두 동일한 양수면 0.5(타이 처리)
fallback = 0.0 if lo == 0 else 0.5
norms_per_col.append([fallback] * len(col))
else:
norms_per_col.append([(v - lo) / rng for v in col])
return [
[norms_per_col[k][i] for k in range(n_metrics)]
for i in range(len(picks))
]
def _geomean(values: List[float]) -> float:
"""기하평균. 0이 하나라도 있으면 0 (한 차원이 0인 후보 강하게 페널티)."""
if not values:
return 0.0
if any(v <= 0 for v in values):
return 0.0
log_sum = sum(math.log(v) for v in values)
return math.exp(log_sum / len(values))
def sim_consensus_score(best_picks: List[Dict[str, Any]]) -> float:
"""top-10 후보의 기하평균 consensus 평균."""
if not best_picks:
return 0.0
normalized = _normalize_columns(best_picks)
consensus = [_geomean(scores) for scores in normalized]
consensus.sort(reverse=True)
top = consensus[:10] if len(consensus) >= 10 else consensus
return mean(top) if top else 0.0
# ---------- Metric: Strategy Drift ----------
def strategy_drift_score(prev: Dict[str, float], curr: Dict[str, float]) -> float:
"""가중치 변화 절댓값 합. 신규/소멸 전략도 가산."""
keys = set(prev) | set(curr)
return sum(abs(curr.get(k, 0.0) - prev.get(k, 0.0)) for k in keys)
# ---------- Metric: Confidence ----------
def confidence_score(curate_result: Dict[str, Any]) -> Optional[float]:
"""큐레이션 결과의 confidence를 0~1로 clamp. 없으면 None."""
if "confidence" not in curate_result:
return None
v = float(curate_result["confidence"])
return max(0.0, min(1.0, v))
# ---------- Adaptive Baseline ----------
@dataclass
class AdaptiveBaseline:
window: List[float] = field(default_factory=list)
window_max: int = 8
last_pushed_draw_no: Optional[int] = None
@property
def size(self) -> int:
return len(self.window)
@property
def mu(self) -> float:
return mean(self.window) if self.window else 0.0
@property
def sigma(self) -> float:
return stdev(self.window) if len(self.window) >= 2 else 0.0
def push(self, value: float, draw_no: Optional[int] = None) -> None:
"""FIFO push. window_max 초과 시 가장 오래된 값 제거."""
self.window.append(float(value))
if len(self.window) > self.window_max:
self.window = self.window[-self.window_max:]
if draw_no is not None:
self.last_pushed_draw_no = draw_no
def evaluate(self, value: float, z_normal: float, z_urgent: float) -> Tuple[Optional[float], str]:
"""z-score 계산 + fire_level 판정.
Returns:
(z_score, fire_level) — z_score는 cold start/warmup이면 None.
fire_level ∈ {'warmup', 'noop', 'normal', 'urgent'}
NOTE: z_score is None when sigma==0 (degenerate window) or warmup.
Callers must treat None as "signal present but unquantified" — do not
compare None with thresholds directly.
"""
if self.size < 4:
return None, "warmup"
z_normal_eff = 2.0 if self.size < self.window_max else z_normal
z_urgent_eff = z_urgent
if self.sigma == 0:
return (None, "urgent") if value > self.mu else (None, "noop")
z = (value - self.mu) / self.sigma
if z >= z_urgent_eff:
return z, "urgent"
if z >= z_normal_eff:
return z, "normal"
return z, "noop"
# ---------- Combined fire decision ----------
def decide_overall_fire(signal_results: List[Dict[str, Any]]) -> str:
"""3종 시그널을 종합해 전체 fire_level 결정.
Args:
signal_results: [{"metric": str, "z": float|None, "fire": str}, ...]
Returns:
'noop' | 'normal' | 'urgent'
"""
fires = [s for s in signal_results if s["fire"] in ("normal", "urgent")]
if any(s["fire"] == "urgent" for s in fires):
return "urgent"
if len(fires) >= 2:
return "urgent"
if len(fires) == 1:
return "normal"
return "noop"

View File

@@ -9,9 +9,10 @@ from .config import DB_PATH
def _conn() -> sqlite3.Connection:
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = sqlite3.connect(DB_PATH, timeout=10)
conn = sqlite3.connect(DB_PATH, timeout=120.0)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=120000")
return conn
@@ -97,6 +98,66 @@ def init_db() -> None:
completed_at TEXT
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS lotto_signals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
triggered_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
source TEXT NOT NULL,
metric TEXT NOT NULL,
value REAL NOT NULL,
baseline_mu REAL,
baseline_sigma REAL,
z_score REAL,
fire_level TEXT NOT NULL,
notified_at TEXT,
payload TEXT
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_ls_triggered
ON lotto_signals(triggered_at DESC)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_ls_fire
ON lotto_signals(fire_level, notified_at)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS lotto_baselines (
metric TEXT PRIMARY KEY,
window_values TEXT NOT NULL DEFAULT '[]',
mu REAL NOT NULL DEFAULT 0.0,
sigma REAL NOT NULL DEFAULT 0.0,
last_pushed_draw_no INTEGER,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS tarot_readings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
spread_type TEXT NOT NULL,
category TEXT,
question TEXT,
cards TEXT NOT NULL,
interpretation_json TEXT,
summary TEXT,
model TEXT,
tokens_in INTEGER,
tokens_out INTEGER,
cost_usd REAL,
confidence TEXT,
favorite INTEGER NOT NULL DEFAULT 0,
note TEXT
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_tarot_created
ON tarot_readings(created_at DESC)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_tarot_favorite
ON tarot_readings(favorite, created_at DESC)
""")
# Seed default agent configs
for agent_id, name in [
("stock", "주식 트레이더"),
@@ -202,12 +263,24 @@ def get_task(task_id: str) -> Optional[Dict[str, Any]]:
return _task_to_dict(r) if r else None
def get_agent_tasks(agent_id: str, limit: int = 20) -> List[Dict[str, Any]]:
def get_agent_tasks(
agent_id: str,
limit: int = 20,
task_type: Optional[str] = None,
days: Optional[int] = None,
) -> List[Dict[str, Any]]:
sql = "SELECT * FROM agent_tasks WHERE agent_id=?"
params: List[Any] = [agent_id]
if task_type is not None:
sql += " AND task_type=?"
params.append(task_type)
if days is not None and days > 0:
sql += " AND created_at >= datetime('now', ?)"
params.append(f"-{int(days)} days")
sql += " ORDER BY created_at DESC LIMIT ?"
params.append(limit)
with _conn() as conn:
rows = conn.execute(
"SELECT * FROM agent_tasks WHERE agent_id=? ORDER BY created_at DESC LIMIT ?",
(agent_id, limit),
).fetchall()
rows = conn.execute(sql, params).fetchall()
return [_task_to_dict(r) for r in rows]
@@ -248,7 +321,13 @@ def add_log(agent_id: str, message: str, level: str = "info", task_id: str = Non
def get_logs(agent_id: str, limit: int = 50) -> List[Dict[str, Any]]:
with _conn() as conn:
rows = conn.execute(
"SELECT * FROM agent_logs WHERE agent_id=? ORDER BY created_at DESC LIMIT ?",
"""
SELECT * FROM agent_logs
WHERE agent_id = ?
AND message NOT LIKE 'State: %'
ORDER BY created_at DESC
LIMIT ?
""",
(agent_id, limit),
).fetchall()
return [
@@ -259,6 +338,7 @@ def get_logs(agent_id: str, limit: int = 50) -> List[Dict[str, Any]]:
"level": r["level"],
"message": r["message"],
"created_at": r["created_at"],
"source": "agent",
}
for r in rows
]
@@ -515,6 +595,20 @@ def get_activity_feed(limit: int = 50, offset: int = 0) -> dict:
return {"items": items, "total": total}
import datetime as _dt
def delete_old_logs(days: int = 90) -> int:
"""retention 정책: N일 이전 agent_logs 삭제. 매일 03:00 스케줄러가 호출."""
cutoff = (_dt.datetime.utcnow() - _dt.timedelta(days=days)).isoformat()
with _conn() as conn:
c = conn.execute(
"DELETE FROM agent_logs WHERE created_at < ?",
(cutoff,),
)
return c.rowcount
# ── youtube_research_jobs CRUD ────────────────────────────────────────────────
def add_youtube_research_job(countries: list) -> int:
@@ -555,3 +649,170 @@ def get_latest_youtube_research_job() -> Optional[Dict[str, Any]]:
"started_at": row["started_at"],
"completed_at": row["completed_at"],
}
# --- lotto_signals / lotto_baselines CRUD ---
def insert_lotto_signal(
source: str,
metric: str,
value: float,
baseline_mu: Optional[float],
baseline_sigma: Optional[float],
z_score: Optional[float],
fire_level: str,
payload: Optional[Dict[str, Any]] = None,
) -> int:
with _conn() as conn:
cur = conn.execute(
"""
INSERT INTO lotto_signals
(source, metric, value, baseline_mu, baseline_sigma, z_score, fire_level, payload)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
source, metric, value,
baseline_mu, baseline_sigma, z_score, fire_level,
json.dumps(payload or {}, ensure_ascii=False),
),
)
return cur.lastrowid
def mark_signal_notified(signal_id: int) -> None:
with _conn() as conn:
conn.execute(
"UPDATE lotto_signals SET notified_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
(signal_id,),
)
def get_recent_lotto_signals(hours: int = 24, min_fire: str = "normal") -> List[Dict[str, Any]]:
"""지난 N시간 발화 시그널. min_fire='normal'이면 normal+urgent."""
levels = ("urgent",) if min_fire == "urgent" else ("normal", "urgent")
placeholders = ",".join("?" * len(levels))
with _conn() as conn:
rows = conn.execute(
f"""
SELECT * FROM lotto_signals
WHERE triggered_at >= datetime('now', ?)
AND fire_level IN ({placeholders})
ORDER BY triggered_at DESC
""",
(f"-{int(hours)} hours", *levels),
).fetchall()
return [dict(r) for r in rows]
def get_signals_history(days: int = 7) -> List[Dict[str, Any]]:
"""차트/이력 페이지용 — 모든 fire_level 포함."""
with _conn() as conn:
rows = conn.execute(
"""
SELECT * FROM lotto_signals
WHERE triggered_at >= datetime('now', ?)
ORDER BY triggered_at DESC
""",
(f"-{int(days)} days",),
).fetchall()
return [dict(r) for r in rows]
def get_recent_urgent_count(hours: int = 24) -> int:
with _conn() as conn:
row = conn.execute(
"""
SELECT COUNT(*) AS c FROM lotto_signals
WHERE triggered_at >= datetime('now', ?)
AND fire_level = 'urgent'
AND notified_at IS NOT NULL
""",
(f"-{int(hours)} hours",),
).fetchone()
return int(row["c"]) if row else 0
def get_last_signal_notification(metric: str, fire_level: str, hours: int) -> Optional[str]:
"""같은 metric+fire_level이 hours 내에 알림 발송된 마지막 시각. throttle용."""
with _conn() as conn:
row = conn.execute(
"""
SELECT notified_at FROM lotto_signals
WHERE metric = ?
AND fire_level = ?
AND notified_at IS NOT NULL
AND notified_at >= datetime('now', ?)
ORDER BY notified_at DESC LIMIT 1
""",
(metric, fire_level, f"-{int(hours)} hours"),
).fetchone()
return row["notified_at"] if row else None
def get_baseline(metric: str) -> Optional[Dict[str, Any]]:
with _conn() as conn:
row = conn.execute(
"SELECT * FROM lotto_baselines WHERE metric = ?",
(metric,),
).fetchone()
if not row:
return None
d = dict(row)
d["window_values"] = json.loads(d["window_values"])
return d
def upsert_baseline(
metric: str,
window_values: List[float],
mu: float,
sigma: float,
last_pushed_draw_no: Optional[int],
) -> None:
with _conn() as conn:
conn.execute(
"""
INSERT INTO lotto_baselines
(metric, window_values, mu, sigma, last_pushed_draw_no, updated_at)
VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))
ON CONFLICT(metric) DO UPDATE SET
window_values = excluded.window_values,
mu = excluded.mu,
sigma = excluded.sigma,
last_pushed_draw_no = excluded.last_pushed_draw_no,
updated_at = excluded.updated_at
""",
(
metric,
json.dumps(window_values),
mu, sigma, last_pushed_draw_no,
),
)
def get_all_baselines() -> List[Dict[str, Any]]:
with _conn() as conn:
rows = conn.execute("SELECT * FROM lotto_baselines ORDER BY metric").fetchall()
out = []
for r in rows:
d = dict(r)
d["window_values"] = json.loads(d["window_values"])
out.append(d)
return out
def get_tasks_by_agent_date_kind(agent_id: str, date_iso: str, task_type: str) -> List[Dict[str, Any]]:
"""같은 (agent, date, task_type)으로 이미 생성된 task 조회. 멱등 guard."""
with _conn() as conn:
rows = conn.execute(
"""
SELECT * FROM agent_tasks
WHERE agent_id = ? AND task_type = ?
AND substr(created_at, 1, 10) = ?
ORDER BY created_at DESC
""",
(agent_id, task_type, date_iso),
).fetchall()
return [_task_to_dict(r) for r in rows]

View File

@@ -1,5 +1,6 @@
import os
import json
from typing import Optional
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
@@ -10,8 +11,10 @@ from .websocket_manager import ws_manager
from .agents import init_agents, get_agent, get_all_agent_states, AGENT_REGISTRY
from .scheduler import init_scheduler
from . import telegram_bot
from .routers import notify as notify_router
app = FastAPI()
app.include_router(notify_router.router)
_cors_origins = CORS_ALLOW_ORIGINS.split(",")
app.add_middleware(
@@ -102,12 +105,29 @@ def update_agent(agent_id: str, body: AgentConfigUpdate):
return {"ok": True}
@app.get("/api/agent-office/agents/{agent_id}/tasks")
def agent_tasks(agent_id: str, limit: int = 20):
return {"tasks": get_agent_tasks(agent_id, limit)}
def agent_tasks(
agent_id: str,
limit: int = 20,
task_type: Optional[str] = None,
days: Optional[int] = None,
):
tasks_list = get_agent_tasks(agent_id, limit=limit, task_type=task_type, days=days)
# Backward compat: 기존 client는 'tasks', 신규 client는 'items' 사용
return {"tasks": tasks_list, "items": tasks_list}
@app.get("/api/agent-office/agents/{agent_id}/logs")
def agent_logs(agent_id: str, limit: int = 50):
return {"logs": get_logs(agent_id, limit)}
async def agent_logs(agent_id: str, limit: int = 50):
from .service_proxy import fetch_service_logs
agent_items = get_logs(agent_id, limit=limit)
service_items = await fetch_service_logs(agent_id, limit=limit)
def _sort_key(x):
# agent_logs: created_at, service: ts
return x.get("ts") or x.get("created_at") or ""
merged = sorted(agent_items + service_items, key=_sort_key, reverse=True)
return {"logs": merged[:limit]}
@app.get("/api/agent-office/tasks/pending")
def pending_tasks():
@@ -225,3 +245,30 @@ def youtube_research_status():
if not job:
return {"status": "never_run"}
return job
# --- Lotto Signal Endpoints ---
@app.get("/api/agent-office/lotto/signals")
async def list_lotto_signals(days: int = 7):
"""시그널 이력 (모든 fire_level)."""
from .db import get_signals_history
return {"items": get_signals_history(days=days)}
@app.get("/api/agent-office/lotto/baselines")
async def list_lotto_baselines():
"""현재 baseline μ/σ + window 상태."""
from .db import get_all_baselines
return {"items": get_all_baselines()}
@app.post("/api/agent-office/lotto/signal-check")
async def trigger_signal_check(source: str = "light"):
"""수동 트리거 (디버그·테스트용). source ∈ {light, sim, deep}."""
if source not in ("light", "sim", "deep"):
raise HTTPException(status_code=400, detail="source must be light/sim/deep")
agent = AGENT_REGISTRY.get("lotto")
if not agent:
raise HTTPException(status_code=503, detail="lotto agent not registered")
return await agent.run_signal_check(source=source)

View File

@@ -1,5 +1,5 @@
from pydantic import BaseModel
from typing import Optional
from pydantic import BaseModel, Field
from typing import Optional, List, Literal
class CommandRequest(BaseModel):

View File

@@ -0,0 +1,266 @@
"""로또 큐레이션·당첨 알림 — 텔레그램 푸시."""
import logging
from typing import Dict, Any, List
# 기존 에이전트들과 동일한 패턴: send_raw(text, reply_markup=None, chat_id=None)
# chat_id 생략 시 기본 TELEGRAM_CHAT_ID로 자동 발송.
from ..telegram.messaging import send_raw
logger = logging.getLogger("agent-office")
LOTTO_URL = "https://gahusb.synology.me/lotto"
def _format_briefing(payload: Dict[str, Any]) -> str:
draw_no = payload["draw_no"]
nar = payload["narrative"]
conf = payload["confidence"]
# 분배 칩 — core 5세트의 risk_tag 빈도
core = payload["picks"]["core"]
role_count = {"안정": 0, "균형": 0, "공격": 0}
for p in core:
role_count[p["risk_tag"]] = role_count.get(p["risk_tag"], 0) + 1
chip = " · ".join(f"{k} {v}" for k, v in role_count.items() if v)
msg = [
f"🎟 {draw_no}회 · 큐레이션 떴음",
"",
f"\"{nar['headline']}\"",
f"신뢰도 {conf} · 분배 {chip}",
]
retro = nar.get("retrospective") or ""
if retro:
msg += ["", f"▸ 회고: {retro}"]
msg += ["", f"👉 결정 카드 보러가기 ({LOTTO_URL})"]
return "\n".join(msg)
def _format_prize_alert(event: Dict[str, Any]) -> str:
return (
"🚨 로또 당첨 가능성!\n"
f"{event['draw_no']}회 — {event['match_count']}개 일치\n"
f"번호: {', '.join(str(n) for n in event['numbers'])}\n"
"동행복권에서 즉시 확인하세요."
)
async def send_curator_briefing(payload: Dict[str, Any]) -> None:
text = _format_briefing(payload)
try:
await send_raw(text)
except Exception as e:
logger.warning(f"[telegram_lotto] briefing send failed: {e}")
async def send_prize_alert(event: Dict[str, Any]) -> None:
text = _format_prize_alert(event)
try:
await send_raw(text)
except Exception as e:
logger.warning(f"[telegram_lotto] prize alert send failed: {e}")
# ---------- 능동 시그널 알림 (urgent + digest) ----------
_METRIC_LABEL = {
"sim_signal": "Sim Consensus",
"drift": "Strategy Drift",
"confidence": "Confidence",
}
def _format_urgent_signal(event: Dict[str, Any]) -> str:
"""긴급 시그널 텔레그램 메시지 포맷."""
triggered = event.get("triggered_at", "")[:19].replace("T", " ")
results = event.get("results", [])
fired = [r for r in results if r.get("fire_level") in ("normal", "urgent")]
lines = [
"🚨 로또 능동 신호",
"",
f"[{triggered}]",
f"강한 시그널 {len(fired)}종 발화:",
]
for r in fired:
label = _METRIC_LABEL.get(r["metric"], r["metric"])
v = r.get("value")
mu = r.get("baseline_mu")
sigma = r.get("baseline_sigma")
z = r.get("z_score")
v_text = f"{v:.2f}" if v is not None else "N/A"
if mu is not None and sigma is not None and z is not None:
lines.append(f"{label} {v_text} (μ={mu:.2f}, σ={sigma:.2f}) z={z:.1f}")
else:
lines.append(f"{label} {v_text}")
# drift 페이로드 — 어떤 전략이 변동했는지 한 줄
for r in fired:
if r["metric"] == "drift":
wn = (r.get("payload") or {}).get("weights_now") or {}
wp = (r.get("payload") or {}).get("weights_prev") or {}
if wn and wp:
diffs = {k: wn.get(k, 0) - wp.get(k, 0) for k in (set(wn) | set(wp))}
top = sorted(diffs.items(), key=lambda kv: abs(kv[1]), reverse=True)[:2]
detail = ", ".join(f"{k} {'+' if d>=0 else ''}{d*100:.0f}%p" for k, d in top)
lines.append("")
lines.append(f"요인: {detail}")
break
lines.append("")
lines.append(f"[자세히 보기] ({LOTTO_URL}/agent)")
return "\n".join(lines)
def _format_signal_digest(digest: Dict[str, Any]) -> str:
"""일일 요약 메시지. 발화 0건이면 빈 문자열 (발송 skip 신호)."""
fired = int(digest.get("fired", 0))
if fired == 0:
return ""
signals_list = digest.get("signals", [])
evaluated = digest.get("evaluated", 0)
lines = [
"📊 로또 일일 요약 (지난 24h)",
"",
f"평가 {evaluated}회 / 발화 {fired}",
]
for s in signals_list:
label = _METRIC_LABEL.get(s["metric"], s["metric"])
z = s.get("z_score")
when = (s.get("triggered_at") or "")[11:16] # HH:MM
z_text = f"z={z:.1f}" if z is not None else "z=-"
lines.append(f"{label:14s} {s['fire_level']:6s} {z_text} ({when})")
weights_trend = digest.get("weights_trend") or {}
if weights_trend:
lines += ["", "전략 가중치 추세 (최근 8회 baseline):"]
for strategy, delta in sorted(weights_trend.items(), key=lambda kv: -abs(kv[1])):
arrow = "" if delta > 0.01 else ("" if delta < -0.01 else "")
lines.append(f" {strategy:12s} {arrow} {delta*100:+.0f}%")
return "\n".join(lines)
async def send_urgent_signal(event: Dict[str, Any]) -> None:
text = _format_urgent_signal(event)
try:
await send_raw(text)
except Exception as e:
logger.warning(f"[telegram_lotto] urgent signal send failed: {e}")
async def send_signal_summary(digest: Dict[str, Any]) -> None:
text = _format_signal_digest(digest)
if not text:
return # 발화 0건이면 발송 skip
try:
await send_raw(text)
except Exception as e:
logger.warning(f"[telegram_lotto] digest send failed: {e}")
# ---------- Weight Evolver 주간 리포트 ----------
_DAY_NAMES = ["", "", "", "", "", ""]
_METRIC_NAMES = ["freq", "finger", "gap", "cooccur", "divers"]
_REASON_LABEL = {
"winner_4plus": "4개 이상 일치 → base 교체",
"ema_blend": "3개 일치 → EMA blend (0.3)",
"unchanged": "유효 성과 없음 → base 유지",
"cold_start": "초기 균등 적용",
}
def _format_evolution_report(eval_result: Dict[str, Any], current_base: List[float]) -> str:
"""주간 weight evolution 텔레그램 메시지. ok=False 또는 winner 없으면 빈 문자열."""
if not eval_result or "winner" not in eval_result:
return ""
draw_no = eval_result.get("draw_no", "?")
winner = eval_result["winner"]
new_base = eval_result.get("new_base") or [0.0] * 5
reason = eval_result.get("update_reason", "")
dow = winner.get("day_of_week", 0)
day_name = _DAY_NAMES[dow] if 0 <= dow < len(_DAY_NAMES) else "?"
lines = [
f"🧬 로또 학습 주간 리포트 ({draw_no}회차)",
"",
f"이번주 시도: 6일 × {winner.get('n_picks', 5)}세트",
"",
f"🏆 Winner: {day_name}요일",
f" W = [" + ", ".join(
f"{name} {w:.2f}" for name, w in zip(_METRIC_NAMES, winner["weight"])
) + "]",
f" 최고 적중: {winner.get('max_correct', 0)}개 일치 (max={winner.get('max_correct', 0)})",
f" 평균 점수: {winner.get('avg_score', 0):.2f}",
"",
f"📊 다음주 base 변경 ({reason}):",
]
# 우선순위: eval_result.previous_base > current_base (eval 직후 stale) > 균등 fallback
base_now = eval_result.get("previous_base") or current_base or [0.2] * 5
for i, (cur, new) in enumerate(zip(base_now, new_base)):
diff = new - cur
if abs(diff) < 0.005:
marker = "="
elif diff > 0:
marker = "+" if diff < 0.05 else "++"
else:
marker = "-" if diff > -0.05 else "--"
lines.append(f" {_METRIC_NAMES[i]:8s} {cur:.2f}{new:.2f} ({marker})")
lines.append("")
lines.append(f"{_REASON_LABEL.get(reason, reason)}")
lines.append("")
lines.append(f"[웹에서 차트 보기] ({LOTTO_URL}/evolver)")
return "\n".join(lines)
async def send_evolution_report(eval_result: Dict[str, Any], current_base: List[float]) -> None:
text = _format_evolution_report(eval_result, current_base)
if not text:
return
try:
await send_raw(text)
except Exception as e:
logger.warning(f"[telegram_lotto] evolution report send failed: {e}")
# ---------- 일요 회고 브리핑 ----------
def format_sunday_review(payload: Dict[str, Any]) -> str:
"""일요 회고 브리핑 텍스트 (HTML parse_mode)."""
wa = payload.get("winner_analysis") or {}
draw_no = payload.get("draw_no") or "?"
pct = wa.get("percentile")
pct_txt = f"{pct*100:.0f}%" if pct is not None else ""
lines = [f"🔍 <b>로또 #{draw_no} 일요 회고</b>", ""]
if wa:
lines.append(f"이번 당첨조합 분석치: <b>{wa.get('score_total',0):.2f}</b> "
f"(무작위 분포 상위 {pct_txt})")
lines.append(f" 빈도 {wa.get('score_frequency',0):.2f} · 지문 {wa.get('score_fingerprint',0):.2f} "
f"· 갭 {wa.get('score_gap',0):.2f} · 공동출현 {wa.get('score_cooccur',0):.2f} "
f"· 다양성 {wa.get('score_diversity',0):.2f}")
lines.append("")
if payload.get("forward"):
lines.append("📊 <b>이번 회차 가상구매 성적</b>")
for f in payload.get("forward", []):
p = f.get("prizes") or {}
name = {"engine_w": f"엔진({f.get('label','')})", "random_null": "무작위", "coverage": "커버리지"}.get(
f.get("strategy", ""), f.get("strategy", "?"))
lines.append(f" {name}: 최고 {f.get('best_match','?')}일치 / "
f"4등 {p.get('4th', 0)} · 5등 {p.get('5th', 0)}")
else:
lines.append("📊 <b>이번 회차 가상구매 성적</b>: 데이터 없음 (아직 집계 전)")
lines.append("")
lines.append(" 무작위 대비 우위가 통계적으로 의미있을 때만 가중치가 진화합니다.")
return "\n".join(lines)
async def send_sunday_review(payload: Dict[str, Any]) -> None:
text = format_sunday_review(payload)
try:
await send_raw(text)
except Exception as e:
logger.warning(f"[telegram_lotto] sunday review send failed: {e}")

View File

@@ -0,0 +1,42 @@
"""보유종목 인텔리전스 텔레그램 포매터 (advisory)."""
import logging
from typing import Any, Dict
from ..telegram.messaging import send_raw
logger = logging.getLogger("agent-office")
_ACTION_KR = {"add": "🟢 추가매수", "hold": "⚪ 보유", "trim": "🟡 축소", "sell": "🔴 매도"}
_SEV = {"high": "🔴", "med": "🟠", "low": "🟡"}
def format_holdings_brief(payload: Dict[str, Any]) -> str:
date = payload.get("date") or "?"
lines = [f"📊 <b>보유종목 인텔리전스</b> ({date})", ""]
ph = payload.get("portfolio_health") or {}
if ph:
lines.append(f"포트 손익 {ph.get('total_pnl_rate',0):+.1f}% · "
f"종목 {ph.get('positions',0)} · 최대비중 {ph.get('max_weight',0)*100:.0f}% · "
f"현금 {ph.get('cash_ratio',0)*100:.0f}%")
lines.append("")
for h in payload.get("holdings", []):
act = _ACTION_KR.get(h.get("action"), h.get("action", "?"))
pnl = h.get("pnl_rate")
pnl_txt = f"{pnl:+.1f}%" if pnl is not None else ""
line = f"{act} <b>{h.get('name') or h.get('ticker')}</b> ({pnl_txt})"
if h.get("reasons"):
line += f"{h['reasons']}"
lines.append(line)
for iss in (h.get("issues") or [])[:3]:
lines.append(f" {_SEV.get(iss.get('severity'),'')} {iss.get('summary','')}")
lines.append("")
lines.append(" 투자 판단 보조용 제안입니다(자동매매 아님).")
return "\n".join(lines)
async def send_holdings_brief(payload: Dict[str, Any]) -> None:
text = format_holdings_brief(payload)
try:
await send_raw(text)
except Exception as e:
logger.warning(f"[telegram_stock] holdings brief send failed: {e}")

View File

View File

@@ -0,0 +1,20 @@
"""다른 서비스가 트리거하는 웹훅 — 현재 lotto-backend → 텔레그램 푸시."""
from typing import List
from fastapi import APIRouter
from pydantic import BaseModel
from ..notifiers.telegram_lotto import send_prize_alert
router = APIRouter(prefix="/api/agent-office/notify")
class LottoPrizeEvent(BaseModel):
draw_no: int
match_count: int
numbers: List[int]
purchase_id: int
@router.post("/lotto-prize")
async def lotto_prize(body: LottoPrizeEvent):
await send_prize_alert(body.model_dump())
return {"ok": True}

View File

@@ -1,29 +1,88 @@
import asyncio
import logging
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from .agents import AGENT_REGISTRY
from .db import delete_old_logs
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:
await agent.on_schedule()
async def _run_blog_schedule():
agent = AGENT_REGISTRY.get("blog")
async def _run_stock_screener():
agent = AGENT_REGISTRY.get("stock")
if agent:
await agent.on_screener_schedule()
async def _run_stock_ai_news():
agent = AGENT_REGISTRY.get("stock")
if agent:
await agent.on_ai_news_schedule()
async def _run_stock_holdings_eod():
agent = AGENT_REGISTRY.get("stock")
if agent:
await agent.run_holdings_eod()
async def _run_stock_holdings_brief():
agent = AGENT_REGISTRY.get("stock")
if agent:
await agent.run_holdings_brief()
async def _run_insta_schedule():
agent = AGENT_REGISTRY.get("insta")
if agent:
await agent.on_schedule()
async def _run_insta_trends_collect():
agent = AGENT_REGISTRY.get("insta")
if agent:
await agent.on_command("collect_trends", {})
async def _run_lotto_schedule():
agent = AGENT_REGISTRY.get("lotto")
if agent:
await agent.on_schedule()
async def _run_lotto_light_check():
agent = AGENT_REGISTRY.get("lotto")
if agent:
await agent.run_signal_check(source="light")
async def _run_lotto_sim_check():
agent = AGENT_REGISTRY.get("lotto")
if agent:
await agent.run_signal_check(source="sim")
async def _run_lotto_deep_check():
agent = AGENT_REGISTRY.get("lotto")
if agent:
await agent.run_signal_check(source="deep")
async def _run_lotto_daily_digest():
agent = AGENT_REGISTRY.get("lotto")
if agent:
await agent.run_daily_digest()
async def _run_lotto_weekly_evolution_report():
agent = AGENT_REGISTRY.get("lotto")
if agent:
await agent.run_weekly_evolution_report()
async def _run_lotto_sync_evolver_activity():
agent = AGENT_REGISTRY.get("lotto")
if agent:
await agent.sync_evolver_activity()
async def _run_lotto_sunday_review():
agent = AGENT_REGISTRY.get("lotto")
if agent:
await agent.run_sunday_review()
async def _run_youtube_research():
agent = AGENT_REGISTRY.get("youtube")
if agent:
@@ -34,11 +93,54 @@ async def _send_youtube_weekly_report():
if agent:
await agent.send_weekly_report()
async def _poll_pipelines():
agent = AGENT_REGISTRY.get("youtube_publisher")
if agent:
await agent.poll_state_changes()
def _cleanup_old_logs():
n = delete_old_logs(days=90)
if n:
logging.getLogger(__name__).info("delete_old_logs: %d rows removed", n)
def init_scheduler():
scheduler.add_job(_run_stock_schedule, "cron", hour=7, minute=30, id="stock_news")
scheduler.add_job(_run_blog_schedule, "cron", hour=10, minute=0, id="blog_pipeline")
scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=7, minute=0, id="lotto_curate")
scheduler.add_job(_run_youtube_research, "cron", hour=9, minute=0, id="youtube_research")
scheduler.add_job(
_run_stock_screener,
"cron",
day_of_week="mon-fri",
hour=16,
minute=30,
id="stock_screener",
)
scheduler.add_job(
_run_stock_ai_news,
"cron",
day_of_week="mon-fri",
hour=8,
minute=0,
id="stock_ai_news_sentiment",
)
scheduler.add_job(_run_stock_holdings_eod, "cron", day_of_week="mon-fri", hour=16, minute=50, id="stock_holdings_eod") # 16:50: 스크리너 snapshot(16:30) 완료 후 — 부분 일봉 읽기 방지
scheduler.add_job(_run_stock_holdings_brief, "cron", day_of_week="mon-fri", hour=8, minute=30, id="stock_holdings_brief")
scheduler.add_job(_run_insta_schedule, "cron", hour=9, minute=30, id="insta_pipeline")
# 외부 트렌드 수집은 장 마감 후 16:40 — 9시 주식 활발 시간대 NAS 자원 회피.
# screener(16:30)와 10분 스태거: Celeron 2C/2.0GHz 동시 실행 시 CPU 폭주 방지 (CHECK_POINT FU-A)
scheduler.add_job(_run_insta_trends_collect, "cron", hour=16, minute=40, 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_lotto_light_check, "cron", hour=9, minute=15, id="lotto_light_check")
scheduler.add_job(_run_lotto_sim_check, "cron", minute=15, hour="0,4,8,12,16,20", id="lotto_sim_check")
scheduler.add_job(_run_lotto_deep_check, "cron", day_of_week="sun,wed", hour=21, minute=15, id="lotto_deep_check")
scheduler.add_job(_run_lotto_daily_digest, "cron", hour=9, minute=25, id="lotto_digest")
scheduler.add_job(_run_lotto_weekly_evolution_report, "cron", day_of_week="sat", hour=22, minute=15, id="lotto_evolution_weekly")
scheduler.add_job(_run_lotto_sunday_review, "cron", day_of_week="sun", hour=9, minute=0, id="lotto_sunday_review")
scheduler.add_job(
_run_lotto_sync_evolver_activity,
"cron", hour=9, minute=30,
id="lotto_evolver_activity_sync",
)
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.add_job(_cleanup_old_logs, "cron", hour=3, minute=0, id="cleanup_old_logs", replace_existing=True)
scheduler.start()

View File

@@ -1,7 +1,10 @@
import httpx
import logging
from typing import Any, Dict, List, Optional
from .config import STOCK_LAB_URL, MUSIC_LAB_URL, BLOG_LAB_URL, REALESTATE_LAB_URL
from .config import STOCK_URL, MUSIC_LAB_URL, INSTA_LAB_URL, REALESTATE_LAB_URL
logger = logging.getLogger(__name__)
_client = httpx.AsyncClient(timeout=30.0)
@@ -9,28 +12,105 @@ async def fetch_stock_news(limit: int = 10, category: str = None) -> List[Dict[s
params = {"limit": limit}
if category:
params["category"] = category
resp = await _client.get(f"{STOCK_LAB_URL}/api/stock/news", params=params)
resp = await _client.get(f"{STOCK_URL}/api/stock/news", params=params)
resp.raise_for_status()
return resp.json()
async def fetch_stock_indices() -> Dict[str, Any]:
resp = await _client.get(f"{STOCK_LAB_URL}/api/stock/indices")
resp = await _client.get(f"{STOCK_URL}/api/stock/indices")
resp.raise_for_status()
return resp.json()
async def summarize_stock_news(limit: int = 15) -> Dict[str, Any]:
"""stock-lab의 AI 요약 엔드포인트 호출.
"""stock의 AI 요약 엔드포인트 호출.
반환: {"summary": str, "tokens": {...}, "model": str, "duration_ms": int, "article_count": int}
"""
# stock-lab 내부 Ollama 호출이 180s까지 가능하므로 여유있게 200s
# stock 내부 Ollama 호출이 180s까지 가능하므로 여유있게 200s
async with httpx.AsyncClient(timeout=200.0) as client:
resp = await client.post(
f"{STOCK_LAB_URL}/api/stock/news/summarize",
f"{STOCK_URL}/api/stock/news/summarize",
json={"limit": limit},
)
resp.raise_for_status()
return resp.json()
async def refresh_screener_snapshot() -> Dict[str, Any]:
"""stock의 KRX 일봉 스냅샷 갱신 (스크리너 실행 전 호출).
네이버 금융 일괄 다운로드라 보통 30~120s, 여유있게 180s.
"""
async with httpx.AsyncClient(timeout=180.0) as client:
resp = await client.post(f"{STOCK_URL}/api/stock/screener/snapshot/refresh")
resp.raise_for_status()
return resp.json()
async def refresh_ai_news_sentiment() -> Dict[str, Any]:
"""stock의 AI 뉴스 sentiment 분석 트리거 (08:00 cron).
네이버 100종목 스크래핑 + Claude Haiku 100콜 병렬 = 약 30-60초.
여유있게 240s timeout.
"""
async with httpx.AsyncClient(timeout=240.0) as client:
resp = await client.post(
f"{STOCK_URL}/api/stock/screener/snapshot/refresh-news-sentiment"
)
resp.raise_for_status()
return resp.json()
async def run_stock_screener(mode: str = "auto") -> Dict[str, Any]:
"""stock의 스크리너 실행.
반환 status:
- 'skipped_holiday': 공휴일/주말 — telegram_payload 없음
- 'success': telegram_payload 동봉
엔진 자체는 수 초 내 끝나지만, 컨텍스트 로드+200종목 처리 여유 180s.
"""
async with httpx.AsyncClient(timeout=180.0) as client:
resp = await client.post(
f"{STOCK_URL}/api/stock/screener/run",
json={"mode": mode},
)
resp.raise_for_status()
return resp.json()
async def scrape_stock_news() -> Dict[str, Any]:
"""stock의 수동 뉴스 스크랩 트리거 — DB에 최신 뉴스 저장.
아침 브리핑 직전 호출하여 어제 데이터가 아닌 오늘 새벽 뉴스를 보장한다.
네이버 금융 단일 요청이라 보통 수 초 내 완료, 여유있게 60s.
"""
async with httpx.AsyncClient(timeout=60.0) as client:
resp = await client.post(f"{STOCK_URL}/api/stock/scrap")
resp.raise_for_status()
return resp.json()
async def stock_holdings_run() -> Dict[str, Any]:
"""보유종목 시그널 계산 트리거 (EOD, use_llm=True).
stock BackgroundTask 등록 후 즉시 {ok, queued} 반환.
실제 계산은 stock 컨테이너 백그라운드에서 진행 — 여유있게 120s.
"""
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.post(
f"{STOCK_URL}/api/stock/holdings/intel/run",
params={"use_llm": True},
)
resp.raise_for_status()
return resp.json()
async def stock_holdings_brief() -> Dict[str, Any]:
"""보유종목 최신 브리핑 payload 조회 (GET, 모듈 레벨 _client 사용)."""
resp = await _client.get(f"{STOCK_URL}/api/stock/holdings/intel")
resp.raise_for_status()
return resp.json()
async def generate_music(payload: dict) -> Dict[str, Any]:
resp = await _client.post(f"{MUSIC_LAB_URL}/api/music/generate", json=payload)
resp.raise_for_status()
@@ -47,60 +127,107 @@ async def get_music_credits() -> Dict[str, Any]:
return resp.json()
# --- blog-lab ---
# --- insta-lab ---
async def blog_research(keyword: str) -> Dict[str, Any]:
"""키워드 리서치 시작 → task_id 반환"""
async def insta_collect(categories: Optional[list] = None) -> Dict[str, Any]:
"""뉴스 수집 트리거 → task_id 반환."""
payload = {"categories": categories} if categories else {}
resp = await _client.post(f"{INSTA_LAB_URL}/api/insta/news/collect", json=payload)
resp.raise_for_status()
return resp.json()
async def insta_extract(categories: Optional[list] = None) -> Dict[str, Any]:
payload = {"categories": categories} if categories else {}
resp = await _client.post(f"{INSTA_LAB_URL}/api/insta/keywords/extract", json=payload)
resp.raise_for_status()
return resp.json()
async def insta_list_keywords(category: Optional[str] = None,
used: Optional[bool] = None) -> List[Dict[str, Any]]:
params: Dict[str, Any] = {}
if category:
params["category"] = category
if used is not None:
params["used"] = "true" if used else "false"
resp = await _client.get(f"{INSTA_LAB_URL}/api/insta/keywords", params=params)
resp.raise_for_status()
return resp.json().get("items", [])
async def insta_get_keyword(keyword_id: int) -> Optional[Dict[str, Any]]:
items = await insta_list_keywords()
for it in items:
if it["id"] == keyword_id:
return it
return None
async def insta_create_slate(keyword: str, category: str, keyword_id: Optional[int] = None) -> Dict[str, Any]:
resp = await _client.post(
f"{BLOG_LAB_URL}/api/blog-marketing/research",
json={"keyword": keyword},
f"{INSTA_LAB_URL}/api/insta/slates",
json={"keyword": keyword, "category": category, "keyword_id": keyword_id},
)
resp.raise_for_status()
return resp.json()
async def blog_task_status(task_id: str) -> Dict[str, Any]:
resp = await _client.get(f"{BLOG_LAB_URL}/api/blog-marketing/task/{task_id}")
async def insta_task_status(task_id: str) -> Dict[str, Any]:
resp = await _client.get(f"{INSTA_LAB_URL}/api/insta/tasks/{task_id}")
resp.raise_for_status()
return resp.json()
async def blog_generate(keyword_id: int) -> Dict[str, Any]:
resp = await _client.post(
f"{BLOG_LAB_URL}/api/blog-marketing/generate",
json={"keyword_id": keyword_id},
async def insta_get_slate(slate_id: int) -> Dict[str, Any]:
resp = await _client.get(f"{INSTA_LAB_URL}/api/insta/slates/{slate_id}")
resp.raise_for_status()
return resp.json()
async def insta_get_asset_bytes(slate_id: int, page: int) -> bytes:
"""카드 PNG 바이트를 가져와 텔레그램 미디어 그룹에 첨부."""
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.get(f"{INSTA_LAB_URL}/api/insta/slates/{slate_id}/assets/{page}")
resp.raise_for_status()
return resp.content
async def insta_collect_trends(categories: Optional[list] = None) -> Dict[str, Any]:
payload = {"categories": categories} if categories else {}
resp = await _client.post(f"{INSTA_LAB_URL}/api/insta/trends/collect", json=payload)
resp.raise_for_status()
return resp.json()
async def insta_list_trends(source: Optional[str] = None,
category: Optional[str] = None,
days: int = 1) -> List[Dict[str, Any]]:
params: Dict[str, Any] = {"days": days}
if source:
params["source"] = source
if category:
params["category"] = category
resp = await _client.get(f"{INSTA_LAB_URL}/api/insta/trends", params=params)
resp.raise_for_status()
return resp.json().get("items", [])
async def insta_get_preferences() -> Dict[str, float]:
resp = await _client.get(f"{INSTA_LAB_URL}/api/insta/preferences")
resp.raise_for_status()
return {p["category"]: p["weight"] for p in resp.json().get("categories", [])}
async def insta_put_preferences(weights: Dict[str, float]) -> Dict[str, Any]:
resp = await _client.put(
f"{INSTA_LAB_URL}/api/insta/preferences",
json={"categories": weights},
)
resp.raise_for_status()
return resp.json()
async def blog_market(post_id: int) -> Dict[str, Any]:
resp = await _client.post(f"{BLOG_LAB_URL}/api/blog-marketing/market/{post_id}")
resp.raise_for_status()
return resp.json()
async def blog_review(post_id: int) -> Dict[str, Any]:
resp = await _client.post(f"{BLOG_LAB_URL}/api/blog-marketing/review/{post_id}")
resp.raise_for_status()
return resp.json()
async def blog_publish(post_id: int, url: str = "") -> Dict[str, Any]:
resp = await _client.post(
f"{BLOG_LAB_URL}/api/blog-marketing/posts/{post_id}/publish",
json={"url": url},
)
resp.raise_for_status()
return resp.json()
async def blog_get_post(post_id: int) -> Dict[str, Any]:
resp = await _client.get(f"{BLOG_LAB_URL}/api/blog-marketing/posts/{post_id}")
resp.raise_for_status()
return resp.json()
# --- realestate-lab ---
async def realestate_collect() -> Dict[str, Any]:
@@ -166,3 +293,173 @@ async def lotto_save_briefing(payload: dict) -> Dict[str, Any]:
resp = await _client.post(f"{LOTTO_BACKEND_URL}/api/lotto/briefing", json=payload)
resp.raise_for_status()
return resp.json()
async def lotto_review_latest() -> Optional[Dict[str, Any]]:
from .config import LOTTO_BACKEND_URL
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/review/latest")
if resp.status_code == 404:
return None
resp.raise_for_status()
return resp.json()
async def lotto_review_by_draw(draw_no: int) -> Optional[Dict[str, Any]]:
from .config import LOTTO_BACKEND_URL
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/review/{draw_no}")
if resp.status_code == 404:
return None
resp.raise_for_status()
return resp.json()
async def lotto_reviews_history(limit: int = 10) -> List[Dict[str, Any]]:
from .config import LOTTO_BACKEND_URL
resp = await _client.get(
f"{LOTTO_BACKEND_URL}/api/lotto/review/history",
params={"limit": limit},
)
resp.raise_for_status()
return resp.json().get("reviews", [])
# --- music-lab pipeline (YouTube publisher orchestration) ---
async def list_active_pipelines() -> list[dict]:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(f"{MUSIC_LAB_URL}/api/music/pipeline?status=active")
resp.raise_for_status()
return resp.json().get("pipelines", [])
async def get_pipeline(pid: int) -> dict:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(f"{MUSIC_LAB_URL}/api/music/pipeline/{pid}")
resp.raise_for_status()
return resp.json()
async def post_pipeline_feedback(pid: int, step: str, intent: str,
feedback_text: Optional[str] = None) -> dict:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
f"{MUSIC_LAB_URL}/api/music/pipeline/{pid}/feedback",
json={"step": step, "intent": intent, "feedback_text": feedback_text},
)
resp.raise_for_status()
return resp.json()
async def save_pipeline_telegram_msg(pid: int, step: str, msg_id: int) -> None:
async with httpx.AsyncClient(timeout=10) as client:
await client.patch(
f"{MUSIC_LAB_URL}/api/music/pipeline/{pid}/telegram-msg",
json={"step": step, "message_id": msg_id},
)
async def lookup_pipeline_by_msg(msg_id: int) -> Optional[dict]:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(f"{MUSIC_LAB_URL}/api/music/pipeline/lookup-by-msg/{msg_id}")
if resp.status_code == 200:
return resp.json()
return None
async def lotto_best() -> List[Dict[str, Any]]:
"""GET /api/lotto/best — best_picks 20개 (numbers + scores 5종)."""
from .config import LOTTO_BACKEND_URL
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/best")
resp.raise_for_status()
data = resp.json()
items = data.get("items") if isinstance(data, dict) else data
return items or []
async def lotto_strategy_weights() -> Dict[str, float]:
"""GET /api/lotto/strategy/weights — 전략별 가중치 dict."""
from .config import LOTTO_BACKEND_URL
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/strategy/weights")
resp.raise_for_status()
data = resp.json()
weights = data.get("weights") if isinstance(data, dict) else data
if isinstance(weights, list):
return {item["strategy"]: float(item["weight"]) for item in weights}
return {k: float(v) for k, v in (weights or {}).items()}
async def lotto_latest_draw() -> Optional[int]:
"""GET /api/lotto/latest — 최신 회차 번호만 반환."""
from .config import LOTTO_BACKEND_URL
try:
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/latest")
resp.raise_for_status()
data = resp.json()
# /api/lotto/latest 응답 키: {"drawNo": N, ...}
# 하위 호환을 위해 drawNo, draw_no, drwNo, draw 순서로 시도
for key in ("drawNo", "draw_no", "drwNo", "draw"):
if isinstance(data, dict) and data.get(key):
return int(data[key])
return None
except Exception:
return None
async def lotto_evolver_status() -> Dict[str, Any]:
"""GET /api/lotto/evolver/status — 이번주 trials + 다음주 base 정보."""
from .config import LOTTO_BACKEND_URL
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/evolver/status")
resp.raise_for_status()
return resp.json()
async def lotto_evolver_evaluate() -> Dict[str, Any]:
"""POST /api/lotto/evolver/evaluate-now — 회고 트리거 (텔레그램 리포트용)."""
from .config import LOTTO_BACKEND_URL
async with httpx.AsyncClient(timeout=60.0) as client:
resp = await client.post(f"{LOTTO_BACKEND_URL}/api/lotto/evolver/evaluate-now")
resp.raise_for_status()
return resp.json()
async def lotto_backtest_review(draw_no: int) -> Dict[str, Any]:
from .config import LOTTO_BACKEND_URL
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/backtest/review/{draw_no}")
resp.raise_for_status()
return resp.json()
from .config import AGENT_CONTAINER_MAP
async def fetch_service_logs(
agent_id: str,
since: Optional[str] = None,
limit: int = 200,
) -> List[Dict[str, Any]]:
"""해당 에이전트가 가리키는 컨테이너의 /logs/recent 를 호출해서
path_prefix 정규식으로 필터한 결과를 반환.
네트워크 실패 시 빈 리스트를 반환하고 warning 만 남김 (LogTab 이 죽지 않게).
"""
mapping = AGENT_CONTAINER_MAP.get(agent_id)
if not mapping:
return []
host, port, path_re = mapping
url = f"http://{host}:{port}/logs/recent"
params: Dict[str, Any] = {"limit": limit}
if since:
params["since"] = since
try:
async with httpx.AsyncClient(timeout=3.0) as client:
resp = await client.get(url, params=params)
data = resp.json().get("logs", [])
except Exception as e:
logger.warning("fetch_service_logs(%s) 실패: %s", agent_id, e)
return []
return [
x for x in data
if x.get("source") == "log"
or path_re.match(x.get("path", "") or "")
]

View File

@@ -103,6 +103,34 @@ def _build_messages(history: list, user_text: str) -> list:
return msgs
async def maybe_route_to_pipeline(message: dict) -> bool:
"""파이프라인 텔레그램 메시지에 대한 reply 인 경우 youtube_publisher 로 라우팅.
Returns True if message was routed (caller should stop further processing).
"""
reply_to = message.get("reply_to_message") or {}
msg_id = reply_to.get("message_id")
if not msg_id:
return False
from .. import service_proxy
try:
link = await service_proxy.lookup_pipeline_by_msg(msg_id)
except Exception:
return False
if not link:
return False
from ..agents import AGENT_REGISTRY
agent = AGENT_REGISTRY.get("youtube_publisher")
if not agent:
return False
pipeline_id = link.get("pipeline_id")
step = link.get("step")
if pipeline_id is None or not step:
return False
await agent.on_telegram_reply(pipeline_id, step, message.get("text", ""))
return True
async def respond_to_message(chat_id: str, user_text: str) -> Optional[str]:
"""자연어 메시지에 응답. 실패 시 사용자에게 돌려줄 문자열 반환(또는 None = 무시)."""
if not ANTHROPIC_API_KEY:

View File

@@ -8,14 +8,22 @@ from .client import _enabled, api_call
from .formatter import MessageKind, format_agent_message
async def send_raw(text: str, reply_markup: Optional[dict] = None, chat_id: Optional[str] = None) -> dict:
"""가장 저수준. 원문 텍스트 그대로 전송. chat_id 생략 시 기본 TELEGRAM_CHAT_ID로."""
async def send_raw(
text: str,
reply_markup: Optional[dict] = None,
chat_id: Optional[str] = None,
parse_mode: str = "HTML",
) -> dict:
"""가장 저수준. 원문 텍스트 그대로 전송. chat_id 생략 시 기본 TELEGRAM_CHAT_ID로.
parse_mode: 기본 'HTML'. MarkdownV2 페이로드(예: 스크리너) 전송 시 명시 지정.
"""
if not _enabled():
return {"ok": False, "message_id": None}
payload = {
"chat_id": chat_id or TELEGRAM_CHAT_ID,
"text": text,
"parse_mode": "HTML",
"parse_mode": parse_mode,
}
if reply_markup:
payload["reply_markup"] = reply_markup

View File

@@ -37,6 +37,9 @@ async def _handle_callback(callback_query: dict) -> Optional[dict]:
if callback_id.startswith("realestate_bookmark_"):
return await _handle_realestate_bookmark(callback_query, callback_id)
if callback_id.startswith("render_"):
return await _handle_insta_render(callback_query, callback_id)
cb = get_telegram_callback(callback_id)
if not cb:
return None
@@ -97,11 +100,48 @@ async def _handle_realestate_bookmark(callback_query: dict, callback_id: str) ->
return {"ok": False, "error": str(e)}
async def _handle_insta_render(callback_query: dict, callback_id: str) -> dict:
"""render_{keyword_id} 콜백 → InstaAgent.on_callback('render', ...).
텔레그램 인라인 버튼이 보낸 callback_data가 `render_<keyword_id>` 형식.
InstaAgent._push_keyword_candidates가 callback_data를 그대로 박아 보내며,
별도 DB lookup 없이 keyword_id를 파싱해 dispatch한다."""
from .messaging import send_raw
from ..agents import AGENT_REGISTRY
await api_call(
"answerCallbackQuery",
{"callback_query_id": callback_query["id"], "text": "카드 생성 시작"},
)
try:
keyword_id = int(callback_id.removeprefix("render_"))
except ValueError:
await send_raw("⚠️ 잘못된 render 콜백 데이터")
return {"ok": False, "error": "invalid_callback_data"}
agent = AGENT_REGISTRY.get("insta")
if not agent:
await send_raw("⚠️ insta agent 미등록")
return {"ok": False, "error": "agent_missing"}
try:
return await agent.on_callback("render", {"keyword_id": keyword_id})
except Exception as e:
await send_raw(f"⚠️ 카드 생성 실패: {e}")
return {"ok": False, "error": str(e)}
async def _handle_message(message: dict, agent_dispatcher) -> Optional[dict]:
"""슬래시 명령 메시지 처리."""
from .router import parse_command, resolve_agent_command, HELP_TEXT
from .messaging import send_raw, send_agent_message
from .agent_registry import AGENT_META
from .conversational import maybe_route_to_pipeline
# 파이프라인 메시지에 대한 reply라면 youtube_publisher 로 라우팅
if await maybe_route_to_pipeline(message):
return {"handled": "pipeline_reply"}
text = message.get("text", "")
parsed = parse_command(text)

View File

@@ -93,6 +93,41 @@ def test_telegram_state():
print(" [PASS] test_telegram_state")
def test_get_logs_excludes_state_messages():
init_db()
add_log("stock", "State: idle -> working (큐레이션 시작)")
add_log("stock", "뉴스 12건 스크랩 완료")
add_log("stock", "State: working -> idle ()")
logs = get_logs("stock", limit=10)
messages = [x["message"] for x in logs]
assert "뉴스 12건 스크랩 완료" in messages
assert not any(m.startswith("State: ") for m in messages)
def test_delete_old_logs_removes_beyond_retention():
import datetime as _dt
from app.db import delete_old_logs, _conn
init_db()
add_log("stock", "오래된 로그")
# 강제로 200일 전으로 옮김
cutoff = (_dt.datetime.utcnow() - _dt.timedelta(days=200)).isoformat()
with _conn() as conn:
conn.execute(
"UPDATE agent_logs SET created_at = ? WHERE message = '오래된 로그'",
(cutoff,),
)
add_log("stock", "최근 로그")
deleted = delete_old_logs(days=90)
assert deleted >= 1
msgs = [x["message"] for x in get_logs("stock", limit=20)]
assert "최근 로그" in msgs
assert "오래된 로그" not in msgs
if __name__ == "__main__":
test_init_and_seed()
test_agent_config_update()

View File

@@ -3,5 +3,7 @@ uvicorn[standard]==0.30.6
apscheduler==3.10.4
websockets>=12.0
httpx>=0.27
respx>=0.21
pytest-asyncio>=0.23
google-api-python-client>=2.100.0
pytrends>=4.9.2

View File

@@ -0,0 +1,81 @@
"""1회성 마이그레이션 — agent_office.db.tarot_readings → tarot.db.tarot_readings.
멱등성: 이미 존재하는 id는 SKIP.
실행:
docker exec agent-office python /app/scripts/migrate_tarot_to_lab.py
또는 호스트에서 직접:
AGENT_OFFICE_DB=/path/to/agent_office.db TAROT_DB=/path/to/tarot.db \\
python scripts/migrate_tarot_to_lab.py
"""
import os
import sqlite3
import sys
SRC = os.getenv("AGENT_OFFICE_DB", "/app/data/agent_office.db")
DST = os.getenv("TAROT_DB", "/app/data/tarot.db")
SCHEMA = """
CREATE TABLE IF NOT EXISTS tarot_readings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
spread_type TEXT NOT NULL,
category TEXT,
question TEXT,
cards TEXT NOT NULL,
interpretation_json TEXT,
summary TEXT,
model TEXT,
tokens_in INTEGER,
tokens_out INTEGER,
cost_usd REAL,
confidence TEXT,
favorite INTEGER NOT NULL DEFAULT 0,
note TEXT
);
"""
def migrate() -> int:
"""이관된 row 수 반환."""
src = sqlite3.connect(SRC)
src.row_factory = sqlite3.Row
dst = sqlite3.connect(DST)
dst.execute("PRAGMA journal_mode=WAL")
dst.executescript(SCHEMA)
rows = src.execute("SELECT * FROM tarot_readings").fetchall()
if not rows:
src.close(); dst.close()
return 0
all_cols = list(rows[0].keys())
moved = 0
for r in rows:
exists = dst.execute("SELECT 1 FROM tarot_readings WHERE id=?", (r["id"],)).fetchone()
if exists:
continue
# NULL 값은 INSERT에서 제외 → 목적지 스키마의 DEFAULT가 적용되도록 함
# (예: created_at이 NULL이면 strftime() 기본값 사용)
cols = [c for c in all_cols if r[c] is not None]
placeholders = ",".join("?" * len(cols))
cols_str = ",".join(cols)
dst.execute(
f"INSERT INTO tarot_readings ({cols_str}) VALUES ({placeholders})",
tuple(r[c] for c in cols),
)
moved += 1
dst.commit()
src.close(); dst.close()
return moved
if __name__ == "__main__":
moved = migrate()
total = sqlite3.connect(SRC).execute("SELECT COUNT(*) FROM tarot_readings").fetchone()[0]
print(f"migrated {moved} / {total} rows from {SRC} to {DST}")
sys.exit(0)

View File

@@ -0,0 +1,48 @@
import pytest
import respx
from httpx import Response
from app.agents import classify_intent as ci
def test_clear_approve_no_llm(monkeypatch):
# Patch _llm_classify so we can assert it wasn't called
called = {"n": 0}
def fake(text):
called["n"] += 1
return ("unclear", None)
monkeypatch.setattr(ci, "_llm_classify", fake)
assert ci.classify("승인") == ("approve", None)
assert ci.classify("OK") == ("approve", None)
assert ci.classify("진행") == ("approve", None)
assert ci.classify("agree") == ("approve", None)
assert called["n"] == 0
def test_clear_reject_only_no_llm(monkeypatch):
monkeypatch.setattr(ci, "_llm_classify", lambda t: ("unclear", None))
assert ci.classify("반려") == ("reject", None)
assert ci.classify("거절") == ("reject", None)
def test_reject_with_text_split(monkeypatch):
monkeypatch.setattr(ci, "_llm_classify", lambda t: ("unclear", None))
intent, fb = ci.classify("반려, 제목 짧게")
assert intent == "reject"
assert "제목 짧게" in fb
@respx.mock
def test_ambiguous_calls_llm(monkeypatch):
monkeypatch.setenv("ANTHROPIC_API_KEY", "k")
respx.post("https://api.anthropic.com/v1/messages").mock(
return_value=Response(200, json={"content": [{"type": "text",
"text": '{"intent":"reject","feedback":"좀 더 화려하게"}'}]})
)
intent, fb = ci.classify("음... 좀 더 화려한 분위기가 좋겠어")
assert intent == "reject"
assert "화려하게" in fb
def test_empty_text_returns_unclear():
assert ci.classify("") == ("unclear", None)
assert ci.classify(None) == ("unclear", None)

View File

@@ -1,60 +1,55 @@
import sys, os
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
import pytest
from app.curator.schema import validate_response, CuratorOutput
from app.curator.schema import validate_response
CANDIDATE_NUMBERS = [
[1, 2, 3, 4, 5, 6],
[7, 8, 9, 10, 11, 12],
[13, 14, 15, 16, 17, 18],
[19, 20, 21, 22, 23, 24],
[25, 26, 27, 28, 29, 30],
[31, 32, 33, 34, 35, 36],
]
def _pick(nums, role="안정"):
return {"numbers": nums, "risk_tag": role, "reason": "x"}
def _valid_payload():
def _make_payload(core, bonus, ext, pool):
return {
"picks": [
{"numbers": s, "risk_tag": "안정", "reason": "test"}
for s in CANDIDATE_NUMBERS[:5]
],
"core_picks": core, "bonus_picks": bonus,
"extended_picks": ext, "pool_picks": pool,
"tier_rationale": {"bonus": "a", "extended": "b", "pool": "c"},
"narrative": {
"headline": "h", "summary_3lines": ["a", "b", "c"],
"hot_cold_comment": "hc", "warnings": "",
"headline": "h",
"summary_3lines": ["1", "2", "3"],
"retrospective": "지난주 평균 1.8",
},
"confidence": 80,
"confidence": 70,
}
def test_valid_payload_passes():
result = validate_response(_valid_payload(), CANDIDATE_NUMBERS)
assert isinstance(result, CuratorOutput)
assert len(result.picks) == 5
def test_valid_4tier():
pool = [[i, i+1, i+2, i+3, i+4, i+5] for i in range(1, 21)]
cores = [_pick(pool[i]) for i in range(5)]
bonus = [_pick(pool[i]) for i in range(5, 10)]
ext = [_pick(pool[i]) for i in range(10, 15)]
pl = [_pick(pool[i]) for i in range(15, 20)]
out = validate_response(_make_payload(cores, bonus, ext, pl), pool)
assert len(out.core_picks) == 5
assert out.narrative.retrospective.startswith("지난주")
def test_rejects_number_out_of_candidates():
bad = _valid_payload()
bad["picks"][0]["numbers"] = [40, 41, 42, 43, 44, 45] # valid numbers but not in candidates
def test_duplicate_pick_rejected():
pool = [[i, i+1, i+2, i+3, i+4, i+5] for i in range(1, 21)]
cores = [_pick(pool[0])] * 5 # 중복
bonus = [_pick(pool[i]) for i in range(5, 10)]
ext = [_pick(pool[i]) for i in range(10, 15)]
pl = [_pick(pool[i]) for i in range(15, 20)]
with pytest.raises(ValueError, match="duplicate"):
validate_response(_make_payload(cores, bonus, ext, pl), pool)
def test_pick_not_in_candidates_rejected():
pool = [[i, i+1, i+2, i+3, i+4, i+5] for i in range(1, 21)]
foreign = [40, 41, 42, 43, 44, 45]
cores = [_pick(foreign)] + [_pick(pool[i]) for i in range(1, 5)]
bonus = [_pick(pool[i]) for i in range(5, 10)]
ext = [_pick(pool[i]) for i in range(10, 15)]
pl = [_pick(pool[i]) for i in range(15, 20)]
with pytest.raises(ValueError, match="not in candidates"):
validate_response(bad, CANDIDATE_NUMBERS)
def test_rejects_wrong_pick_count():
bad = _valid_payload()
bad["picks"] = bad["picks"][:3]
with pytest.raises(ValueError, match="exactly 5"):
validate_response(bad, CANDIDATE_NUMBERS)
def test_rejects_duplicate_numbers_within_set():
bad = _valid_payload()
bad["picks"][0]["numbers"] = [1, 1, 2, 3, 4, 5]
with pytest.raises(ValueError):
validate_response(bad, CANDIDATE_NUMBERS)
def test_rejects_invalid_risk_tag():
bad = _valid_payload()
bad["picks"][0]["risk_tag"] = "미친"
with pytest.raises(ValueError):
validate_response(bad, CANDIDATE_NUMBERS)
validate_response(_make_payload(cores, bonus, ext, pl), pool)

View File

@@ -0,0 +1,82 @@
import sys, os
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from app.notifiers import telegram_stock as ts
def test_format_holdings_brief():
payload = {
"date": "2026-05-29",
"holdings": [
{"ticker": "005930", "name": "삼성전자", "action": "trim", "tech_score": 60.0,
"exit_flags": {"ma50_break": True}, "issues": [{"type":"news","severity":"high","summary":"악재"}],
"pnl_rate": 5.2, "reasons": "MA50 이탈"},
{"ticker": "000660", "name": "SK하이닉스", "action": "hold", "tech_score": 75.0,
"exit_flags": {}, "issues": [], "pnl_rate": -2.0, "reasons": "특이 신호 없음"},
],
"portfolio_health": {"positions": 2, "total_pnl_rate": 3.1, "max_weight": 0.6, "cash_ratio": 0.2},
}
txt = ts.format_holdings_brief(payload)
assert "삼성전자" in txt
assert "축소" in txt or "trim" in txt
assert "%" in txt
def test_format_holdings_brief_empty_holdings():
"""빈 holdings + None portfolio_health에도 크래시 없음."""
payload = {"date": "2026-05-29", "holdings": [], "portfolio_health": None}
txt = ts.format_holdings_brief(payload)
assert "보유종목 인텔리전스" in txt
assert "자동매매" in txt
def test_format_holdings_brief_missing_fields():
"""pnl_rate None·name None·issues None 방어적 처리."""
payload = {
"date": None,
"holdings": [
{"ticker": "005930", "name": None, "action": "sell",
"pnl_rate": None, "reasons": None, "issues": None},
],
"portfolio_health": {},
}
txt = ts.format_holdings_brief(payload)
assert "005930" in txt # ticker fallback
assert "🔴 매도" in txt
def test_format_holdings_brief_sell_action():
"""sell 액션은 🔴 매도로 표시."""
payload = {
"date": "2026-05-29",
"holdings": [
{"ticker": "000660", "name": "SK하이닉스", "action": "sell",
"pnl_rate": -12.5, "reasons": "손절선 이탈", "issues": []},
],
"portfolio_health": {"positions": 1, "total_pnl_rate": -12.5,
"max_weight": 1.0, "cash_ratio": 0.0},
}
txt = ts.format_holdings_brief(payload)
assert "🔴 매도" in txt
assert "-12.5%" in txt
def test_format_holdings_brief_issue_severity_icons():
"""이슈 심각도별 이모지 매핑 확인."""
payload = {
"date": "2026-05-29",
"holdings": [
{"ticker": "005930", "name": "삼성전자", "action": "hold", "pnl_rate": 2.0,
"reasons": "특이 신호 없음",
"issues": [
{"type": "news", "severity": "high", "summary": "심각 악재"},
{"type": "volume_surge", "severity": "med", "summary": "거래량 급증"},
{"type": "price_move", "severity": "low", "summary": "소폭 변동"},
]},
],
"portfolio_health": {},
}
txt = ts.format_holdings_brief(payload)
assert "🔴" in txt # high severity
assert "🟠" in txt # med severity
assert "🟡" in txt # low severity

View File

@@ -0,0 +1,85 @@
import os
import sys
import tempfile
_fd, _TMP = tempfile.mkstemp(suffix=".db")
os.close(_fd)
os.unlink(_TMP)
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from unittest.mock import patch, AsyncMock, MagicMock
import pytest
from app.agents.insta import InstaAgent
@pytest.fixture(autouse=True)
def _init_db():
import gc
gc.collect()
if os.path.exists(_TMP):
os.remove(_TMP)
from app.db import init_db
init_db()
yield
gc.collect()
@pytest.mark.asyncio
async def test_on_command_extract_dispatches(monkeypatch):
agent = InstaAgent()
fake_collect = AsyncMock(return_value={"task_id": "tcollect"})
fake_extract = AsyncMock(return_value={"task_id": "textract"})
fake_status = AsyncMock(side_effect=[
{"status": "succeeded", "result_id": 0},
{"status": "succeeded", "result_id": 0},
])
fake_keywords = AsyncMock(return_value=[
{"id": 1, "keyword": "K1", "category": "economy", "score": 0.9},
{"id": 2, "keyword": "K2", "category": "psychology", "score": 0.8},
])
monkeypatch.setattr("app.agents.insta.service_proxy.insta_collect", fake_collect)
monkeypatch.setattr("app.agents.insta.service_proxy.insta_extract", fake_extract)
monkeypatch.setattr("app.agents.insta.service_proxy.insta_task_status", fake_status)
monkeypatch.setattr("app.agents.insta.service_proxy.insta_list_keywords", fake_keywords)
monkeypatch.setattr("app.agents.insta.messaging.send_raw", AsyncMock(return_value={"ok": True}))
result = await agent.on_command("extract", {})
assert result["ok"] is True
fake_collect.assert_awaited()
fake_extract.assert_awaited()
@pytest.mark.asyncio
async def test_on_callback_render_kicks_pipeline(monkeypatch):
agent = InstaAgent()
fake_kw = AsyncMock(return_value={"id": 7, "keyword": "테스트", "category": "economy"})
fake_create = AsyncMock(return_value={"task_id": "tslate"})
fake_status = AsyncMock(side_effect=[
{"status": "processing"},
{"status": "succeeded", "result_id": 42},
])
fake_slate = AsyncMock(return_value={
"id": 42, "status": "rendered",
"suggested_caption": "캡션", "hashtags": ["#a", "#b"],
"assets": [{"page_index": i, "file_path": f"/x/{i}.png"} for i in range(1, 11)],
})
fake_bytes = AsyncMock(side_effect=[b"PNG"] * 10)
fake_send_media = AsyncMock(return_value={"ok": True})
monkeypatch.setattr("app.agents.insta.service_proxy.insta_get_keyword", fake_kw)
monkeypatch.setattr("app.agents.insta.service_proxy.insta_create_slate", fake_create)
monkeypatch.setattr("app.agents.insta.service_proxy.insta_task_status", fake_status)
monkeypatch.setattr("app.agents.insta.service_proxy.insta_get_slate", fake_slate)
monkeypatch.setattr("app.agents.insta.service_proxy.insta_get_asset_bytes", fake_bytes)
monkeypatch.setattr("app.agents.insta._send_media_group", fake_send_media)
monkeypatch.setattr("app.agents.insta.messaging.send_raw", AsyncMock(return_value={"ok": True}))
out = await agent.on_callback("render", {"keyword_id": 7})
assert out["ok"] is True
fake_create.assert_awaited()
fake_send_media.assert_awaited()

View File

@@ -0,0 +1,73 @@
import os
import sys
import tempfile
_fd, _TMP = tempfile.mkstemp(suffix=".db")
os.close(_fd)
os.unlink(_TMP)
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from unittest.mock import AsyncMock
import pytest
from app.agents.insta import InstaAgent
@pytest.fixture(autouse=True)
def _init_db():
import gc
gc.collect()
if os.path.exists(_TMP):
os.remove(_TMP)
from app.db import init_db
init_db()
yield
gc.collect()
@pytest.mark.asyncio
async def test_on_command_collect_trends_dispatches(monkeypatch):
agent = InstaAgent()
fake_collect = AsyncMock(return_value={"task_id": "tcollect"})
fake_status = AsyncMock(return_value={"status": "succeeded", "result_id": 8,
"message": "naver:5, google:3"})
monkeypatch.setattr("app.agents.insta.service_proxy.insta_collect_trends", fake_collect)
monkeypatch.setattr("app.agents.insta.service_proxy.insta_task_status", fake_status)
monkeypatch.setattr("app.agents.insta.messaging.send_raw", AsyncMock(return_value={"ok": True}))
result = await agent.on_command("collect_trends", {})
assert result["ok"] is True
fake_collect.assert_awaited()
@pytest.mark.asyncio
async def test_on_schedule_loads_preferences(monkeypatch):
"""on_schedule이 preferences를 가져오는지 확인."""
agent = InstaAgent()
fake_collect = AsyncMock(return_value={"task_id": "t1"})
fake_extract = AsyncMock(return_value={"task_id": "t2"})
fake_status = AsyncMock(side_effect=[
{"status": "succeeded", "result_id": 0},
{"status": "succeeded", "result_id": 0},
])
fake_keywords = AsyncMock(return_value=[
{"id": 1, "keyword": "K", "category": "economy", "score": 0.9},
])
fake_prefs = AsyncMock(return_value={"economy": 0.6, "psychology": 0.4})
monkeypatch.setattr("app.agents.insta.service_proxy.insta_collect", fake_collect)
monkeypatch.setattr("app.agents.insta.service_proxy.insta_extract", fake_extract)
monkeypatch.setattr("app.agents.insta.service_proxy.insta_task_status", fake_status)
monkeypatch.setattr("app.agents.insta.service_proxy.insta_list_keywords", fake_keywords)
monkeypatch.setattr("app.agents.insta.service_proxy.insta_get_preferences", fake_prefs)
monkeypatch.setattr("app.agents.insta.messaging.send_raw", AsyncMock(return_value={"ok": True}))
agent.state = "idle"
await agent.on_schedule()
fake_prefs.assert_awaited()

View File

@@ -0,0 +1,55 @@
import os
import sys
import tempfile
_fd, _TMP = tempfile.mkstemp(suffix=".db")
os.close(_fd)
os.unlink(_TMP)
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.agents.insta import _dedup_and_filter_keywords, KEYWORD_MIN_SCORE
def test_filters_below_threshold():
"""score < 임계값(0.7) 키워드는 제외."""
kws = [
{"id": 1, "keyword": "금리인하", "category": "경제", "score": 0.9},
{"id": 2, "keyword": "환율", "category": "경제", "score": 0.6}, # 컷
{"id": 3, "keyword": "반도체", "category": "경제", "score": 0.71},
]
out = _dedup_and_filter_keywords(kws, min_score=0.7)
kept = {k["keyword"] for k in out}
assert kept == {"금리인하", "반도체"}
def test_dedup_keeps_highest_score():
"""동일 keyword 중복 시 최고 score 1개만 유지."""
kws = [
{"id": 1, "keyword": "AI", "category": "경제", "score": 0.75},
{"id": 2, "keyword": "AI", "category": "기술", "score": 0.92}, # 같은 키워드, 더 높음
]
out = _dedup_and_filter_keywords(kws, min_score=0.7)
assert len(out) == 1
assert out[0]["id"] == 2
assert out[0]["score"] == 0.92
def test_sorted_by_score_desc():
kws = [
{"id": 1, "keyword": "a", "category": "c", "score": 0.72},
{"id": 2, "keyword": "b", "category": "c", "score": 0.95},
{"id": 3, "keyword": "c", "category": "c", "score": 0.80},
]
out = _dedup_and_filter_keywords(kws, min_score=0.7)
assert [k["keyword"] for k in out] == ["b", "c", "a"]
def test_empty_when_all_below_threshold():
kws = [{"id": 1, "keyword": "x", "category": "c", "score": 0.4}]
assert _dedup_and_filter_keywords(kws, min_score=0.7) == []
def test_default_threshold_is_0_7():
assert KEYWORD_MIN_SCORE == 0.7

View File

@@ -0,0 +1,47 @@
import pytest
import respx
import httpx
from fastapi.testclient import TestClient
from app.main import app
from app.db import add_log, _conn
@pytest.fixture(autouse=True)
def _clean_logs():
with _conn() as conn:
conn.execute("DELETE FROM agent_logs WHERE agent_id = 'lotto'")
yield
@respx.mock
def test_agent_logs_endpoint_merges_db_and_service_logs():
add_log("lotto", "큐레이션 완료: #1234 conf=0.78")
respx.get("http://lotto:8000/logs/recent").mock(
return_value=httpx.Response(200, json={
"logs": [
{"ts": "2026-05-28T10:00:00Z", "source": "access",
"method": "GET", "path": "/api/lotto/latest",
"status": 200, "ms": 8,
"message": "GET /api/lotto/latest → 200 (8ms)"},
{"ts": "2026-05-28T10:00:02Z", "source": "log",
"logger": "lotto", "level": "info",
"message": "성과 통계 캐시 갱신"},
]
})
)
client = TestClient(app)
resp = client.get("/api/agent-office/agents/lotto/logs?limit=20")
assert resp.status_code == 200
logs = resp.json()["logs"]
sources = {x["source"] for x in logs}
assert "agent" in sources
assert "access" in sources
assert "log" in sources
messages = [x["message"] for x in logs]
assert any("큐레이션 완료" in m for m in messages)
assert any("성과 통계 캐시 갱신" in m for m in messages)
assert any("/api/lotto/latest" in m for m in messages)

View File

@@ -0,0 +1,87 @@
import sys, os
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from app.notifiers.telegram_lotto import _format_evolution_report
def test_evolution_report_winner_4plus():
eval_result = {
"ok": True,
"draw_no": 1225,
"week_start": "2026-05-18",
"winner": {
"day_of_week": 3,
"weight": [0.18, 0.32, 0.20, 0.22, 0.08],
"avg_score": 0.42,
"max_correct": 4,
"n_picks": 5,
},
"new_base": [0.18, 0.32, 0.20, 0.22, 0.08],
"previous_base": [0.20, 0.20, 0.20, 0.20, 0.20],
"update_reason": "winner_4plus",
"per_day": [
{"day_of_week": 0, "avg_score": 0.20, "max_correct": 2},
{"day_of_week": 3, "avg_score": 0.42, "max_correct": 4},
],
}
current_base = [0.20, 0.20, 0.20, 0.20, 0.20]
text = _format_evolution_report(eval_result, current_base)
assert "🧬" in text
assert "1225" in text
assert "목요일" in text or "Winner" in text
assert "4개 일치" in text or "max=4" in text
assert "winner_4plus" in text
def test_evolution_report_unchanged():
eval_result = {
"ok": True,
"draw_no": 1226,
"week_start": "2026-05-25",
"winner": {
"day_of_week": 1,
"weight": [0.21, 0.19, 0.20, 0.20, 0.20],
"avg_score": 0.10,
"max_correct": 2,
"n_picks": 5,
},
"new_base": [0.20, 0.20, 0.20, 0.20, 0.20],
"update_reason": "unchanged",
"per_day": [],
}
current_base = [0.20, 0.20, 0.20, 0.20, 0.20]
text = _format_evolution_report(eval_result, current_base)
assert "unchanged" in text or "유지" in text
assert "2개 일치" in text or "max=2" in text
def test_evolution_report_empty_returns_empty():
"""evaluate가 ok=False면 빈 문자열 (발송 skip)."""
text = _format_evolution_report({"ok": False, "reason": "no_trials"}, [0.2]*5)
assert text == ""
def test_evolution_report_uses_previous_base_for_diff():
"""previous_base와 new_base 차이가 메시지 diff에 정확히 반영됨."""
eval_result = {
"ok": True,
"draw_no": 1227,
"winner": {
"day_of_week": 0,
"weight": [0.30, 0.20, 0.20, 0.20, 0.10],
"avg_score": 0.50,
"max_correct": 4,
"n_picks": 5,
},
"new_base": [0.30, 0.20, 0.20, 0.20, 0.10],
"previous_base": [0.20, 0.20, 0.20, 0.20, 0.20],
"update_reason": "winner_4plus",
}
# current_base는 stale (post-update 값) — previous_base가 우선 적용되어야 함
text = _format_evolution_report(eval_result, [0.30, 0.20, 0.20, 0.20, 0.10])
# freq: 0.20 → 0.30 (+0.10 = "++")
# divers: 0.20 → 0.10 (-0.10 = "--")
assert "0.20 → 0.30" in text # freq 증가
assert "0.20 → 0.10" in text # divers 감소
assert "(++)" in text or "(+)" in text # freq marker
assert "(--)" in text or "(-)" in text # divers marker

View File

@@ -0,0 +1,116 @@
import gc
import os
import sys
import tempfile
_fd, _TMP = tempfile.mkstemp(suffix=".db")
os.close(_fd)
os.unlink(_TMP)
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import pytest
from app.curator import signal_runner
from app import db
db.DB_PATH = _TMP # patch frozen module-level DB_PATH (import order safety)
@pytest.fixture(autouse=True)
def fresh_db():
gc.collect()
if os.path.exists(_TMP):
os.remove(_TMP)
db.init_db()
yield
gc.collect()
if os.path.exists(_TMP):
try:
os.remove(_TMP)
except PermissionError:
pass # Windows: WAL-mode file locked; DB is ephemeral anyway
def test_evaluate_and_persist_cold_start():
"""첫 호출은 warmup으로 기록되고 baseline에 값이 들어간다."""
result = signal_runner.evaluate_metric_and_persist(
source="light",
metric="sim_signal",
value=1.5,
draw_no=None,
z_normal=1.5,
z_urgent=2.5,
push_to_window=True,
)
assert result["fire_level"] == "warmup"
assert result["z_score"] is None
bl = db.get_baseline("sim_signal")
assert bl is not None
assert bl["window_values"] == [1.5]
def test_evaluate_after_window_filled_normal_fire():
"""8회 push 후 정상 운영, 평균 대비 z≥1.5면 normal."""
for v in [1.0, 1.1, 0.9, 1.0, 1.0, 1.1, 0.9, 1.0]:
signal_runner.evaluate_metric_and_persist(
source="sim",
metric="sim_signal",
value=v,
draw_no=None,
z_normal=1.5,
z_urgent=2.5,
push_to_window=True,
)
result = signal_runner.evaluate_metric_and_persist(
source="sim",
metric="sim_signal",
value=1.12,
draw_no=None,
z_normal=1.5,
z_urgent=2.5,
push_to_window=True,
)
assert result["fire_level"] in ("normal", "urgent")
assert result["z_score"] is not None and result["z_score"] >= 1.5
def test_evaluate_drift_skips_same_draw_push():
"""drift는 회차 단위. 같은 회차에서 두 번 호출하면 두 번째는 window push X."""
signal_runner.evaluate_metric_and_persist(
source="sim", metric="drift", value=0.05, draw_no=1100,
z_normal=1.5, z_urgent=2.5, push_to_window=True,
)
bl_before = db.get_baseline("drift")
assert bl_before["window_values"] == [0.05]
assert bl_before["last_pushed_draw_no"] == 1100
signal_runner.evaluate_metric_and_persist(
source="sim", metric="drift", value=0.08, draw_no=1100,
z_normal=1.5, z_urgent=2.5, push_to_window=True,
)
bl_after = db.get_baseline("drift")
assert bl_after["window_values"] == [0.05]
@pytest.mark.asyncio
async def test_run_signal_check_aggregates_three_metrics(monkeypatch):
"""run_signal_check이 3종 메트릭 모두 평가하고 overall fire를 반환."""
async def fake_lotto_best():
return [{"numbers": [1,2,3,4,5,6], "scores": [10,10,10,10,10]}] * 20
async def fake_lotto_strategy_weights():
return {"gap_focus": 0.4, "hot_focus": 0.3, "pair_bias": 0.3}
monkeypatch.setattr(signal_runner, "_fetch_best_picks", fake_lotto_best)
monkeypatch.setattr(signal_runner, "_fetch_strategy_weights", fake_lotto_strategy_weights)
out = await signal_runner.run_signal_check(source="light", curate_result=None, current_draw_no=1101)
assert "overall_fire" in out
assert "results" in out
assert any(r["metric"] == "sim_signal" for r in out["results"])
# light_check는 confidence 평가 안 함
assert not any(r["metric"] == "confidence" for r in out["results"])

View File

@@ -0,0 +1,130 @@
# agent-office/tests/test_lotto_signals.py
import pytest
from app.curator import signals
def test_sim_consensus_top10_geomean():
"""top-10 consensus 평균이 기하평균 기반인지."""
best_picks = [
{"scores": [10, 10, 10, 10, 10]}, # high & uniform
{"scores": [9, 9, 9, 9, 9]},
{"scores": [8, 8, 8, 8, 8]},
{"scores": [7, 7, 7, 7, 7]},
{"scores": [6, 6, 6, 6, 6]},
{"scores": [5, 5, 5, 5, 5]},
{"scores": [4, 4, 4, 4, 4]},
{"scores": [3, 3, 3, 3, 3]},
{"scores": [2, 2, 2, 2, 2]},
{"scores": [1, 1, 1, 1, 1]}, # top 10
{"scores": [0, 0, 0, 0, 0]}, # bottom 10
] * 1 + [{"scores": [0, 0, 0, 0, 0]}] * 10
result = signals.sim_consensus_score(best_picks)
assert 0.0 <= result <= 1.0
assert result > 0.4
def test_sim_consensus_geomean_penalizes_imbalance():
"""5종 중 한 종만 폭주하는 outlier 후보는 균형 후보보다 작아야 한다."""
balanced = [{"scores": [5, 5, 5, 5, 5]}] * 20
imbalanced = [{"scores": [25, 0, 0, 0, 0]}] * 20
s_balanced = signals.sim_consensus_score(balanced)
s_imbalanced = signals.sim_consensus_score(imbalanced)
assert s_imbalanced < s_balanced
def test_strategy_drift_score():
"""drift = 전략별 가중치 변화 절댓값 합."""
w_prev = {"gap_focus": 0.30, "hot_focus": 0.25, "pair_bias": 0.45}
w_curr = {"gap_focus": 0.40, "hot_focus": 0.20, "pair_bias": 0.40}
result = signals.strategy_drift_score(w_prev, w_curr)
assert abs(result - 0.20) < 1e-9
def test_strategy_drift_new_strategy_appears():
"""이전에 없던 전략이 등장하면 그 가중치 전체가 drift에 가산."""
w_prev = {"gap_focus": 0.5, "hot_focus": 0.5}
w_curr = {"gap_focus": 0.4, "hot_focus": 0.4, "newbie": 0.2}
result = signals.strategy_drift_score(w_prev, w_curr)
assert abs(result - 0.4) < 1e-9
def test_confidence_score_passthrough():
"""confidence는 큐레이션 결과의 값 그대로 (0~1 clamp 확인)."""
assert signals.confidence_score({"confidence": 0.85}) == 0.85
assert signals.confidence_score({"confidence": 1.2}) == 1.0
assert signals.confidence_score({"confidence": -0.1}) == 0.0
assert signals.confidence_score({}) is None
def test_adaptive_baseline_cold_start():
"""window 크기 < 4 → warmup, z=None."""
bl = signals.AdaptiveBaseline(window=[1.0, 1.1, 0.9], window_max=8)
z, fire = bl.evaluate(value=1.5, z_normal=1.5, z_urgent=2.5)
assert fire == "warmup"
assert z is None
def test_adaptive_baseline_preparing():
"""window 4~7 → 보수적 임계치 z=2.0."""
bl = signals.AdaptiveBaseline(window=[1.0, 1.0, 1.0, 1.0], window_max=8)
z, fire = bl.evaluate(value=3.0, z_normal=1.5, z_urgent=2.5)
assert fire in ("normal", "urgent")
def test_adaptive_baseline_normal_window_full():
"""window 8 풀, value가 평균보다 1.5σ 이상이면 normal."""
bl = signals.AdaptiveBaseline(
window=[1.0, 1.1, 0.9, 1.0, 1.0, 1.1, 0.9, 1.0],
window_max=8,
)
z, fire = bl.evaluate(value=1.12, z_normal=1.5, z_urgent=2.5)
assert fire == "normal"
assert z is not None and z >= 1.5
def test_adaptive_baseline_urgent():
"""z >= 2.5 → urgent."""
bl = signals.AdaptiveBaseline(
window=[1.0, 1.1, 0.9, 1.0, 1.0, 1.1, 0.9, 1.0],
window_max=8,
)
z, fire = bl.evaluate(value=2.0, z_normal=1.5, z_urgent=2.5)
assert fire == "urgent"
def test_adaptive_baseline_push_updates_window():
"""push 시 FIFO 동작."""
bl = signals.AdaptiveBaseline(window=[1, 2, 3, 4, 5, 6, 7, 8], window_max=8)
bl.push(9.0)
assert bl.window == [2, 3, 4, 5, 6, 7, 8, 9.0]
def test_decide_fire_level_two_normals_escalate():
sigs = [
{"metric": "sim", "z": 1.6, "fire": "normal"},
{"metric": "drift", "z": 1.7, "fire": "normal"},
{"metric": "conf", "z": 0.5, "fire": "noop"},
]
assert signals.decide_overall_fire(sigs) == "urgent"
def test_decide_fire_level_single_normal():
sigs = [
{"metric": "sim", "z": 1.6, "fire": "normal"},
{"metric": "drift", "z": 0.3, "fire": "noop"},
]
assert signals.decide_overall_fire(sigs) == "normal"
def test_decide_fire_level_single_urgent():
sigs = [
{"metric": "sim", "z": 3.0, "fire": "urgent"},
{"metric": "drift", "z": 0.2, "fire": "noop"},
]
assert signals.decide_overall_fire(sigs) == "urgent"
def test_decide_fire_level_all_noop():
sigs = [{"metric": "sim", "z": 0.5, "fire": "noop"}]
assert signals.decide_overall_fire(sigs) == "noop"

View File

@@ -0,0 +1,154 @@
# agent-office/tests/test_lotto_task_wrap.py
import os
import sys
import tempfile
import gc
_fd, _TMP = tempfile.mkstemp(suffix=".db")
os.close(_fd)
os.unlink(_TMP)
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import pytest
from app import db
db.DB_PATH = _TMP
@pytest.fixture(autouse=True)
def fresh_db():
# Re-patch DB_PATH at the start of every test (cross-file isolation)
db.DB_PATH = _TMP
gc.collect()
if os.path.exists(_TMP):
os.remove(_TMP)
db.init_db()
yield
gc.collect()
if os.path.exists(_TMP):
try:
os.remove(_TMP)
except PermissionError:
pass
@pytest.mark.asyncio
async def test_run_signal_check_creates_task_row(monkeypatch):
"""run_signal_check이 agent_tasks에 row를 만들고 result_data를 저장."""
from app.agents.lotto import LottoAgent
from app.curator import signal_runner
async def fake_run_signal_check(**kwargs):
return {
"overall_fire": "normal",
"results": [
{"signal_id": 1, "metric": "sim_signal",
"value": 0.6, "z_score": 1.7, "fire_level": "normal",
"baseline_mu": 0.5, "baseline_sigma": 0.05, "payload": {}},
],
}
monkeypatch.setattr(signal_runner, "run_signal_check", fake_run_signal_check)
from app import service_proxy
async def fake_latest():
return 1226
monkeypatch.setattr(service_proxy, "lotto_latest_draw", fake_latest)
from app.notifiers import telegram_lotto
async def fake_send(_event): pass
monkeypatch.setattr(telegram_lotto, "send_urgent_signal", fake_send)
agent = LottoAgent()
result = await agent.run_signal_check(source="light")
assert result["ok"] is True
tasks = db.get_agent_tasks("lotto", task_type="signal_check", days=1)
assert len(tasks) == 1
t = tasks[0]
assert t["status"] == "succeeded"
assert t["result_data"]["source"] == "light"
assert t["result_data"]["overall_fire"] == "normal"
assert "sim_signal" in t["result_data"]["fired_metrics"]
@pytest.mark.asyncio
async def test_run_signal_check_failure_marks_task_failed(monkeypatch):
from app.agents.lotto import LottoAgent
from app.curator import signal_runner
from app import service_proxy
async def boom(**kwargs):
raise RuntimeError("boom")
monkeypatch.setattr(signal_runner, "run_signal_check", boom)
async def fake_latest():
return 1226
monkeypatch.setattr(service_proxy, "lotto_latest_draw", fake_latest)
agent = LottoAgent()
result = await agent.run_signal_check(source="sim")
assert result["ok"] is False
tasks = db.get_agent_tasks("lotto", task_type="signal_check", days=1)
assert len(tasks) == 1
assert tasks[0]["status"] == "failed"
assert "boom" in tasks[0]["result_data"]["error"]
@pytest.mark.asyncio
async def test_run_daily_digest_creates_task(monkeypatch):
"""run_daily_digest이 agent_tasks에 task 생성 + result_data 저장."""
from app.agents.lotto import LottoAgent
from app.notifiers import telegram_lotto
async def fake_send(_d): pass
monkeypatch.setattr(telegram_lotto, "send_signal_summary", fake_send)
agent = LottoAgent()
result = await agent.run_daily_digest()
assert result["ok"] is True
tasks = db.get_agent_tasks("lotto", task_type="daily_digest", days=1)
assert len(tasks) == 1
assert tasks[0]["status"] == "succeeded"
assert "fired" in tasks[0]["result_data"]
assert "evaluated" in tasks[0]["result_data"]
@pytest.mark.asyncio
async def test_run_weekly_evolution_report_creates_task(monkeypatch):
"""run_weekly_evolution_report이 task 생성 + result_data 저장."""
from app.agents.lotto import LottoAgent
from app import service_proxy
from app.notifiers import telegram_lotto
async def fake_eval():
return {
"ok": True, "draw_no": 1225,
"winner": {"day_of_week": 3, "weight": [0.18, 0.32, 0.20, 0.22, 0.08],
"avg_score": 0.42, "max_correct": 4, "n_picks": 5},
"new_base": [0.18, 0.32, 0.20, 0.22, 0.08],
"previous_base": [0.2] * 5,
"update_reason": "winner_4plus",
}
async def fake_status():
return {"current_base": [0.2] * 5}
async def fake_send(_e, _b): pass
monkeypatch.setattr(service_proxy, "lotto_evolver_evaluate", fake_eval)
monkeypatch.setattr(service_proxy, "lotto_evolver_status", fake_status)
monkeypatch.setattr(telegram_lotto, "send_evolution_report", fake_send)
agent = LottoAgent()
result = await agent.run_weekly_evolution_report()
assert result["ok"] is True
tasks = db.get_agent_tasks("lotto", task_type="weekly_evolution_report", days=1)
assert len(tasks) == 1
r = tasks[0]["result_data"]
assert tasks[0]["status"] == "succeeded"
assert r["draw_no"] == 1225
assert r["update_reason"] == "winner_4plus"
assert r["winner_day_of_week"] == 3
assert r["winner_max_correct"] == 4

View File

@@ -0,0 +1,49 @@
from app.notifiers.telegram_lotto import (
_format_urgent_signal,
_format_signal_digest,
)
def test_urgent_signal_format_basic():
event = {
"fire_level": "urgent",
"triggered_at": "2026-05-20T07:18:00.000Z",
"results": [
{"metric": "sim_signal", "value": 1.84, "z_score": 3.9,
"baseline_mu": 1.02, "baseline_sigma": 0.21, "payload": {},
"fire_level": "urgent"},
{"metric": "drift", "value": 0.18, "z_score": 3.0,
"baseline_mu": 0.06, "baseline_sigma": 0.04, "fire_level": "normal",
"payload": {"weights_now": {"gap_focus": 0.5, "hot_focus": 0.5},
"weights_prev": {"gap_focus": 0.3, "hot_focus": 0.7}}},
],
}
text = _format_urgent_signal(event)
assert "🚨" in text
assert "Sim Consensus" in text
assert "z=3.9" in text
assert "Strategy Drift" in text
def test_signal_digest_format_with_signals():
digest = {
"evaluated": 6,
"fired": 2,
"signals": [
{"metric": "sim_signal", "fire_level": "normal", "z_score": 1.7,
"triggered_at": "2026-05-20T16:18:00Z", "payload": {}},
{"metric": "confidence", "fire_level": "normal", "z_score": 1.6,
"triggered_at": "2026-05-20T09:05:00Z", "payload": {}},
],
"weights_trend": {"gap_focus": +0.12, "hot_focus": -0.02, "pair_bias": -0.08},
}
text = _format_signal_digest(digest)
assert "📊" in text
assert "지난 24h" in text
assert "z=1.7" in text
def test_signal_digest_empty_returns_empty_string():
"""발화 0건이면 빈 문자열 → 발송 자체 skip 가능."""
text = _format_signal_digest({"evaluated": 6, "fired": 0, "signals": [], "weights_trend": {}})
assert text == ""

View File

@@ -0,0 +1,72 @@
"""migrate_tarot_to_lab.py 단위 테스트 — 멱등성 + 데이터 보존."""
import sqlite3
import sys
import os
import pytest
@pytest.fixture
def src_db(tmp_path):
p = tmp_path / "agent_office.db"
conn = sqlite3.connect(str(p))
conn.execute("""
CREATE TABLE tarot_readings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT, spread_type TEXT, category TEXT, question TEXT,
cards TEXT, interpretation_json TEXT, summary TEXT, model TEXT,
tokens_in INTEGER, tokens_out INTEGER, cost_usd REAL,
confidence TEXT, favorite INTEGER, note TEXT
)
""")
conn.execute("""
INSERT INTO tarot_readings (id, spread_type, category, cards, model, favorite)
VALUES (1, 'three_card', '연애', '[]', 'm', 0),
(2, 'one_card', '재물', '[]', 'm', 1)
""")
conn.commit()
conn.close()
return str(p)
@pytest.fixture
def dst_db(tmp_path):
return str(tmp_path / "tarot.db")
def _import_migrate(src, dst, monkeypatch):
monkeypatch.setenv("AGENT_OFFICE_DB", src)
monkeypatch.setenv("TAROT_DB", dst)
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))
import migrate_tarot_to_lab as m
import importlib
importlib.reload(m)
return m
def test_first_run_copies_all_rows(src_db, dst_db, monkeypatch):
m = _import_migrate(src_db, dst_db, monkeypatch)
moved = m.migrate()
assert moved == 2
conn = sqlite3.connect(dst_db)
rows = conn.execute("SELECT id, spread_type, category FROM tarot_readings ORDER BY id").fetchall()
conn.close()
assert rows == [(1, "three_card", "연애"), (2, "one_card", "재물")]
def test_idempotent_second_run(src_db, dst_db, monkeypatch):
m = _import_migrate(src_db, dst_db, monkeypatch)
m.migrate()
moved2 = m.migrate()
assert moved2 == 0
def test_partial_migration(src_db, dst_db, monkeypatch):
"""dst에 id=1만 있는 상태에서 다시 돌리면 id=2만 옮김."""
m = _import_migrate(src_db, dst_db, monkeypatch)
m.migrate()
conn = sqlite3.connect(dst_db)
conn.execute("DELETE FROM tarot_readings WHERE id=2")
conn.commit()
conn.close()
moved = m.migrate()
assert moved == 1

View File

@@ -0,0 +1,132 @@
import os
import sys
import tempfile
_fd, _TMP = tempfile.mkstemp(suffix=".db")
os.close(_fd)
os.unlink(_TMP)
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import pytest
from unittest.mock import AsyncMock, patch
@pytest.fixture(autouse=True)
def _init_db():
import gc
gc.collect()
if os.path.exists(_TMP):
os.remove(_TMP)
from app.db import init_db
init_db()
yield
gc.collect()
@pytest.mark.asyncio
async def test_poll_notifies_once_per_state():
from app.agents.youtube_publisher import YoutubePublisherAgent
pipelines = [{
"id": 1,
"state": "cover_pending",
"cover_url": "/x.jpg",
"track_title": "Test",
"feedback_count_per_step": {},
}]
with patch(
"app.agents.youtube_publisher.service_proxy.list_active_pipelines",
new=AsyncMock(return_value=pipelines),
), patch(
"app.agents.youtube_publisher.send_raw",
new=AsyncMock(return_value={"ok": True, "message_id": 99}),
) as mock_send, patch(
"app.agents.youtube_publisher.service_proxy.save_pipeline_telegram_msg",
new=AsyncMock(),
):
a = YoutubePublisherAgent()
await a.poll_state_changes()
await a.poll_state_changes() # 같은 상태 — 두 번째는 알림 안 함
assert mock_send.call_count == 1
@pytest.mark.asyncio
async def test_poll_renotifies_on_reject_regen(monkeypatch):
from app.agents.youtube_publisher import YoutubePublisherAgent
pipelines_v1 = [{"id": 1, "state": "cover_pending", "cover_url": "/x.jpg",
"track_title": "Test", "feedback_count_per_step": {}}]
pipelines_v2 = [{"id": 1, "state": "cover_pending", "cover_url": "/x2.jpg",
"track_title": "Test", "feedback_count_per_step": {"cover": 1}}]
list_mock = AsyncMock(side_effect=[pipelines_v1, pipelines_v2])
with patch("app.agents.youtube_publisher.service_proxy.list_active_pipelines", list_mock), \
patch("app.agents.youtube_publisher.send_raw",
new=AsyncMock(return_value={"ok": True, "message_id": 99})), \
patch("app.agents.youtube_publisher.service_proxy.save_pipeline_telegram_msg",
new=AsyncMock()):
a = YoutubePublisherAgent()
await a.poll_state_changes() # 1st: notify
await a.poll_state_changes() # 2nd: feedback count differs → notify again
from app.agents.youtube_publisher import send_raw as sr
assert sr.call_count == 2
@pytest.mark.asyncio
async def test_on_telegram_reply_approve_calls_feedback():
from app.agents.youtube_publisher import YoutubePublisherAgent
with patch(
"app.agents.youtube_publisher.service_proxy.post_pipeline_feedback",
new=AsyncMock(),
) as mock_fb, patch(
"app.agents.youtube_publisher.send_raw",
new=AsyncMock(),
):
a = YoutubePublisherAgent()
await a.on_telegram_reply(pipeline_id=42, step="cover", user_text="승인")
mock_fb.assert_called_once_with(42, "cover", "approve", None)
@pytest.mark.asyncio
async def test_on_telegram_reply_reject_with_feedback():
from app.agents.youtube_publisher import YoutubePublisherAgent
with patch(
"app.agents.youtube_publisher.service_proxy.post_pipeline_feedback",
new=AsyncMock(),
) as mock_fb, patch(
"app.agents.youtube_publisher.send_raw",
new=AsyncMock(),
):
a = YoutubePublisherAgent()
await a.on_telegram_reply(pipeline_id=43, step="meta", user_text="반려, 제목 짧게")
args = mock_fb.call_args[0]
assert args[0] == 43
assert args[1] == "meta"
assert args[2] == "reject"
assert "제목 짧게" in (args[3] or "")
@pytest.mark.asyncio
async def test_on_telegram_reply_unclear_asks_again():
from app.agents.youtube_publisher import YoutubePublisherAgent
sent = []
async def mock_send(text=None, **kw):
sent.append(text)
return {"ok": True, "message_id": 1}
with patch(
"app.agents.youtube_publisher.send_raw",
new=mock_send,
), patch(
"app.agents.youtube_publisher.classify_intent.classify",
return_value=("unclear", None),
):
a = YoutubePublisherAgent()
await a.on_telegram_reply(pipeline_id=44, step="cover", user_text="huh?")
assert any("다시 입력" in (s or "") for s in sent)

View File

@@ -0,0 +1,47 @@
import sys, os
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
import pytest
from unittest.mock import AsyncMock, patch
from app.curator.retrospective import build_retrospective, _detect_bias
def test_detect_bias_persistent_low():
reviews = [
{"pattern_delta": "저번호 편향 +1.2 / 합계 -18"},
{"pattern_delta": "저번호 편향 +0.8"},
{"pattern_delta": "저번호 편향 +1.0 / 홀짝 +0.5"},
]
assert "저번호" in _detect_bias(reviews)
def test_detect_bias_no_persistence():
reviews = [
{"pattern_delta": "저번호 편향 +1.2"},
{"pattern_delta": "고번호 편향 +0.8"},
]
assert _detect_bias(reviews) == ""
@pytest.mark.asyncio
async def test_build_retrospective_with_data():
with patch("app.service_proxy.lotto_review_by_draw", new=AsyncMock(return_value={
"draw_no": 1153, "curator_avg_match": 1.8, "curator_best_tier": "안정",
"user_avg_match": 2.0, "user_5plus_prizes": 1, "pattern_delta": "저번호 편향 +1.2",
})), patch("app.service_proxy.lotto_reviews_history", new=AsyncMock(return_value=[
{"draw_no": 1153, "curator_avg_match": 1.8, "user_avg_match": 2.0, "pattern_delta": "저번호 편향 +1.2"},
{"draw_no": 1152, "curator_avg_match": 1.6, "user_avg_match": 1.5, "pattern_delta": "저번호 편향 +0.8"},
{"draw_no": 1151, "curator_avg_match": 1.7, "user_avg_match": 1.8, "pattern_delta": "저번호 편향 +1.0"},
{"draw_no": 1150, "curator_avg_match": 1.9, "user_avg_match": 2.2, "pattern_delta": ""},
])):
out = await build_retrospective(1154)
assert out["last_draw"]["draw_no"] == 1153
assert out["trend_4w"]["curator_avg_4w"] == round((1.8+1.6+1.7+1.9)/4, 2)
assert "저번호" in out["trend_4w"]["user_persistent_bias"]
@pytest.mark.asyncio
async def test_build_retrospective_no_review():
with patch("app.service_proxy.lotto_review_by_draw", new=AsyncMock(return_value=None)):
out = await build_retrospective(1154)
assert out is None

View File

@@ -0,0 +1,53 @@
import pytest
import respx
import httpx
from app.service_proxy import fetch_service_logs
@pytest.mark.asyncio
@respx.mock
async def test_fetch_service_logs_filters_by_path_prefix():
# lotto 컨테이너 응답: lotto + personal 섞임
respx.get("http://lotto:8000/logs/recent").mock(
return_value=httpx.Response(200, json={
"logs": [
{"ts": "2026-05-28T10:00:00Z", "source": "access",
"method": "GET", "path": "/api/lotto/recommend",
"status": 200, "ms": 12,
"message": "GET /api/lotto/recommend → 200 (12ms)"},
{"ts": "2026-05-28T10:00:01Z", "source": "access",
"method": "GET", "path": "/api/blog/posts",
"status": 200, "ms": 5,
"message": "GET /api/blog/posts → 200 (5ms)"},
{"ts": "2026-05-28T10:00:02Z", "source": "log",
"logger": "lotto", "level": "info",
"message": "성과 통계 캐시 갱신"},
]
})
)
result = await fetch_service_logs("lotto", limit=50)
# lotto path 와 모든 log 이벤트만 통과
paths = [x.get("path") for x in result]
assert "/api/lotto/recommend" in paths
assert "/api/blog/posts" not in paths
# 비즈니스 로그도 포함
assert any(x["source"] == "log" and x["message"] == "성과 통계 캐시 갱신"
for x in result)
@pytest.mark.asyncio
async def test_fetch_service_logs_unknown_agent_returns_empty():
result = await fetch_service_logs("nonexistent", limit=50)
assert result == []
@pytest.mark.asyncio
@respx.mock
async def test_fetch_service_logs_handles_connection_error():
respx.get("http://lotto:8000/logs/recent").mock(
side_effect=httpx.ConnectError("connection refused")
)
result = await fetch_service_logs("lotto", limit=50)
assert result == []

View File

@@ -0,0 +1,177 @@
"""StockAgent.on_screener_schedule — 평일 16:30 KST 자동 잡 단위 테스트.
stock HTTP 호출은 service_proxy mock, 텔레그램은 messaging.send_raw mock.
"""
import os
import sys
import tempfile
_fd, _TMP = tempfile.mkstemp(suffix=".db")
os.close(_fd)
os.unlink(_TMP)
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import asyncio
from unittest.mock import AsyncMock, patch
import pytest
@pytest.fixture(autouse=True)
def _init_db():
import gc
gc.collect()
if os.path.exists(_TMP):
os.remove(_TMP)
from app.db import init_db
init_db()
yield
gc.collect()
def _success_body(asof="2026-05-12"):
return {
"asof": asof,
"mode": "auto",
"status": "success",
"run_id": 42,
"survivors_count": 600,
"top_n": 20,
"results": [],
"telegram_payload": {
"chat_target": "default",
"parse_mode": "MarkdownV2",
"text": "*KRX 강세주 스크리너* test body",
},
"warnings": [],
}
def _holiday_body(asof="2026-05-05"):
return {
"asof": asof,
"mode": "auto",
"status": "skipped_holiday",
"run_id": None,
"survivors_count": None,
"top_n": 0,
"results": [],
"telegram_payload": None,
"warnings": [f"{asof} is a holiday — skipped"],
}
def test_screener_success_sends_markdownv2_telegram():
from app.agents.stock import StockAgent
from app import service_proxy
from app.telegram import messaging
fake_snap = AsyncMock(return_value={"status": "ok"})
fake_run = AsyncMock(return_value=_success_body())
fake_send = AsyncMock(return_value={"ok": True, "message_id": 7777})
with patch.object(service_proxy, "refresh_screener_snapshot", fake_snap), \
patch.object(service_proxy, "run_stock_screener", fake_run), \
patch.object(messaging, "send_raw", fake_send):
agent = StockAgent()
asyncio.run(agent.on_screener_schedule())
fake_snap.assert_awaited_once()
fake_run.assert_awaited_once_with(mode="auto")
fake_send.assert_awaited_once()
args, kwargs = fake_send.call_args
# 첫 인자(text) 또는 kwargs로 전달
text = args[0] if args else kwargs.get("text")
assert "KRX 강세주 스크리너" in text
assert kwargs.get("parse_mode") == "MarkdownV2"
assert agent.state == "idle"
def test_screener_holiday_skips_telegram():
from app.agents.stock import StockAgent
from app import service_proxy
from app.telegram import messaging
fake_snap = AsyncMock(return_value={"status": "skipped_weekend"})
fake_run = AsyncMock(return_value=_holiday_body())
fake_send = AsyncMock(return_value={"ok": True, "message_id": 1})
with patch.object(service_proxy, "refresh_screener_snapshot", fake_snap), \
patch.object(service_proxy, "run_stock_screener", fake_run), \
patch.object(messaging, "send_raw", fake_send):
agent = StockAgent()
asyncio.run(agent.on_screener_schedule())
fake_run.assert_awaited_once()
# 휴일이면 텔레그램 미발신
fake_send.assert_not_awaited()
assert agent.state == "idle"
def test_screener_snapshot_failure_still_runs_screener():
"""스냅샷 실패는 경고만 남기고 screener 호출은 계속됨."""
from app.agents.stock import StockAgent
from app import service_proxy
from app.telegram import messaging
fake_snap = AsyncMock(side_effect=RuntimeError("snapshot upstream down"))
fake_run = AsyncMock(return_value=_success_body())
fake_send = AsyncMock(return_value={"ok": True, "message_id": 8888})
with patch.object(service_proxy, "refresh_screener_snapshot", fake_snap), \
patch.object(service_proxy, "run_stock_screener", fake_run), \
patch.object(messaging, "send_raw", fake_send):
agent = StockAgent()
asyncio.run(agent.on_screener_schedule())
fake_snap.assert_awaited_once()
fake_run.assert_awaited_once_with(mode="auto")
fake_send.assert_awaited_once()
def test_screener_run_failure_notifies_operator():
"""screener/run 실패 시 운영자 알림 텔레그램 발송."""
from app.agents.stock import StockAgent
from app import service_proxy
from app.telegram import messaging
fake_snap = AsyncMock(return_value={"status": "ok"})
fake_run = AsyncMock(side_effect=RuntimeError("stock 500"))
fake_send = AsyncMock(return_value={"ok": True, "message_id": 1})
with patch.object(service_proxy, "refresh_screener_snapshot", fake_snap), \
patch.object(service_proxy, "run_stock_screener", fake_run), \
patch.object(messaging, "send_raw", fake_send):
agent = StockAgent()
asyncio.run(agent.on_screener_schedule())
# 운영자 알림 1회는 호출
assert fake_send.await_count == 1
args, kwargs = fake_send.call_args
text = args[0] if args else kwargs.get("text")
assert "스크리너 실패" in text
assert agent.state == "idle"
def test_screener_unexpected_status_treated_as_failure():
from app.agents.stock import StockAgent
from app import service_proxy
from app.telegram import messaging
fake_snap = AsyncMock(return_value={"status": "ok"})
fake_run = AsyncMock(return_value={"status": "weird", "asof": "2026-05-12"})
fake_send = AsyncMock(return_value={"ok": True, "message_id": 1})
with patch.object(service_proxy, "refresh_screener_snapshot", fake_snap), \
patch.object(service_proxy, "run_stock_screener", fake_run), \
patch.object(messaging, "send_raw", fake_send):
agent = StockAgent()
asyncio.run(agent.on_screener_schedule())
# 운영자 알림 1회 + screener payload 미발송
assert fake_send.await_count == 1
args, kwargs = fake_send.call_args
text = args[0] if args else kwargs.get("text")
assert "스크리너 실패" in text

View File

@@ -0,0 +1,38 @@
import sys, os
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from app.notifiers import telegram_lotto as tl
def test_format_sunday_review_text():
payload = {
"draw_no": 1170,
"winner_analysis": {"score_total": 0.41, "percentile": 0.33,
"score_frequency": 0.4, "score_fingerprint": 0.5, "score_gap": 0.3,
"score_cooccur": 0.45, "score_diversity": 0.6},
"forward": [
{"strategy": "engine_w", "label": "w1", "prizes": {"1st":0,"2nd":0,"3rd":0,"4th":1,"5th":12}, "best_match": 4, "avg_meta_score": 0.55},
{"strategy": "random_null", "label": "-", "prizes": {"1st":0,"2nd":0,"3rd":0,"4th":0,"5th":10}, "best_match": 3, "avg_meta_score": 0.33},
],
"track_record": {},
"calibration_trend": [{"draw_no":1170,"score_total":0.41,"percentile":0.33}],
}
txt = tl.format_sunday_review(payload)
assert "1170" in txt
assert "%" in txt # percentile 표기
assert "engine" in txt.lower() or "엔진" in txt
def test_format_sunday_review_no_calibration():
payload = {"draw_no": 1171, "winner_analysis": None, "forward": []}
txt = tl.format_sunday_review(payload)
assert "1171" in txt
assert "%" not in txt # no percentile section when calibration absent
assert "데이터 없음" in txt
def test_format_sunday_review_missing_prizes_no_crash():
payload = {"draw_no": 1171, "winner_analysis": None,
"forward": [{"strategy": "engine_w", "label": "w1", "best_match": 3}]} # no 'prizes'
txt = tl.format_sunday_review(payload) # must NOT raise
assert "1171" in txt

View File

@@ -0,0 +1,123 @@
# agent-office/tests/test_sync_evolver_activity.py
import os
import sys
import tempfile
import gc
from datetime import datetime, timezone, timedelta
_fd, _TMP = tempfile.mkstemp(suffix=".db")
os.close(_fd)
os.unlink(_TMP)
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import pytest
from app import db
db.DB_PATH = _TMP
@pytest.fixture(autouse=True)
def fresh_db():
# Re-patch DB_PATH at the start of every test (cross-file isolation)
db.DB_PATH = _TMP
gc.collect()
if os.path.exists(_TMP):
os.remove(_TMP)
db.init_db()
yield
gc.collect()
if os.path.exists(_TMP):
try:
os.remove(_TMP)
except PermissionError:
pass
def _today_dow_clamped():
"""오늘의 weekday() (일요일=6은 5로 clamp)."""
KST = timezone(timedelta(hours=9))
dow = datetime.now(KST).weekday()
return 5 if dow == 6 else dow
def _fake_status_with_picks(dow_with_picks):
async def fake():
return {
"week_start": "2026-05-18",
"current_base": [0.2] * 5,
"trials": [
{
"id": 100 + i,
"day_of_week": i,
"weight": [0.2] * 5,
"source": "perturb",
"picks": ([
{"id": j, "numbers": [1,2,3,4,5,6], "meta_score": 0.5}
for j in range(5)
] if i == dow_with_picks else []),
}
for i in range(6)
],
}
return fake
@pytest.mark.asyncio
async def test_sync_evolver_activity_creates_apply_task(monkeypatch):
"""오늘 trial에 picks가 있으면 evolver_apply task 1개 생성."""
from app.agents.lotto import LottoAgent
from app import service_proxy
dow = _today_dow_clamped()
monkeypatch.setattr(service_proxy, "lotto_evolver_status", _fake_status_with_picks(dow))
agent = LottoAgent()
await agent.sync_evolver_activity()
apply_tasks = db.get_agent_tasks("lotto", task_type="evolver_apply", days=1)
assert len(apply_tasks) == 1
assert apply_tasks[0]["result_data"]["n_picks"] == 5
assert apply_tasks[0]["input_data"]["day_of_week"] == dow
@pytest.mark.asyncio
async def test_sync_evolver_activity_idempotent(monkeypatch):
"""같은 날 두 번 호출해도 task는 1개만 (멱등)."""
from app.agents.lotto import LottoAgent
from app import service_proxy
dow = _today_dow_clamped()
monkeypatch.setattr(service_proxy, "lotto_evolver_status", _fake_status_with_picks(dow))
agent = LottoAgent()
await agent.sync_evolver_activity()
await agent.sync_evolver_activity()
apply_tasks = db.get_agent_tasks("lotto", task_type="evolver_apply", days=1)
assert len(apply_tasks) == 1
@pytest.mark.asyncio
async def test_sync_evolver_activity_no_picks_no_task(monkeypatch):
"""오늘 trial에 picks가 없으면 task 생성하지 않음."""
from app.agents.lotto import LottoAgent
from app import service_proxy
async def fake_status():
return {
"week_start": "2026-05-18",
"current_base": [0.2] * 5,
"trials": [
{"id": 100 + i, "day_of_week": i, "weight": [0.2]*5,
"source": "perturb", "picks": []}
for i in range(6)
],
}
monkeypatch.setattr(service_proxy, "lotto_evolver_status", fake_status)
agent = LottoAgent()
await agent.sync_evolver_activity()
apply_tasks = db.get_agent_tasks("lotto", task_type="evolver_apply", days=1)
assert len(apply_tasks) == 0

View File

@@ -0,0 +1,44 @@
import sys, os
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from app.notifiers.telegram_lotto import _format_briefing, _format_prize_alert
def test_briefing_with_retrospective():
payload = {
"draw_no": 1154,
"confidence": 72,
"narrative": {
"headline": "안정 +1, 콜드 누적 보강",
"summary_3lines": ["a", "b", "c"],
"retrospective": "너 2.0 / 나 1.8 — 저번호 편향",
},
"picks": {
"core": [
{"risk_tag": "안정"}, {"risk_tag": "안정"}, {"risk_tag": "안정"},
{"risk_tag": "균형"}, {"risk_tag": "공격"},
],
"bonus": [], "extended": [], "pool": [],
},
}
text = _format_briefing(payload)
assert "1154회" in text
assert "신뢰도 72" in text
assert "안정 3" in text
assert "회고: 너 2.0" in text
def test_briefing_without_retrospective():
payload = {
"draw_no": 1, "confidence": 50,
"narrative": {"headline": "h", "summary_3lines": ["a","b","c"], "retrospective": ""},
"picks": {"core": [{"risk_tag":"안정"}]*5, "bonus":[],"extended":[],"pool":[]},
}
text = _format_briefing(payload)
assert "회고" not in text
def test_prize_alert():
text = _format_prize_alert({"draw_no": 1154, "match_count": 5, "numbers": [3,11,17,25,33,8]})
assert "5개 일치" in text
assert "3, 11, 17, 25, 33, 8" in text

View File

@@ -1,15 +0,0 @@
import os
# Anthropic Claude API
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
CLAUDE_MODEL = os.getenv("CLAUDE_MODEL", "claude-sonnet-4-20250514")
# Naver Search API
NAVER_CLIENT_ID = os.getenv("NAVER_CLIENT_ID", "")
NAVER_CLIENT_SECRET = os.getenv("NAVER_CLIENT_SECRET", "")
# Database
DB_PATH = os.getenv("BLOG_DB_PATH", "/app/data/blog_marketing.db")
# CORS
CORS_ALLOW_ORIGINS = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080")

View File

@@ -1,172 +0,0 @@
"""Claude API 기반 콘텐츠 생성 — 트렌드 브리프 + 블로그 글 작성."""
import json
import logging
from datetime import date
from typing import Any, Dict, Optional
import anthropic
from .config import ANTHROPIC_API_KEY, CLAUDE_MODEL
from .db import get_template
logger = logging.getLogger(__name__)
_client: Optional[anthropic.Anthropic] = None
def _get_client() -> anthropic.Anthropic:
global _client
if _client is None:
_client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
return _client
def _call_claude(prompt: str, max_tokens: int = 4096) -> str:
"""Claude API 호출. 단일 user 메시지. 현재 날짜 시스템 프롬프트 포함."""
client = _get_client()
today = date.today().isoformat()
resp = client.messages.create(
model=CLAUDE_MODEL,
max_tokens=max_tokens,
system=f"현재 날짜는 {today}입니다. 모든 콘텐츠는 이 날짜 기준으로 작성하세요.",
messages=[{"role": "user", "content": prompt}],
)
return resp.content[0].text
def generate_trend_brief(analysis: Dict[str, Any]) -> str:
"""키워드 분석 데이터를 바탕으로 트렌드 브리프 생성."""
template = get_template("trend_brief")
if not template:
raise RuntimeError("trend_brief 템플릿이 없습니다")
top_blogs_text = "\n".join(
f"- {b.get('title', '')}" for b in analysis.get("top_blogs", [])
) or "없음"
top_products_text = "\n".join(
f"- {p.get('title', '')} ({p.get('lprice', '?')}원, {p.get('mallName', '')})"
for p in analysis.get("top_products", [])
) or "없음"
prompt = template.format(
keyword=analysis.get("keyword", ""),
competition=analysis.get("competition", 0),
opportunity=analysis.get("opportunity", 0),
top_blogs=top_blogs_text,
top_products=top_products_text,
)
return _call_claude(prompt)
def _parse_blog_json(raw: str, keyword: str) -> Dict[str, str]:
"""Claude 응답에서 블로그 JSON을 파싱."""
try:
text = raw.strip()
if text.startswith("```"):
lines = text.split("\n")
lines = [l for l in lines if not l.strip().startswith("```")]
text = "\n".join(lines)
result = json.loads(text)
return {
"title": result.get("title", ""),
"body": result.get("body", ""),
"excerpt": result.get("excerpt", ""),
"tags": result.get("tags", []),
}
except (json.JSONDecodeError, KeyError):
logger.warning("Blog post JSON parse failed, using raw text")
return {
"title": f"{keyword} 추천 리뷰",
"body": raw,
"excerpt": raw[:200],
"tags": [keyword],
}
def generate_blog_post(
analysis: Dict[str, Any],
trend_brief: str,
brand_links: Optional[list] = None,
) -> Dict[str, str]:
"""트렌드 브리프를 바탕으로 블로그 글 작성.
Returns:
{"title": str, "body": str, "excerpt": str, "tags": [...]}
"""
template = get_template("blog_write")
if not template:
raise RuntimeError("blog_write 템플릿이 없습니다")
top_products_text = "\n".join(
f"- {p.get('title', '')} ({p.get('lprice', '?')}원, {p.get('mallName', '')})"
for p in analysis.get("top_products", [])
) or "없음"
# 크롤링된 블로그 본문 참고 자료
reference_blogs_text = ""
for blog in analysis.get("top_blogs", []):
content = blog.get("content", "")
if content:
reference_blogs_text += f"\n### {blog.get('title', '제목 없음')}\n{content}\n"
if not reference_blogs_text:
reference_blogs_text = "없음"
# 브랜드커넥트 링크 정보
brand_products_text = ""
if brand_links:
for link in brand_links:
brand_products_text += (
f"- 상품명: {link.get('product_name', '')}\n"
f" 설명: {link.get('description', '')}\n"
f" 링크: {link.get('url', '')}\n"
f" 배치 힌트: {link.get('placement_hint', '자연스럽게')}\n"
)
if not brand_products_text:
brand_products_text = "없음 (제휴 링크 없이 일반 리뷰로 작성)"
prompt = template.format(
keyword=analysis.get("keyword", ""),
trend_brief=trend_brief,
top_products=top_products_text,
reference_blogs=reference_blogs_text,
brand_products=brand_products_text,
)
# 구조화된 응답을 위한 추가 지시
prompt += (
"\n\n---\n"
"응답은 반드시 아래 JSON 형식으로 해주세요 (JSON만 출력, 다른 텍스트 없이):\n"
'{"title": "블로그 제목", "body": "HTML 본문", "excerpt": "2줄 요약", '
'"tags": ["태그1", "태그2", ...]}'
)
raw = _call_claude(prompt, max_tokens=8192)
return _parse_blog_json(raw, analysis.get("keyword", ""))
def regenerate_blog_post(
analysis: Dict[str, Any],
trend_brief: str,
previous_body: str,
feedback: str,
) -> Dict[str, str]:
"""피드백을 반영하여 블로그 글 재생성."""
prompt = (
"당신은 네이버 블로그에서 월 100만 이상 수익을 올리는 전문 블로거입니다.\n"
f"키워드: {analysis.get('keyword', '')}\n\n"
f"이전에 작성한 글:\n{previous_body[:3000]}\n\n"
f"리뷰어 피드백:\n{feedback}\n\n"
"위 피드백을 반영하여 글을 개선해주세요.\n"
"작성 규칙: 1인칭 체험기, 2,000자 이상, 자연스러운 구어체, "
"제품 비교표 포함, 광고 고지 문구 포함.\n"
"HTML 형식으로 작성하되, 네이버 블로그에서 바로 붙여넣기 가능한 형태로.\n\n"
"---\n"
"응답은 반드시 아래 JSON 형식으로 해주세요 (JSON만 출력):\n"
'{"title": "블로그 제목", "body": "HTML 본문", "excerpt": "2줄 요약", '
'"tags": ["태그1", "태그2", ...]}'
)
raw = _call_claude(prompt, max_tokens=8192)
return _parse_blog_json(raw, analysis.get("keyword", ""))

View File

@@ -1,789 +0,0 @@
import os
import sqlite3
import json
from typing import Any, Dict, List, Optional
from .config import DB_PATH
def _conn() -> sqlite3.Connection:
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
return conn
def init_db() -> None:
with _conn() as conn:
# 키워드/상품 분석 결과
conn.execute("""
CREATE TABLE IF NOT EXISTS keyword_analyses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
keyword TEXT NOT NULL,
blog_total INTEGER NOT NULL DEFAULT 0,
shop_total INTEGER NOT NULL DEFAULT 0,
competition REAL NOT NULL DEFAULT 0,
opportunity REAL NOT NULL DEFAULT 0,
avg_price INTEGER,
min_price INTEGER,
max_price INTEGER,
top_products TEXT NOT NULL DEFAULT '[]',
top_blogs TEXT NOT NULL DEFAULT '[]',
ai_summary TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_ka_created ON keyword_analyses(created_at DESC)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_ka_keyword ON keyword_analyses(keyword)")
# 블로그 포스트
conn.execute("""
CREATE TABLE IF NOT EXISTS blog_posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
keyword_id INTEGER REFERENCES keyword_analyses(id),
title TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
excerpt TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'draft',
review_score INTEGER,
review_detail TEXT NOT NULL DEFAULT '{}',
naver_url TEXT NOT NULL DEFAULT '',
trend_brief TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_bp_created ON blog_posts(created_at DESC)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_bp_status ON blog_posts(status)")
# 수익(커미션) 추적
conn.execute("""
CREATE TABLE IF NOT EXISTS commissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
post_id INTEGER REFERENCES blog_posts(id),
month TEXT NOT NULL,
clicks INTEGER NOT NULL DEFAULT 0,
purchases INTEGER NOT NULL DEFAULT 0,
revenue INTEGER NOT NULL DEFAULT 0,
note TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_comm_month ON commissions(month)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_comm_post ON commissions(post_id)")
# 비동기 작업 상태 (research / generate / review)
conn.execute("""
CREATE TABLE IF NOT EXISTS generation_tasks (
id TEXT PRIMARY KEY,
type TEXT NOT NULL DEFAULT 'research',
status TEXT NOT NULL DEFAULT 'queued',
progress INTEGER NOT NULL DEFAULT 0,
message TEXT NOT NULL DEFAULT '',
result_id INTEGER,
error TEXT,
params TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_gt_created ON generation_tasks(created_at DESC)")
# AI 프롬프트 템플릿
conn.execute("""
CREATE TABLE IF NOT EXISTS prompt_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
template TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)
""")
# 브랜드커넥트 제휴 링크
conn.execute("""
CREATE TABLE IF NOT EXISTS brand_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
post_id INTEGER REFERENCES blog_posts(id),
keyword_id INTEGER REFERENCES keyword_analyses(id),
url TEXT NOT NULL,
product_name TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
placement_hint TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_bl_post ON brand_links(post_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_bl_keyword ON brand_links(keyword_id)")
# 기본 프롬프트 템플릿 시딩 (존재하지 않을 때만)
_seed_templates(conn)
_migrate_templates(conn)
def _seed_templates(conn: sqlite3.Connection) -> None:
"""기본 프롬프트 템플릿을 DB에 시딩."""
templates = [
{
"name": "trend_brief",
"description": "네이버 블로그 트렌드 분석 + 제목/훅 전략 브리프",
"template": (
"당신은 네이버 블로그 마케팅 전문가입니다.\n"
"아래 키워드 분석 데이터를 바탕으로 블로그 포스팅 전략 브리프를 작성하세요.\n\n"
"키워드: {keyword}\n"
"블로그 경쟁도: {competition} (0-100, 높을수록 경쟁 치열)\n"
"쇼핑 기회 점수: {opportunity} (0-100, 높을수록 기회 큼)\n"
"상위 블로그 제목들: {top_blogs}\n"
"상위 상품들: {top_products}\n\n"
"다음을 포함해주세요:\n"
"1. 클릭을 유도하는 제목 공식 3가지\n"
"2. 도입부 훅 전략 (공감형, 질문형, 충격형 중 추천)\n"
"3. 추천 해시태그 5-10개\n"
"4. 경쟁 분석 요약 (기존 글 대비 차별화 포인트)\n"
"5. SEO 키워드 배치 전략"
),
},
{
"name": "blog_write",
"description": "공감형 1인칭 체험기 블로그 글 작성",
"template": (
"당신은 네이버 블로그에서 월 100만 이상 수익을 올리는 전문 블로거입니다.\n"
"아래 브리프를 바탕으로 블로그 글을 작성하세요.\n\n"
"키워드: {keyword}\n"
"트렌드 브리프: {trend_brief}\n"
"상위 상품 정보: {top_products}\n\n"
"작성 규칙:\n"
"- 1인칭 체험기 형식 (\"제가 직접 써봤는데요\")\n"
"- 1,500자 이상\n"
"- 자연스러운 구어체 (네이버 블로그 톤)\n"
"- 제품 비교표 포함 (마크다운 테이블)\n"
"- 장단점 솔직하게 작성\n"
"- 광고 고지 문구 포함: \"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.\"\n"
"- 추천 매트릭스 (가성비/품질/디자인 기준)\n"
"- 자연스러운 CTA (구매 링크 유도)\n\n"
"HTML 형식으로 작성하되, 네이버 블로그에서 바로 붙여넣기 가능한 형태로 만들어주세요."
),
},
{
"name": "quality_review",
"description": "블로그 글 품질 리뷰 (6기준 × 10점)",
"template": (
"당신은 블로그 콘텐츠 품질 평가 전문가입니다.\n"
"아래 블로그 글을 6가지 기준으로 평가해주세요.\n\n"
"제목: {title}\n"
"본문: {body}\n\n"
"평가 기준 (각 1-10점):\n"
"1. 독자 공감도 (empathy): 1인칭 체험기가 자연스럽고 공감되는가?\n"
"2. 제목 클릭 유도력 (click_appeal): 검색 결과에서 클릭하고 싶은 제목인가?\n"
"3. 구매 전환력 (conversion): 읽고 나서 제품을 사고 싶어지는가?\n"
"4. SEO 최적화 (seo): 키워드 배치, 소제목, 길이가 적절한가?\n"
"5. 형식 완성도 (format): 비교표, 이미지 설명, 단락 구성이 잘 되어있는가?\n"
"6. 링크 자연스러움 (link_natural): 제휴 링크가 광고처럼 느껴지지 않고 자연스럽게 녹아있는가? (링크가 없으면 5점 기본)\n\n"
"JSON 형식으로 응답:\n"
"{{\n"
" \"scores\": {{\n"
" \"empathy\": N,\n"
" \"click_appeal\": N,\n"
" \"conversion\": N,\n"
" \"seo\": N,\n"
" \"format\": N,\n"
" \"link_natural\": N\n"
" }},\n"
" \"total\": N,\n"
" \"pass\": true/false,\n"
" \"feedback\": \"개선 사항 설명\"\n"
"}}"
),
},
{
"name": "marketer_enhance",
"description": "마케터 전환율 강화 + 제휴 링크 삽입",
"template": (
"당신은 네이버 블로그 수익화 전문 마케터입니다.\n"
"아래 블로그 초안에 제휴 링크를 자연스럽게 삽입하고 전환율을 강화하세요.\n\n"
"=== 블로그 초안 ===\n{draft_body}\n\n"
"=== 타겟 키워드 ===\n{keyword}\n\n"
"=== 삽입할 제휴 링크 ===\n{brand_links_info}\n\n"
"작업 규칙:\n"
"- 제휴 링크를 <a href=\"URL\" target=\"_blank\">상품명</a> 형태로 본문 흐름에 맞게 2~3곳 삽입\n"
"- 결론에 CTA(Call-to-Action) 블록 추가 (\"지금 확인하기\" 등)\n"
"- 글 맨 아래에 광고 고지 문구 자동 삽입: \"이 포스팅은 브랜드로부터 소정의 수수료를 받을 수 있습니다\"\n"
"- 작가의 1인칭 톤과 구어체를 유지\n"
"- 과도한 광고 느낌 없이 자연스러운 추천 흐름 유지\n"
"- 구매 심리를 자극하는 표현 강화 (한정 수량, 가격 비교, 실사용 만족도 등)\n"
"- 배치 힌트가 있으면 참고하되, 문맥이 더 자연스러운 위치 우선\n"
"- 기존 본문의 구조와 길이를 크게 변경하지 않음"
),
},
]
for t in templates:
existing = conn.execute(
"SELECT id FROM prompt_templates WHERE name = ?", (t["name"],)
).fetchone()
if not existing:
conn.execute(
"INSERT INTO prompt_templates (name, description, template) VALUES (?, ?, ?)",
(t["name"], t["description"], t["template"]),
)
def _migrate_templates(conn: sqlite3.Connection) -> None:
"""기존 템플릿을 최신 버전으로 업데이트."""
new_blog_write = (
"당신은 네이버 블로그에서 월 100만 이상 수익을 올리는 전문 블로거입니다.\n"
"아래 브리프와 참고 자료를 바탕으로 블로그 글을 작성하세요.\n\n"
"키워드: {keyword}\n"
"트렌드 브리프: {trend_brief}\n\n"
"=== 상위 블로그 참고 자료 ===\n"
"{reference_blogs}\n\n"
"=== 상위 상품 정보 ===\n"
"{top_products}\n\n"
"=== 제휴 상품 (브랜드커넥트 링크) ===\n"
"{brand_products}\n\n"
"작성 규칙:\n"
"- 1인칭 체험기 형식 (\"제가 직접 써봤는데요\")\n"
"- 2,000자 이상\n"
"- 자연스러운 구어체 (네이버 블로그 톤)\n"
"- 상위 블로그 참고하되 표절 금지 (자신만의 시각으로 재구성)\n"
"- 제품 비교표 포함 (HTML 테이블)\n"
"- 장단점 솔직하게 작성\n"
"- 제휴 상품이 있으면 자연스럽게 체험 맥락에 녹여서 작성\n"
"- 제휴 링크는 <a> 태그로 자연스럽게 삽입\n"
"- 추천 매트릭스 (가성비/품질/디자인 기준)\n"
"- 자연스러운 CTA (구매 링크 유도)\n\n"
"HTML 형식으로 작성하되, 네이버 블로그에서 바로 붙여넣기 가능한 형태로 만들어주세요."
)
conn.execute(
"UPDATE prompt_templates SET template = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE name = 'blog_write'",
(new_blog_write,),
)
new_quality_review = (
"당신은 블로그 콘텐츠 품질 평가 전문가입니다.\n"
"아래 블로그 글을 6가지 기준으로 평가해주세요.\n\n"
"제목: {title}\n"
"본문: {body}\n\n"
"평가 기준 (각 1-10점):\n"
"1. 독자 공감도 (empathy): 1인칭 체험기가 자연스럽고 공감되는가?\n"
"2. 제목 클릭 유도력 (click_appeal): 검색 결과에서 클릭하고 싶은 제목인가?\n"
"3. 구매 전환력 (conversion): 읽고 나서 제품을 사고 싶어지는가?\n"
"4. SEO 최적화 (seo): 키워드 배치, 소제목, 길이가 적절한가?\n"
"5. 형식 완성도 (format): 비교표, 이미지 설명, 단락 구성이 잘 되어있는가?\n"
"6. 링크 자연스러움 (link_natural): 제휴 링크가 광고처럼 느껴지지 않고 자연스럽게 녹아있는가? (링크가 없으면 5점 기본)\n\n"
"JSON 형식으로 응답:\n"
"{{\n"
" \"scores\": {{\n"
" \"empathy\": N,\n"
" \"click_appeal\": N,\n"
" \"conversion\": N,\n"
" \"seo\": N,\n"
" \"format\": N,\n"
" \"link_natural\": N\n"
" }},\n"
" \"total\": N,\n"
" \"pass\": true/false,\n"
" \"feedback\": \"개선 사항 설명\"\n"
"}}"
)
conn.execute(
"UPDATE prompt_templates SET template = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE name = 'quality_review'",
(new_quality_review,),
)
# marketer_enhance가 없으면 추가
existing = conn.execute("SELECT id FROM prompt_templates WHERE name = 'marketer_enhance'").fetchone()
if not existing:
conn.execute(
"INSERT INTO prompt_templates (name, description, template) VALUES (?, ?, ?)",
("marketer_enhance", "마케터 전환율 강화 + 제휴 링크 삽입",
"당신은 네이버 블로그 수익화 전문 마케터입니다.\n"
"아래 블로그 초안에 제휴 링크를 자연스럽게 삽입하고 전환율을 강화하세요.\n\n"
"=== 블로그 초안 ===\n{draft_body}\n\n"
"=== 타겟 키워드 ===\n{keyword}\n\n"
"=== 삽입할 제휴 링크 ===\n{brand_links_info}\n\n"
"작업 규칙:\n"
"- 제휴 링크를 <a href=\"URL\" target=\"_blank\">상품명</a> 형태로 본문 흐름에 맞게 2~3곳 삽입\n"
"- 결론에 CTA(Call-to-Action) 블록 추가\n"
"- 글 맨 아래에 광고 고지 문구 자동 삽입\n"
"- 작가의 1인칭 톤과 구어체를 유지\n"
"- 과도한 광고 느낌 없이 자연스러운 추천 흐름 유지"),
)
# ── keyword_analyses CRUD ────────────────────────────────────────────────────
def _ka_row_to_dict(r) -> Dict[str, Any]:
return {
"id": r["id"],
"keyword": r["keyword"],
"blog_total": r["blog_total"],
"shop_total": r["shop_total"],
"competition": r["competition"],
"opportunity": r["opportunity"],
"avg_price": r["avg_price"],
"min_price": r["min_price"],
"max_price": r["max_price"],
"top_products": json.loads(r["top_products"]) if r["top_products"] else [],
"top_blogs": json.loads(r["top_blogs"]) if r["top_blogs"] else [],
"ai_summary": r["ai_summary"],
"created_at": r["created_at"],
}
def add_keyword_analysis(data: Dict[str, Any]) -> Dict[str, Any]:
with _conn() as conn:
conn.execute(
"""INSERT INTO keyword_analyses
(keyword, blog_total, shop_total, competition, opportunity,
avg_price, min_price, max_price, top_products, top_blogs, ai_summary)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
data.get("keyword", ""),
data.get("blog_total", 0),
data.get("shop_total", 0),
data.get("competition", 0),
data.get("opportunity", 0),
data.get("avg_price"),
data.get("min_price"),
data.get("max_price"),
json.dumps(data.get("top_products", []), ensure_ascii=False),
json.dumps(data.get("top_blogs", []), ensure_ascii=False),
data.get("ai_summary", ""),
),
)
row = conn.execute(
"SELECT * FROM keyword_analyses WHERE rowid = last_insert_rowid()"
).fetchone()
return _ka_row_to_dict(row)
def get_keyword_analysis(analysis_id: int) -> Optional[Dict[str, Any]]:
with _conn() as conn:
row = conn.execute(
"SELECT * FROM keyword_analyses WHERE id = ?", (analysis_id,)
).fetchone()
return _ka_row_to_dict(row) if row else None
def get_keyword_analyses(limit: int = 30) -> List[Dict[str, Any]]:
with _conn() as conn:
rows = conn.execute(
"SELECT * FROM keyword_analyses ORDER BY created_at DESC LIMIT ?", (limit,)
).fetchall()
return [_ka_row_to_dict(r) for r in rows]
def delete_keyword_analysis(analysis_id: int) -> bool:
with _conn() as conn:
row = conn.execute(
"SELECT id FROM keyword_analyses WHERE id = ?", (analysis_id,)
).fetchone()
if not row:
return False
conn.execute("DELETE FROM keyword_analyses WHERE id = ?", (analysis_id,))
return True
# ── blog_posts CRUD ──────────────────────────────────────────────────────────
def _post_row_to_dict(r) -> Dict[str, Any]:
return {
"id": r["id"],
"keyword_id": r["keyword_id"],
"title": r["title"],
"body": r["body"],
"excerpt": r["excerpt"],
"tags": json.loads(r["tags"]) if r["tags"] else [],
"status": r["status"],
"review_score": r["review_score"],
"review_detail": json.loads(r["review_detail"]) if r["review_detail"] else {},
"naver_url": r["naver_url"],
"trend_brief": r["trend_brief"],
"created_at": r["created_at"],
"updated_at": r["updated_at"],
}
def add_post(data: Dict[str, Any]) -> Dict[str, Any]:
with _conn() as conn:
conn.execute(
"""INSERT INTO blog_posts
(keyword_id, title, body, excerpt, tags, status, review_score,
review_detail, naver_url, trend_brief)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
data.get("keyword_id"),
data.get("title", ""),
data.get("body", ""),
data.get("excerpt", ""),
json.dumps(data.get("tags", []), ensure_ascii=False),
data.get("status", "draft"),
data.get("review_score"),
json.dumps(data.get("review_detail", {}), ensure_ascii=False),
data.get("naver_url", ""),
data.get("trend_brief", ""),
),
)
row = conn.execute(
"SELECT * FROM blog_posts WHERE rowid = last_insert_rowid()"
).fetchone()
return _post_row_to_dict(row)
def get_post(post_id: int) -> Optional[Dict[str, Any]]:
with _conn() as conn:
row = conn.execute(
"SELECT * FROM blog_posts WHERE id = ?", (post_id,)
).fetchone()
return _post_row_to_dict(row) if row else None
def get_posts(status: Optional[str] = None, limit: int = 50) -> List[Dict[str, Any]]:
with _conn() as conn:
if status:
rows = conn.execute(
"SELECT * FROM blog_posts WHERE status = ? ORDER BY created_at DESC LIMIT ?",
(status, limit),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM blog_posts ORDER BY created_at DESC LIMIT ?", (limit,)
).fetchall()
return [_post_row_to_dict(r) for r in rows]
def update_post(post_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
with _conn() as conn:
fields = []
values = []
for k in ("title", "body", "excerpt", "status", "naver_url", "trend_brief"):
if k in data:
fields.append(f"{k} = ?")
values.append(data[k])
if "tags" in data:
fields.append("tags = ?")
values.append(json.dumps(data["tags"], ensure_ascii=False))
if "review_score" in data:
fields.append("review_score = ?")
values.append(data["review_score"])
if "review_detail" in data:
fields.append("review_detail = ?")
values.append(json.dumps(data["review_detail"], ensure_ascii=False))
if not fields:
return get_post(post_id)
fields.append("updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')")
values.append(post_id)
conn.execute(
f"UPDATE blog_posts SET {', '.join(fields)} WHERE id = ?", values
)
row = conn.execute(
"SELECT * FROM blog_posts WHERE id = ?", (post_id,)
).fetchone()
return _post_row_to_dict(row) if row else None
def delete_post(post_id: int) -> bool:
with _conn() as conn:
row = conn.execute(
"SELECT id FROM blog_posts WHERE id = ?", (post_id,)
).fetchone()
if not row:
return False
conn.execute("DELETE FROM blog_posts WHERE id = ?", (post_id,))
return True
# ── commissions CRUD ─────────────────────────────────────────────────────────
def _comm_row_to_dict(r) -> Dict[str, Any]:
return {
"id": r["id"],
"post_id": r["post_id"],
"month": r["month"],
"clicks": r["clicks"],
"purchases": r["purchases"],
"revenue": r["revenue"],
"note": r["note"],
"created_at": r["created_at"],
}
def add_commission(data: Dict[str, Any]) -> Dict[str, Any]:
with _conn() as conn:
conn.execute(
"""INSERT INTO commissions (post_id, month, clicks, purchases, revenue, note)
VALUES (?, ?, ?, ?, ?, ?)""",
(
data.get("post_id"),
data.get("month", ""),
data.get("clicks", 0),
data.get("purchases", 0),
data.get("revenue", 0),
data.get("note", ""),
),
)
row = conn.execute(
"SELECT * FROM commissions WHERE rowid = last_insert_rowid()"
).fetchone()
return _comm_row_to_dict(row)
def get_commissions(post_id: Optional[int] = None, limit: int = 100) -> List[Dict[str, Any]]:
with _conn() as conn:
if post_id:
rows = conn.execute(
"SELECT * FROM commissions WHERE post_id = ? ORDER BY month DESC LIMIT ?",
(post_id, limit),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM commissions ORDER BY month DESC LIMIT ?", (limit,)
).fetchall()
return [_comm_row_to_dict(r) for r in rows]
def update_commission(comm_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
with _conn() as conn:
fields = []
values = []
for k in ("month", "clicks", "purchases", "revenue", "note"):
if k in data:
fields.append(f"{k} = ?")
values.append(data[k])
if not fields:
return None
values.append(comm_id)
conn.execute(
f"UPDATE commissions SET {', '.join(fields)} WHERE id = ?", values
)
row = conn.execute(
"SELECT * FROM commissions WHERE id = ?", (comm_id,)
).fetchone()
return _comm_row_to_dict(row) if row else None
def delete_commission(comm_id: int) -> bool:
with _conn() as conn:
row = conn.execute(
"SELECT id FROM commissions WHERE id = ?", (comm_id,)
).fetchone()
if not row:
return False
conn.execute("DELETE FROM commissions WHERE id = ?", (comm_id,))
return True
# ── brand_links CRUD ────────────────────────────────────────────────────────
def _bl_row_to_dict(r) -> Dict[str, Any]:
return {
"id": r["id"],
"post_id": r["post_id"],
"keyword_id": r["keyword_id"],
"url": r["url"],
"product_name": r["product_name"],
"description": r["description"],
"placement_hint": r["placement_hint"],
"created_at": r["created_at"],
}
def add_brand_link(data: Dict[str, Any]) -> Dict[str, Any]:
with _conn() as conn:
conn.execute(
"""INSERT INTO brand_links (post_id, keyword_id, url, product_name, description, placement_hint)
VALUES (?, ?, ?, ?, ?, ?)""",
(
data.get("post_id"),
data.get("keyword_id"),
data.get("url", ""),
data.get("product_name", ""),
data.get("description", ""),
data.get("placement_hint", ""),
),
)
row = conn.execute(
"SELECT * FROM brand_links WHERE rowid = last_insert_rowid()"
).fetchone()
return _bl_row_to_dict(row)
def get_brand_links(
post_id: Optional[int] = None,
keyword_id: Optional[int] = None,
) -> List[Dict[str, Any]]:
with _conn() as conn:
if post_id is not None:
rows = conn.execute(
"SELECT * FROM brand_links WHERE post_id = ? ORDER BY id", (post_id,)
).fetchall()
elif keyword_id is not None:
rows = conn.execute(
"SELECT * FROM brand_links WHERE keyword_id = ? ORDER BY id", (keyword_id,)
).fetchall()
else:
rows = conn.execute("SELECT * FROM brand_links ORDER BY id DESC LIMIT 100").fetchall()
return [_bl_row_to_dict(r) for r in rows]
def update_brand_link(link_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
with _conn() as conn:
fields = []
values = []
for k in ("post_id", "keyword_id", "url", "product_name", "description", "placement_hint"):
if k in data:
fields.append(f"{k} = ?")
values.append(data[k])
if not fields:
row = conn.execute("SELECT * FROM brand_links WHERE id = ?", (link_id,)).fetchone()
return _bl_row_to_dict(row) if row else None
values.append(link_id)
conn.execute(f"UPDATE brand_links SET {', '.join(fields)} WHERE id = ?", values)
row = conn.execute("SELECT * FROM brand_links WHERE id = ?", (link_id,)).fetchone()
return _bl_row_to_dict(row) if row else None
def delete_brand_link(link_id: int) -> bool:
with _conn() as conn:
row = conn.execute("SELECT id FROM brand_links WHERE id = ?", (link_id,)).fetchone()
if not row:
return False
conn.execute("DELETE FROM brand_links WHERE id = ?", (link_id,))
return True
def link_brand_links_to_post(keyword_id: int, post_id: int) -> None:
"""keyword_id로 등록된 링크들을 post_id에도 연결."""
with _conn() as conn:
conn.execute(
"UPDATE brand_links SET post_id = ? WHERE keyword_id = ? AND post_id IS NULL",
(post_id, keyword_id),
)
def get_dashboard_stats() -> Dict[str, Any]:
"""대시보드 집계: 총 포스트/클릭/구매/수익 + 월별 추이."""
with _conn() as conn:
total_posts = conn.execute("SELECT COUNT(*) FROM blog_posts").fetchone()[0]
published = conn.execute(
"SELECT COUNT(*) FROM blog_posts WHERE status = 'published'"
).fetchone()[0]
agg = conn.execute(
"SELECT COALESCE(SUM(clicks),0), COALESCE(SUM(purchases),0), COALESCE(SUM(revenue),0) FROM commissions"
).fetchone()
monthly = conn.execute(
"""SELECT month, SUM(clicks) as clicks, SUM(purchases) as purchases, SUM(revenue) as revenue
FROM commissions GROUP BY month ORDER BY month DESC LIMIT 12"""
).fetchall()
top_posts = conn.execute(
"""SELECT bp.id, bp.title, COALESCE(SUM(c.revenue),0) as total_revenue
FROM blog_posts bp LEFT JOIN commissions c ON c.post_id = bp.id
GROUP BY bp.id ORDER BY total_revenue DESC LIMIT 5"""
).fetchall()
return {
"total_posts": total_posts,
"published_posts": published,
"total_clicks": agg[0],
"total_purchases": agg[1],
"total_revenue": agg[2],
"monthly": [
{"month": r["month"], "clicks": r["clicks"], "purchases": r["purchases"], "revenue": r["revenue"]}
for r in monthly
],
"top_posts": [
{"id": r["id"], "title": r["title"], "total_revenue": r["total_revenue"]}
for r in top_posts
],
}
# ── generation_tasks CRUD ────────────────────────────────────────────────────
def _task_row_to_dict(r) -> Dict[str, Any]:
return {
"task_id": r["id"],
"type": r["type"],
"status": r["status"],
"progress": r["progress"],
"message": r["message"],
"result_id": r["result_id"],
"error": r["error"],
"params": json.loads(r["params"]) if r["params"] else {},
"created_at": r["created_at"],
"updated_at": r["updated_at"],
}
def create_task(task_id: str, task_type: str, params: Dict[str, Any]) -> Dict[str, Any]:
with _conn() as conn:
conn.execute(
"INSERT INTO generation_tasks (id, type, params) VALUES (?, ?, ?)",
(task_id, task_type, json.dumps(params, ensure_ascii=False)),
)
row = conn.execute(
"SELECT * FROM generation_tasks WHERE id = ?", (task_id,)
).fetchone()
return _task_row_to_dict(row)
def update_task(
task_id: str,
status: str,
progress: int,
message: str,
result_id: Optional[int] = None,
error: Optional[str] = None,
) -> None:
with _conn() as conn:
conn.execute(
"""UPDATE generation_tasks
SET status = ?, progress = ?, message = ?, result_id = ?, error = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
WHERE id = ?""",
(status, progress, message, result_id, error, task_id),
)
def get_task(task_id: str) -> Optional[Dict[str, Any]]:
with _conn() as conn:
row = conn.execute(
"SELECT * FROM generation_tasks WHERE id = ?", (task_id,)
).fetchone()
return _task_row_to_dict(row) if row else None
# ── prompt_templates CRUD ────────────────────────────────────────────────────
def get_template(name: str) -> Optional[str]:
with _conn() as conn:
row = conn.execute(
"SELECT template FROM prompt_templates WHERE name = ?", (name,)
).fetchone()
return row["template"] if row else None
def get_all_templates() -> List[Dict[str, Any]]:
with _conn() as conn:
rows = conn.execute("SELECT * FROM prompt_templates ORDER BY name").fetchall()
return [
{"id": r["id"], "name": r["name"], "description": r["description"],
"template": r["template"], "updated_at": r["updated_at"]}
for r in rows
]
def update_template(name: str, template: str) -> bool:
with _conn() as conn:
conn.execute(
"UPDATE prompt_templates SET template = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE name = ?",
(template, name),
)
return conn.execute(
"SELECT id FROM prompt_templates WHERE name = ?", (name,)
).fetchone() is not None

View File

@@ -1,440 +0,0 @@
import os
import uuid
import logging
from fastapi import FastAPI, HTTPException, BackgroundTasks, Query
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import List, Optional
from .config import CORS_ALLOW_ORIGINS, NAVER_CLIENT_ID, ANTHROPIC_API_KEY
from .db import (
init_db,
get_keyword_analyses, get_keyword_analysis, delete_keyword_analysis,
add_keyword_analysis,
get_posts, get_post, add_post, update_post, delete_post,
get_commissions, add_commission, update_commission, delete_commission,
get_dashboard_stats,
get_task, create_task, update_task,
add_brand_link, get_brand_links, update_brand_link, delete_brand_link,
link_brand_links_to_post,
)
from .naver_search import analyze_keyword_with_crawling
from .content_generator import generate_trend_brief, generate_blog_post, regenerate_blog_post
from .quality_reviewer import review_post
from .marketer import enhance_for_conversion
logger = logging.getLogger(__name__)
app = FastAPI()
_cors_origins = CORS_ALLOW_ORIGINS.split(",")
app.add_middleware(
CORSMiddleware,
allow_origins=[o.strip() for o in _cors_origins],
allow_credentials=False,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Content-Type"],
)
@app.on_event("startup")
def on_startup():
init_db()
os.makedirs("/app/data", exist_ok=True)
@app.get("/health")
def health():
return {"ok": True}
@app.get("/api/blog-marketing/status")
def service_status():
"""서비스 상태 및 설정 현황."""
return {
"ok": True,
"naver_api": bool(NAVER_CLIENT_ID),
"claude_api": bool(ANTHROPIC_API_KEY),
}
# ── 키워드 분석 API ──────────────────────────────────────────────────────────
class ResearchRequest(BaseModel):
keyword: str
def _run_research(task_id: str, keyword: str):
"""BackgroundTask: 네이버 검색 → 키워드 분석 → DB 저장."""
try:
update_task(task_id, "processing", 30, "네이버 검색 중...")
result = analyze_keyword_with_crawling(keyword)
update_task(task_id, "processing", 80, "분석 결과 저장 중...")
saved = add_keyword_analysis(result)
update_task(task_id, "succeeded", 100, "분석 완료", result_id=saved["id"])
except Exception as e:
logger.exception("Research failed for keyword=%s", keyword)
update_task(task_id, "failed", 0, "", error=str(e))
@app.post("/api/blog-marketing/research")
def start_research(req: ResearchRequest, background_tasks: BackgroundTasks):
"""키워드 분석 시작 (BackgroundTask). task_id 즉시 반환."""
if not NAVER_CLIENT_ID:
raise HTTPException(status_code=400, detail="Naver API 키가 설정되지 않았습니다")
if not req.keyword.strip():
raise HTTPException(status_code=400, detail="키워드를 입력하세요")
task_id = str(uuid.uuid4())
create_task(task_id, "research", {"keyword": req.keyword.strip()})
background_tasks.add_task(_run_research, task_id, req.keyword.strip())
return {"task_id": task_id}
@app.get("/api/blog-marketing/research/history")
def list_research(limit: int = Query(30, ge=1, le=100)):
return {"analyses": get_keyword_analyses(limit)}
@app.get("/api/blog-marketing/research/{analysis_id}")
def get_research(analysis_id: int):
result = get_keyword_analysis(analysis_id)
if not result:
raise HTTPException(status_code=404, detail="Analysis not found")
return result
@app.delete("/api/blog-marketing/research/{analysis_id}")
def remove_research(analysis_id: int):
if not delete_keyword_analysis(analysis_id):
raise HTTPException(status_code=404, detail="Analysis not found")
return {"ok": True}
# ── 작업 상태 폴링 API ──────────────────────────────────────────────────────
@app.get("/api/blog-marketing/task/{task_id}")
def get_task_status(task_id: str):
task = get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task
# ── AI 글 생성 API ──────────────────────────────────────────────────────────
class GenerateRequest(BaseModel):
keyword_id: int # keyword_analyses.id
class LinkRequest(BaseModel):
url: str
product_name: str
keyword_id: Optional[int] = None
post_id: Optional[int] = None
description: str = ""
placement_hint: str = ""
def _run_generate(task_id: str, keyword_id: int):
"""BackgroundTask: 트렌드 브리프 → 블로그 글 생성 → DB 저장."""
try:
analysis = get_keyword_analysis(keyword_id)
if not analysis:
update_task(task_id, "failed", 0, "", error="키워드 분석 결과를 찾을 수 없습니다")
return
# 연결된 브랜드커넥트 링크 조회
brand_links = get_brand_links(keyword_id=keyword_id)
update_task(task_id, "processing", 20, "트렌드 브리프 생성 중...")
trend_brief = generate_trend_brief(analysis)
update_task(task_id, "processing", 60, "블로그 글 작성 중...")
post_data = generate_blog_post(analysis, trend_brief, brand_links=brand_links)
update_task(task_id, "processing", 90, "저장 중...")
saved = add_post({
"keyword_id": keyword_id,
"title": post_data["title"],
"body": post_data["body"],
"excerpt": post_data["excerpt"],
"tags": post_data["tags"],
"status": "draft",
"trend_brief": trend_brief,
})
# keyword_id에 연결된 링크를 post_id에도 연결
link_brand_links_to_post(keyword_id=keyword_id, post_id=saved["id"])
update_task(task_id, "succeeded", 100, "글 생성 완료", result_id=saved["id"])
except Exception as e:
logger.exception("Generate failed for keyword_id=%s", keyword_id)
update_task(task_id, "failed", 0, "", error=str(e))
@app.post("/api/blog-marketing/generate")
def start_generate(req: GenerateRequest, background_tasks: BackgroundTasks):
"""AI 블로그 글 생성 시작. task_id 즉시 반환."""
if not ANTHROPIC_API_KEY:
raise HTTPException(status_code=400, detail="Claude API 키가 설정되지 않았습니다")
analysis = get_keyword_analysis(req.keyword_id)
if not analysis:
raise HTTPException(status_code=404, detail="키워드 분석 결과를 찾을 수 없습니다")
task_id = str(uuid.uuid4())
create_task(task_id, "generate", {"keyword_id": req.keyword_id})
background_tasks.add_task(_run_generate, task_id, req.keyword_id)
return {"task_id": task_id}
# ── 품질 리뷰 API ───────────────────────────────────────────────────────────
def _run_review(task_id: str, post_id: int):
"""BackgroundTask: 블로그 글 품질 리뷰."""
try:
post = get_post(post_id)
if not post:
update_task(task_id, "failed", 0, "", error="포스트를 찾을 수 없습니다")
return
update_task(task_id, "processing", 50, "품질 리뷰 중...")
result = review_post(post["title"], post["body"])
update_post(post_id, {
"review_score": result["total"],
"review_detail": result,
"status": "reviewed" if result["pass"] else "draft",
})
update_task(task_id, "succeeded", 100, "리뷰 완료", result_id=post_id)
except Exception as e:
logger.exception("Review failed for post_id=%s", post_id)
update_task(task_id, "failed", 0, "", error=str(e))
@app.post("/api/blog-marketing/review/{post_id}")
def start_review(post_id: int, background_tasks: BackgroundTasks):
"""블로그 글 품질 리뷰 시작. task_id 즉시 반환."""
if not ANTHROPIC_API_KEY:
raise HTTPException(status_code=400, detail="Claude API 키가 설정되지 않았습니다")
post = get_post(post_id)
if not post:
raise HTTPException(status_code=404, detail="Post not found")
task_id = str(uuid.uuid4())
create_task(task_id, "review", {"post_id": post_id})
background_tasks.add_task(_run_review, task_id, post_id)
return {"task_id": task_id}
# ── 재생성 API ───────────────────────────────────────────────────────────────
def _run_regenerate(task_id: str, post_id: int):
"""BackgroundTask: 피드백 기반 블로그 글 재생성."""
try:
post = get_post(post_id)
if not post:
update_task(task_id, "failed", 0, "", error="포스트를 찾을 수 없습니다")
return
analysis = get_keyword_analysis(post["keyword_id"]) if post["keyword_id"] else {}
feedback = post.get("review_detail", {}).get("feedback", "개선이 필요합니다")
update_task(task_id, "processing", 50, "글 재생성 중...")
result = regenerate_blog_post(
analysis or {"keyword": ""},
post.get("trend_brief", ""),
post["body"],
feedback,
)
update_post(post_id, {
"title": result["title"],
"body": result["body"],
"excerpt": result["excerpt"],
"tags": result["tags"],
"status": "draft",
"review_score": None,
"review_detail": {},
})
update_task(task_id, "succeeded", 100, "재생성 완료", result_id=post_id)
except Exception as e:
logger.exception("Regenerate failed for post_id=%s", post_id)
update_task(task_id, "failed", 0, "", error=str(e))
@app.post("/api/blog-marketing/regenerate/{post_id}")
def start_regenerate(post_id: int, background_tasks: BackgroundTasks):
"""피드백 기반 블로그 글 재생성. task_id 즉시 반환."""
if not ANTHROPIC_API_KEY:
raise HTTPException(status_code=400, detail="Claude API 키가 설정되지 않았습니다")
post = get_post(post_id)
if not post:
raise HTTPException(status_code=404, detail="Post not found")
task_id = str(uuid.uuid4())
create_task(task_id, "regenerate", {"post_id": post_id})
background_tasks.add_task(_run_regenerate, task_id, post_id)
return {"task_id": task_id}
# ── 포스트 CRUD API ──────────────────────────────────────────────────────────
@app.get("/api/blog-marketing/posts")
def list_posts(status: str = None, limit: int = Query(50, ge=1, le=100)):
return {"posts": get_posts(status=status, limit=limit)}
@app.get("/api/blog-marketing/posts/{post_id}")
def get_post_detail(post_id: int):
post = get_post(post_id)
if not post:
raise HTTPException(status_code=404, detail="Post not found")
return post
@app.put("/api/blog-marketing/posts/{post_id}")
def edit_post(post_id: int, data: dict):
result = update_post(post_id, data)
if not result:
raise HTTPException(status_code=404, detail="Post not found")
return result
@app.delete("/api/blog-marketing/posts/{post_id}")
def remove_post(post_id: int):
if not delete_post(post_id):
raise HTTPException(status_code=404, detail="Post not found")
return {"ok": True}
@app.post("/api/blog-marketing/posts/{post_id}/publish")
def publish_post(post_id: int, data: dict = None):
"""네이버 URL 등록 + 상태를 published로 변경."""
naver_url = (data or {}).get("naver_url", "")
result = update_post(post_id, {"status": "published", "naver_url": naver_url})
if not result:
raise HTTPException(status_code=404, detail="Post not found")
return result
# ── 브랜드커넥트 링크 API ──────────────────────────────────────────────────
@app.post("/api/blog-marketing/links", status_code=201)
def create_link(req: LinkRequest):
return add_brand_link(req.model_dump())
@app.get("/api/blog-marketing/links")
def list_links(post_id: int = None, keyword_id: int = None):
return {"links": get_brand_links(post_id=post_id, keyword_id=keyword_id)}
@app.put("/api/blog-marketing/links/{link_id}")
def edit_link(link_id: int, data: dict):
result = update_brand_link(link_id, data)
if not result:
raise HTTPException(status_code=404, detail="Link not found")
return result
@app.delete("/api/blog-marketing/links/{link_id}")
def remove_link(link_id: int):
if not delete_brand_link(link_id):
raise HTTPException(status_code=404, detail="Link not found")
return {"ok": True}
# ── 마케터 API ──────────────────────────────────────────────────────────────
def _run_market(task_id: str, post_id: int):
"""BackgroundTask: 마케터 전환율 강화."""
try:
post = get_post(post_id)
if not post:
update_task(task_id, "failed", 0, "", error="포스트를 찾을 수 없습니다")
return
brand_links = get_brand_links(post_id=post_id)
if not brand_links and post.get("keyword_id"):
brand_links = get_brand_links(keyword_id=post["keyword_id"])
if not brand_links:
update_task(task_id, "failed", 0, "", error="브랜드커넥트 링크가 없습니다. 먼저 링크를 등록하세요.")
return
analysis = get_keyword_analysis(post["keyword_id"]) if post.get("keyword_id") else {}
keyword = (analysis or {}).get("keyword", "")
update_task(task_id, "processing", 50, "마케터가 전환율 강화 중...")
result = enhance_for_conversion(
post_body=post["body"],
post_title=post["title"],
brand_links=brand_links,
keyword=keyword,
)
update_post(post_id, {
"title": result["title"],
"body": result["body"],
"excerpt": result["excerpt"],
"status": "marketed",
})
update_task(task_id, "succeeded", 100, "마케팅 강화 완료", result_id=post_id)
except Exception as e:
logger.exception("Market failed for post_id=%s", post_id)
update_task(task_id, "failed", 0, "", error=str(e))
@app.post("/api/blog-marketing/market/{post_id}")
def start_market(post_id: int, background_tasks: BackgroundTasks):
"""마케터 단계 실행. task_id 즉시 반환."""
if not ANTHROPIC_API_KEY:
raise HTTPException(status_code=400, detail="Claude API 키가 설정되지 않았습니다")
post = get_post(post_id)
if not post:
raise HTTPException(status_code=404, detail="Post not found")
task_id = str(uuid.uuid4())
create_task(task_id, "market", {"post_id": post_id})
background_tasks.add_task(_run_market, task_id, post_id)
return {"task_id": task_id}
# ── 수익 추적 API ────────────────────────────────────────────────────────────
@app.get("/api/blog-marketing/commissions")
def list_commissions(post_id: int = None, limit: int = Query(100, ge=1, le=100)):
return {"commissions": get_commissions(post_id=post_id, limit=limit)}
@app.post("/api/blog-marketing/commissions", status_code=201)
def create_commission(data: dict):
return add_commission(data)
@app.put("/api/blog-marketing/commissions/{comm_id}")
def edit_commission(comm_id: int, data: dict):
result = update_commission(comm_id, data)
if not result:
raise HTTPException(status_code=404, detail="Commission not found")
return result
@app.delete("/api/blog-marketing/commissions/{comm_id}")
def remove_commission(comm_id: int):
if not delete_commission(comm_id):
raise HTTPException(status_code=404, detail="Commission not found")
return {"ok": True}
# ── 대시보드 API ─────────────────────────────────────────────────────────────
@app.get("/api/blog-marketing/dashboard")
def dashboard():
return get_dashboard_stats()

View File

@@ -1,105 +0,0 @@
"""마케터 단계 — 전환율 강화 + 브랜드커넥트 링크 삽입."""
import json
import logging
from datetime import date
from typing import Any, Dict, List, Optional
import anthropic
from .config import ANTHROPIC_API_KEY, CLAUDE_MODEL
from .db import get_template
logger = logging.getLogger(__name__)
_client: Optional[anthropic.Anthropic] = None
def _get_client() -> anthropic.Anthropic:
global _client
if _client is None:
_client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
return _client
def _call_claude(prompt: str, max_tokens: int = 8192) -> str:
client = _get_client()
today = date.today().isoformat()
resp = client.messages.create(
model=CLAUDE_MODEL,
max_tokens=max_tokens,
system=f"현재 날짜는 {today}입니다. 모든 콘텐츠는 이 날짜 기준으로 작성하세요.",
messages=[{"role": "user", "content": prompt}],
)
return resp.content[0].text
def enhance_for_conversion(
post_body: str,
post_title: str,
brand_links: List[Dict[str, Any]],
keyword: str,
) -> Dict[str, str]:
"""초안에 제휴 링크를 자연스럽게 삽입하고 전환율을 강화.
Args:
post_body: 작가 초안 HTML 본문
post_title: 작가 초안 제목
brand_links: 브랜드커넥트 링크 리스트
keyword: 타겟 키워드
Returns:
{"title": str, "body": str, "excerpt": str}
Raises:
ValueError: 브랜드 링크가 없을 때
"""
if not brand_links:
raise ValueError("브랜드커넥트 링크가 필요합니다")
template = get_template("marketer_enhance")
if not template:
raise RuntimeError("marketer_enhance 템플릿이 없습니다")
brand_links_text = ""
for i, link in enumerate(brand_links, 1):
brand_links_text += (
f"{i}. 상품명: {link.get('product_name', '')}\n"
f" 설명: {link.get('description', '')}\n"
f" URL: {link.get('url', '')}\n"
f" 배치 힌트: {link.get('placement_hint', '자연스럽게')}\n\n"
)
prompt = template.format(
draft_body=post_body[:6000],
keyword=keyword,
brand_links_info=brand_links_text,
)
prompt += (
"\n\n---\n"
"응답은 반드시 아래 JSON 형식으로 해주세요 (JSON만 출력):\n"
'{"title": "개선된 제목", "body": "개선된 HTML 본문", "excerpt": "2줄 요약"}'
)
raw = _call_claude(prompt)
try:
text = raw.strip()
if text.startswith("```"):
lines = text.split("\n")
lines = [l for l in lines if not l.strip().startswith("```")]
text = "\n".join(lines)
result = json.loads(text)
return {
"title": result.get("title", post_title),
"body": result.get("body", post_body),
"excerpt": result.get("excerpt", ""),
}
except (json.JSONDecodeError, KeyError):
logger.warning("Marketer JSON parse failed, using raw text")
return {
"title": post_title,
"body": raw,
"excerpt": raw[:200],
}

View File

@@ -1,203 +0,0 @@
"""네이버 검색 API 연동 — 블로그 + 쇼핑 검색."""
import asyncio
import logging
import re
import requests
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
from .config import NAVER_CLIENT_ID, NAVER_CLIENT_SECRET
BLOG_URL = "https://openapi.naver.com/v1/search/blog.json"
SHOP_URL = "https://openapi.naver.com/v1/search/shop.json"
_HEADERS = {
"X-Naver-Client-Id": NAVER_CLIENT_ID,
"X-Naver-Client-Secret": NAVER_CLIENT_SECRET,
}
_TAG_RE = re.compile(r"<[^>]+>")
def _strip_html(text: str) -> str:
return _TAG_RE.sub("", text).strip()
def search_blog(keyword: str, display: int = 10, sort: str = "sim") -> Dict[str, Any]:
"""네이버 블로그 검색.
Args:
keyword: 검색 키워드
display: 결과 수 (1-100)
sort: sim(정확도) | date(날짜)
Returns:
{"total": int, "items": [...]}
"""
resp = requests.get(
BLOG_URL,
headers=_HEADERS,
params={"query": keyword, "display": display, "sort": sort},
timeout=10,
)
resp.raise_for_status()
data = resp.json()
items = [
{
"title": _strip_html(item.get("title", "")),
"description": _strip_html(item.get("description", "")),
"link": item.get("link", ""),
"bloggername": item.get("bloggername", ""),
"postdate": item.get("postdate", ""),
}
for item in data.get("items", [])
]
return {"total": data.get("total", 0), "items": items}
def search_shopping(keyword: str, display: int = 20, sort: str = "sim") -> Dict[str, Any]:
"""네이버 쇼핑 검색.
Args:
keyword: 검색 키워드
display: 결과 수 (1-100)
sort: sim(정확도) | date(날짜) | asc(가격↑) | dsc(가격↓)
Returns:
{"total": int, "items": [...], "price_stats": {...}}
"""
resp = requests.get(
SHOP_URL,
headers=_HEADERS,
params={"query": keyword, "display": display, "sort": sort},
timeout=10,
)
resp.raise_for_status()
data = resp.json()
items = []
prices = []
for item in data.get("items", []):
lprice = _safe_int(item.get("lprice"))
hprice = _safe_int(item.get("hprice"))
parsed = {
"title": _strip_html(item.get("title", "")),
"link": item.get("link", ""),
"image": item.get("image", ""),
"lprice": lprice,
"hprice": hprice,
"mallName": item.get("mallName", ""),
"productId": item.get("productId", ""),
"productType": item.get("productType", ""),
"category1": item.get("category1", ""),
"category2": item.get("category2", ""),
"category3": item.get("category3", ""),
"brand": item.get("brand", ""),
"maker": item.get("maker", ""),
}
items.append(parsed)
if lprice and lprice > 0:
prices.append(lprice)
price_stats = None
if prices:
price_stats = {
"min": min(prices),
"max": max(prices),
"avg": int(sum(prices) / len(prices)),
"count": len(prices),
}
return {
"total": data.get("total", 0),
"items": items,
"price_stats": price_stats,
}
def _safe_int(val) -> Optional[int]:
if val is None:
return None
try:
return int(val)
except (ValueError, TypeError):
return None
def analyze_keyword(keyword: str) -> Dict[str, Any]:
"""키워드 경쟁도/기회 분석.
블로그 총 결과수, 쇼핑 총 결과수, 가격 통계를 기반으로
competition_score(경쟁도)와 opportunity_score(기회점수) 산출.
Returns:
{
"keyword", "blog_total", "shop_total",
"competition", "opportunity",
"avg_price", "min_price", "max_price",
"top_products": [...], "top_blogs": [...]
}
"""
blog = search_blog(keyword, display=10, sort="sim")
shop = search_shopping(keyword, display=20, sort="sim")
blog_total = blog["total"]
shop_total = shop["total"]
# 경쟁도: 블로그 결과 수 기반 (로그 스케일 0-100)
import math
if blog_total > 0:
competition = min(100, int(math.log10(blog_total + 1) * 15))
else:
competition = 0
# 기회 점수: 쇼핑 수요가 높고 블로그 경쟁이 낮을수록 높음
if shop_total > 0 and blog_total > 0:
ratio = shop_total / blog_total
opportunity = min(100, int(ratio * 20))
elif shop_total > 0:
opportunity = 90 # 경쟁 없이 수요만 있으면 높은 기회
else:
opportunity = 10 # 쇼핑 수요 없음
price_stats = shop.get("price_stats") or {}
return {
"keyword": keyword,
"blog_total": blog_total,
"shop_total": shop_total,
"competition": competition,
"opportunity": opportunity,
"avg_price": price_stats.get("avg"),
"min_price": price_stats.get("min"),
"max_price": price_stats.get("max"),
"top_products": shop["items"][:5],
"top_blogs": blog["items"][:5],
}
def _run_enrich(top_blogs: list) -> list:
"""동기 컨텍스트에서 비동기 enrich_top_blogs 실행."""
from .web_crawler import enrich_top_blogs
try:
loop = asyncio.get_event_loop()
if loop.is_running():
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as pool:
return pool.submit(
asyncio.run, enrich_top_blogs(top_blogs)
).result(timeout=60)
else:
return asyncio.run(enrich_top_blogs(top_blogs))
except Exception as e:
logger.warning("블로그 크롤링 실패, 기존 데이터 사용: %s", e)
return top_blogs
def analyze_keyword_with_crawling(keyword: str) -> Dict[str, Any]:
"""analyze_keyword + 상위 블로그 본문 크롤링."""
result = analyze_keyword(keyword)
result["top_blogs"] = _run_enrich(result["top_blogs"])
return result

View File

@@ -1,85 +0,0 @@
"""Claude API 기반 블로그 글 품질 리뷰 — 6기준 × 10점, 42/60 통과."""
import json
import logging
from datetime import date
from typing import Any, Dict, Optional
import anthropic
from .config import ANTHROPIC_API_KEY, CLAUDE_MODEL
from .db import get_template
logger = logging.getLogger(__name__)
PASS_THRESHOLD = 42 # 60점 만점 중 42점 이상이면 통과 (70%)
_client: Optional[anthropic.Anthropic] = None
def _get_client() -> anthropic.Anthropic:
global _client
if _client is None:
_client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
return _client
def review_post(title: str, body: str) -> Dict[str, Any]:
"""블로그 글 품질 리뷰.
Returns:
{
"scores": {
"empathy": N, "click_appeal": N, "conversion": N,
"seo": N, "format": N, "link_natural": N
},
"total": N,
"pass": bool,
"feedback": str
}
"""
template = get_template("quality_review")
if not template:
raise RuntimeError("quality_review 템플릿이 없습니다")
prompt = template.format(title=title, body=body[:6000])
client = _get_client()
today = date.today().isoformat()
resp = client.messages.create(
model=CLAUDE_MODEL,
max_tokens=2048,
system=f"현재 날짜는 {today}입니다.",
messages=[{"role": "user", "content": prompt}],
)
raw = resp.content[0].text
try:
text = raw.strip()
if text.startswith("```"):
lines = text.split("\n")
lines = [l for l in lines if not l.strip().startswith("```")]
text = "\n".join(lines)
result = json.loads(text)
scores = result.get("scores", {})
total = sum(scores.values())
passed = total >= PASS_THRESHOLD
return {
"scores": scores,
"total": total,
"pass": passed,
"feedback": result.get("feedback", ""),
}
except (json.JSONDecodeError, KeyError, TypeError) as e:
logger.warning("Quality review JSON parse failed: %s", e)
return {
"scores": {
"empathy": 0, "click_appeal": 0, "conversion": 0,
"seo": 0, "format": 0, "link_natural": 0,
},
"total": 0,
"pass": False,
"feedback": f"리뷰 파싱 실패. 원본 응답:\n{raw[:500]}",
}

View File

@@ -1,97 +0,0 @@
"""네이버 블로그 본문 크롤링 모듈."""
import asyncio
import logging
import re
from typing import Any, Dict, List, Optional, Tuple
import httpx
from bs4 import BeautifulSoup
logger = logging.getLogger(__name__)
_TIMEOUT = 10 # 글당 크롤링 타임아웃 (초)
_MAX_CONTENT_LENGTH = 2000 # 본문 최대 길이
# 네이버 블로그 URL 패턴: blog.naver.com/{blogId}/{logNo}
_BLOG_URL_RE = re.compile(r"blog\.naver\.com/([^/]+)/(\d+)")
def _parse_naver_blog_url(url: str) -> Optional[Tuple[str, str]]:
"""네이버 블로그 URL에서 blogId, logNo 추출. 실패 시 None."""
match = _BLOG_URL_RE.search(url)
if not match:
return None
return match.group(1), match.group(2)
async def _fetch_html(url: str) -> str:
"""URL에서 HTML을 가져온다."""
async with httpx.AsyncClient(timeout=_TIMEOUT, follow_redirects=True) as client:
resp = await client.get(url, headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
})
resp.raise_for_status()
return resp.text
def _extract_text(html: str) -> str:
"""HTML에서 본문 텍스트를 추출한다."""
soup = BeautifulSoup(html, "html.parser")
# 스마트에디터 3 (SE3)
container = soup.select_one("div.se-main-container")
if not container:
# 구 에디터
container = soup.select_one("div#postViewArea")
if not container:
# 폴백: body 전체
container = soup.body
if not container:
return ""
# 스크립트/스타일 제거
for tag in container.find_all(["script", "style"]):
tag.decompose()
text = container.get_text(separator="\n", strip=True)
return text[:_MAX_CONTENT_LENGTH]
async def crawl_blog_content(url: str) -> str:
"""네이버 블로그 URL에서 본문 텍스트 추출.
- 네이버 블로그가 아니면 빈 문자열
- 크롤링 실패 시 빈 문자열 (에러 로그만)
- 본문 최대 2,000자
"""
parsed = _parse_naver_blog_url(url)
if not parsed:
return ""
blog_id, log_no = parsed
# iframe 내부 실제 본문 URL
post_url = f"https://blog.naver.com/PostView.naver?blogId={blog_id}&logNo={log_no}"
try:
html = await _fetch_html(post_url)
return _extract_text(html)
except Exception as e:
logger.warning("블로그 크롤링 실패 (%s): %s", url, e)
return ""
async def enrich_top_blogs(top_blogs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""top_blogs 리스트 각 항목에 content 필드를 추가.
개별 크롤링 실패 시 해당 항목의 content를 빈 문자열로 설정하고 나머지 계속 진행.
"""
result = []
for blog in top_blogs:
enriched = dict(blog)
try:
enriched["content"] = await crawl_blog_content(blog.get("link", ""))
except Exception:
enriched["content"] = ""
result.append(enriched)
return result

View File

@@ -1,9 +0,0 @@
"""공통 테스트 픽스처."""
import os
import sys
# app 패키지를 blog_lab_app으로도 import 가능하게
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
if "blog_lab_app" not in sys.modules:
import app as blog_lab_app
sys.modules["blog_lab_app"] = blog_lab_app

View File

@@ -1,85 +0,0 @@
"""브랜드커넥트 링크 API 테스트."""
import os
import pytest
from fastapi.testclient import TestClient
@pytest.fixture(autouse=True)
def setup_db(tmp_path):
test_db = str(tmp_path / "test.db")
import app.config as config
config.DB_PATH = test_db
from app import db
db.DB_PATH = test_db
db.init_db()
yield
@pytest.fixture
def client():
from app.main import app
return TestClient(app)
def test_create_link(client):
resp = client.post("/api/blog-marketing/links", json={
"keyword_id": 1,
"url": "https://link.coupang.com/abc",
"product_name": "테스트 상품",
"description": "상품 설명",
})
assert resp.status_code == 201
data = resp.json()
assert data["url"] == "https://link.coupang.com/abc"
assert data["product_name"] == "테스트 상품"
def test_create_link_requires_url(client):
resp = client.post("/api/blog-marketing/links", json={
"product_name": "상품",
})
assert resp.status_code == 422
def test_create_link_requires_product_name(client):
resp = client.post("/api/blog-marketing/links", json={
"url": "https://a.com",
})
assert resp.status_code == 422
def test_list_links_by_keyword_id(client):
client.post("/api/blog-marketing/links", json={
"keyword_id": 1, "url": "https://a.com", "product_name": "A",
})
client.post("/api/blog-marketing/links", json={
"keyword_id": 2, "url": "https://b.com", "product_name": "B",
})
resp = client.get("/api/blog-marketing/links?keyword_id=1")
assert resp.status_code == 200
assert len(resp.json()["links"]) == 1
def test_update_link(client):
create_resp = client.post("/api/blog-marketing/links", json={
"url": "https://a.com", "product_name": "원래",
})
link_id = create_resp.json()["id"]
resp = client.put(f"/api/blog-marketing/links/{link_id}", json={
"product_name": "새이름",
})
assert resp.status_code == 200
assert resp.json()["product_name"] == "새이름"
def test_delete_link(client):
create_resp = client.post("/api/blog-marketing/links", json={
"url": "https://a.com", "product_name": "삭제",
})
link_id = create_resp.json()["id"]
resp = client.delete(f"/api/blog-marketing/links/{link_id}")
assert resp.status_code == 200
assert resp.json()["ok"] is True
resp = client.delete(f"/api/blog-marketing/links/{link_id}")
assert resp.status_code == 404

View File

@@ -1,67 +0,0 @@
"""brand_links DB CRUD 테스트."""
import os
import pytest
from app import db
from app.config import DB_PATH
@pytest.fixture(autouse=True)
def setup_db(tmp_path):
"""테스트용 임시 DB 사용."""
test_db = str(tmp_path / "test.db")
import app.config as config
config.DB_PATH = test_db
db.DB_PATH = test_db
db.init_db()
yield
def test_add_brand_link():
link = db.add_brand_link({
"keyword_id": 1,
"url": "https://link.coupang.com/abc",
"product_name": "테스트 상품",
"description": "상품 설명",
"placement_hint": "본문 중간",
})
assert link["id"] is not None
assert link["url"] == "https://link.coupang.com/abc"
assert link["product_name"] == "테스트 상품"
assert link["keyword_id"] == 1
assert link["post_id"] is None
def test_get_brand_links_by_keyword_id():
db.add_brand_link({"keyword_id": 1, "url": "https://a.com", "product_name": "A"})
db.add_brand_link({"keyword_id": 1, "url": "https://b.com", "product_name": "B"})
db.add_brand_link({"keyword_id": 2, "url": "https://c.com", "product_name": "C"})
links = db.get_brand_links(keyword_id=1)
assert len(links) == 2
def test_get_brand_links_by_post_id():
db.add_brand_link({"post_id": 10, "url": "https://a.com", "product_name": "A"})
links = db.get_brand_links(post_id=10)
assert len(links) == 1
assert links[0]["post_id"] == 10
def test_update_brand_link():
link = db.add_brand_link({"url": "https://a.com", "product_name": "원래 이름"})
updated = db.update_brand_link(link["id"], {"product_name": "새 이름", "post_id": 5})
assert updated["product_name"] == "새 이름"
assert updated["post_id"] == 5
def test_delete_brand_link():
link = db.add_brand_link({"url": "https://a.com", "product_name": "삭제할 링크"})
assert db.delete_brand_link(link["id"]) is True
assert db.delete_brand_link(link["id"]) is False
def test_link_keyword_to_post():
db.add_brand_link({"keyword_id": 1, "url": "https://a.com", "product_name": "A"})
db.add_brand_link({"keyword_id": 1, "url": "https://b.com", "product_name": "B"})
db.link_brand_links_to_post(keyword_id=1, post_id=10)
links = db.get_brand_links(post_id=10)
assert len(links) == 2

View File

@@ -1,74 +0,0 @@
"""평가자 단계 테스트 — 6기준 60점."""
import json
import pytest
from unittest.mock import patch
def test_review_post_has_6_criteria():
"""6개 기준으로 채점하는지 확인."""
from app.quality_reviewer import review_post
mock_response = json.dumps({
"scores": {
"empathy": 8, "click_appeal": 7, "conversion": 9,
"seo": 8, "format": 7, "link_natural": 9,
},
"total": 48,
"pass": True,
"feedback": "전체적으로 우수합니다",
})
with patch("app.quality_reviewer._get_client") as mock_client_fn, \
patch("app.quality_reviewer.get_template", return_value="제목: {title}\n본문: {body}"):
mock_client = mock_client_fn.return_value
mock_client.messages.create.return_value.content = [type("C", (), {"text": mock_response})()]
result = review_post("테스트 제목", "<p>본문</p>")
assert "link_natural" in result["scores"]
assert len(result["scores"]) == 6
assert result["total"] == 48
assert result["pass"] is True
def test_review_pass_threshold_is_42():
"""통과 기준이 42점인지 확인."""
from app.quality_reviewer import PASS_THRESHOLD
assert PASS_THRESHOLD == 42
def test_review_fails_below_42():
"""42점 미만이면 불통과."""
from app.quality_reviewer import review_post
mock_response = json.dumps({
"scores": {
"empathy": 5, "click_appeal": 5, "conversion": 5,
"seo": 5, "format": 5, "link_natural": 5,
},
"total": 30,
"pass": False,
"feedback": "개선 필요",
})
with patch("app.quality_reviewer._get_client") as mock_client_fn, \
patch("app.quality_reviewer.get_template", return_value="제목: {title}\n본문: {body}"):
mock_client = mock_client_fn.return_value
mock_client.messages.create.return_value.content = [type("C", (), {"text": mock_response})()]
result = review_post("제목", "<p>본문</p>")
assert result["pass"] is False
def test_review_handles_parse_failure():
"""JSON 파싱 실패 시 기본값 반환 (6개 기준)."""
from app.quality_reviewer import review_post
with patch("app.quality_reviewer._get_client") as mock_client_fn, \
patch("app.quality_reviewer.get_template", return_value="제목: {title}\n본문: {body}"):
mock_client = mock_client_fn.return_value
mock_client.messages.create.return_value.content = [type("C", (), {"text": "잘못된 응답"})()]
result = review_post("제목", "<p>본문</p>")
assert result["pass"] is False
assert "link_natural" in result["scores"]
assert result["total"] == 0

View File

@@ -1,66 +0,0 @@
"""마케터 단계 테스트."""
import json
import pytest
from unittest.mock import patch
def test_enhance_for_conversion_inserts_links():
"""마케터가 브랜드 링크를 본문에 삽입."""
from app.marketer import enhance_for_conversion
brand_links = [
{"url": "https://link.coupang.com/abc", "product_name": "갤럭시 버즈3",
"description": "노이즈캔슬링", "placement_hint": "본문 중간"},
]
mock_response = json.dumps({
"title": "마케팅된 제목",
"body": '<p>본문 <a href="https://link.coupang.com/abc">갤럭시 버즈3</a></p>',
"excerpt": "요약",
})
with patch("app.marketer._call_claude", return_value=mock_response) as mock_call, \
patch("app.marketer.get_template", return_value="초안: {draft_body}\n키워드: {keyword}\n링크:\n{brand_links_info}"):
result = enhance_for_conversion(
post_body="<p>초안 본문</p>",
post_title="초안 제목",
brand_links=brand_links,
keyword="무선 이어폰",
)
prompt_used = mock_call.call_args[0][0]
assert "갤럭시 버즈3" in prompt_used
assert "노이즈캔슬링" in prompt_used
assert result["title"] == "마케팅된 제목"
def test_enhance_requires_brand_links():
"""브랜드 링크가 없으면 ValueError."""
from app.marketer import enhance_for_conversion
with pytest.raises(ValueError, match="브랜드커넥트 링크가 필요합니다"):
enhance_for_conversion(
post_body="<p>본문</p>",
post_title="제목",
brand_links=[],
keyword="테스트",
)
def test_enhance_json_parse_fallback():
"""JSON 파싱 실패 시 원본 제목 유지."""
from app.marketer import enhance_for_conversion
brand_links = [{"url": "https://a.com", "product_name": "상품"}]
with patch("app.marketer._call_claude", return_value="잘못된 JSON"), \
patch("app.marketer.get_template", return_value="초안: {draft_body}\n키워드: {keyword}\n링크:\n{brand_links_info}"):
result = enhance_for_conversion(
post_body="<p>원본</p>",
post_title="원본 제목",
brand_links=brand_links,
keyword="테스트",
)
assert result["title"] == "원본 제목"
assert result["body"] == "잘못된 JSON"

View File

@@ -1,146 +0,0 @@
"""4단계 파이프라인 통합 테스트."""
import os
import pytest
from unittest.mock import patch
from fastapi.testclient import TestClient
@pytest.fixture(autouse=True)
def setup_db(tmp_path):
test_db = str(tmp_path / "test.db")
import app.config as config
config.DB_PATH = test_db
from app import db
db.DB_PATH = test_db
db.init_db()
yield
@pytest.fixture
def client():
from app.main import app
return TestClient(app)
def test_full_pipeline_status_flow(client):
"""draft → marketed → reviewed → published 상태 흐름."""
from app import db
# 1. 키워드 분석 결과 직접 삽입
analysis = db.add_keyword_analysis({
"keyword": "무선 이어폰",
"blog_total": 1000,
"shop_total": 500,
"competition": 45,
"opportunity": 60,
"top_products": [{"title": "에어팟", "lprice": 200000, "mallName": "애플"}],
"top_blogs": [{"title": "리뷰", "link": "https://blog.naver.com/user/123", "content": "본문"}],
})
# 2. 브랜드 링크 등록
resp = client.post("/api/blog-marketing/links", json={
"keyword_id": analysis["id"],
"url": "https://link.coupang.com/abc",
"product_name": "삼성 버즈3",
"description": "노이즈캔슬링",
})
assert resp.status_code == 201
# 3. 포스트 직접 생성 (generate는 Claude API 필요)
post = db.add_post({
"keyword_id": analysis["id"],
"title": "무선 이어폰 추천",
"body": "<p>초안 본문</p>",
"excerpt": "요약",
"tags": ["이어폰"],
"status": "draft",
})
db.link_brand_links_to_post(keyword_id=analysis["id"], post_id=post["id"])
# 4. 상태 확인: draft
resp = client.get(f"/api/blog-marketing/posts/{post['id']}")
assert resp.json()["status"] == "draft"
# 5. marketed 상태
db.update_post(post["id"], {"status": "marketed", "body": "<p>마케팅된 본문</p>"})
resp = client.get(f"/api/blog-marketing/posts/{post['id']}")
assert resp.json()["status"] == "marketed"
# 6. reviewed 상태 (점수 48/60 = 통과)
db.update_post(post["id"], {
"status": "reviewed",
"review_score": 48,
"review_detail": {
"scores": {"empathy": 8, "click_appeal": 8, "conversion": 8, "seo": 8, "format": 8, "link_natural": 8},
"total": 48, "pass": True, "feedback": "우수"
},
})
resp = client.get(f"/api/blog-marketing/posts/{post['id']}")
assert resp.json()["status"] == "reviewed"
assert resp.json()["review_score"] == 48
# 7. 발행
resp = client.post(f"/api/blog-marketing/posts/{post['id']}/publish", json={
"naver_url": "https://blog.naver.com/mypost/123",
})
assert resp.json()["status"] == "published"
def test_links_associated_with_post(client):
"""keyword_id로 등록한 링크가 post 생성 후 post_id로도 조회 가능."""
from app import db
analysis = db.add_keyword_analysis({"keyword": "테스트", "blog_total": 10, "shop_total": 5})
client.post("/api/blog-marketing/links", json={
"keyword_id": analysis["id"],
"url": "https://link.com/1",
"product_name": "상품1",
})
post = db.add_post({"keyword_id": analysis["id"], "title": "제목", "body": "본문", "status": "draft"})
db.link_brand_links_to_post(keyword_id=analysis["id"], post_id=post["id"])
resp = client.get(f"/api/blog-marketing/links?post_id={post['id']}")
links = resp.json()["links"]
assert len(links) == 1
assert links[0]["product_name"] == "상품1"
@patch("app.main.ANTHROPIC_API_KEY", "fake-key-for-test")
def test_market_endpoint_returns_404_for_missing_post(client):
"""존재하지 않는 post_id로 마케터 호출 시 404."""
resp = client.post("/api/blog-marketing/market/9999")
assert resp.status_code == 404
@patch("app.main.ANTHROPIC_API_KEY", "fake-key-for-test")
def test_review_endpoint_returns_404_for_missing_post(client):
"""존재하지 않는 post_id로 리뷰 호출 시 404."""
resp = client.post("/api/blog-marketing/review/9999")
assert resp.status_code == 404
def test_multiple_links_per_keyword(client):
"""하나의 키워드에 복수 링크 등록 가능."""
from app import db
analysis = db.add_keyword_analysis({"keyword": "테스트", "blog_total": 10, "shop_total": 5})
for i in range(3):
resp = client.post("/api/blog-marketing/links", json={
"keyword_id": analysis["id"],
"url": f"https://link.com/{i}",
"product_name": f"상품{i}",
})
assert resp.status_code == 201
resp = client.get(f"/api/blog-marketing/links?keyword_id={analysis['id']}")
assert len(resp.json()["links"]) == 3
def test_dashboard_still_works(client):
"""대시보드 API가 여전히 정상 작동."""
resp = client.get("/api/blog-marketing/dashboard")
assert resp.status_code == 200
data = resp.json()
assert "total_posts" in data
assert "published_posts" in data

View File

@@ -1,58 +0,0 @@
"""리서치 단계 크롤링 통합 테스트."""
from unittest.mock import patch
def test_analyze_keyword_with_crawling_enriches_top_blogs():
"""analyze_keyword_with_crawling가 top_blogs에 content 필드를 추가."""
from app.naver_search import analyze_keyword_with_crawling
mock_blog_result = {
"total": 100,
"items": [
{"title": "테스트 블로그", "link": "https://blog.naver.com/user1/111",
"bloggername": "유저1", "description": "설명", "postdate": "20260401"},
],
}
mock_shop_result = {
"total": 50,
"items": [{"title": "상품1", "lprice": 10000, "mallName": "쿠팡"}],
"price_stats": {"min": 10000, "max": 10000, "avg": 10000, "count": 1},
}
with patch("app.naver_search.search_blog", return_value=mock_blog_result), \
patch("app.naver_search.search_shopping", return_value=mock_shop_result), \
patch("app.naver_search._run_enrich", return_value=[
{"title": "테스트 블로그", "link": "https://blog.naver.com/user1/111",
"bloggername": "유저1", "description": "설명", "postdate": "20260401",
"content": "크롤링된 본문 내용"}
]):
result = analyze_keyword_with_crawling("테스트 키워드")
assert "content" in result["top_blogs"][0]
assert result["top_blogs"][0]["content"] == "크롤링된 본문 내용"
def test_analyze_keyword_with_crawling_fallback_on_enrich_failure():
"""크롤링 실패 시 기존 데이터 유지."""
from app.naver_search import analyze_keyword_with_crawling
mock_blog_result = {
"total": 50,
"items": [{"title": "블로그", "link": "https://blog.naver.com/u/1", "bloggername": "유저", "description": "설명"}],
}
mock_shop_result = {"total": 10, "items": [], "price_stats": None}
with patch("app.naver_search.search_blog", return_value=mock_blog_result), \
patch("app.naver_search.search_shopping", return_value=mock_shop_result), \
patch("app.naver_search._run_enrich", side_effect=Exception("크롤링 실패")):
# _run_enrich 내부에서 예외를 잡으므로 실제로는 이 테스트에서는
# _run_enrich 자체가 예외를 던지는 상황을 시뮬레이션
# 하지만 _run_enrich는 내부에서 잡으므로, 직접 fallback 테스트
pass
# _run_enrich 자체 fallback 테스트
from app.naver_search import _run_enrich
original_blogs = [{"title": "원본", "link": "https://blog.naver.com/u/1"}]
with patch("app.web_crawler.enrich_top_blogs", side_effect=Exception("fail")):
result = _run_enrich(original_blogs)
assert result == original_blogs # fallback으로 원본 반환

View File

@@ -1,94 +0,0 @@
"""web_crawler 모듈 테스트."""
import pytest
from unittest.mock import patch, AsyncMock
from app.web_crawler import crawl_blog_content, enrich_top_blogs, _parse_naver_blog_url, _extract_text
def test_parse_naver_blog_url_valid():
"""blog.naver.com URL에서 blogId와 logNo를 올바르게 파싱."""
result = _parse_naver_blog_url("https://blog.naver.com/testuser/123456")
assert result == ("testuser", "123456")
def test_parse_returns_none_for_invalid_url():
"""잘못된 URL은 None 반환."""
result = _parse_naver_blog_url("https://example.com/post")
assert result is None
def test_extract_text_prefers_se_main_container():
"""SE3 에디터 컨테이너를 우선 선택."""
html = '<div class="se-main-container"><p>SE3 본문</p></div><div id="postViewArea"><p>구 에디터</p></div>'
assert _extract_text(html) == "SE3 본문"
def test_extract_text_falls_back_to_post_view_area():
"""SE3 없으면 구 에디터 컨테이너 사용."""
html = '<div id="postViewArea"><p>구 에디터 본문</p></div>'
assert _extract_text(html) == "구 에디터 본문"
def test_extract_text_removes_script_and_style():
"""스크립트/스타일 태그 제거."""
html = '<div class="se-main-container"><p>본문</p><script>alert(1)</script><style>.x{}</style></div>'
result = _extract_text(html)
assert "alert" not in result
assert ".x" not in result
assert "본문" in result
def test_extract_text_returns_empty_on_no_container():
"""컨테이너가 없고 body도 없으면 빈 문자열."""
assert _extract_text("") == ""
@pytest.mark.asyncio
async def test_crawl_returns_empty_on_non_naver_url():
"""네이버 블로그가 아닌 URL은 빈 문자열 반환."""
result = await crawl_blog_content("https://example.com/post")
assert result == ""
@pytest.mark.asyncio
async def test_crawl_truncates_to_2000_chars():
"""본문이 2000자를 초과하면 잘라낸다."""
long_html = f'<div class="se-main-container"><p>{"" * 3000}</p></div>'
with patch("app.web_crawler._fetch_html", new_callable=AsyncMock, return_value=long_html):
result = await crawl_blog_content("https://blog.naver.com/testuser/123")
assert len(result) <= 2000
@pytest.mark.asyncio
async def test_crawl_returns_empty_on_fetch_failure():
"""HTTP 요청 실패 시 빈 문자열 반환."""
with patch("app.web_crawler._fetch_html", new_callable=AsyncMock, side_effect=Exception("timeout")):
result = await crawl_blog_content("https://blog.naver.com/testuser/123")
assert result == ""
@pytest.mark.asyncio
async def test_enrich_top_blogs_adds_content_field():
"""enrich_top_blogs가 각 블로그에 content 필드를 추가."""
blogs = [
{"title": "테스트", "link": "https://blog.naver.com/user1/111", "bloggername": "유저1", "description": "설명"},
{"title": "테스트2", "link": "https://blog.naver.com/user2/222", "bloggername": "유저2", "description": "설명2"},
]
with patch("app.web_crawler.crawl_blog_content", new_callable=AsyncMock, return_value="크롤링된 본문"):
result = await enrich_top_blogs(blogs)
assert len(result) == 2
assert result[0]["content"] == "크롤링된 본문"
assert result[1]["content"] == "크롤링된 본문"
@pytest.mark.asyncio
async def test_enrich_top_blogs_handles_partial_failure():
"""일부 크롤링 실패 시에도 나머지는 정상 처리."""
blogs = [
{"title": "성공", "link": "https://blog.naver.com/user1/111"},
{"title": "실패", "link": "https://blog.naver.com/user2/222"},
]
side_effects = ["성공 본문", Exception("fail")]
with patch("app.web_crawler.crawl_blog_content", new_callable=AsyncMock, side_effect=side_effects):
result = await enrich_top_blogs(blogs)
assert result[0]["content"] == "성공 본문"
assert result[1]["content"] == ""

View File

@@ -1,86 +0,0 @@
"""작가 단계 테스트 -- 크롤링 본문 + 링크 참조 글 생성."""
import json
import pytest
from unittest.mock import patch
def test_generate_blog_post_includes_crawled_content():
"""크롤링 본문이 프롬프트에 포함되는지 확인."""
from app.content_generator import generate_blog_post
analysis = {
"keyword": "무선 이어폰",
"top_products": [{"title": "에어팟", "lprice": 200000, "mallName": "애플"}],
"top_blogs": [
{"title": "에어팟 리뷰", "content": "에어팟을 한 달간 써봤는데 음질이 정말 좋았습니다."},
],
}
mock_response = json.dumps({
"title": "무선 이어폰 추천",
"body": "<p>본문</p>",
"excerpt": "요약",
"tags": ["이어폰"],
})
with patch("app.content_generator._call_claude", return_value=mock_response) as mock_call, \
patch("app.content_generator.get_template", return_value=(
"키워드: {keyword}\n참고 블로그:\n{reference_blogs}\n상품: {top_products}\n링크 상품: {brand_products}"
)):
result = generate_blog_post(analysis, "트렌드 브리프", brand_links=[])
prompt_used = mock_call.call_args[0][0]
assert "에어팟을 한 달간 써봤는데" in prompt_used
assert result["title"] == "무선 이어폰 추천"
def test_generate_blog_post_includes_brand_links():
"""브랜드커넥트 링크 정보가 프롬프트에 포함되는지 확인."""
from app.content_generator import generate_blog_post
analysis = {"keyword": "무선 이어폰", "top_products": [], "top_blogs": []}
brand_links = [
{"url": "https://link.coupang.com/abc", "product_name": "삼성 버즈3",
"description": "노이즈캔슬링 지원", "placement_hint": "본문 중간"},
]
mock_response = json.dumps({
"title": "제목", "body": "<p>본문</p>", "excerpt": "요약", "tags": ["태그"],
})
with patch("app.content_generator._call_claude", return_value=mock_response) as mock_call, \
patch("app.content_generator.get_template", return_value=(
"키워드: {keyword}\n참고 블로그:\n{reference_blogs}\n상품: {top_products}\n링크 상품: {brand_products}"
)):
result = generate_blog_post(analysis, "트렌드 브리프", brand_links=brand_links)
prompt_used = mock_call.call_args[0][0]
assert "삼성 버즈3" in prompt_used
assert "노이즈캔슬링 지원" in prompt_used
def test_generate_blog_post_works_without_links():
"""링크 없이도 정상 동작."""
from app.content_generator import generate_blog_post
analysis = {"keyword": "테스트", "top_products": [], "top_blogs": []}
mock_response = json.dumps({
"title": "제목", "body": "<p>본문</p>", "excerpt": "요약", "tags": ["태그"],
})
with patch("app.content_generator._call_claude", return_value=mock_response), \
patch("app.content_generator.get_template", return_value=(
"키워드: {keyword}\n참고 블로그:\n{reference_blogs}\n상품: {top_products}\n링크 상품: {brand_products}"
)):
result = generate_blog_post(analysis, "브리프")
assert result["title"] == "제목"
def test_parse_blog_json_fallback():
"""JSON 파싱 실패 시 원본 텍스트를 body로 사용."""
from app.content_generator import _parse_blog_json
result = _parse_blog_json("잘못된 JSON", "테스트 키워드")
assert result["title"] == "테스트 키워드 추천 리뷰"
assert result["body"] == "잘못된 JSON"

View File

@@ -14,20 +14,27 @@ services:
- TZ=${TZ:-Asia/Seoul}
- LOTTO_ALL_URL=${LOTTO_ALL_URL:-https://smok95.github.io/lotto/results/all.json}
- LOTTO_LATEST_URL=${LOTTO_LATEST_URL:-https://smok95.github.io/lotto/results/latest.json}
- PYTHONPATH=/app:/shared
volumes:
- ${RUNTIME_PATH}/data:/app/data
- ${RUNTIME_PATH}/_shared:/shared/_shared:ro
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
interval: 60s
timeout: 5s
retries: 3
stock-lab:
stock:
build:
context: ./stock-lab
context: ./stock
args:
APP_VERSION: ${APP_VERSION:-dev}
container_name: stock-lab
container_name: stock
restart: unless-stopped
ports:
- "18500:8000"
@@ -43,11 +50,19 @@ services:
- OLLAMA_URL=${OLLAMA_URL:-http://192.168.45.59:11435}
- OLLAMA_MODEL=${OLLAMA_MODEL:-qwen3:14b}
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
- WEBAI_API_KEY=${WEBAI_API_KEY:-}
- PYTHONPATH=/app:/shared
volumes:
- ${RUNTIME_PATH}/data/stock:/app/data
- ${RUNTIME_PATH}/_shared:/shared/_shared:ro
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
interval: 60s
timeout: 5s
retries: 3
@@ -61,39 +76,121 @@ services:
environment:
- TZ=${TZ:-Asia/Seoul}
- MUSIC_AI_SERVER_URL=${MUSIC_AI_SERVER_URL:-}
- SUNO_API_KEY=${SUNO_API_KEY:-}
- MUSIC_MEDIA_BASE=${MUSIC_MEDIA_BASE:-/media/music}
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
- PEXELS_API_KEY=${PEXELS_API_KEY:-}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- YOUTUBE_OAUTH_CLIENT_ID=${YOUTUBE_OAUTH_CLIENT_ID:-}
- YOUTUBE_OAUTH_CLIENT_SECRET=${YOUTUBE_OAUTH_CLIENT_SECRET:-}
- YOUTUBE_OAUTH_REDIRECT_URI=${YOUTUBE_OAUTH_REDIRECT_URI:-}
- CLAUDE_HAIKU_MODEL=${CLAUDE_HAIKU_MODEL:-claude-haiku-4-5-20251001}
- CLAUDE_SONNET_MODEL=${CLAUDE_SONNET_MODEL:-claude-sonnet-4-6}
- VIDEO_DATA_DIR=${VIDEO_DATA_DIR:-/app/data/videos}
- WINDOWS_VIDEO_ENCODER_URL=${WINDOWS_VIDEO_ENCODER_URL:-}
- NAS_VIDEOS_ROOT=${NAS_VIDEOS_ROOT:-/volume1/docker/webpage/data/videos}
- NAS_MUSIC_ROOT=${NAS_MUSIC_ROOT:-/volume1/docker/webpage/data/music}
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
- MUSIC_RENDER_URL=${MUSIC_RENDER_URL:-http://192.168.45.59:18711}
- PYTHONPATH=/app:/shared
volumes:
- ${RUNTIME_PATH}/data/music:/app/data
- ${RUNTIME_PATH:-.}/data/videos:/app/data/videos
- ${RUNTIME_PATH}/_shared:/shared/_shared:ro
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
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
blog-lab:
video-lab:
build:
context: ./blog-lab
container_name: blog-lab
context: ./video-lab
container_name: video-lab
restart: unless-stopped
ports:
- "18801:8000"
environment:
- TZ=${TZ:-Asia/Seoul}
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
- VIDEO_DATA_DIR=/app/data
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
volumes:
- ${RUNTIME_PATH}/data/video:/app/data
depends_on:
- redis
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 60s
timeout: 5s
retries: 3
image-lab:
build: ./image-lab
container_name: image-lab
restart: unless-stopped
ports:
- "18802:8000"
environment:
- TZ=${TZ:-Asia/Seoul}
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
- IMAGE_DATA_DIR=/app/data
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
volumes:
- ${RUNTIME_PATH}/data/image:/app/data
depends_on:
- redis
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 60s
timeout: 5s
retries: 3
insta-lab:
build:
context: ./insta-lab
container_name: insta-lab
restart: unless-stopped
ports:
- "18700:8000"
environment:
- TZ=${TZ:-Asia/Seoul}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- ANTHROPIC_MODEL_HAIKU=${ANTHROPIC_MODEL_HAIKU:-claude-haiku-4-5-20251001}
- 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:-}
- PYTHONPATH=/app:/shared
volumes:
- ${RUNTIME_PATH}/data/blog:/app/data
- ${RUNTIME_PATH}/data/insta:/app/data
- ${RUNTIME_PATH}/_shared:/shared/_shared:ro
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
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
@@ -109,11 +206,18 @@ services:
- DATA_GO_KR_API_KEY=${DATA_GO_KR_API_KEY:-}
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
- AGENT_OFFICE_URL=${AGENT_OFFICE_URL:-http://agent-office:8000}
- PYTHONPATH=/app:/shared
volumes:
- ${RUNTIME_PATH}/data/realestate:/app/data
- ${RUNTIME_PATH}/_shared:/shared/_shared:ro
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
interval: 60s
timeout: 5s
retries: 3
@@ -127,9 +231,9 @@ services:
environment:
- TZ=${TZ:-Asia/Seoul}
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
- STOCK_LAB_URL=http://stock-lab:8000
- STOCK_URL=http://stock:8000
- MUSIC_LAB_URL=http://music-lab:8000
- BLOG_LAB_URL=http://blog-lab:8000
- INSTA_LAB_URL=http://insta-lab:8000
- REALESTATE_LAB_URL=http://realestate-lab:8000
- REALESTATE_DASHBOARD_URL=${REALESTATE_DASHBOARD_URL:-http://localhost:8080/realestate}
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
@@ -137,6 +241,8 @@ services:
- TELEGRAM_WEBHOOK_URL=${TELEGRAM_WEBHOOK_URL:-}
- TELEGRAM_WIFE_CHAT_ID=${TELEGRAM_WIFE_CHAT_ID:-}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- CLAUDE_HAIKU_MODEL=${CLAUDE_HAIKU_MODEL:-claude-haiku-4-5-20251001}
- CLAUDE_SONNET_MODEL=${CLAUDE_SONNET_MODEL:-claude-sonnet-4-6}
- LOTTO_BACKEND_URL=${LOTTO_BACKEND_URL:-http://lotto:8000}
- LOTTO_CURATOR_MODEL=${LOTTO_CURATOR_MODEL:-claude-sonnet-4-5}
- CONVERSATION_MODEL=${CONVERSATION_MODEL:-claude-haiku-4-5-20251001}
@@ -146,13 +252,61 @@ services:
volumes:
- ${RUNTIME_PATH:-.}/data/agent-office:/app/data
depends_on:
- stock-lab
- stock
- music-lab
- blog-lab
- insta-lab
- 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
tarot-lab:
build:
context: ./tarot-lab
container_name: tarot-lab
restart: unless-stopped
ports:
- "18250:8000"
environment:
- TZ=${TZ:-Asia/Seoul}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- TAROT_MODEL=${TAROT_MODEL:-claude-sonnet-4-6}
- TAROT_COST_INPUT_PER_M=${TAROT_COST_INPUT_PER_M:-3.0}
- TAROT_COST_OUTPUT_PER_M=${TAROT_COST_OUTPUT_PER_M:-15.0}
- TAROT_TIMEOUT_SEC=${TAROT_TIMEOUT_SEC:-180}
- TAROT_DATA_PATH=/app/data
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
volumes:
- ${RUNTIME_PATH:-.}/data/tarot:/app/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 60s
timeout: 5s
retries: 3
saju-lab:
build:
context: ./saju-lab
container_name: saju-lab
restart: unless-stopped
ports:
- "18300:8000"
environment:
- TZ=${TZ:-Asia/Seoul}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- SAJU_MODEL=${SAJU_MODEL:-claude-sonnet-4-6}
- SAJU_COST_INPUT_PER_M=${SAJU_COST_INPUT_PER_M:-3.0}
- SAJU_COST_OUTPUT_PER_M=${SAJU_COST_OUTPUT_PER_M:-15.0}
- SAJU_TIMEOUT_SEC=${SAJU_TIMEOUT_SEC:-240}
- SAJU_DATA_PATH=/app/data
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
volumes:
- ${RUNTIME_PATH:-.}/data/saju:/app/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 60s
timeout: 5s
retries: 3
@@ -171,7 +325,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
@@ -187,16 +341,18 @@ services:
- DSM_HOST=${DSM_HOST:-}
- DSM_USER=${DSM_USER:-}
- DSM_PASS=${DSM_PASS:-}
- DSM_VERIFY_SSL=${DSM_VERIFY_SSL:-true}
- BACKEND_HMAC_SECRET=${BACKEND_HMAC_SECRET:-}
- SUPABASE_URL=${SUPABASE_URL:-}
- SUPABASE_SERVICE_KEY=${SUPABASE_SERVICE_KEY:-}
- UPLOAD_TOKEN_TTL_SEC=${UPLOAD_TOKEN_TTL_SEC:-1800}
- PACK_BASE_DIR=${PACK_BASE_DIR:-/app/data/packs}
- PACK_HOST_DIR=${PACK_HOST_DIR:-${PACK_DATA_PATH:-./data/packs}}
volumes:
- ${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
@@ -219,18 +375,30 @@ 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
frontend:
image: nginx:alpine
# ngx_http_rewrite_module 힙 오버플로우 2건 대응 (미고정 nginx:alpine → 패치 stable 고정)
# - CVE-2026-42945 (NGINX Rift, CVSS 9.2): fixed in 1.30.1+ / 1.31.0+
# - CVE-2026-9256 (nginx-poolslip, 영향 ~1.31.0): fixed in 1.30.2+ / 1.31.1+
# → 둘 다 커버하는 최소 stable = 1.30.2
image: nginx:1.30.2-alpine
container_name: frontend
restart: unless-stopped
depends_on:
- lotto
- stock
- music-lab
- blog-lab
- insta-lab
- realestate-lab
- agent-office
- personal
- packs-lab
- travel-proxy
- video-lab
- image-lab
ports:
- "8080:80"
volumes:
@@ -240,11 +408,13 @@ services:
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:ro
- ${RUNTIME_PATH}/data/music:/data/music:ro
- ${RUNTIME_PATH}/data/videos:/data/videos:ro
- ${RUNTIME_PATH}/data/video:/data/video:ro
- ${RUNTIME_PATH}/data/insta/insta_cards:/data/insta_cards:ro
extra_hosts:
- "host.docker.internal:host-gateway"
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:80/"]
interval: 30s
interval: 60s
timeout: 5s
retries: 3
@@ -264,3 +434,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

@@ -0,0 +1,252 @@
# 로또랩 프리미엄 서비스 고도화 로드맵
> 작성일: 2026-03-19
> 목표: 번호 생성 도구 → 데이터 기반 로또 전략 코치
---
## 1. 현재 서비스 한계
현재 구조는 **"번호 생성 도구"** 수준으로 수익화에 한계가 있음.
| 문제 | 내용 |
|------|------|
| 차별점 부재 | 무료 로또 번호 생성기와 구분되지 않음 |
| 신뢰 근거 부족 | 사용자가 결과를 믿을 데이터 시각화 없음 |
| 리텐션 약함 | 지속적으로 돌아올 이유가 없음 |
---
## 2. 포지셔닝 전환
> **"번호 생성"이 아니라 "데이터 기반 로또 전략 코치"**
사람들이 구독료를 지불하는 심리적 동기:
- **확신**: 내가 선택한 번호가 좋은 선택이라는 데이터 근거
- **FOMO**: 이번 주 리포트를 못 받으면 놓치는 느낌
- **소유감**: 내 데이터와 이력이 축적된다는 느낌
---
## 3. 고도화 방향 (5가지)
### 3-1. 당첨 근접도 추적 — 신뢰 기반 구축
**목표**: 기존 채점 데이터(`check_results_for_draw`)를 신뢰 지표로 전환
**구현 내용**:
- 추천 번호의 회차별 일치 개수 통계 집계
- 전국 평균 대비 성과 비교 지표 노출
- 매주 "지난 주 내 번호 성과" 이메일/푸시 발송
**예시 UI 문구**:
```
"지난 52주간 우리 추천번호의 평균 일치 개수: 2.7개 (전국 평균 1.9개)"
"3개 일치율이 일반 무작위 대비 43% 높습니다"
```
**활용 데이터**: 기존 `recommendations` + `draws` 테이블 채점 결과
**우선순위**: ⭐⭐⭐ (데이터 이미 존재, 즉시 구현 가능)
---
### 3-2. 개인화 분석 리포트 — 프리미엄 핵심 기능
**목표**: 모든 사용자에게 동일한 번호 → 개인 패턴 기반 맞춤 추천
**구현 내용**:
- 사용자 번호 선택 이력 패턴 분석
- 홀짝 비율, 번호대 분포, 연속번호 포함률 등 개인 성향 분석
- 약점을 보완한 AI 보정 추천번호 생성
**예시 분석 항목**:
```
"당신은 홀수를 선호하는 경향 (67%)"
"당신이 자주 피하는 번호대: 30번대"
"당신 번호의 약점: 연속번호 포함률 낮음"
→ "이를 보완한 AI 보정 추천번호 제공"
```
**신규 테이블**: `user_preferences`
**우선순위**: ⭐⭐ (신규 테이블 및 분석 로직 필요)
---
### 3-3. 회차별 공략 리포트 — 킬러 콘텐츠
**목표**: 매주 추첨 전 발행하는 주간 분석 레포트 → 구독 유지 동기
**구현 내용**:
- 매주 자동 생성되는 회차별 공략 리포트
- 과출현/냉각 번호 분석
- 패턴 기반 번호군 추천
- AI 신뢰도 점수 표시
**예시 리포트 구조**:
```
[1180회 공략 리포트]
- 최근 10회 과출현 번호 제외 추천
- 이번 주 "냉각 구간" 번호 (오랫동안 미출현)
- 패턴 분석: 직전 3회 연속 출현한 번호군
- AI 신뢰도 점수: 87/100
```
**스케줄러**: 매주 토요일 추첨 전 자동 생성 (APScheduler)
**우선순위**: ⭐⭐⭐ (주간 구독 모델의 핵심 훅)
---
### 3-4. 번호 포트폴리오 관리 — 차별화 UX
**목표**: 로또를 투자처럼 관리하는 경험 제공
**구현 내용**:
- 세트 분류: 고위험/안정형/균형형
- 구매 금액 직접 입력 → 수익률 자동 계산
- 누적 투자 대비 당첨금 통계
**예시 화면**:
```
내 번호 포트폴리오
├── 고위험/고수익 세트 (출현 빈도 낮은 번호 조합)
├── 안정형 세트 (평균 출현 패턴)
└── 균형형 세트 (시뮬레이션 최적화)
이번 주 매입: 3세트 (₩3,000)
누적 투자: ₩240,000 / 누적 당첨: ₩45,000
수익률: -81.2% (전국 평균 대비 +12.1%)
```
**활용 데이터**: `best_picks`, `recommendations` 확장
**우선순위**: ⭐⭐ (UX 임팩트 큼, 중기 구현)
---
### 3-5. 커뮤니티 + 소셜 증거 — 바이럴 유도
**목표**: 사용자 참여 및 구전 마케팅
**구현 내용**:
- 이번 주 가장 많이 선택된 번호 TOP 10 공개
- "나와 같은 번호 선택한 회원 수" 표시
- AI 추천으로 X개 일치 달성한 회원 수 표시
**예시**:
```
"이번 주 가장 많이 선택된 번호 TOP 10"
"AI 추천 번호로 3개 일치 달성한 회원: 1,247명"
"나와 같은 번호를 선택한 회원: 34명"
```
**전략**: 무료 티어에 일부 공개 → 상세 분석은 유료 전환
**우선순위**: ⭐ (회원 시스템 구축 후 가능)
---
## 4. 구독 티어 설계
| 기능 | 무료 | 스탠다드 (₩2,900/월) | 프리미엄 (₩5,900/월) |
|------|:----:|:----:|:----:|
| 기본 추천 번호 | 1세트 | 5세트 | 무제한 |
| 통계 분석 | 기본 | 심화 | 전체 |
| 회차 공략 리포트 | - | 주간 요약 | 풀 리포트 |
| 개인 패턴 분석 | - | - | ✓ |
| 번호 포트폴리오 | - | ✓ | ✓ |
| 당첨 근접도 통계 | - | ✓ | ✓ |
| 당첨 알림 | - | 이메일 | 이메일 + 앱 |
---
## 5. 기술 구현 로드맵
### Phase 1 — 즉시 가능 (데이터 이미 존재)
- [ ] 추천 이력 채점 통계 API (`GET /api/lotto/stats/performance`)
- [ ] 신뢰도 지표 UI (평균 일치 개수, 전국 평균 비교)
- [ ] 회차별 공략 리포트 API (`GET /api/lotto/report/{drw_no}`)
- [ ] 개인 추천 이력 성과 대시보드
### Phase 2 — 단기 (1-2주)
- [ ] `user_preferences` 테이블 설계 및 구현
- [ ] 개인 패턴 분석 API (`GET /api/lotto/analysis/personal`)
- [ ] 주간 리포트 자동 생성 스케줄러 (토요일 오전)
- [ ] 투자 추적 기능 (구매 금액 입력 → 수익률 계산)
- [ ] `purchase_history` 테이블 추가
### Phase 3 — 중기 (1개월)
- [ ] 회원 시스템 구축 (JWT 인증, SQLite `users` 테이블)
- [ ] 구독 플랜 관리 (`subscription_plans`, `user_subscriptions` 테이블)
- [ ] 결제 연동 (Toss Payments 또는 Stripe)
- [ ] 이메일 발송 자동화 (SendGrid)
- [ ] 소셜 증거 데이터 집계 API
---
## 6. DB 스키마 확장 계획
```sql
-- Phase 2
CREATE TABLE purchase_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
draw_no INTEGER NOT NULL,
amount INTEGER NOT NULL, -- 구매 금액 (원)
sets INTEGER NOT NULL DEFAULT 1, -- 구매 세트 수
prize INTEGER DEFAULT 0, -- 당첨금
note TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE user_preferences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
odd_ratio REAL, -- 홀수 선호 비율
high_ratio REAL, -- 고번호(23+) 선호 비율
consecutive INTEGER, -- 연속번호 포함 선호 여부
excluded_numbers TEXT, -- JSON 배열, 기피 번호
updated_at TEXT DEFAULT (datetime('now'))
);
-- Phase 3
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
plan TEXT DEFAULT 'free', -- free | standard | premium
plan_expires_at TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
```
---
## 7. API 확장 계획
| Phase | 메서드 | 경로 | 설명 |
|-------|--------|------|------|
| 1 | GET | `/api/lotto/stats/performance` | 추천 성과 통계 (평균 일치 수 등) |
| 1 | GET | `/api/lotto/report/latest` | 최신 회차 공략 리포트 |
| 1 | GET | `/api/lotto/report/{drw_no}` | 특정 회차 공략 리포트 |
| 2 | GET | `/api/lotto/purchase` | 구매 이력 조회 |
| 2 | POST | `/api/lotto/purchase` | 구매 이력 추가 |
| 2 | GET | `/api/lotto/purchase/stats` | 투자 수익률 통계 |
| 2 | GET | `/api/lotto/analysis/personal` | 개인 패턴 분석 |
| 3 | POST | `/api/auth/register` | 회원가입 |
| 3 | POST | `/api/auth/login` | 로그인 |
| 3 | GET | `/api/subscription/plans` | 구독 플랜 목록 |
| 3 | POST | `/api/subscription/checkout` | 결제 시작 |
---
## 참고
- 현재 운영 중인 lotto API: `CLAUDE.md``lotto-lab API 목록` 섹션 참고
- 채점 로직: `backend/app/checker.py`
- 시뮬레이션 로직: `backend/app/recommender.py`
- DB 스키마: `backend/app/db.py` `init_db()`

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,681 +0,0 @@
# Travel-Proxy 성능 개선 구현 계획
> **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:** travel-proxy의 os.scandir 기반 아키텍처를 SQLite 인덱스 DB로 전환하고, 앨범 커버 지정 + 썸네일 사전 생성을 지원한다.
**Architecture:** 기존 main.py의 스캔/캐시/썸네일 로직을 db.py(스키마+쿼리)와 indexer.py(동기화+썸네일)로 분리. main.py는 라우트만 담당. DB 경로는 `/data/thumbs/travel.db`.
**Tech Stack:** Python 3.12, FastAPI, SQLite (표준 라이브러리 sqlite3), Pillow
---
### 파일 구조
| 파일 | 역할 | 상태 |
|------|------|------|
| `travel-proxy/app/db.py` | SQLite 스키마 정의, 커넥션 헬퍼, 쿼리 함수 | 신규 |
| `travel-proxy/app/indexer.py` | 폴더 스캔 → DB 동기화 + 썸네일 일괄 생성 | 신규 |
| `travel-proxy/app/main.py` | FastAPI 라우트 (기존 수정 + 신규 추가) | 수정 |
---
### Task 1: db.py — SQLite 스키마 및 쿼리 헬퍼
**Files:**
- Create: `travel-proxy/app/db.py`
- [ ] **Step 1: db.py 파일 생성**
```python
import os
import sqlite3
from typing import Any, Dict, List, Optional
DB_PATH = os.getenv("TRAVEL_DB_PATH", "/data/thumbs/travel.db")
def _conn() -> sqlite3.Connection:
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
return conn
def init_db() -> None:
with _conn() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS photos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
album TEXT NOT NULL,
filename TEXT NOT NULL,
mtime REAL NOT NULL,
has_thumb INTEGER DEFAULT 0,
indexed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
UNIQUE(album, filename)
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_photos_album ON photos(album)")
conn.execute("""
CREATE TABLE IF NOT EXISTS album_covers (
album TEXT PRIMARY KEY,
filename TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)
""")
def get_photos_by_region(albums: List[str], page: int, size: int) -> Dict[str, Any]:
"""region에 속한 앨범들의 사진을 페이지네이션하여 반환."""
if not albums:
return {"items": [], "total": 0, "has_next": False, "matched_albums": []}
placeholders = ",".join("?" for _ in albums)
with _conn() as conn:
# 앨범별 사진 수
rows = conn.execute(
f"SELECT album, COUNT(*) as cnt FROM photos WHERE album IN ({placeholders}) GROUP BY album",
albums,
).fetchall()
matched_albums = [{"album": r["album"], "count": r["cnt"]} for r in rows]
# 전체 수
total_row = conn.execute(
f"SELECT COUNT(*) as cnt FROM photos WHERE album IN ({placeholders})",
albums,
).fetchone()
total = total_row["cnt"]
# 페이지네이션
offset = (page - 1) * size
items = conn.execute(
f"""SELECT album, filename, mtime FROM photos
WHERE album IN ({placeholders})
ORDER BY album, filename
LIMIT ? OFFSET ?""",
[*albums, size, offset],
).fetchall()
return {
"items": [dict(r) for r in items],
"total": total,
"has_next": (offset + size) < total,
"matched_albums": matched_albums,
}
def get_all_albums() -> List[Dict[str, Any]]:
"""전체 앨범 목록 + 사진 수 + 커버 정보."""
with _conn() as conn:
rows = conn.execute("""
SELECT p.album, COUNT(*) as count,
COALESCE(c.filename, MIN(p.filename)) as cover_filename
FROM photos p
LEFT JOIN album_covers c ON p.album = c.album
GROUP BY p.album
ORDER BY p.album
""").fetchall()
return [dict(r) for r in rows]
def set_album_cover(album: str, filename: str) -> bool:
"""앨범 커버 지정. 해당 photo가 존재하면 True, 없으면 False."""
with _conn() as conn:
exists = conn.execute(
"SELECT 1 FROM photos WHERE album = ? AND filename = ?",
(album, filename),
).fetchone()
if not exists:
return False
conn.execute(
"""INSERT INTO album_covers (album, filename, updated_at)
VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))
ON CONFLICT(album) DO UPDATE SET
filename = excluded.filename,
updated_at = excluded.updated_at""",
(album, filename),
)
return True
def get_album_cover(album: str) -> Optional[str]:
"""앨범 커버 파일명 반환. 미지정 시 None."""
with _conn() as conn:
row = conn.execute(
"SELECT filename FROM album_covers WHERE album = ?",
(album,),
).fetchone()
return row["filename"] if row else None
def upsert_photo(album: str, filename: str, mtime: float) -> str:
"""사진 upsert. 반환: 'added' | 'updated' | 'unchanged'."""
with _conn() as conn:
existing = conn.execute(
"SELECT mtime, has_thumb FROM photos WHERE album = ? AND filename = ?",
(album, filename),
).fetchone()
if not existing:
conn.execute(
"INSERT INTO photos (album, filename, mtime, has_thumb) VALUES (?, ?, ?, 0)",
(album, filename, mtime),
)
return "added"
elif existing["mtime"] != mtime:
conn.execute(
"UPDATE photos SET mtime = ?, has_thumb = 0 WHERE album = ? AND filename = ?",
(mtime, album, filename),
)
return "updated"
return "unchanged"
def remove_missing_photos(album: str, existing_filenames: set) -> int:
"""폴더에 없는 사진을 DB에서 제거. 제거 수 반환."""
with _conn() as conn:
db_rows = conn.execute(
"SELECT filename FROM photos WHERE album = ?", (album,)
).fetchall()
db_filenames = {r["filename"] for r in db_rows}
to_remove = db_filenames - existing_filenames
if to_remove:
placeholders = ",".join("?" for _ in to_remove)
conn.execute(
f"DELETE FROM photos WHERE album = ? AND filename IN ({placeholders})",
[album, *to_remove],
)
# 삭제된 파일이 커버였으면 커버도 제거
conn.execute(
f"DELETE FROM album_covers WHERE album = ? AND filename IN ({placeholders})",
[album, *to_remove],
)
return len(to_remove)
def get_photos_without_thumb() -> List[Dict[str, str]]:
"""썸네일 미생성 사진 목록."""
with _conn() as conn:
rows = conn.execute(
"SELECT album, filename FROM photos WHERE has_thumb = 0"
).fetchall()
return [dict(r) for r in rows]
def mark_thumb_done(album: str, filename: str) -> None:
"""썸네일 생성 완료 표시."""
with _conn() as conn:
conn.execute(
"UPDATE photos SET has_thumb = 1 WHERE album = ? AND filename = ?",
(album, filename),
)
```
- [ ] **Step 2: 커밋**
```bash
git add travel-proxy/app/db.py
git commit -m "feat(travel-proxy): db.py — SQLite 스키마 + 쿼리 헬퍼"
```
---
### Task 2: indexer.py — 폴더 동기화 + 썸네일 일괄 생성
**Files:**
- Create: `travel-proxy/app/indexer.py`
- [ ] **Step 1: indexer.py 파일 생성**
기존 main.py의 `ensure_thumb` 로직(라인 105-144)과 `scan_album` 로직(라인 146-166)을 기반으로 작성. `IMAGE_EXT`, `THUMB_SIZE`, 경로 상수는 main.py에서 import.
```python
import os
import time
import json
import logging
from pathlib import Path
from typing import Any, Dict, List, Set
from PIL import Image
from . import db
logger = logging.getLogger(__name__)
IMAGE_EXT = {".jpg", ".jpeg", ".png", ".webp"}
THUMB_SIZE = (480, 480)
def _scan_folder(folder: Path) -> List[Dict[str, Any]]:
"""폴더 내 이미지 파일 목록 수집 (os.scandir)."""
if not folder.exists():
return []
items = []
with os.scandir(folder) as entries:
for entry in entries:
if entry.is_file() and Path(entry.name).suffix.lower() in IMAGE_EXT:
items.append({
"filename": entry.name,
"mtime": entry.stat().st_mtime,
})
return items
def _generate_thumb(src: Path, dest: Path) -> bool:
"""원본에서 480x480 썸네일 생성. 성공 시 True."""
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_name(dest.stem + ".tmp" + dest.suffix)
try:
with Image.open(src) as im:
im.thumbnail(THUMB_SIZE)
ext = dest.suffix.lower()
if ext in (".jpg", ".jpeg"):
fmt = "JPEG"
elif ext == ".png":
fmt = "PNG"
elif ext == ".webp":
fmt = "WEBP"
else:
fmt = (im.format or "").upper() or "JPEG"
im.save(tmp, format=fmt, quality=85, optimize=True)
tmp.replace(dest)
return True
except Exception as e:
logger.warning("Thumb generation failed: %s%s", src, e)
try:
if tmp.exists():
tmp.unlink()
except Exception:
pass
return False
def sync(
travel_root: Path,
thumb_root: Path,
region_map_path: Path,
) -> Dict[str, Any]:
"""
폴더 스캔 → DB 동기화 + 썸네일 일괄 생성.
Returns:
{"added": int, "removed": int, "thumbs_generated": int, "duration_sec": float}
"""
start = time.time()
# 1. region_map.json에서 전체 앨범 폴더 수집
with open(region_map_path, "r", encoding="utf-8") as f:
region_map = json.load(f)
all_albums: Set[str] = set()
for v in region_map.values():
if isinstance(v, list):
all_albums.update(v)
elif isinstance(v, dict) and isinstance(v.get("albums"), list):
all_albums.update(v["albums"])
# 2. 각 앨범 폴더 스캔 → DB 동기화
added = 0
removed = 0
for album in sorted(all_albums):
folder = travel_root / album
items = _scan_folder(folder)
existing_filenames = set()
for item in items:
existing_filenames.add(item["filename"])
result = db.upsert_photo(album, item["filename"], item["mtime"])
if result == "added":
added += 1
removed += db.remove_missing_photos(album, existing_filenames)
# 3. 썸네일 미생성 분 일괄 생성
no_thumb = db.get_photos_without_thumb()
thumbs_generated = 0
for photo in no_thumb:
src = travel_root / photo["album"] / photo["filename"]
dest = thumb_root / photo["album"] / photo["filename"]
if _generate_thumb(src, dest):
db.mark_thumb_done(photo["album"], photo["filename"])
thumbs_generated += 1
duration = round(time.time() - start, 2)
logger.info(
"Sync complete: added=%d removed=%d thumbs=%d duration=%.2fs",
added, removed, thumbs_generated, duration,
)
return {
"added": added,
"removed": removed,
"thumbs_generated": thumbs_generated,
"duration_sec": duration,
}
```
- [ ] **Step 2: 커밋**
```bash
git add travel-proxy/app/indexer.py
git commit -m "feat(travel-proxy): indexer.py — 폴더 동기화 + 썸네일 일괄 생성"
```
---
### Task 3: main.py 리팩토링 — DB 기반 photos API + 캐시 제거
**Files:**
- Modify: `travel-proxy/app/main.py`
이 Task에서 main.py의 메모리 캐시, `scan_album()`, 기존 `photos()` 라우트를 DB 기반으로 교체한다.
- [ ] **Step 1: main.py를 DB 기반으로 재작성**
main.py 전체를 아래로 교체:
```python
import os
import json
import logging
from pathlib import Path
from typing import Any, List
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import FileResponse
from pydantic import BaseModel
from PIL import Image
from .db import init_db, get_photos_by_region, get_all_albums, set_album_cover, get_album_cover
from .indexer import sync
logger = logging.getLogger(__name__)
app = FastAPI()
# -----------------------------
# Env / Paths
# -----------------------------
ROOT = Path(os.getenv("TRAVEL_ROOT", "/data/travel")).resolve()
MEDIA_BASE = os.getenv("TRAVEL_MEDIA_BASE", "/media/travel")
META_DIR = ROOT / "_meta"
REGION_MAP_PATH = META_DIR / "region_map.json"
REGIONS_GEOJSON_PATH = META_DIR / "regions.geojson"
THUMB_ROOT = Path(os.getenv("TRAVEL_THUMB_ROOT", "/data/thumbs")).resolve()
THUMB_SIZE = (480, 480)
THUMB_ROOT.mkdir(parents=True, exist_ok=True)
IMAGE_EXT = {".jpg", ".jpeg", ".png", ".webp"}
# -----------------------------
# DB init
# -----------------------------
init_db()
# -----------------------------
# Helpers
# -----------------------------
def _read_json(path: Path) -> Any:
if not path.exists():
raise HTTPException(500, f"Missing required file: {path}")
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def load_region_map() -> dict:
return _read_json(REGION_MAP_PATH)
def load_regions_geojson() -> dict:
return _read_json(REGIONS_GEOJSON_PATH)
def _get_albums_for_region(region: str, region_map: dict) -> List[str]:
if region not in region_map:
raise HTTPException(400, "Unknown region")
v = region_map[region]
if isinstance(v, list):
return v
if isinstance(v, dict) and isinstance(v.get("albums"), list):
return v["albums"]
raise HTTPException(500, "Invalid region_map format")
def _ensure_thumb_fallback(src: Path, album: str) -> Path:
"""온디맨드 썸네일 폴백 (sync 누락 분 대응)."""
out = THUMB_ROOT / album / src.name
if out.exists():
return out
out.parent.mkdir(parents=True, exist_ok=True)
tmp = out.with_name(out.stem + ".tmp" + out.suffix)
try:
with Image.open(src) as im:
im.thumbnail(THUMB_SIZE)
ext = out.suffix.lower()
if ext in (".jpg", ".jpeg"):
fmt = "JPEG"
elif ext == ".png":
fmt = "PNG"
elif ext == ".webp":
fmt = "WEBP"
else:
fmt = (im.format or "").upper() or "JPEG"
im.save(tmp, format=fmt, quality=85, optimize=True)
tmp.replace(out)
return out
finally:
try:
if tmp.exists():
tmp.unlink()
except Exception:
pass
# -----------------------------
# Models
# -----------------------------
class CoverRequest(BaseModel):
filename: str
# -----------------------------
# Routes
# -----------------------------
@app.get("/health")
def health():
return {"status": "healthy", "service": "travel-proxy"}
@app.get("/api/travel/regions")
def regions():
return load_regions_geojson()
@app.get("/api/travel/photos")
def photos(
region: str = Query(...),
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
):
region_map = load_region_map()
albums = _get_albums_for_region(region, region_map)
result = get_photos_by_region(albums, page, size)
# URL 조합 (DB에는 경로를 저장하지 않음)
items = []
for row in result["items"]:
items.append({
"album": row["album"],
"file": row["filename"],
"url": f"{MEDIA_BASE}/{row['album']}/{row['filename']}",
"thumb": f"{MEDIA_BASE}/.thumb/{row['album']}/{row['filename']}",
"mtime": row["mtime"],
})
return {
"region": region,
"page": page,
"size": size,
"total": result["total"],
"has_next": result["has_next"],
"items": items,
"matched_albums": result["matched_albums"],
}
@app.post("/api/travel/sync")
def sync_endpoint():
result = sync(
travel_root=ROOT,
thumb_root=THUMB_ROOT,
region_map_path=REGION_MAP_PATH,
)
return result
@app.get("/api/travel/albums")
def albums_list():
rows = get_all_albums()
result = []
for r in rows:
cover = r["cover_filename"]
result.append({
"album": r["album"],
"count": r["count"],
"cover_url": f"{MEDIA_BASE}/{r['album']}/{cover}",
"cover_thumb": f"{MEDIA_BASE}/.thumb/{r['album']}/{cover}",
})
return result
@app.put("/api/travel/albums/{album}/cover")
def set_cover(album: str, body: CoverRequest):
ok = set_album_cover(album, body.filename)
if not ok:
raise HTTPException(404, f"Photo not found: {album}/{body.filename}")
return {
"album": album,
"filename": body.filename,
"cover_url": f"{MEDIA_BASE}/{album}/{body.filename}",
"cover_thumb": f"{MEDIA_BASE}/.thumb/{album}/{body.filename}",
}
@app.get("/media/travel/.thumb/{album}/{filename}")
def get_thumb(album: str, filename: str):
if ".." in album or ".." in filename:
raise HTTPException(400, "Invalid path")
src = (ROOT / album / filename).resolve()
if not str(src).startswith(str(ROOT)):
raise HTTPException(403, "Access denied")
if not src.exists() or not src.is_file():
raise HTTPException(404, "Source not found")
p = _ensure_thumb_fallback(src, album)
if not p.exists() or not p.is_file():
raise HTTPException(404, "Thumbnail not found")
return FileResponse(str(p))
@app.get("/api/version")
def version():
return {"version": os.getenv("APP_VERSION", "dev")}
```
- [ ] **Step 2: 커밋**
```bash
git add travel-proxy/app/main.py
git commit -m "refactor(travel-proxy): main.py DB 기반 전환 — 메모리 캐시 제거 + 신규 API"
```
---
### Task 4: 통합 검증
**Files:**
- 없음 (기존 파일 검증만)
- [ ] **Step 1: import 구조 확인**
travel-proxy/app/ 디렉토리에 `__init__.py`가 필요한지 확인. FastAPI uvicorn 실행 명령이 `app.main:app`이므로 패키지 import가 동작하려면 `__init__.py`가 필요.
```bash
ls travel-proxy/app/
```
`__init__.py`가 없으면 생성:
```python
# travel-proxy/app/__init__.py
```
- [ ] **Step 2: Dockerfile 확인**
현재 Dockerfile의 `COPY app /app/app` 라인이 db.py, indexer.py를 포함하는지 확인. 디렉토리 단위 복사이므로 추가 파일은 자동 포함됨. 변경 불필요.
- [ ] **Step 3: docker-compose.yml 환경변수 확인**
`TRAVEL_DB_PATH` 환경변수를 docker-compose.yml에 추가:
```yaml
# docker-compose.yml의 travel-proxy 서비스 environment에 추가
- TRAVEL_DB_PATH=${TRAVEL_DB_PATH:-/data/thumbs/travel.db}
```
- [ ] **Step 4: photos 응답 호환성 검증**
기존 응답 필드와 비교:
- `region`
- `page`, `size`
- `total`, `has_next`
- `items[].album`, `items[].file`, `items[].url`, `items[].thumb`, `items[].mtime`
- `matched_albums` — 기존에는 `photos()` 응답에 없었으나 캐시 데이터에 포함. DB 버전은 항상 포함.
- [ ] **Step 5: 커밋 (변경 있을 시)**
```bash
git add travel-proxy/app/__init__.py docker-compose.yml
git commit -m "chore(travel-proxy): __init__.py + TRAVEL_DB_PATH 환경변수 추가"
```
---
### Task 5: CLAUDE.md 업데이트
**Files:**
- Modify: `CLAUDE.md`
- [ ] **Step 1: travel-proxy 섹션에 DB 정보 추가**
CLAUDE.md의 travel-proxy 섹션에 아래 내용 추가:
- DB: `/data/thumbs/travel.db` (photos, album_covers 테이블)
- 파일 구조에 `db.py`, `indexer.py` 추가
API 목록 테이블에 신규 API 3개 추가:
| 메서드 | 경로 | 설명 |
|--------|------|------|
| POST | `/api/travel/sync` | 폴더 스캔 → DB 동기화 + 썸네일 생성 |
| GET | `/api/travel/albums` | 앨범 목록 + 사진 수 + 커버 정보 |
| PUT | `/api/travel/albums/{album}/cover` | 앨범 커버 지정 |
`POST /api/travel/reload` 제거 표기.
- [ ] **Step 2: 커밋**
```bash
git add CLAUDE.md
git commit -m "docs: CLAUDE.md travel-proxy DB·API 업데이트"
```

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,971 +0,0 @@
# 청약 타겟팅 프론트엔드 구현 계획
> **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:** 백엔드에서 추가된 자치구 5티어 매칭 기능을 `web-ui`의 청약(Subscription) 페이지에 노출 — 프로필 편집 UI(드래그&드롭 + 슬라이더 + 토글) + 카드/상세에 district·5티어·reasons 표시.
**Architecture:** `Subscription.jsx`(1354줄, 단일 파일)의 ProfileTab에 신규 컴포넌트 2개(`DistrictTierEditor`, `NotificationSettings`)를 추가하고, `AnnouncementCard`/`AnnouncementDetail`/`MatchesTab` 3 곳에 district + 5티어 뱃지 + reasons 표시를 추가한다. 백엔드 응답은 이미 모든 필요 데이터를 포함하므로 API 변경 없음.
**Tech Stack:** React 18 + Vite + JavaScript / Native HTML5 drag-and-drop / `window.matchMedia` 분기 / ESLint / 단위 테스트 인프라 없음(빌드 + lint + 수동 시각 검증)
**스펙 참조:** `web-backend/docs/superpowers/specs/2026-04-28-realestate-frontend-targeting-design.md`
**작업 디렉토리:** `C:\Users\jaeoh\Desktop\workspace\web-ui` (web-backend와 별도 git repo. commit/push도 web-ui repo에서 처리.)
**검증 방식:**
- 단위 테스트 인프라 없음 → 각 task는 `npm run build` 통과 + `npm run lint` 통과로 1차 검증
- 마지막 task에서 `npm run dev` + 브라우저로 수동 시각 검증 시나리오 일괄 실행
- 백엔드는 NAS에 이미 배포됨(2a8635e..a508a56) → 실제 응답으로 동작 확인 가능
---
## Task 1: 모듈 상단 변경 — DEFAULT_PROFILE 확장 + extractTier 헬퍼
`Subscription.jsx` 모듈 상단에 신규 3 필드 default와 reasons → tier 추출 헬퍼를 추가. 이후 task들이 이 두 가지를 의존.
**Files:**
- Modify: `web-ui/src/pages/subscription/Subscription.jsx` (모듈 상단 — `DEFAULT_PROFILE` 상수 + 새 헬퍼)
- [ ] **Step 1: DEFAULT_PROFILE에 신규 3 필드 default 추가**
`Subscription.jsx`에서 `DEFAULT_PROFILE` 상수 정의를 찾는다 (grep `DEFAULT_PROFILE =`). 끝부분에 3 필드 추가:
```javascript
const DEFAULT_PROFILE = {
// ... 기존 필드 그대로 유지
preferred_regions: '',
preferred_types: '',
min_area: '',
max_area: '',
max_price: '',
// 신규 (자치구 5티어 + 알림 설정)
preferred_districts: {},
min_match_score: 70,
notify_enabled: true,
};
```
(주의: 기존 마지막 필드 뒤에 콤마가 있는지 확인 후 일관성 유지)
- [ ] **Step 2: `extractTier` 헬퍼 함수 추가**
`DEFAULT_PROFILE` 정의 위(또는 fmt 헬퍼들 근처, 모듈 최상단 영역) 어딘가에 추가:
```javascript
// 매칭 reasons에서 자치구 티어를 추출 ("자치구 S티어: 강남구 (+25)" → "S")
function extractTier(reasons) {
for (const r of reasons || []) {
const m = r.match(/자치구 ([SABCD])티어/);
if (m) return m[1];
}
return null;
}
```
- [ ] **Step 3: 빌드 + 린트 검증**
```bash
cd C:\Users\jaeoh\Desktop\workspace\web-ui
npm run build
npm run lint
```
Expected: build 성공, lint 0 errors / 0 warnings.
- [ ] **Step 4: 커밋**
```bash
cd C:\Users\jaeoh\Desktop\workspace\web-ui
git add src/pages/subscription/Subscription.jsx
git commit -m "feat(subscription): DEFAULT_PROFILE 신규 3필드 + extractTier 헬퍼"
```
---
## Task 2: DistrictTierEditor 컴포넌트 신규
자치구 5티어 분류 UI. 데스크톱 드래그&드롭 + 모바일 read-only.
**Files:**
- Create: `web-ui/src/pages/subscription/components/DistrictTierEditor.jsx`
- [ ] **Step 1: 컴포넌트 파일 생성**
```jsx
import { useEffect, useState } from "react";
const SEOUL_DISTRICTS = [
"강남구","강동구","강북구","강서구","관악구",
"광진구","구로구","금천구","노원구","도봉구",
"동대문구","동작구","마포구","서대문구","서초구",
"성동구","성북구","송파구","양천구","영등포구",
"용산구","은평구","종로구","중구","중랑구",
];
const TIERS = [
{ key: "S", label: "S", weight: "100%" },
{ key: "A", label: "A", weight: "80%" },
{ key: "B", label: "B", weight: "60%" },
{ key: "C", label: "C", weight: "40%" },
{ key: "D", label: "D", weight: "20%" },
];
const EMPTY_TIERS = { S: [], A: [], B: [], C: [], D: [] };
function useIsDesktop() {
const [isDesktop, setIsDesktop] = useState(
typeof window !== "undefined" && window.matchMedia("(min-width: 768px)").matches
);
useEffect(() => {
if (typeof window === "undefined") return;
const mq = window.matchMedia("(min-width: 768px)");
const handler = (e) => setIsDesktop(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
return isDesktop;
}
export default function DistrictTierEditor({ value, onChange }) {
const isDesktop = useIsDesktop();
const [dragOver, setDragOver] = useState(null); // 현재 hover 중인 zone key
const current = value && Object.keys(value).length > 0 ? value : EMPTY_TIERS;
const unassigned = SEOUL_DISTRICTS.filter(
d => !TIERS.some(t => (current[t.key] || []).includes(d))
);
const moveDistrict = (district, targetTier /* null = 미할당 */) => {
const next = { S: [], A: [], B: [], C: [], D: [] };
for (const t of Object.keys(next)) {
next[t] = (current[t] || []).filter(d => d !== district);
}
if (targetTier) {
next[targetTier] = [...next[targetTier], district];
}
onChange(next);
};
const onDragStart = (e, district) => {
e.dataTransfer.setData("text/district", district);
e.dataTransfer.effectAllowed = "move";
};
const onDragOver = (e, key) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (dragOver !== key) setDragOver(key);
};
const onDragLeave = () => setDragOver(null);
const onDrop = (e, targetTier /* null = 미할당 */) => {
e.preventDefault();
const district = e.dataTransfer.getData("text/district");
setDragOver(null);
if (district) moveDistrict(district, targetTier);
};
if (!isDesktop) {
return (
<div className="sub-panel">
<div className="sub-panel__head">
<p className="sub-panel__eyebrow">자치구 우선순위</p>
<h3>지역 5티어</h3>
</div>
<div className="sub-panel__body" style={{ display: "grid", gap: 10 }}>
{TIERS.map(t => (
<div key={t.key} className="dte-row dte-row--readonly">
<span className={`sub-chip sub-chip--tier sub-chip--tier-${t.key}`}>
{t.label} {t.weight}
</span>
<span className="dte-row__list">
{(current[t.key] || []).length === 0
? <span className="dte-empty">(없음)</span>
: (current[t.key] || []).join(", ")}
</span>
</div>
))}
<p className="dte-mobile-hint"> 자치구 분류는 PC에서 편집할 있어요</p>
</div>
</div>
);
}
return (
<div className="sub-panel">
<div className="sub-panel__head">
<p className="sub-panel__eyebrow">자치구 우선순위</p>
<h3>지역 5티어 (드래그해서 분류)</h3>
</div>
<div className="sub-panel__body" style={{ display: "grid", gap: 12 }}>
{/* 미할당 풀 */}
<div
className={`dte-pool ${dragOver === "_unassigned" ? "dte-pool--over" : ""}`}
onDragOver={(e) => onDragOver(e, "_unassigned")}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, null)}
>
<p className="dte-pool__title">미할당 ({unassigned.length})</p>
<div className="dte-chips">
{unassigned.map(d => (
<span
key={d}
draggable
onDragStart={(e) => onDragStart(e, d)}
className="sub-chip sub-chip--district dte-chip"
>
{d}
</span>
))}
</div>
</div>
{/* 5티어 그리드 */}
<div className="dte-grid">
{TIERS.map(t => (
<div
key={t.key}
className={`dte-zone ${dragOver === t.key ? "dte-zone--over" : ""}`}
onDragOver={(e) => onDragOver(e, t.key)}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, t.key)}
>
<div className={`dte-zone__head sub-chip--tier-${t.key}`}>
{t.label} <span className="dte-zone__weight">{t.weight}</span>
</div>
<div className="dte-zone__chips">
{(current[t.key] || []).map(d => (
<span
key={d}
draggable
onDragStart={(e) => onDragStart(e, d)}
className="sub-chip sub-chip--district dte-chip"
>
{d}
<button
type="button"
className="dte-chip__remove"
onClick={() => moveDistrict(d, null)}
aria-label={`${d} 미할당으로`}
>
×
</button>
</span>
))}
</div>
</div>
))}
</div>
</div>
</div>
);
}
```
- [ ] **Step 2: 빌드 + 린트 검증**
```bash
cd C:\Users\jaeoh\Desktop\workspace\web-ui
npm run build
npm run lint
```
Expected: build 성공, lint 0 errors. 컴포넌트가 아직 사용되지 않으므로 dead code warning은 무시.
- [ ] **Step 3: 커밋**
```bash
cd C:\Users\jaeoh\Desktop\workspace\web-ui
git add src/pages/subscription/components/DistrictTierEditor.jsx
git commit -m "feat(subscription): DistrictTierEditor — 자치구 5티어 드래그앤드롭"
```
---
## Task 3: NotificationSettings 컴포넌트 신규
임계값 슬라이더 + 알림 토글 + 미리보기.
**Files:**
- Create: `web-ui/src/pages/subscription/components/NotificationSettings.jsx`
- [ ] **Step 1: 컴포넌트 파일 생성**
```jsx
export default function NotificationSettings({ minScore, notifyEnabled, onChange }) {
const score = minScore ?? 70;
const enabled = notifyEnabled ?? true;
return (
<div className="sub-panel">
<div className="sub-panel__head">
<p className="sub-panel__eyebrow">알림 설정</p>
<h3>🔔 텔레그램 알림</h3>
</div>
<div className="sub-panel__body" style={{ display: "grid", gap: 16 }}>
<label className="ns-row">
<span className="ns-row__label">텔레그램 알림</span>
<span className="ns-toggle">
<input
type="checkbox"
className="sub-toggle"
checked={enabled}
onChange={(e) => onChange({ notify_enabled: e.target.checked })}
/>
<span className="sub-toggle__label">{enabled ? "ON" : "OFF"}</span>
</span>
</label>
<label className="ns-row ns-row--column">
<span className="ns-row__label">매칭 임계값 {score}</span>
<input
type="range"
min="0"
max="100"
step="5"
value={score}
onChange={(e) => onChange({ min_match_score: Number(e.target.value) })}
className="ns-slider"
disabled={!enabled}
/>
<div className="ns-scale">
<span>0</span>
<span>50</span>
<span>100</span>
</div>
</label>
<p className="ns-hint">
{enabled
? `💡 ${score}점 이상 매치 시 텔레그램에 자동 알림합니다.`
: "⚠️ 알림 OFF — 임계값을 통과한 매칭이 있어도 메시지가 발송되지 않습니다."}
</p>
</div>
</div>
);
}
```
- [ ] **Step 2: 빌드 + 린트 검증**
```bash
cd C:\Users\jaeoh\Desktop\workspace\web-ui
npm run build
npm run lint
```
Expected: build 성공, lint 0 errors.
- [ ] **Step 3: 커밋**
```bash
cd C:\Users\jaeoh\Desktop\workspace\web-ui
git add src/pages/subscription/components/NotificationSettings.jsx
git commit -m "feat(subscription): NotificationSettings — 임계값 슬라이더 + 알림 토글"
```
---
## Task 4: ProfileTab에 두 컴포넌트 통합 + handleSave 변경
신규 컴포넌트 2개를 ProfileTab에 import·렌더하고, handleSave가 신규 3 필드를 PUT body에 포함하도록 변경.
**Files:**
- Modify: `web-ui/src/pages/subscription/Subscription.jsx` ProfileTab 함수 (956~1299줄 부근)
- [ ] **Step 1: import 추가 (파일 상단의 다른 import들 근처)**
```javascript
import DistrictTierEditor from "./components/DistrictTierEditor";
import NotificationSettings from "./components/NotificationSettings";
```
- [ ] **Step 2: handleSave 안 신규 3 필드 처리 추가**
`handleSave` 함수 안에서 payload 변환 부분을 찾아 다음을 추가. 기존 `preferred_regions` / `preferred_types` 변환 직후에:
```javascript
// 신규: preferred_districts (객체), min_match_score, notify_enabled
payload.preferred_districts = profile.preferred_districts && typeof profile.preferred_districts === "object"
? profile.preferred_districts
: {};
payload.min_match_score = profile.min_match_score ?? null;
payload.notify_enabled = profile.notify_enabled ?? null;
```
- [ ] **Step 3: ProfileTab의 GET 응답 처리에 신규 3 필드 매핑 보강**
`useEffect` 안의 `apiGet('/api/realestate/profile')` 응답 처리에서 `display = { ...DEFAULT_PROFILE, ...data }` 라인이 이미 있어 자동으로 spread 됨. 별도 수정 불필요. (백엔드가 항상 응답에 포함시키므로 fallback도 자연스러움.)
확인 차원에서 `min_match_score`/`notify_enabled`/`preferred_districts`가 응답에 없을 경우 DEFAULT 값이 사용되는지 검증.
- [ ] **Step 4: ProfileTab 렌더 — DistrictTierEditor / NotificationSettings 추가**
`return ()` 안에서 기존 "선호 조건" 패널과 저장 버튼 사이에 두 컴포넌트 삽입. 정확한 위치는 기존 코드의 마지막 `<div className="sub-panel">` (선호 조건 패널) 다음 + 저장 버튼 직전:
```jsx
{/* 자치구 5티어 */}
<DistrictTierEditor
value={profile.preferred_districts}
onChange={(next) => setProfile(prev => ({ ...prev, preferred_districts: next }))}
/>
{/* 알림 설정 */}
<NotificationSettings
minScore={profile.min_match_score ?? 70}
notifyEnabled={profile.notify_enabled ?? true}
onChange={(patch) => setProfile(prev => ({ ...prev, ...patch }))}
/>
```
- [ ] **Step 5: 빌드 + 린트 검증**
```bash
cd C:\Users\jaeoh\Desktop\workspace\web-ui
npm run build
npm run lint
```
Expected: build 성공, lint 0 errors.
- [ ] **Step 6: 커밋**
```bash
cd C:\Users\jaeoh\Desktop\workspace\web-ui
git add src/pages/subscription/Subscription.jsx
git commit -m "feat(subscription): ProfileTab에 5티어/알림 설정 통합"
```
---
## Task 5: Subscription.css — 5티어 + 드래그영역 + 토글 + 슬라이더 + 매칭분석 스타일
신규 컴포넌트와 카드 표시 변경에 필요한 모든 CSS를 한 번에 추가.
**Files:**
- Modify: `web-ui/src/pages/subscription/Subscription.css` (파일 끝에 신규 섹션 추가)
- [ ] **Step 1: 5티어 + district 뱃지 색상**
`Subscription.css` 파일 끝에 추가:
```css
/* === 신규: 자치구 5티어 + district 뱃지 ============================== */
.sub-chip--district {
background: #f3f4f6;
color: #374151;
border-color: #d1d5db;
}
.sub-chip--tier {
font-weight: 700;
}
.sub-chip--tier-S { background: #fee2e2; color: #dc2626; border-color: #fca5a5; }
.sub-chip--tier-A { background: #fef3c7; color: #d97706; border-color: #fcd34d; }
.sub-chip--tier-B { background: #d1fae5; color: #059669; border-color: #6ee7b7; }
.sub-chip--tier-C { background: #dbeafe; color: #2563eb; border-color: #93c5fd; }
.sub-chip--tier-D { background: #ede9fe; color: #7c3aed; border-color: #c4b5fd; }
```
- [ ] **Step 2: DistrictTierEditor 드래그&드롭 영역**
같은 파일에 이어서 추가:
```css
/* === 신규: DistrictTierEditor ====================================== */
.dte-pool {
border: 1px dashed var(--border-soft, #e5e7eb);
border-radius: 12px;
padding: 12px;
transition: background 0.15s, border-color 0.15s;
}
.dte-pool--over {
background: #f0f9ff;
border-color: #38bdf8;
}
.dte-pool__title {
margin: 0 0 8px;
font-size: 12px;
color: var(--text-muted, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.dte-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.dte-chip {
cursor: grab;
user-select: none;
}
.dte-chip:active { cursor: grabbing; }
.dte-chip__remove {
background: transparent;
border: 0;
color: inherit;
margin-left: 4px;
padding: 0 2px;
cursor: pointer;
font-size: 14px;
line-height: 1;
opacity: 0.6;
}
.dte-chip__remove:hover { opacity: 1; }
.dte-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
}
.dte-zone {
border: 1px solid var(--border-soft, #e5e7eb);
border-radius: 12px;
padding: 8px;
min-height: 120px;
transition: background 0.15s, border-color 0.15s;
}
.dte-zone--over {
background: #f0f9ff;
border-color: #38bdf8;
}
.dte-zone__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
border-radius: 6px;
font-weight: 700;
margin-bottom: 8px;
}
.dte-zone__weight {
font-size: 11px;
font-weight: 500;
opacity: 0.8;
}
.dte-zone__chips {
display: flex;
flex-direction: column;
gap: 4px;
}
/* 모바일 read-only 뷰 */
.dte-row {
display: grid;
grid-template-columns: 80px 1fr;
align-items: center;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid var(--border-soft, #e5e7eb);
}
.dte-row:last-of-type { border-bottom: 0; }
.dte-row__list {
color: var(--text, #1f2937);
font-size: 14px;
}
.dte-empty {
color: var(--text-muted, #6b7280);
font-style: italic;
}
.dte-mobile-hint {
margin: 4px 0 0;
color: var(--text-muted, #6b7280);
font-size: 13px;
text-align: center;
}
```
- [ ] **Step 3: NotificationSettings — 토글 + 슬라이더**
같은 파일에 이어서 추가:
```css
/* === 신규: NotificationSettings ==================================== */
.ns-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.ns-row--column {
flex-direction: column;
align-items: stretch;
}
.ns-row__label {
font-weight: 600;
color: var(--text, #1f2937);
}
.ns-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
}
.sub-toggle {
appearance: none;
width: 40px;
height: 22px;
background: #d1d5db;
border-radius: 11px;
position: relative;
cursor: pointer;
transition: background 0.2s;
margin: 0;
}
.sub-toggle::before {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
background: #fff;
border-radius: 50%;
transition: transform 0.2s;
}
.sub-toggle:checked {
background: #10b981;
}
.sub-toggle:checked::before {
transform: translateX(18px);
}
.sub-toggle__label {
font-size: 12px;
font-weight: 600;
color: var(--text-muted, #6b7280);
}
.ns-slider {
width: 100%;
margin: 8px 0;
}
.ns-slider:disabled {
opacity: 0.5;
}
.ns-scale {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--text-muted, #6b7280);
}
.ns-hint {
margin: 0;
font-size: 13px;
color: var(--text-muted, #6b7280);
line-height: 1.5;
}
```
- [ ] **Step 4: 매칭 분석 섹션 + 모바일 그리드 fallback**
같은 파일에 이어서 추가:
```css
/* === 신규: 매칭 분석 섹션 ========================================== */
.sub-match-analysis {
display: grid;
gap: 12px;
padding: 16px;
background: var(--surface-soft, #f9fafb);
border-radius: 12px;
margin-top: 16px;
}
.sub-match-analysis__score {
font-family: var(--font-display, system-ui);
font-size: 28px;
font-weight: 700;
color: var(--accent, #3b82f6);
}
.sub-match-analysis__reasons {
margin: 0;
padding-left: 18px;
color: var(--text, #1f2937);
font-size: 14px;
line-height: 1.7;
}
.sub-match-analysis__reasons li {
margin: 2px 0;
}
.sub-match-analysis__elig {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
/* 모바일 dte-grid → 1칼럼 */
@media (max-width: 767px) {
.dte-grid {
grid-template-columns: 1fr;
}
}
```
- [ ] **Step 5: 빌드 검증**
```bash
cd C:\Users\jaeoh\Desktop\workspace\web-ui
npm run build
```
Expected: build 성공 (CSS 추가는 lint 영향 없음).
- [ ] **Step 6: 커밋**
```bash
cd C:\Users\jaeoh\Desktop\workspace\web-ui
git add src/pages/subscription/Subscription.css
git commit -m "feat(subscription): 5티어 뱃지 + 드래그영역 + 토글 + 슬라이더 스타일"
```
---
## Task 6: AnnouncementCard에 district + 5티어 뱃지
매칭 결과 데이터가 있는 경우만 뱃지 표시.
**Files:**
- Modify: `web-ui/src/pages/subscription/Subscription.jsx` AnnouncementCard 함수 (315~389줄 부근)
- [ ] **Step 1: AnnouncementCard 안 메타 라인에 뱃지 추가**
`AnnouncementCard` 컴포넌트의 JSX에서 단지명·지역 라인 직후 또는 메타 라인에 다음 뱃지를 추가:
```jsx
{item.district && (
<span className="sub-chip sub-chip--district">{item.district}</span>
)}
{(() => {
const tier = extractTier(item.match_reasons);
return tier ? (
<span className={`sub-chip sub-chip--tier sub-chip--tier-${tier}`}>
{tier}티어
</span>
) : null;
})()}
```
(`extractTier`는 Task 1에서 모듈 상단에 정의됨. JSX 안에서 직접 호출 가능.)
정확한 삽입 위치: 카드 헤더의 region/area 라인 옆, 또는 status 뱃지 옆에 자연스럽게 배치. 기존 카드 구조에 맞춰 조정.
- [ ] **Step 2: 빌드 + 린트 검증**
```bash
cd C:\Users\jaeoh\Desktop\workspace\web-ui
npm run build
npm run lint
```
Expected: build 성공, lint 0 errors.
- [ ] **Step 3: 커밋**
```bash
cd C:\Users\jaeoh\Desktop\workspace\web-ui
git add src/pages/subscription/Subscription.jsx
git commit -m "feat(subscription): AnnouncementCard에 district + 5티어 뱃지"
```
---
## Task 7: AnnouncementDetail에 매칭 분석 섹션
매칭 결과가 있는 공고만 매칭 분석 섹션을 노출.
**Files:**
- Modify: `web-ui/src/pages/subscription/Subscription.jsx` AnnouncementDetail 함수 (390~595줄 부근)
- [ ] **Step 1: AnnouncementDetail 안 매칭 분석 섹션 추가**
`AnnouncementDetail` 컴포넌트의 JSX 마지막 부분(다른 모든 섹션 다음)에 추가:
```jsx
{item.match_score !== undefined && item.match_score !== null && (
<div className="sub-match-analysis">
<div>
<p className="sub-panel__eyebrow">매칭 분석</p>
<span className="sub-match-analysis__score">
{item.match_score}<span style={{ fontSize: 14, color: "var(--text-muted)" }}> / 100</span>
</span>
</div>
{item.match_reasons && item.match_reasons.length > 0 && (
<div>
<p className="sub-panel__eyebrow" style={{ marginTop: 8 }}>💡 매칭 사유</p>
<ul className="sub-match-analysis__reasons">
{item.match_reasons.map((r, idx) => (
<li key={idx}>{r}</li>
))}
</ul>
</div>
)}
{item.eligible_types && item.eligible_types.length > 0 && (
<div>
<p className="sub-panel__eyebrow" style={{ marginTop: 8 }}> 신청 자격</p>
<div className="sub-match-analysis__elig">
{item.eligible_types.map(t => (
<span key={t} className="sub-chip">{t}</span>
))}
</div>
</div>
)}
</div>
)}
```
- [ ] **Step 2: 빌드 + 린트 검증**
```bash
cd C:\Users\jaeoh\Desktop\workspace\web-ui
npm run build
npm run lint
```
Expected: build 성공, lint 0 errors.
- [ ] **Step 3: 커밋**
```bash
cd C:\Users\jaeoh\Desktop\workspace\web-ui
git add src/pages/subscription/Subscription.jsx
git commit -m "feat(subscription): AnnouncementDetail에 매칭 분석 섹션"
```
---
## Task 8: MatchesTab 매치 카드에 district + 5티어 뱃지
`MatchesTab`은 별도의 매치 카드 마크업을 가지고 있을 가능성이 높다. `AnnouncementCard`와 동일한 helper(`extractTier`) + 뱃지 패턴을 적용.
**Files:**
- Modify: `web-ui/src/pages/subscription/Subscription.jsx` MatchesTab 함수 (763~955줄 부근)
- [ ] **Step 1: MatchesTab의 매치 카드 마크업을 찾아 district + 5티어 뱃지 삽입**
`MatchesTab` 함수 안에서 매치 한 건당 렌더하는 영역(보통 `match.house_nm` / `match.region_name` 등을 표시하는 곳)을 찾는다. 거기에 다음 뱃지를 추가:
```jsx
{match.district && (
<span className="sub-chip sub-chip--district">{match.district}</span>
)}
{(() => {
const tier = extractTier(match.match_reasons);
return tier ? (
<span className={`sub-chip sub-chip--tier sub-chip--tier-${tier}`}>
{tier}티어
</span>
) : null;
})()}
```
(`match` 변수명은 실제 코드의 변수명에 맞춰 조정. 보통 `match`, `m`, 또는 `item`)
- [ ] **Step 2: 빌드 + 린트 검증**
```bash
cd C:\Users\jaeoh\Desktop\workspace\web-ui
npm run build
npm run lint
```
Expected: build 성공, lint 0 errors.
- [ ] **Step 3: 커밋**
```bash
cd C:\Users\jaeoh\Desktop\workspace\web-ui
git add src/pages/subscription/Subscription.jsx
git commit -m "feat(subscription): MatchesTab 카드에 district + 5티어 뱃지"
```
---
## Task 9: CLAUDE.md 업데이트 + 수동 시각 검증
**Files:**
- Modify: `web-ui/CLAUDE.md` (페이지/엔드포인트 표 업데이트)
- [ ] **Step 1: CLAUDE.md 업데이트**
`web-ui/CLAUDE.md`를 열고:
1. Subscription 페이지 설명 섹션에 신규 기능 한 줄 추가:
```
- 프로필: 자치구 5티어 분류(드래그&드롭), 알림 임계값/토글 (백엔드 2026-04-28-realestate-targeting-enhancement-design 참조)
- 카드/상세: district + 5티어 뱃지 + 매칭 사유 텍스트
```
2. API 엔드포인트 매핑 표에 `/api/realestate/profile` PUT body가 `preferred_districts` (object), `min_match_score` (int), `notify_enabled` (bool)을 받는다는 한 줄 추가.
- [ ] **Step 2: 수동 시각 검증 (dev server)**
```bash
cd C:\Users\jaeoh\Desktop\workspace\web-ui
npm run dev
```
브라우저에서 `http://localhost:3007` 접속 후 청약 페이지(Subscription) 진입.
검증 시나리오 (모두 통과해야 함):
| # | 시나리오 | 기대 결과 |
|---|---------|----------|
| 1 | 데스크톱 뷰포트(>=768px) → 프로필 탭 → 자치구 영역 표시 | 미할당 풀 + 5티어 그리드(S/A/B/C/D) 노출, 25개 자치구가 미할당 풀에 보임 |
| 2 | "강남구"를 S 슬롯으로 드래그 | S 슬롯에 들어가고 미할당에서 사라짐 |
| 3 | "송파구"를 A 슬롯으로, "강남구"를 다시 S에서 A로 드래그 | A 슬롯에 둘 다, S는 비워짐 |
| 4 | A 슬롯의 "송파구" 칩의 × 버튼 클릭 | 미할당 풀로 복귀 |
| 5 | 알림 토글 OFF → 슬라이더 disabled, 안내 텍스트가 "알림 OFF" 톤 |
| 6 | 슬라이더 80으로 변경 → "80점 이상 매치 시…" 텍스트 즉시 갱신 |
| 7 | "저장" 버튼 클릭 → 새로고침 → 자치구/임계값/토글 값 유지 |
| 8 | 모바일 뷰포트(<768px) | 자치구 영역이 read-only 리스트로 변경, 편집 영역 숨김, "PC에서 편집해주세요" 안내 표시 |
| 9 | 공고 탭 → 매칭 결과 있는 공고 카드 | district 뱃지 + 5티어 뱃지 표시 (매칭 데이터 있는 경우만) |
| 10 | 공고 카드 클릭 → 상세 모달 | 매칭 분석 섹션에 점수 + reasons + 자격 표시 |
| 11 | 매칭 탭 → 카드들 | district + 5티어 뱃지 표시 |
| 12 | 회귀 — 기존 프로필 필드(나이/청약통장/특공) 입력·저장 | 정상 동작 |
문제 발견 시 해당 task로 돌아가 수정.
- [ ] **Step 3: 빌드 최종 검증**
```bash
cd C:\Users\jaeoh\Desktop\workspace\web-ui
npm run build
npm run lint
```
Expected: 에러·경고 없음.
- [ ] **Step 4: 커밋**
```bash
cd C:\Users\jaeoh\Desktop\workspace\web-ui
git add CLAUDE.md
git commit -m "docs(web-ui): 청약 5티어 + 알림 설정 문서 업데이트"
```
---
## 완료 기준
- 9개 task 모두 commit 완료
- `npm run build` warning/error 없이 통과
- `npm run lint` 0 errors / 0 warnings
- 12개 수동 시각 검증 시나리오 모두 통과
- 매칭 점수 70점 이상 + notify_enabled=true + 자치구 S 티어에 강남구 설정 시, 백엔드(이미 NAS에 배포됨)가 신규 매칭 발견 시 텔레그램 알림 송신 (end-to-end)
---
## 배포
```bash
cd C:\Users\jaeoh\Desktop\workspace\web-ui
npm run release:nas
```
NAS에 robocopy로 빌드 산출물 업로드. NAS Z 드라이브 연결이 전제(`\\gahusb.synology.me\docker`).
---
## 참고 — 후속 별도 plan
- Subscription.jsx 자체 분할 (1354줄 → 별도 리팩토링)
- 5축 점수 분해 progress bar (백엔드 응답에 5축 점수 추가 필요)
- 임계값 통과 매치 카운트 미리보기 (`dashboard.pass_count` 백엔드 신설 필요)
- 자치구 5티어 자동 추천 (사용자 가점·예산 기반)
- 알림 채널 추가 (이메일/Slack)
- 모바일 자치구 편집 지원 (touch backend 도입 시)

File diff suppressed because it is too large Load Diff

View File

@@ -682,12 +682,13 @@ git commit -m "feat(packs-lab): Supabase pack_files DDL + 활성/삭제 인덱
---
## Task 7: 인프라 통합 — docker-compose / nginx / .env.example
## Task 7: 인프라 통합 — docker-compose / nginx / .env.example / deploy-nas.sh
**Files:**
- Modify: `docker-compose.yml` (packs-lab 서비스 추가)
- Modify: `docker-compose.yml` (packs-lab 서비스 추가, env에 PACK_BASE_DIR/PACK_HOST_DIR 포함)
- Modify: `nginx/default.conf` (`/api/packs/` 라우팅)
- Modify: `.env.example` (6+1 환경변수)
- Modify: `.env.example` (DSM/HMAC/Supabase 6 + PACK 3 path)
- Modify: `scripts/deploy-nas.sh` (SERVICES 화이트리스트에 `packs-lab` 추가 — 누락 시 NAS 컨테이너 미등장)
- [ ] **Step 1: docker-compose.yml — packs-lab 서비스 추가**

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,737 @@
# GPU 영상 인코딩 오프로드 — 구현 계획
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development.
**Goal:** NAS의 ffmpeg 영상 인코딩을 Windows PC(RTX 5070 Ti) NVENC로 오프로드.
**Architecture:** music-lab(NAS) → HTTP POST → music_ai(Windows, port 8765 `/encode_video`) → ffmpeg NVENC → SMB로 NAS에 직접 mp4 저장. Windows 서버 다운 시 NAS는 즉시 실패.
**Tech Stack:** httpx (NAS 측 HTTP 클라이언트), FastAPI (Windows 서버 endpoint), ffmpeg.exe with NVENC.
**Spec:** `docs/superpowers/specs/2026-05-09-gpu-video-offload-design.md`
---
## File Structure
| 경로 | 책임 |
|------|------|
| `music_ai/video_encoder.py` (new) | 경로 변환 + ffmpeg NVENC subprocess 호출 + 검증 |
| `music_ai/server.py` (modify) | `/encode_video` POST endpoint 등록, `/health`에 ffmpeg/nvenc 정보 추가 |
| `music_ai/.env.example` (modify) | NAS_VOLUME_PREFIX, WINDOWS_DRIVE_ROOT, FFMPEG_PATH 문서화 |
| `music_ai/tests/test_video_encoder.py` (new) | translate_path, encode endpoint 단위 테스트 |
| `music-lab/app/pipeline/video.py` (rewrite) | subprocess 제거, httpx로 Windows 서버 호출 |
| `music-lab/tests/test_video_thumb.py` (rewrite video tests) | respx mock 기반 |
| `web-backend/docker-compose.yml` (modify) | music-lab env 3개 추가 |
---
## Task 1: Windows `music_ai/video_encoder.py` + 테스트
**Files:**
- Create: `music_ai/video_encoder.py`
- Create: `music_ai/tests/test_video_encoder.py`
### Step 1: Write failing test
```python
# music_ai/tests/test_video_encoder.py
import os
import pytest
from unittest.mock import patch, MagicMock
from video_encoder import translate_path, encode_video, EncodeError
@pytest.fixture
def env(monkeypatch):
monkeypatch.setenv("NAS_VOLUME_PREFIX", "/volume1/")
monkeypatch.setenv("WINDOWS_DRIVE_ROOT", "Z:\\")
monkeypatch.setenv("FFMPEG_PATH", "C:\\ffmpeg\\bin\\ffmpeg.exe")
def test_translate_path_basic(env):
assert translate_path("/volume1/docker/webpage/data/x.jpg") == r"Z:\docker\webpage\data\x.jpg"
def test_translate_path_nested(env):
assert translate_path("/volume1/docker/webpage/data/videos/3/cover.jpg") == r"Z:\docker\webpage\data\videos\3\cover.jpg"
def test_translate_path_rejects_bad_prefix(env):
with pytest.raises(ValueError):
translate_path("/etc/passwd")
@patch("subprocess.run")
def test_encode_video_success(mock_run, env, tmp_path):
# 입력 파일 fake
cover = tmp_path / "cover.jpg"
cover.write_bytes(b"\x00" * 100)
audio = tmp_path / "audio.mp3"
audio.write_bytes(b"\x00" * 100)
out = tmp_path / "video.mp4"
def fake_run(cmd, **kwargs):
# ffmpeg 실행을 흉내내어 출력 파일을 만듦
out.write_bytes(b"\x00" * (2 * 1024 * 1024)) # 2MB
return MagicMock(returncode=0, stderr="")
mock_run.side_effect = fake_run
# translate_path를 mock해서 입력 경로를 직접 사용
with patch("video_encoder.translate_path", side_effect=lambda p: str(p).replace("/volume1/", str(tmp_path) + "/")):
result = encode_video(
cover_path_nas="/volume1/cover.jpg",
audio_path_nas="/volume1/audio.mp3",
output_path_nas="/volume1/video.mp4",
resolution="1920x1080",
duration_sec=120,
)
assert result["ok"] is True
assert result["encoder"] == "h264_nvenc"
assert result["output_bytes"] > 1024 * 1024
@patch("subprocess.run")
def test_encode_video_input_missing(mock_run, env, tmp_path):
with pytest.raises(EncodeError) as exc:
encode_video(
cover_path_nas="/volume1/missing.jpg",
audio_path_nas="/volume1/missing.mp3",
output_path_nas="/volume1/out.mp4",
resolution="1920x1080",
duration_sec=120,
)
assert "input_validation" in str(exc.value)
@patch("subprocess.run")
def test_encode_video_ffmpeg_failure(mock_run, env, tmp_path):
cover = tmp_path / "cover.jpg"; cover.write_bytes(b"\x00")
audio = tmp_path / "audio.mp3"; audio.write_bytes(b"\x00")
mock_run.return_value = MagicMock(returncode=1, stderr="invalid codec\n" * 50)
with patch("video_encoder.translate_path", side_effect=lambda p: str(p).replace("/volume1/", str(tmp_path) + "/")):
with pytest.raises(EncodeError) as exc:
encode_video(
cover_path_nas="/volume1/cover.jpg",
audio_path_nas="/volume1/audio.mp3",
output_path_nas="/volume1/out.mp4",
resolution="1920x1080",
duration_sec=120,
)
assert "ffmpeg" in str(exc.value).lower()
@patch("subprocess.run")
def test_encode_video_output_too_small(mock_run, env, tmp_path):
cover = tmp_path / "cover.jpg"; cover.write_bytes(b"\x00")
audio = tmp_path / "audio.mp3"; audio.write_bytes(b"\x00")
def fake_run(cmd, **kwargs):
(tmp_path / "out.mp4").write_bytes(b"\x00" * 100) # 100 bytes — too small
return MagicMock(returncode=0, stderr="")
mock_run.side_effect = fake_run
with patch("video_encoder.translate_path", side_effect=lambda p: str(p).replace("/volume1/", str(tmp_path) + "/")):
with pytest.raises(EncodeError) as exc:
encode_video(
cover_path_nas="/volume1/cover.jpg",
audio_path_nas="/volume1/audio.mp3",
output_path_nas="/volume1/out.mp4",
resolution="1920x1080",
duration_sec=120,
)
assert "output_check" in str(exc.value)
def test_resolution_validation(env):
with pytest.raises(EncodeError) as exc:
encode_video(
cover_path_nas="/volume1/x.jpg",
audio_path_nas="/volume1/x.mp3",
output_path_nas="/volume1/out.mp4",
resolution="invalid",
duration_sec=120,
)
assert "resolution" in str(exc.value).lower()
```
### Step 2: Run test to verify it fails
```bash
cd music_ai && python -m pytest tests/test_video_encoder.py -v
```
Expected: ImportError on `video_encoder` module.
### Step 3: Implement `video_encoder.py`
```python
"""GPU(NVENC) 영상 인코더 — NAS music-lab에서 호출."""
import os
import re
import subprocess
import logging
logger = logging.getLogger("music_ai.video_encoder")
NAS_VOLUME_PREFIX = os.getenv("NAS_VOLUME_PREFIX", "/volume1/")
WINDOWS_DRIVE_ROOT = os.getenv("WINDOWS_DRIVE_ROOT", "Z:\\")
FFMPEG_PATH = os.getenv("FFMPEG_PATH", "ffmpeg")
FFMPEG_TIMEOUT_S = 180
RESOLUTION_RE = re.compile(r"^\d{3,4}x\d{3,4}$")
MIN_OUTPUT_BYTES = 1024 * 1024 # 1MB
class EncodeError(Exception):
"""{stage: input_validation|path_translate|ffmpeg|output_check, message: ...}"""
def __init__(self, stage: str, message: str):
self.stage = stage
self.message = message
super().__init__(f"[{stage}] {message}")
def translate_path(nas_path: str) -> str:
"""NAS 절대경로 → Windows SMB 경로."""
if not nas_path.startswith(NAS_VOLUME_PREFIX):
raise ValueError(f"NAS prefix 불일치: {nas_path}")
rel = nas_path[len(NAS_VOLUME_PREFIX):]
return WINDOWS_DRIVE_ROOT + rel.replace("/", "\\")
def encode_video(*, cover_path_nas: str, audio_path_nas: str,
output_path_nas: str, resolution: str,
duration_sec: int = 0, style: str = "visualizer") -> dict:
"""영상 인코딩 + Z:\\에 직접 저장."""
# 1) Resolution 검증
if not RESOLUTION_RE.match(resolution):
raise EncodeError("input_validation", f"invalid resolution: {resolution}")
w, h = resolution.split("x")
# 2) 경로 변환
try:
cover_win = translate_path(cover_path_nas)
audio_win = translate_path(audio_path_nas)
out_win = translate_path(output_path_nas)
except ValueError as e:
raise EncodeError("path_translate", str(e))
# 3) 입력 존재 확인
if not os.path.isfile(cover_win):
raise EncodeError("input_validation", f"cover not found: {cover_win}")
if not os.path.isfile(audio_win):
raise EncodeError("input_validation", f"audio not found: {audio_win}")
# 4) 출력 디렉토리 보장
os.makedirs(os.path.dirname(out_win), exist_ok=True)
# 5) ffmpeg 명령
cmd = [
FFMPEG_PATH, "-y",
"-hwaccel", "cuda",
"-loop", "1", "-i", cover_win,
"-i", audio_win,
"-filter_complex",
f"[0:v]scale={w}:{h},format=yuv420p[bg];"
f"[1:a]showwaves=s={w}x200:mode=cline:colors=0xFF4444@0.8[wave];"
f"[bg][wave]overlay=0:({h}-200)[out]",
"-map", "[out]", "-map", "1:a",
"-c:v", "h264_nvenc",
"-preset", "p4",
"-rc", "vbr",
"-cq", "23",
"-b:v", "0",
"-pix_fmt", "yuv420p",
"-c:a", "aac", "-b:a", "192k",
"-shortest", out_win,
]
logger.info("ffmpeg: %s", " ".join(cmd))
# 6) ffmpeg 실행
import time
t0 = time.time()
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=FFMPEG_TIMEOUT_S)
except subprocess.TimeoutExpired:
raise EncodeError("ffmpeg", f"timeout after {FFMPEG_TIMEOUT_S}s")
duration_ms = int((time.time() - t0) * 1000)
if result.returncode != 0:
raise EncodeError("ffmpeg", f"returncode={result.returncode}: {result.stderr[-800:]}")
# 7) 출력 검증
if not os.path.isfile(out_win):
raise EncodeError("output_check", "output file not created")
output_bytes = os.path.getsize(out_win)
if output_bytes < MIN_OUTPUT_BYTES:
raise EncodeError("output_check", f"output too small: {output_bytes} bytes")
return {
"ok": True,
"duration_ms": duration_ms,
"output_path_nas": output_path_nas,
"output_bytes": output_bytes,
"encoder": "h264_nvenc",
"preset": "p4",
}
def check_ffmpeg_nvenc() -> bool:
"""서버 시작 시 NVENC 가용성 확인."""
try:
result = subprocess.run(
[FFMPEG_PATH, "-encoders"],
capture_output=True, text=True, timeout=10,
)
return "h264_nvenc" in result.stdout
except Exception:
return False
```
### Step 4: Run tests
```bash
cd music_ai && python -m pytest tests/test_video_encoder.py -v
```
Expected: 6 PASS
### Step 5: Commit
```bash
cd C:/Users/jaeoh/Desktop/workspace/music_ai
git init 2>/dev/null || true # may not be a git repo, that's OK
# music_ai is local-only per CLAUDE.md, no remote push
```
(music_ai is local-only; just save the file. No git push needed.)
---
## Task 2: Windows `music_ai/server.py` — `/encode_video` endpoint + 헬스 확장
**Files:**
- Modify: `music_ai/server.py`
- Modify: `music_ai/.env.example`
### Step 1: Read existing server.py to understand FastAPI pattern + existing /health
### Step 2: Add `/encode_video` endpoint
```python
# server.py — 추가
from pydantic import BaseModel
from fastapi import HTTPException
import video_encoder
class EncodeVideoRequest(BaseModel):
cover_path_nas: str
audio_path_nas: str
output_path_nas: str
resolution: str = "1920x1080"
duration_sec: int = 0
style: str = "visualizer"
@app.post("/encode_video")
def encode_video_endpoint(req: EncodeVideoRequest):
try:
result = video_encoder.encode_video(
cover_path_nas=req.cover_path_nas,
audio_path_nas=req.audio_path_nas,
output_path_nas=req.output_path_nas,
resolution=req.resolution,
duration_sec=req.duration_sec,
style=req.style,
)
return result
except video_encoder.EncodeError as e:
# input_validation, path_translate → 400
# ffmpeg, output_check → 500
status_code = 400 if e.stage in ("input_validation", "path_translate") else 500
raise HTTPException(
status_code=status_code,
detail={"ok": False, "stage": e.stage, "error": e.message},
)
```
### Step 3: 확장된 `/health`
기존 `/health` 응답에 추가:
```python
import torch # if existing health uses it
import video_encoder
# Module-level cache so health doesn't run ffmpeg every call
_FFMPEG_NVENC_CACHED = None
def _ffmpeg_nvenc_available():
global _FFMPEG_NVENC_CACHED
if _FFMPEG_NVENC_CACHED is None:
_FFMPEG_NVENC_CACHED = video_encoder.check_ffmpeg_nvenc()
return _FFMPEG_NVENC_CACHED
@app.get("/health")
def health():
return {
"ok": True,
"gpu": torch.cuda.get_device_name(0) if torch.cuda.is_available() else None, # 또는 기존 형식 유지
"musicgen_loaded": True, # 기존 그대로
"ffmpeg_path": video_encoder.FFMPEG_PATH,
"ffmpeg_nvenc": _ffmpeg_nvenc_available(),
}
```
(기존 `/health`의 정확한 형식은 코드 읽고 매칭. 위는 예시.)
### Step 4: `.env.example` 업데이트
```env
# Existing
MODEL_NAME=facebook/musicgen-stereo-large
OUTPUT_DIR=output
SERVER_PORT=8765
# New for video encoder
NAS_VOLUME_PREFIX=/volume1/
WINDOWS_DRIVE_ROOT=Z:\
FFMPEG_PATH=C:\ffmpeg\bin\ffmpeg.exe
```
### Step 5: 수동 검증
```bash
cd music_ai && start.bat # 또는 적절한 시작 명령
curl http://localhost:8765/health
# Expected: {..., "ffmpeg_nvenc": true}
curl -X POST http://localhost:8765/encode_video -H "Content-Type: application/json" -d '{
"cover_path_nas": "/volume1/docker/webpage/data/videos/3/cover.jpg",
"audio_path_nas": "/volume1/docker/webpage/data/1c695df3-8a82-4c09-ba7b-82c07608ec5b.mp3",
"output_path_nas": "/volume1/docker/webpage/data/videos/test/video.mp4",
"resolution": "1920x1080",
"duration_sec": 176
}'
# Expected: 200 + duration_ms ~ 10-20초
```
(실제 파일 경로는 사용자 환경에 맞게 조정)
### Step 6: Commit (music_ai is local-only, no remote)
---
## Task 3: NAS music-lab — `pipeline/video.py` 재작성 + 테스트
**Files:**
- Rewrite: `music-lab/app/pipeline/video.py`
- Rewrite: `music-lab/tests/test_video_thumb.py` (video 부분만)
### Step 1: Replace failing tests
```python
# music-lab/tests/test_video_thumb.py — video 관련 테스트 부분만 교체
import pytest
import respx
import httpx
from httpx import Response
from app.pipeline import video, thumb, storage
@pytest.fixture
def encoder_env(monkeypatch):
monkeypatch.setenv("WINDOWS_VIDEO_ENCODER_URL", "http://192.168.45.59:8765")
monkeypatch.setattr(video, "ENCODER_URL", "http://192.168.45.59:8765")
@respx.mock
def test_generate_video_calls_remote_encoder(encoder_env, tmp_path, monkeypatch):
monkeypatch.setattr(storage, "VIDEO_DATA_DIR", str(tmp_path))
respx.post("http://192.168.45.59:8765/encode_video").mock(
return_value=Response(200, json={
"ok": True, "duration_ms": 12000,
"output_path_nas": "/volume1/docker/webpage/data/videos/3/video.mp4",
"output_bytes": 28000000,
"encoder": "h264_nvenc", "preset": "p4",
})
)
out = video.generate(
pipeline_id=3,
audio_path="/app/data/1c695df3.mp3",
cover_path="/app/data/videos/3/cover.jpg",
genre="lo-fi", duration_sec=120, resolution="1920x1080",
style="visualizer",
)
assert out["url"].endswith("/3/video.mp4")
assert out["used_fallback"] is False
assert out["encode_duration_ms"] == 12000
@respx.mock
def test_generate_video_raises_on_connection_error(encoder_env, monkeypatch, tmp_path):
monkeypatch.setattr(storage, "VIDEO_DATA_DIR", str(tmp_path))
respx.post("http://192.168.45.59:8765/encode_video").mock(
side_effect=httpx.ConnectError("Connection refused")
)
with pytest.raises(video.VideoGenerationError) as exc:
video.generate(
pipeline_id=4,
audio_path="/app/data/x.mp3", cover_path="/app/data/videos/4/cover.jpg",
genre="lo-fi", duration_sec=120, resolution="1920x1080",
)
assert "연결 실패" in str(exc.value) or "Connection" in str(exc.value)
@respx.mock
def test_generate_video_raises_on_500(encoder_env, monkeypatch, tmp_path):
monkeypatch.setattr(storage, "VIDEO_DATA_DIR", str(tmp_path))
respx.post("http://192.168.45.59:8765/encode_video").mock(
return_value=Response(500, json={"ok": False, "stage": "ffmpeg", "error": "bad codec"})
)
with pytest.raises(video.VideoGenerationError) as exc:
video.generate(
pipeline_id=5,
audio_path="/app/data/x.mp3", cover_path="/app/data/videos/5/cover.jpg",
genre="lo-fi", duration_sec=120, resolution="1920x1080",
)
assert "Windows 인코더 오류" in str(exc.value)
assert "ffmpeg" in str(exc.value)
def test_generate_video_no_url_configured(monkeypatch, tmp_path):
monkeypatch.setattr(storage, "VIDEO_DATA_DIR", str(tmp_path))
monkeypatch.setattr(video, "ENCODER_URL", "")
with pytest.raises(video.VideoGenerationError) as exc:
video.generate(
pipeline_id=6,
audio_path="/app/data/x.mp3", cover_path="/app/data/videos/6/cover.jpg",
genre="lo-fi", duration_sec=120, resolution="1920x1080",
)
assert "WINDOWS_VIDEO_ENCODER_URL" in str(exc.value)
def test_container_to_nas_videos_path(monkeypatch):
monkeypatch.setenv("NAS_VIDEOS_ROOT", "/volume1/docker/webpage/data/videos")
monkeypatch.setenv("NAS_MUSIC_ROOT", "/volume1/docker/webpage/data/music")
assert video._container_to_nas("/app/data/videos/3/cover.jpg") == "/volume1/docker/webpage/data/videos/3/cover.jpg"
def test_container_to_nas_music_path(monkeypatch):
monkeypatch.setenv("NAS_VIDEOS_ROOT", "/volume1/docker/webpage/data/videos")
monkeypatch.setenv("NAS_MUSIC_ROOT", "/volume1/docker/webpage/data/music")
assert video._container_to_nas("/app/data/abc.mp3") == "/volume1/docker/webpage/data/music/abc.mp3"
```
기존 `test_generate_video_calls_ffmpeg`, `test_generate_video_failure_marks_failed` 삭제. thumb 관련 테스트는 그대로 유지.
### Step 2: Run, verify fail
```bash
cd music-lab && python -m pytest tests/test_video_thumb.py -v
```
Expected: video 관련 테스트들이 실패 (또는 ImportError).
### Step 3: Rewrite `app/pipeline/video.py`
```python
"""영상 비주얼 생성 — Windows GPU 서버 (NVENC) 호출.
Windows 서버 다운/실패 시 즉시 예외 (NAS 로컬 폴백 없음 — 의도적 결정).
"""
import os
import logging
import httpx
from . import storage
logger = logging.getLogger("music-lab.video")
ENCODER_URL = os.getenv("WINDOWS_VIDEO_ENCODER_URL", "")
ENCODER_TIMEOUT_S = 200 # Windows 서버 ffmpeg 180s + 마진
# NAS 호스트 절대경로 prefix — docker bind mount의 host 측
NAS_VIDEOS_ROOT = os.getenv("NAS_VIDEOS_ROOT", "/volume1/docker/webpage/data/videos")
NAS_MUSIC_ROOT = os.getenv("NAS_MUSIC_ROOT", "/volume1/docker/webpage/data/music")
class VideoGenerationError(Exception):
pass
def generate(*, pipeline_id: int, audio_path: str, cover_path: str,
genre: str, duration_sec: int, resolution: str = "1920x1080",
style: str = "visualizer") -> dict:
"""원격 Windows GPU 서버 호출. 다운/실패 시 즉시 예외."""
if not ENCODER_URL:
raise VideoGenerationError(
"WINDOWS_VIDEO_ENCODER_URL 미설정 — Windows 인코더 서버 주소 필요"
)
out_path = os.path.join(storage.pipeline_dir(pipeline_id), "video.mp4")
nas_audio = _container_to_nas(audio_path)
nas_cover = _container_to_nas(cover_path)
nas_output = _container_to_nas(out_path)
payload = {
"cover_path_nas": nas_cover,
"audio_path_nas": nas_audio,
"output_path_nas": nas_output,
"resolution": resolution,
"duration_sec": duration_sec,
"style": style,
}
logger.info("Windows 인코더 호출: pipeline=%d audio=%s", pipeline_id, audio_path)
try:
with httpx.Client(timeout=ENCODER_TIMEOUT_S) as client:
resp = client.post(f"{ENCODER_URL}/encode_video", json=payload)
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout, httpx.NetworkError) as e:
raise VideoGenerationError(f"Windows 인코더 연결 실패: {e}")
if resp.status_code != 200:
try:
detail = resp.json().get("detail", resp.json())
except Exception:
detail = {"error": resp.text[:300]}
stage = detail.get("stage", "?") if isinstance(detail, dict) else "?"
error = detail.get("error", str(detail)) if isinstance(detail, dict) else str(detail)
raise VideoGenerationError(
f"Windows 인코더 오류 ({resp.status_code}): {stage}{error}"
)
data = resp.json()
if not data.get("ok"):
raise VideoGenerationError(f"Windows 인코더 응답 ok=false: {data}")
return {
"url": storage.media_url(pipeline_id, "video.mp4"),
"used_fallback": False,
"duration_sec": duration_sec,
"encode_duration_ms": data.get("duration_ms"),
"encoder": data.get("encoder", "h264_nvenc"),
}
def _container_to_nas(container_path: str) -> str:
""" /app/data/videos/3/cover.jpg → /volume1/docker/webpage/data/videos/3/cover.jpg
/app/data/abc.mp3 → /volume1/docker/webpage/data/music/abc.mp3
"""
if container_path.startswith("/app/data/videos/"):
return container_path.replace("/app/data/videos/", NAS_VIDEOS_ROOT + "/", 1)
if container_path.startswith("/app/data/"):
rel = container_path[len("/app/data/"):]
return NAS_MUSIC_ROOT + "/" + rel
return container_path
```
### Step 4: Run tests
```bash
cd music-lab && python -m pytest tests/ -v
```
Expected: 73 PASS — 2 (제거) + 6 (신규) = 77? 아니면 73 그대로 — count 확인.
### Step 5: Commit + push
```bash
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add music-lab/app/pipeline/video.py \
music-lab/tests/test_video_thumb.py
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(music-lab): 영상 인코딩을 Windows GPU 서버로 오프로드
- pipeline/video.py 재작성: subprocess.run 제거, httpx로 192.168.45.59:8765/encode_video 호출
- Windows 서버 다운 시 즉시 VideoGenerationError (NAS 로컬 폴백 X)
- /app/data/* → /volume1/docker/webpage/data/* 경로 변환 (_container_to_nas)
- 테스트는 respx mock 기반으로 교체 (6개 신규)"
git -C C:/Users/jaeoh/Desktop/workspace/web-backend push origin main
```
---
## Task 4: docker-compose.yml env 추가
**Files:**
- Modify: `web-backend/docker-compose.yml`
### Step 1: music-lab 서비스 environment에 추가
```yaml
music-lab:
environment:
# ... existing ...
- WINDOWS_VIDEO_ENCODER_URL=${WINDOWS_VIDEO_ENCODER_URL}
- NAS_VIDEOS_ROOT=${NAS_VIDEOS_ROOT:-/volume1/docker/webpage/data/videos}
- NAS_MUSIC_ROOT=${NAS_MUSIC_ROOT:-/volume1/docker/webpage/data/music}
```
### Step 2: docker-compose syntax 검증
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-backend && python -c "import yaml; yaml.safe_load(open('docker-compose.yml'))" && echo OK
```
### Step 3: Commit + push
```bash
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add docker-compose.yml
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "chore(infra): GPU 인코더 env 추가 (WINDOWS_VIDEO_ENCODER_URL)"
git -C C:/Users/jaeoh/Desktop/workspace/web-backend push origin main
```
---
## Task 5: 사용자 매뉴얼 단계 (사람이 직접)
후속 단계, 코드 작업 아님:
1. **Windows PC: ffmpeg 설치 + PATH 설정**
- https://www.gyan.dev/ffmpeg/builds/ → "release full" 다운로드
- `C:\ffmpeg\` 압축 해제 → `C:\ffmpeg\bin\ffmpeg.exe` 확인
- 시스템 PATH에 `C:\ffmpeg\bin` 추가
- 검증: `ffmpeg -version` + `ffmpeg -encoders | findstr h264_nvenc`
2. **Windows PC: `music_ai/.env` 추가**
```env
NAS_VOLUME_PREFIX=/volume1/
WINDOWS_DRIVE_ROOT=Z:\
FFMPEG_PATH=C:\ffmpeg\bin\ffmpeg.exe
```
3. **Windows PC: SMB 마운트 확인** — `Z:\docker\webpage\data\` 접근 가능
4. **Windows PC: `music_ai` 서버 재시작**`start.bat`
5. **Windows PC 헬스 체크**`curl http://localhost:8765/health``ffmpeg_nvenc: true` 확인
6. **NAS `.env`에 추가**
```env
WINDOWS_VIDEO_ENCODER_URL=http://192.168.45.59:8765
```
7. **NAS music-lab 재시작** — `docker compose up -d music-lab`
8. **E2E 테스트** — 진행 탭에서 새 파이프라인 시작, 영상 단계가 1020초에 완료되는지 확인
---
## Self-Review
**Spec coverage:**
- §4 Windows endpoint → Task 1, 2 ✓
- §5 NAS video.py → Task 3 ✓
- §6 에러 처리 → Task 3 (httpx 예외 catch) ✓
- §7 헬스 모니터링 → Task 2 (`/health` 확장) ✓
- §8 테스트 → Task 1, 3 ✓
- §9 Windows 사전 준비 → Task 5 (사용자 수동) ✓
- §10 산출물 → 4 task로 모두 커버
**Placeholder scan:** 없음.
**Type consistency:**
- `EncodeError(stage, message)` Task 1 정의, Task 2에서 `e.stage`/`e.message` 사용 ✓
- `VideoGenerationError` Task 3에서 raise, 기존 orchestrator에서 catch ✓
- 응답 JSON 형식 spec §4-2와 일치 ✓
- 환경변수 이름 일관 (`NAS_VOLUME_PREFIX`, `WINDOWS_DRIVE_ROOT`, `FFMPEG_PATH`, `WINDOWS_VIDEO_ENCODER_URL`, `NAS_VIDEOS_ROOT`, `NAS_MUSIC_ROOT`)
---

View File

@@ -0,0 +1,815 @@
# Batch Music Generation — Implementation Plan
> **For agentic workers:** Use `superpowers:subagent-driven-development`. Steps use `- [ ]` checkboxes.
**Goal:** 장르 1개로 N(1-10) 트랙 Suno 자동 순차 생성 + 자동 컴파일 + 영상 파이프라인 자동 시작.
**Architecture:** music-lab 신규 `batch_generator` 모듈이 BackgroundTask로 N회 Suno 호출 → compile_job 자동 생성 → orchestrator.run_step("cover") 자동 호출.
**Spec:** `docs/superpowers/specs/2026-05-10-batch-music-generation-design.md`
---
## File Structure
| 경로 | 책임 |
|------|------|
| `music-lab/app/db.py` (modify) | `music_batch_jobs` 테이블 + 5 헬퍼 |
| `music-lab/app/random_pools.py` (new) | 장르별 mood/instr/BPM/key/scale 랜덤 풀 + `randomize()` |
| `music-lab/app/batch_generator.py` (new) | `run_batch(batch_id)` 순차 오케스트레이션 |
| `music-lab/app/main.py` (modify) | 3개 endpoint (POST /generate-batch, GET /:id, GET 목록) |
| `web-ui/src/api.js` (modify) | 3개 헬퍼 |
| `web-ui/src/pages/music/components/BatchProgress.jsx` (new) | 진행 표시 컴포넌트 |
| `web-ui/src/pages/music/MusicStudio.jsx` (modify) | Create 탭에 배치 섹션 + 폴링 |
| `web-ui/src/pages/music/MusicStudio.css` (modify) | 배치 섹션 스타일 |
---
## Task 1: DB 테이블 + 헬퍼 + random_pools
**Files:**
- Modify: `music-lab/app/db.py`
- Create: `music-lab/app/random_pools.py`
- Test: `music-lab/tests/test_batch_db.py`
- [ ] **Step 1: random_pools.py 작성**
```python
"""장르별 음악 파라미터 랜덤 풀."""
import random
POOLS = {
"lo-fi": {
"moods": ["chill", "relaxing", "dreamy", "melancholic", "mellow", "nostalgic", "peaceful"],
"instruments_pool": ["piano", "synth", "drums", "vinyl", "rhodes", "soft bass", "ambient pads"],
"instruments_count": (3, 4),
"bpm": (70, 90),
"keys": ["C", "D", "F", "G", "A"],
"scales": ["minor", "major"],
"prompt_modifiers": ["cozy bedroom vibes", "rainy night", "late night study", "cafe ambience"],
},
"phonk": {
"moods": ["dark", "aggressive", "moody", "intense", "hypnotic"],
"instruments_pool": ["808 bass", "hi-hat", "synth lead", "vocal chops", "bass drops", "trap drums"],
"instruments_count": (3, 4),
"bpm": (130, 160),
"keys": ["C", "D", "F", "G"],
"scales": ["minor"],
"prompt_modifiers": ["drift atmosphere", "dark neon", "midnight drive"],
},
"ambient": {
"moods": ["peaceful", "meditative", "ethereal", "spacious", "dreamy"],
"instruments_pool": ["pad synths", "atmospheric guitar", "soft strings", "field recordings", "drone bass"],
"instruments_count": (2, 3),
"bpm": (50, 75),
"keys": ["C", "D", "E", "G", "A"],
"scales": ["major", "minor"],
"prompt_modifiers": ["misty mountain morning", "deep space", "still water", "forest dawn"],
},
"pop": {
"moods": ["uplifting", "happy", "energetic", "romantic", "catchy"],
"instruments_pool": ["acoustic guitar", "piano", "drums", "bass", "synth", "vocals harmonies"],
"instruments_count": (3, 5),
"bpm": (95, 130),
"keys": ["C", "D", "E", "F", "G", "A"],
"scales": ["major"],
"prompt_modifiers": ["radio-ready", "summer vibe", "feel-good"],
},
"default": {
"moods": ["chill", "relaxing", "uplifting", "mellow"],
"instruments_pool": ["piano", "synth", "drums", "guitar", "bass", "strings"],
"instruments_count": (3, 4),
"bpm": (80, 110),
"keys": ["C", "D", "F", "G", "A"],
"scales": ["minor", "major"],
"prompt_modifiers": [""],
},
}
def randomize(genre: str, rng=None) -> dict:
rng = rng or random.Random()
pool = POOLS.get(genre.lower(), POOLS["default"])
n_instr = rng.randint(*pool["instruments_count"])
instruments = rng.sample(pool["instruments_pool"], min(n_instr, len(pool["instruments_pool"])))
return {
"moods": [rng.choice(pool["moods"])],
"instruments": instruments,
"bpm": rng.randint(*pool["bpm"]),
"key": rng.choice(pool["keys"]),
"scale": rng.choice(pool["scales"]),
"prompt_modifier": rng.choice(pool["prompt_modifiers"]),
}
```
- [ ] **Step 2: DB 테이블 + 헬퍼 추가** (db.py)
`init_db()`에 추가:
```python
cursor.execute("""
CREATE TABLE IF NOT EXISTS music_batch_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
genre TEXT NOT NULL,
count INTEGER NOT NULL,
target_duration_sec INTEGER NOT NULL DEFAULT 180,
auto_pipeline INTEGER NOT NULL DEFAULT 1,
completed INTEGER NOT NULL DEFAULT 0,
track_ids_json TEXT NOT NULL DEFAULT '[]',
current_track_index INTEGER NOT NULL DEFAULT 0,
current_track_status TEXT,
status TEXT NOT NULL DEFAULT 'queued',
error TEXT,
compile_job_id INTEGER,
pipeline_id INTEGER,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
""")
```
`db.py` 끝에 헬퍼:
```python
_BATCH_ALLOWED_COLS = frozenset([
"completed", "track_ids_json", "current_track_index",
"current_track_status", "status", "error",
"compile_job_id", "pipeline_id",
])
def create_batch_job(genre: str, count: int, target_duration_sec: int = 180,
auto_pipeline: bool = True) -> int:
with _conn() as conn:
now = _now()
cur = conn.cursor()
cur.execute("""
INSERT INTO music_batch_jobs
(genre, count, target_duration_sec, auto_pipeline,
status, created_at, updated_at)
VALUES (?, ?, ?, ?, 'queued', ?, ?)
""", (genre, count, target_duration_sec, 1 if auto_pipeline else 0, now, now))
return cur.lastrowid
def get_batch_job(batch_id: int) -> dict | None:
with _conn() as conn:
row = conn.execute(
"SELECT * FROM music_batch_jobs WHERE id = ?", (batch_id,)
).fetchone()
if not row:
return None
d = dict(row)
d["track_ids"] = json.loads(d.get("track_ids_json") or "[]")
return d
def update_batch_job(batch_id: int, **fields) -> None:
unknown = set(fields) - _BATCH_ALLOWED_COLS
if unknown:
raise ValueError(f"unknown batch job columns: {unknown}")
cols = ", ".join(f"{k} = ?" for k in fields)
vals = list(fields.values()) + [_now(), batch_id]
with _conn() as conn:
conn.execute(
f"UPDATE music_batch_jobs SET {cols}, updated_at = ? WHERE id = ?",
vals,
)
def append_batch_track(batch_id: int, track_id: int) -> None:
"""track_ids_json에 새 track_id 추가 + completed += 1 (atomic)."""
with _conn() as conn:
row = conn.execute(
"SELECT track_ids_json, completed FROM music_batch_jobs WHERE id = ?",
(batch_id,),
).fetchone()
if not row:
return
ids = json.loads(row["track_ids_json"] or "[]")
ids.append(track_id)
conn.execute(
"UPDATE music_batch_jobs SET track_ids_json = ?, completed = ?, updated_at = ? WHERE id = ?",
(json.dumps(ids), row["completed"] + 1, _now(), batch_id),
)
def list_batch_jobs(active_only: bool = False) -> list[dict]:
sql = "SELECT * FROM music_batch_jobs"
if active_only:
sql += " WHERE status NOT IN ('failed','cancelled','piped')"
sql += " ORDER BY created_at DESC"
with _conn() as conn:
rows = conn.execute(sql).fetchall()
out = []
for r in rows:
d = dict(r)
d["track_ids"] = json.loads(d.get("track_ids_json") or "[]")
out.append(d)
return out
```
- [ ] **Step 3: Test 작성**
```python
# tests/test_batch_db.py
import pytest
from app import db
@pytest.fixture
def fresh_db(monkeypatch, tmp_path):
monkeypatch.setattr(db, "DB_PATH", str(tmp_path / "music.db"))
db.init_db()
return db
def test_create_batch_job(fresh_db):
bid = db.create_batch_job(genre="lo-fi", count=10)
j = db.get_batch_job(bid)
assert j["genre"] == "lo-fi"
assert j["count"] == 10
assert j["status"] == "queued"
assert j["track_ids"] == []
assert j["auto_pipeline"] == 1
def test_update_batch_job(fresh_db):
bid = db.create_batch_job(genre="phonk", count=5)
db.update_batch_job(bid, status="generating", current_track_index=2)
j = db.get_batch_job(bid)
assert j["status"] == "generating"
assert j["current_track_index"] == 2
def test_update_batch_rejects_unknown_col(fresh_db):
bid = db.create_batch_job(genre="lo-fi", count=1)
with pytest.raises(ValueError):
db.update_batch_job(bid, evil_col="x")
def test_append_batch_track(fresh_db):
bid = db.create_batch_job(genre="lo-fi", count=3)
db.append_batch_track(bid, 101)
db.append_batch_track(bid, 102)
j = db.get_batch_job(bid)
assert j["track_ids"] == [101, 102]
assert j["completed"] == 2
def test_list_batch_jobs_active_filter(fresh_db):
b1 = db.create_batch_job(genre="lo-fi", count=1)
b2 = db.create_batch_job(genre="phonk", count=1)
db.update_batch_job(b1, status="failed")
actives = db.list_batch_jobs(active_only=True)
assert all(j["status"] not in ("failed",) for j in actives)
assert any(j["id"] == b2 for j in actives)
assert not any(j["id"] == b1 for j in actives)
def test_random_pools_randomize():
from app.random_pools import randomize, POOLS
import random
rng = random.Random(42)
result = randomize("lo-fi", rng)
assert result["bpm"] in range(70, 91)
assert result["key"] in POOLS["lo-fi"]["keys"]
assert result["scale"] in POOLS["lo-fi"]["scales"]
assert len(result["moods"]) == 1
assert result["moods"][0] in POOLS["lo-fi"]["moods"]
assert 3 <= len(result["instruments"]) <= 4
def test_random_pools_unknown_genre_uses_default():
from app.random_pools import randomize, POOLS
import random
result = randomize("nonexistent", random.Random(0))
assert result["bpm"] in range(80, 111) # default range
```
- [ ] **Step 4: Run + commit**
```bash
cd music-lab && python -m pytest tests/test_batch_db.py -v
```
Expected: 7 PASS.
```bash
git add music-lab/app/db.py music-lab/app/random_pools.py music-lab/tests/test_batch_db.py
git commit -m "feat(music-lab): music_batch_jobs 테이블 + 장르별 랜덤 풀"
```
---
## Task 2: batch_generator + 3 엔드포인트
**Files:**
- Create: `music-lab/app/batch_generator.py`
- Modify: `music-lab/app/main.py`
- Test: `music-lab/tests/test_batch_endpoints.py`
- [ ] **Step 1: batch_generator.py 작성**
```python
"""배치 음악 생성 + 자동 컴파일·영상 파이프라인."""
import asyncio
import logging
from . import db
from .random_pools import randomize
logger = logging.getLogger("music-lab.batch")
POLL_INTERVAL_S = 5
TRACK_GEN_TIMEOUT_S = 240
async def run_batch(batch_id: int) -> None:
job = db.get_batch_job(batch_id)
if not job:
return
genre = job["genre"]
count = job["count"]
duration = job["target_duration_sec"]
auto_pipe = bool(job["auto_pipeline"])
db.update_batch_job(batch_id, status="generating")
track_ids: list[int] = []
for i in range(1, count + 1):
title = f"{genre.title()} Mix Track {i}"
params = randomize(genre)
db.update_batch_job(batch_id,
current_track_index=i,
current_track_status="generating")
track_id = await _generate_one_track(title=title, genre=genre,
duration_sec=duration,
params=params)
if track_id:
track_ids.append(track_id)
db.append_batch_track(batch_id, track_id)
db.update_batch_job(batch_id, current_track_status="succeeded")
else:
db.update_batch_job(batch_id, current_track_status="failed")
logger.warning("배치 %d 트랙 %d 실패 — 계속 진행", batch_id, i)
if not track_ids:
db.update_batch_job(batch_id, status="failed",
error="모든 트랙 생성 실패")
return
db.update_batch_job(batch_id, status="generated")
if not auto_pipe:
return
# 자동 컴파일
db.update_batch_job(batch_id, status="compiling")
try:
compile_id = db.create_compile_job(
title=f"{genre.title()} Mix",
track_ids=track_ids,
crossfade_sec=3,
)
db.update_batch_job(batch_id, compile_job_id=compile_id)
except Exception as e:
db.update_batch_job(batch_id, status="failed", error=f"compile create: {e}")
return
from . import compiler
try:
await asyncio.to_thread(compiler.run, compile_id)
except Exception as e:
db.update_batch_job(batch_id, status="failed", error=f"compile run: {e}")
return
job_after = db.get_compile_job(compile_id)
if not job_after or job_after.get("status") not in ("done", "succeeded"):
db.update_batch_job(
batch_id, status="failed",
error=f"compile not done (status={job_after.get('status') if job_after else 'unknown'})"
)
return
# 자동 영상 파이프라인
pipeline_id = db.create_pipeline(compile_job_id=compile_id)
db.update_batch_job(batch_id, pipeline_id=pipeline_id, status="piped")
from .pipeline import orchestrator
await orchestrator.run_step(pipeline_id, "cover")
async def _generate_one_track(*, title: str, genre: str, duration_sec: int,
params: dict) -> int | None:
"""기존 Suno generate 호출 + 완료까지 polling. 성공 시 새 track id 반환."""
from .suno_provider import run_suno_generation
from .db import create_task, get_task
import uuid
task_id = str(uuid.uuid4())
suno_params = {
"title": title,
"genre": genre,
"moods": params["moods"],
"instruments": params["instruments"],
"duration_sec": duration_sec,
"bpm": params["bpm"],
"key": params["key"],
"scale": params["scale"],
"prompt": params.get("prompt_modifier", ""),
}
create_task(task_id, suno_params, provider="suno")
# Suno background task 직접 호출 (BackgroundTasks 미사용 — 우리가 await)
asyncio.create_task(asyncio.to_thread(run_suno_generation, task_id, suno_params))
# Polling
waited = 0
while waited < TRACK_GEN_TIMEOUT_S:
await asyncio.sleep(POLL_INTERVAL_S)
waited += POLL_INTERVAL_S
task = get_task(task_id)
if not task:
continue
if task.get("status") == "succeeded":
tr = task.get("track")
return tr.get("id") if tr else None
if task.get("status") == "failed":
return None
return None # timeout
```
NOTE: This assumes existing `db.create_task`, `db.get_task`, `suno_provider.run_suno_generation` are reusable. Read existing code to confirm function signatures, adjust if needed (especially `task["track"]["id"]` vs other format).
- [ ] **Step 2: main.py에 3 endpoint 추가**
```python
from app.batch_generator import run_batch as _run_batch
class BatchGenerateRequest(BaseModel):
genre: str
count: int = 10
target_duration_sec: int = 180
auto_pipeline: bool = True
@app.post("/api/music/generate-batch", status_code=201)
async def generate_batch(req: BatchGenerateRequest, bg: BackgroundTasks):
if not (1 <= req.count <= 10):
raise HTTPException(400, "count는 1-10 사이")
if not (60 <= req.target_duration_sec <= 300):
raise HTTPException(400, "target_duration_sec는 60-300 사이")
if not req.genre:
raise HTTPException(400, "genre 필수")
if not SUNO_API_KEY:
raise HTTPException(400, "SUNO_API_KEY 미설정")
batch_id = _db_module.create_batch_job(
genre=req.genre, count=req.count,
target_duration_sec=req.target_duration_sec,
auto_pipeline=req.auto_pipeline,
)
bg.add_task(_run_batch, batch_id)
return _db_module.get_batch_job(batch_id)
@app.get("/api/music/generate-batch/{batch_id}")
def get_batch(batch_id: int):
j = _db_module.get_batch_job(batch_id)
if not j:
raise HTTPException(404)
# tracks 메타 LEFT JOIN (id, title, audio_url)
if j["track_ids"]:
ids_csv = ",".join(str(i) for i in j["track_ids"])
# 간단한 in-Python 매핑 (sqlite IN (...))
import sqlite3
conn = sqlite3.connect(_db_module.DB_PATH)
conn.row_factory = sqlite3.Row
rows = conn.execute(
f"SELECT id, title, audio_url, duration_sec FROM music_library WHERE id IN ({ids_csv})"
).fetchall()
conn.close()
j["tracks"] = [dict(r) for r in rows]
else:
j["tracks"] = []
return j
@app.get("/api/music/generate-batch")
def list_batches(status: str = "all"):
return {"batches": _db_module.list_batch_jobs(active_only=(status == "active"))}
```
(SUNO_API_KEY는 main.py에 이미 import돼있다고 가정. 없으면 `_db_module` 패턴처럼 처리.)
- [ ] **Step 3: 테스트 작성**
```python
# tests/test_batch_endpoints.py
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from fastapi.testclient import TestClient
from app.main import app
from app import db
@pytest.fixture
def client(monkeypatch, tmp_path):
monkeypatch.setattr(db, "DB_PATH", str(tmp_path / "music.db"))
db.init_db()
monkeypatch.setenv("SUNO_API_KEY", "test")
return TestClient(app)
def test_create_batch_201(client):
with patch("app.main._run_batch", new=AsyncMock()):
r = client.post("/api/music/generate-batch",
json={"genre": "lo-fi", "count": 3})
assert r.status_code == 201
body = r.json()
assert body["genre"] == "lo-fi"
assert body["count"] == 3
assert body["status"] == "queued"
def test_create_batch_rejects_count_too_high(client):
r = client.post("/api/music/generate-batch",
json={"genre": "lo-fi", "count": 11})
assert r.status_code == 400
def test_create_batch_rejects_count_zero(client):
r = client.post("/api/music/generate-batch",
json={"genre": "lo-fi", "count": 0})
assert r.status_code == 400
def test_create_batch_rejects_no_genre(client):
r = client.post("/api/music/generate-batch", json={"count": 3})
# Pydantic missing 필드 → 422 (FastAPI default validation)
assert r.status_code in (400, 422)
def test_get_batch_returns_tracks(client):
bid = db.create_batch_job(genre="lo-fi", count=2)
db.append_batch_track(bid, 999) # phantom track id (not in library)
r = client.get(f"/api/music/generate-batch/{bid}")
assert r.status_code == 200
body = r.json()
assert body["track_ids"] == [999]
# tracks 배열은 비어있음 (해당 track 미존재)
assert body["tracks"] == []
def test_list_batches(client):
db.create_batch_job(genre="lo-fi", count=1)
db.create_batch_job(genre="phonk", count=2)
r = client.get("/api/music/generate-batch")
assert len(r.json()["batches"]) == 2
```
- [ ] **Step 4: Run + commit + push**
```bash
cd music-lab && python -m pytest tests/ -v
```
Expected: 모두 PASS.
```bash
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add music-lab/app/batch_generator.py \
music-lab/app/main.py \
music-lab/tests/test_batch_endpoints.py
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(music-lab): 배치 음악 생성 endpoint + orchestrator"
git -C C:/Users/jaeoh/Desktop/workspace/web-backend push origin main
```
---
## Task 3: Frontend Create 탭 배치 섹션
**Files:**
- Modify: `web-ui/src/api.js`
- Create: `web-ui/src/pages/music/components/BatchProgress.jsx`
- Modify: `web-ui/src/pages/music/MusicStudio.jsx`
- Modify: `web-ui/src/pages/music/MusicStudio.css`
- [ ] **Step 1: api.js 헬퍼**
```javascript
// === Batch generation ===
export const startBatchGen = (payload) => apiPost('/api/music/generate-batch', payload);
export const getBatchJob = (id) => apiGet(`/api/music/generate-batch/${id}`);
export const listBatchJobs = (status='all') => apiGet(`/api/music/generate-batch?status=${status}`);
```
- [ ] **Step 2: BatchProgress.jsx 신규**
```jsx
const STATUS_LABELS = {
queued: '대기 중', generating: '음악 생성 중', generated: '음악 완료, 컴파일 대기',
compiling: '컴파일 중', piped: '영상 파이프라인 시작됨',
failed: '실패', cancelled: '취소',
};
export default function BatchProgress({ batch }) {
if (!batch) return null;
const trackList = Array.from({ length: batch.count }, (_, i) => i + 1);
return (
<div className="ms-batch-progress">
<div className="ms-batch-header">
배치 #{batch.id} {batch.genre} ·{' '}
{batch.completed}/{batch.count} 완료 ·{' '}
<strong>{STATUS_LABELS[batch.status] || batch.status}</strong>
</div>
{batch.error && <div className="ms-error">에러: {batch.error}</div>}
<ol className="ms-batch-tracks">
{trackList.map(n => {
const completed = n <= batch.completed;
const current = n === batch.current_track_index && batch.status === 'generating';
const tr = (batch.tracks || [])[n - 1];
return (
<li key={n} className={completed ? 'done' : current ? 'current' : 'pending'}>
{completed ? '✓' : current ? '⏳' : '○'}
{' '}Track {n}: {tr?.title || (current ? '생성 중...' : '대기')}
</li>
);
})}
</ol>
{batch.compile_job_id && (
<div className="ms-batch-link">📀 컴파일 #{batch.compile_job_id}</div>
)}
{batch.pipeline_id && (
<div className="ms-batch-link">
🎬 영상 파이프라인 #{batch.pipeline_id}
{' '}<em>YouTube 진행 탭에서 확인</em>
</div>
)}
</div>
);
}
```
- [ ] **Step 3: MusicStudio.jsx Create 탭에 배치 섹션 추가**
Create 탭 jsx 영역 (handleGenerate 근처) 위 또는 옆에:
```jsx
import BatchProgress from './components/BatchProgress';
import { startBatchGen, getBatchJob } from '../../api';
// 컴포넌트 내부 state:
const [batchOpen, setBatchOpen] = useState(false);
const [batchGenre, setBatchGenre] = useState('lo-fi');
const [batchCount, setBatchCount] = useState(10);
const [batchDuration, setBatchDuration] = useState(180);
const [batchAutoPipe, setBatchAutoPipe] = useState(true);
const [currentBatch, setCurrentBatch] = useState(null);
const [batchPolling, setBatchPolling] = useState(false);
const batchPollRef = useRef(null);
const startBatch = async () => {
try {
const res = await startBatchGen({
genre: batchGenre,
count: batchCount,
target_duration_sec: batchDuration,
auto_pipeline: batchAutoPipe,
});
setCurrentBatch(res);
setBatchPolling(true);
} catch (e) {
alert(`배치 시작 실패: ${e.message || e}`);
}
};
useEffect(() => {
if (!batchPolling || !currentBatch?.id) return;
const tick = async () => {
const j = await getBatchJob(currentBatch.id).catch(() => null);
if (j) {
setCurrentBatch(j);
if (['piped', 'failed', 'cancelled'].includes(j.status)) {
setBatchPolling(false);
if (j.pipeline_id) loadLibrary?.(); // refresh library to show new tracks
}
}
};
batchPollRef.current = setInterval(tick, 5000);
return () => clearInterval(batchPollRef.current);
}, [batchPolling, currentBatch?.id]);
// ... Create 탭 jsx 안:
<details className="ms-batch-section" open={batchOpen} onToggle={(e) => setBatchOpen(e.target.open)}>
<summary>🎲 배치 생성 (장르 1-10트랙 + 자동 영상)</summary>
<div className="ms-batch-form">
<label>장르
<select value={batchGenre} onChange={e => setBatchGenre(e.target.value)}>
<option value="lo-fi">Lo-Fi</option>
<option value="phonk">Phonk</option>
<option value="ambient">Ambient</option>
<option value="pop">Pop</option>
</select>
</label>
<label>트랙 : {batchCount}
<input type="range" min={1} max={10} value={batchCount}
onChange={e => setBatchCount(parseInt(e.target.value))} />
</label>
<label>트랙당 길이: {batchDuration}
<input type="range" min={60} max={300} step={10} value={batchDuration}
onChange={e => setBatchDuration(parseInt(e.target.value))} />
</label>
<label className="ms-batch-checkbox">
<input type="checkbox" checked={batchAutoPipe}
onChange={e => setBatchAutoPipe(e.target.checked)} />
모든 트랙 생성 자동 영상 파이프라인 시작
</label>
<p className="ms-batch-estimate">
예상: {Math.ceil(batchCount * 1.5)}-{batchCount * 2} ·
비용 ~${(batchCount * 0.005 + (batchAutoPipe ? 0.05 : 0)).toFixed(2)}
</p>
<button className="button primary" onClick={startBatch}
disabled={batchPolling}>
🎵 배치 생성 시작
</button>
</div>
{currentBatch && <BatchProgress batch={currentBatch} />}
</details>
```
- [ ] **Step 4: CSS 추가**
```css
/* === Batch generation section === */
.ms-batch-section { margin: 16px 0; padding: 12px; background: rgba(0,0,0,.2);
border: 1px solid var(--ms-line, #2a2a3a); border-radius: 12px; }
.ms-batch-section summary { cursor: pointer; font-weight: bold; color: var(--ms-text, #f0f0f5); }
.ms-batch-form { display: flex; flex-direction: column; gap: 10px; padding: 12px 0; }
.ms-batch-form label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
.ms-batch-form input[type="range"] { width: 100%; }
.ms-batch-checkbox { flex-direction: row !important; align-items: center; gap: 8px; }
.ms-batch-checkbox input { width: auto; }
.ms-batch-estimate { font-size: 12px; color: var(--ms-muted, #a0a0b0); }
.ms-batch-progress { margin-top: 12px; padding: 12px; background: rgba(0,0,0,.3);
border-radius: 8px; }
.ms-batch-header { font-size: 13px; margin-bottom: 8px; }
.ms-batch-tracks { padding-left: 24px; font-size: 12px; }
.ms-batch-tracks li { margin: 2px 0; }
.ms-batch-tracks li.done { color: #86efac; }
.ms-batch-tracks li.current { color: var(--ms-accent, #38bdf8); font-weight: bold; }
.ms-batch-tracks li.pending { color: var(--ms-muted, #a0a0b0); }
.ms-batch-link { margin-top: 8px; font-size: 12px; color: var(--ms-muted, #a0a0b0); }
```
- [ ] **Step 5: Build + verify + commit + push + deploy**
```bash
cd web-ui && npm run build 2>&1 | tail -5
npx eslint src/pages/music/components/BatchProgress.jsx src/pages/music/MusicStudio.jsx 2>&1 | tail
```
```bash
git -C C:/Users/jaeoh/Desktop/workspace/web-ui add src/api.js \
src/pages/music/components/BatchProgress.jsx \
src/pages/music/MusicStudio.jsx \
src/pages/music/MusicStudio.css
git -C C:/Users/jaeoh/Desktop/workspace/web-ui commit -m "feat(web-ui): Create 탭 배치 생성 섹션 + BatchProgress"
git -C C:/Users/jaeoh/Desktop/workspace/web-ui push origin main
cd C:/Users/jaeoh/Desktop/workspace/web-ui && npm run release:nas
```
---
## Task 4: 수동 E2E 검증
- [ ] Create 탭 → 배치 생성 섹션 펼침 → genre=lo-fi, count=3 (테스트로 적게), duration=120s, auto_pipeline=on → "배치 생성 시작"
- [ ] BatchProgress에 Track 1/2/3 진행 표시 확인
- [ ] ~5분 후 Library에 3개 트랙 추가됨
- [ ] 컴파일 진행 확인 (status: compiling)
- [ ] 영상 파이프라인 시작됨 (status: piped) + pipeline_id 표시
- [ ] YouTube 탭 → 진행 탭에 새 카드, cover 단계 진행 중
- [ ] 텔레그램에 cover 알림 도착
- [ ] 일반 흐름대로 5단계 승인 후 발행
---
## Self-Review
**Spec coverage:**
- §3 사용자 흐름 → Task 3 (UI 섹션)
- §4 데이터 모델 → Task 1
- §5 백엔드 (random_pools, batch_generator) → Task 1, 2
- §6 API → Task 2
- §7 프론트엔드 → Task 3
- §8 에러 처리 → Task 2 (validation, try/except)
- §9 테스트 → Task 1, 2
- §10 산출물 → 4 task로 모두 커버
**Placeholder scan:** 없음.
**Type consistency:**
- `batch_id` int, `count` int, `genre` str — 일관
- `track_ids` list[int]
- `status` 7값 (queued/generating/generated/compiling/piped/failed/cancelled) 일관
**스펙 보정:** §5-2 batch_generator의 `_generate_one_track`에서 `db.create_task`/`db.get_task` 사용 — 이 함수들이 기존 db.py에 있는지 미확인. Task 2 Step 1 NOTE에 명시함.

Some files were not shown because too many files have changed in this diff Show More