602 Commits

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

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

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

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

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

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

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

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

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

테스트 8/8 PASS (기존 6 + placeholder/fallback 2 신규).
2026-05-17 09:21:38 +09:00
ad6c744f2c fix(deploy): increase docker/buildkit/pip timeouts for NAS slow build
webhook 자동 배포가 pip install (pytrends 추가 후 75s+)에서 buildkit
context deadline exceeded로 실패하던 이슈 대응. scripts/deploy.sh
상단에 COMPOSE_HTTP_TIMEOUT/DOCKER_CLIENT_TIMEOUT/BUILDKIT_STEP_LOG_MAX_SIZE
10분 환경변수 설정 + insta-lab Dockerfile의 pip install에 --timeout 600
--retries 5 추가. NAS Celeron J4025 환경 영구 대응.
2026-05-17 09:03:20 +09:00
aad9bfbe8b Merge pull request 'feat/insta-trends' (#4) from feat/insta-trends into main
Reviewed-on: https://gitea.gahusb.synology.me/gahusb/web-page-backend/pulls/4
2026-05-17 08:52:49 +09:00
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: https://gitea.gahusb.synology.me/gahusb/web-page-backend/pulls/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
24a57f2b69 docs: STATUS.md — 서비스 현황 + 향후 계획 정리
10개 컨테이너 운영 상태와 Phase별 향후 작업 인덱스. CLAUDE.md를 보완.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:57:28 +09:00
b9d3242341 Merge branch 'feat/packs-lab-infra'
packs-lab 인프라 통합 + admin mint-token 구현 — 9 task TDD

- conftest.py로 테스트 HMAC secret 통일
- POST /api/packs/admin/mint-token 라우트 (Vercel HMAC → 일회성 upload 토큰)
- 기존 4 라우트 회귀 테스트 + DSM client mock 테스트
- Supabase pack_files DDL + 활성/삭제 인덱스
- docker-compose 18950 + nginx /api/packs/ 5GB streaming + env 8개
- PACK_BASE_DIR 환경변수화 (마운트 경로와 정합성 확보)
- web-backend CLAUDE.md 5곳 + workspace CLAUDE.md 1줄 갱신
- 24 tests passing
2026-05-06 03:35:37 +09:00
5e9a51c9e8 docs(claude): packs-lab 10번째 서비스로 등록 (포트/라우팅/API 표 + 신규 섹션)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 01:42:15 +09:00
5844567048 fix(packs-lab): PACK_BASE_DIR을 환경변수로 — 컨테이너 마운트 경로와 routes 정합성 확보
이전 docker-compose는 컨테이너 내부 /app/data/packs로 마운트하지만 routes.py는
/volume1/docker/webpage/media/packs를 하드코딩하고 있어 mismatch였다.

- routes.py: PACK_BASE_DIR = Path(os.getenv("PACK_BASE_DIR", "/app/data/packs"))
- docker-compose: PACK_BASE_DIR env 추가 + volume 마운트가 같은 경로 사용
- .env.example: PACK_BASE_DIR 신규 명시 (마운트 경로와 반드시 일치 안내)
2026-05-06 01:40:07 +09:00
0906c3ba35 chore(infra): packs-lab 서비스 통합 (compose 18950 + nginx 5GB streaming + env 7개)
- docker-compose.yml: 포트 18910→18950 수정, env 형식을 list 스타일로 통일,
  TZ/UPLOAD_TOKEN_TTL_SEC 추가, volume 경로를 /app/data/packs으로 정정
- .env.example: packs-lab 섹션 신규 추가 (DSM_HOST/DSM_USER/DSM_PASS/
  BACKEND_HMAC_SECRET/SUPABASE_URL/SUPABASE_SERVICE_KEY/UPLOAD_TOKEN_TTL_SEC/PACK_DATA_PATH)
- nginx/default.conf: 이전 커밋(9a0bbec)에 이미 포함 — 변경 없음

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 01:37:29 +09:00
ff4ef299ad feat(packs-lab): Supabase pack_files DDL + 활성/삭제 인덱스 2026-05-06 01:35:19 +09:00
5ebcbae8b5 docs(packs-lab): routes 모듈 docstring 정리 (mint-token 추가, DSM 자동 만료 명시) 2026-05-06 01:34:47 +09:00
1cd3cf8830 test(packs-lab): DSM client mock 테스트 (login/share/logout 순서)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 01:33:11 +09:00
c18fd8e52b test(packs-lab): upload size/replay + delete soft-delete + list filter 회귀 테스트
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 01:30:46 +09:00
dc482b32e4 feat(packs-lab): POST /api/packs/admin/mint-token 라우트 + 통합 테스트
MintTokenRequest/Response 스키마 추가, mint_token 라우트 구현 (HMAC 인증 + 확장자 검증 + JTI 발급), 테스트 3건 추가.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 01:27:43 +09:00
ef026e7ac6 test(packs-lab): conftest로 HMAC secret 통일
모든 테스트에서 BACKEND_HMAC_SECRET 환경변수와 auth._SECRET 모듈 캐시를
동일한 값으로 설정하는 autouse fixture 추가.
기존 test_auth.py / test_routes.py와 동일한 secret 값 사용.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 01:24:17 +09:00
80a54d056e docs(plan): packs-lab 인프라 통합 + admin mint-token 구현 계획
9 task TDD 분할:
- Task 1: tests/conftest.py — autouse HMAC secret
- Task 2: admin mint-token (스키마 + 라우트 + 통합 테스트 3건)
- Task 3: 기존 4 라우트 회귀 테스트 (sign-link/upload/list/delete, 8건)
- Task 4: test_dsm_client.py — DSM 7.x mock (4건)
- Task 5: routes 모듈 docstring 정리
- Task 6: Supabase pack_files DDL
- Task 7: 인프라 통합 (compose 18950 + nginx 5GB streaming + env 7개)
- Task 8: CLAUDE.md 5곳 + workspace 1줄
- Task 9: 회귀 검증 + NAS 디렉토리 가이드

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:42:41 +09:00
83192eb66c docs(spec): packs-lab 인프라 통합 + admin mint-token 설계
- POST /api/packs/admin/mint-token (Vercel HMAC → 일회성 upload 토큰)
- Supabase pack_files DDL + 활성/삭제 인덱스
- docker-compose 18950 + nginx 5GB streaming + .env.example 6+1 환경변수
- tests: routes 통합 + DSM client mock + autouse HMAC fixture
- CLAUDE.md: web-backend 5곳 + workspace 1곳 갱신
- DELETE 라우트 docstring 정리(자동 만료)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:51:24 +09:00
9a0bbeccd5 feat(packs-lab): docker-compose 서비스 + nginx 라우팅 (5GB body limit) 2026-05-02 08:57:36 +09:00
7a9690526a test(packs-lab): routes 통합 테스트 (DSM·supabase mock) 2026-05-02 08:55:26 +09:00
7a7e3d1ce0 fix(packs-lab): 누락된 A1-A3 파일 복구 (Dockerfile + auth + DSM client + tests)
Tasks A1-A3 의 파일이 working tree에 있었으나 git에 stage되지 않은 상태에서
A4 commit이 진행되어 routes.py 의 import (auth, dsm_client)가 깨진 상태였음.
복구: 8 files 일괄 commit.

- packs-lab/Dockerfile
- packs-lab/app/__init__.py
- packs-lab/app/requirements.txt
- packs-lab/app/auth.py (HMAC verify_request_hmac + verify_upload_token)
- packs-lab/app/dsm_client.py (DSM 7.x Sharing.create wrapper)
- packs-lab/tests/{__init__.py, test_auth.py}
- packs-lab/pytest.ini
2026-05-02 08:53:26 +09:00
eb547a0367 feat(packs-lab): 4 라우트 — sign-link, upload, list, delete (HMAC + supabase) 2026-05-02 08:52:24 +09:00
096e291ed8 feat(music-lab): 다중 트랙 컴파일 백엔드 (FFmpeg concat+crossfade → MP4)
- db.py: compile_jobs 테이블 추가 + CRUD 5종 (create/get/list/update/delete)
- compiler.py: acrossfade 필터 체인 + 그라디언트 배경 + MP4 렌더링 워커
- main.py: /api/music/compile POST·GET·DELETE + /api/music/compiles GET + /api/music/compile/{id}/export GET

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 16:54:53 +09:00
7c8d079f74 fix(music-lab): trend report list에 top_genres/recommended_styles 포함
GET /api/music/market/report 응답에 top_genres, recommended_styles를 포함해
히스토리 리포트 클릭 시 장르 차트와 Suno 프롬프트가 비어 보이는 버그 수정.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 15:03:27 +09:00
85e5f96379 docs(plan): music YouTube 탭 프론트엔드 구현 플랜
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 14:40:04 +09:00
47a4b1e231 docs(spec): music YouTube 탭 프론트엔드 설계 스펙
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 14:32:32 +09:00
be0094b83f feat: YouTube 수익화 파이프라인 — 영상 제작·수익 추적·시장 조사 에이전트
- music-lab: FFmpeg 비주얼라이저·슬라이드쇼 영상 제작 (video_producer.py)
- music-lab: 영상 프로젝트 6개 + 수익화 5개 + 시장조사 5개 API 추가
- music-lab: video_projects·revenue_records·market_trends·trend_reports DB 추가
- agent-office: YouTubeResearchAgent (YouTube API·pytrends·Billboard 수집, 09:00 daily)
- agent-office: youtube_researcher.py + /youtube/research API 2개 추가
- infra: Dockerfile ffmpeg + Nginx /media/videos/ + docker-compose 볼륨·환경변수
2026-05-01 12:50:38 +09:00
e948393906 docs(CLAUDE.md): YouTube 수익화 기능 — API·DB·환경변수·스케줄러 문서 업데이트 2026-05-01 12:37:30 +09:00
0beceefeef fix(deploy): docker-compose에 PEXELS_API_KEY·YOUTUBE_DATA_API_KEY·VIDEO_DATA_DIR 환경변수 추가 2026-05-01 12:35:44 +09:00
355667cf9c fix(music-lab): market API 타입 강화·ANTHROPIC_API_KEY call-time·HTTP 레이어 테스트 추가 2026-05-01 12:32:24 +09:00
26b9eea0dc feat(music-lab): market_trends·trend_reports DB + market.py + /api/music/market 5개 API
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 12:26:37 +09:00
3b9dcfe0dd fix(agent-office): YouTubeResearchAgent 품질 개선 (동시실행 가드·에러 로깅·타입 수정)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 12:22:33 +09:00
1d4354e402 feat(agent-office): YouTubeResearchAgent + 스케줄러 + /youtube/research API
- db.py: youtube_research_jobs 테이블 추가 + CRUD 3종 (add/update/get_latest)
- agents/youtube.py: YouTubeResearchAgent 신규 구현 (on_schedule/on_command/on_approval/_run_research/send_weekly_report)
- agents/__init__.py: YouTubeResearchAgent 등록
- scheduler.py: youtube_research(매일 09:00) + youtube_weekly_report(월 08:00) cron 추가
- main.py: POST /api/agent-office/youtube/research + GET /api/agent-office/youtube/research/status 엔드포인트 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 12:13:12 +09:00
8604c6292d fix(agent-office): get_running_loop + pytrends timeout + UA 수정
- asyncio.get_event_loop() → asyncio.get_running_loop() (python 3.10+ 권장)
- TrendReq에 timeout=(5, 15) 추가 (connect, read timeout)
- User-Agent에서 'bot' 제거: 표준 Chrome UA로 변경

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 12:10:09 +09:00
21666f4372 feat(agent-office): youtube_researcher — YouTube API·pytrends·Billboard 수집
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 12:05:58 +09:00
f83b900320 fix: frontend 서비스에 /data/videos 볼륨 마운트 추가 2026-05-01 12:04:32 +09:00
a7b2fc0d9d chore: FFmpeg 설치 + Nginx /media/videos/ + docker-compose volumes + 환경변수 2026-05-01 12:03:19 +09:00
327d0b4e81 fix(music-lab): VIDEO_DATA_DIR 기본값 통일 + lazy import 정리
- VIDEO_DATA_DIR 기본값을 /app/data/videos로 수정 (기존 /app/data에 videos 서브디렉토리를 중복 붙이던 버그 수정)
- delete_project, export_project의 경로에서 중복된 "videos" 서브디렉토리 제거
- create_project 내부의 get_track_by_id lazy import를 파일 상단 import 블록으로 이동
2026-05-01 12:01:59 +09:00
8e7a3806c5 feat(music-lab): 영상 프로젝트 6개 + 수익화 5개 API 추가
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 11:59:11 +09:00
abf475433b fix(music-lab): xfade offset 누적 오차 수정 + 테스트 보강
- _build_slideshow_cmd: offset 공식을 `duration_per_image * i - xd * i`로 수정 (누적 전환 오차 제거)
- _generate_metadata: genre 빈 문자열일 때 yt_tags에 빈 문자열 삽입 방지
- test: VIDEO_DATA_DIR 패치를 monkeypatch로 교체 (자동 복원 보장)
- test: xfade offset 값 검증 테스트 추가 (29.00, 58.00)
- test: 미사용 import 제거 (pytest, sqlite3)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 11:56:41 +09:00
7336fd090e feat(music-lab): video_producer — FFmpeg 비주얼라이저·슬라이드쇼 + Claude 메타데이터
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 11:49:42 +09:00
62d79b2669 fix(music-lab): revenue avg_rpm 공식 수정 + UNIQUE 제약 + 테스트 보강
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 11:46:00 +09:00
56fbe3fc4b fix(agent-office): realestate 에이전트 텔레그램 명령 등록
AGENT_META + AGENT_COMMAND_MAP에 realestate 누락으로 /realestate 명령 인식 불가.
- /realestate matches → fetch_matches
- /realestate dashboard → dashboard
- /help에 청약 에이전트 항목 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 11:45:45 +09:00
a5495aeaa4 feat(music-lab): video_projects·revenue_records DB 마이그레이션 + CRUD
- init_db()에 video_projects, revenue_records 테이블 추가 (CREATE IF NOT EXISTS)
- video_projects CRUD: create/get/get_all/update_status/delete + get_track_by_id
- revenue_records CRUD: create/get_all/update/delete/get_revenue_dashboard (RPM 자동 계산)
- TDD: tests/test_db_video.py 5개 테스트 모두 PASSED
- pytest.ini 추가 (pythonpath=. 설정)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 11:41:07 +09:00
88b5ea9ce2 chore: .worktrees/ gitignore 추가 2026-05-01 11:36:56 +09:00
54d67f892c docs(spec): music-lab YouTube 수익화 고도화 설계 문서 추가
시장 조사 자동화 + 영상 제작 파이프라인 + 수익화 추적 전체 설계.
Phase 1(영상 제작) → Phase 2(시장 조사) → Phase 3(YouTube API) 로드맵 포함.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 11:14:25 +09:00
8411e2c73e feat(realestate): 결과발표 공고 매칭 점수 보존
run_matching() 대상을 '청약예정/청약중/결과발표'로 확장.
삭제는 '완료' 상태만 — 결과발표 단계의 매칭 기록은 유지.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 10:32:58 +09:00
86a6b75124 feat(realestate): API 수집 retry 로직 추가 (최대 2회, 지수 백오프)
페이지 요청 실패 시 즉시 중단 대신 1초·2초 대기 후 재시도.
2회 모두 실패 시에만 해당 엔드포인트 수집 종료, 나머지 엔드포인트 계속 진행.
JSON 파싱 오류는 재시도 없이 즉시 skip.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 10:32:26 +09:00
08a32e4357 feat(realestate): 신혼부부 특공 혼인 기간 검증 추가 (84개월 이내)
marriage_months 미입력 시 기존대로 통과, 85개월 이상이면 신혼부부 특공 자격 제외.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 10:30:39 +09:00
f6de95afb6 feat(realestate): 소득 기준 검증 추가 (특별공급 자격 판정)
income_level(도시근로자 월평균 대비 %) 필드를 활용하여 특별공급 자격 검증:
- 신혼부부·생애최초: 160% 이하 (맞벌이 민간분양 상한)
- 신생아: 200% 이하 (맞벌이 기준)
- 청년: 140% 이하
- 다자녀·노부모부양: 소득 기준 없음
- 미입력 시 검증 생략 (기존 동작 유지)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 10:12:21 +09:00
caacb072a2 feat(realestate): 5축 점수 breakdown + 대시보드 pass_count
- matcher: _compute_score()에 score_breakdown {region/type/area/price/eligibility} 반환
- matcher: run_matching() DB INSERT에 score_breakdown JSON 저장
- db: match_results에 score_breakdown 컬럼 마이그레이션
- db: _enrich_items / get_matches에서 score_breakdown 파싱 포함
- db: get_matches에 a.district 컬럼 추가
- db: get_dashboard()에 pass_count (min_match_score 임계값 통과 건수) 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 08:56:27 +09:00
f80683ce82 docs(claude): realestate-lab + agent-office 청약 타겟팅 흐름 보강
- realestate-lab: 자치구 5티어 매칭 모델 / scheduled_collect 4단계 / 신규 컬럼 / notifier.py / 신규 endpoint
- agent-office: RealestateAgent.on_new_matches / 콜백 라우팅 / 신규 환경변수 / 데일리 cron 폐기
- 매칭 점수 모델: 35/10/15/15/25 = 100점 명시

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 06:58:24 +09:00
71f52e4d59 docs(plan): 청약 타겟팅 프론트엔드 구현 계획
9 task TDD 분할 (단위 테스트 인프라 없음, 빌드+린트+수동 시각 검증):
- Task 1: DEFAULT_PROFILE 확장 + extractTier 헬퍼
- Task 2: DistrictTierEditor (드래그&드롭 + 모바일 read-only)
- Task 3: NotificationSettings (슬라이더 + 토글)
- Task 4: ProfileTab 통합 + handleSave
- Task 5: Subscription.css (5티어 + 드래그 영역 + 토글 + 슬라이더)
- Task 6-8: AnnouncementCard / AnnouncementDetail / MatchesTab district + 5티어 뱃지
- Task 9: CLAUDE.md + 수동 시각 검증 12 시나리오

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:47:20 +09:00
756f280bbc docs(spec): 청약 타겟팅 프론트엔드 설계
- DistrictTierEditor: 데스크톱 드래그&드롭 + 모바일 read-only
- NotificationSettings: 임계값 슬라이더 + 알림 토글
- AnnouncementCard/MatchesTab: district + 5티어 뱃지
- AnnouncementDetail: 매칭 분석 섹션 (점수 + reasons + 자격)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:40:36 +09:00
a508a5633a Merge branch 'feat/realestate-targeting'
청약 서비스 타겟팅 고도화 — 12 task TDD 구현
- realestate-lab: 자치구 5티어 가중치 매칭, 30일 윈도우 수집, 90일 grace 자동 정리
- agent-office: 신규 매칭 즉시 텔레그램 푸시 + 인라인 키보드 (북마크/공고 보기)
- 단일 SoT: preferred_districts/min_match_score/notify_enabled 프로필 필드
- 데일리 리포트 cron 폐기, 09:00 수집 직후 push로 통합
- 62 tests passing (realestate-lab 44 + agent-office 18)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:55:23 +09:00
1d6c1b4329 fix(agent-office): bookmark field name + service_proxy contract + mktemp
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 09:09:05 +09:00
7b3ddd1b19 chore(deploy): wire realestate↔agent-office URLs for push notify
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 09:03:43 +09:00
32e021cfc7 feat(agent-office): drop daily realestate cron + bookmark callback routing
- scheduler.py: remove _run_realestate_schedule() and its 09:15 cron job
- service_proxy.py: add realestate_bookmark_toggle() helper (PATCH bookmark endpoint)
- webhook.py: add _handle_realestate_bookmark() dispatcher before DB-lookup path;
  realestate_bookmark_{id} callbacks are handled inline without a DB entry
- tests/test_realestate_callback.py: 4 new unit tests covering happy path,
  invalid id, proxy error, and regression that approve/reject still uses DB path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 09:01:38 +09:00
3749d79168 feat(agent-office): realestate on_new_matches + /notify endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 08:57:52 +09:00
0de2d3cf93 feat(agent-office-telegram): realestate match formatter + keyboard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 08:54:34 +09:00
55c37df703 feat(realestate): wire cleanup + notifier into scheduled flow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 08:52:00 +09:00
c2939459e7 fix(realestate-notifier): preserve threshold=0 and test sent_ids retry path
- Replace `or 70` fallback with explicit None-check so that
  min_match_score=0 ("notify all matches") is no longer silently
  coerced to 70
- Add test: 200 OK + sent_ids=[] must not mark matches notified
- Add test: threshold=0 correctly pushes low-score matches

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 08:50:29 +09:00
7aa7ccc6d5 feat(realestate-notifier): push unnotified matches to agent-office
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 08:46:59 +09:00
d46d2cb30b test(realestate): truncate tables for isolation instead of file delete
Replace os.remove() in conftest autouse fixture with per-table DELETE
to avoid Windows SQLite file-lock PermissionError being swallowed
silently and leaking state across tests. Remove the inline DELETE
workaround from test_profile_api.py that was coupling the test to
internal DB knowledge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 08:45:03 +09:00
20b51f706c feat(realestate-profile): expose 5tier districts + min_match_score + notify_enabled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 08:40:51 +09:00
eb04b954a5 refactor(realestate-matcher): integer tier points + clearer legacy path docstring
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 08:38:09 +09:00
a75ff069df feat(realestate-matcher): 5-tier district weighting + eligibility curve
지역 점수를 35점(광역 10 + 자치구 S/A/B/C/D 티어 0~25)으로 재배분하고,
자격 점수를 25점(첫 자격 15 + 추가당 5, 최대 +10) 곡선으로 변경.
총점 구성: 지역 35 + 유형 10 + 면적 15 + 가격 15 + 자격 25 = 100.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 08:34:19 +09:00
d39d9f26ac fix(realestate-collector): district regex tolerates missing separator 2026-04-28 08:31:55 +09:00
9dd517e82a feat(realestate-collector): 30-day window + district extraction + completed skip
- Add _extract_district() helper with DISTRICT_PATTERN regex (서울 only)
- collect_all() now passes RCRIT_PBLANC_DE_FROM param (30-day window) to all detail endpoints
- collect_all() skips announcements where compute_status() returns '완료'
- collect_all() stamps district on each parsed announcement before upsert
- upsert_announcement(): add district to INSERT/VALUES/ON CONFLICT UPDATE; data.setdefault('district', None)
- ANNOUNCEMENT_COLUMNS: add 'district' (closes deferred gap from Task 2 review)
- 9 new tests in realestate-lab/tests/test_collector.py (6 unit + 3 integration)
- Full suite: 22 passed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 08:28:10 +09:00
496e3a6a73 refactor(realestate-db): tuple param + status column in unnotified query
- mark_matches_notified: pass tuple(match_ids) to conn.execute for consistency
- get_unnotified_matches: add a.status to SELECT so notifier/formatter can skip stale '완료' matches
- add regression test: test_get_unnotified_matches_includes_status

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 08:24:03 +09:00
d6547edf0d feat(realestate-db): add notify queue + 90-day grace cleanup
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 08:19:03 +09:00
5749d4d35d feat(realestate-db): add district / notify / 5tier columns with migration
- announcements.district + idx_ann_district 인덱스
- user_profile: preferred_districts(JSON obj), min_match_score(int 70), notify_enabled(bool)
- match_results: notified_at(TEXT)
- _profile_row_to_dict: notify_enabled bool화, preferred_districts dict 역직렬화
- PROFILE_COLUMNS 확장 (3 신규 필드)
- upsert_profile: list|dict 모두 JSON 직렬화

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 08:13:44 +09:00
2477342272 test(realestate): fix mktemp deprecation + narrow exception in conftest
Replace deprecated mktemp with mkstemp to eliminate TOCTOU race, narrow
OSError catches to PermissionError (Windows SQLite lock intent only), and
add a deferred-import comment for clarity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 08:11:19 +09:00
62a9009fea test(realestate): add pytest harness with isolated SQLite fixture
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 08:07:59 +09:00
0fadc774d8 docs(plan): 청약 타겟팅 고도화 구현 계획
12 task TDD 분할:
- realestate-lab: 테스트 셋업 → 스키마 마이그 → 신규 함수 → collector/matcher → profile API → notifier → 흐름 통합
- agent-office: 텔레그램 fmt → on_new_matches + endpoint → cron 폐기 + 콜백 라우팅
- 마지막: docker-compose 환경변수 + 회귀 검증

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 03:45:10 +09:00
eef2e3967e docs(spec): 청약 타겟팅 고도화 설계
- 수집 사전 좁힘(30일 윈도우) + 완료 공고 90일 grace 자동 정리
- 자치구 5티어 가중치 매칭 (S/A/B/C/D)
- realestate-lab → agent-office push 기반 즉시 텔레그램 알림
- 데일리 리포트 cron 폐기, 임계값 통과 신규 매칭만 푸시

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 03:36:38 +09:00
2a8635e9ed refactor: backend→lotto 서비스 리네이밍 + lotto.db 레거시 테이블 스키마 제거
- backend/ → lotto/ 디렉토리 이동
- docker-compose: lotto-backend→lotto, lotto-frontend→frontend
- deploy scripts, nginx, agent-office config 네이밍 일괄 반영
- lotto/app/db.py에서 todos·blog_posts CREATE TABLE 제거 (personal로 이관 완료)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 17:29:13 +09:00
6c46759848 fix(deploy): deploy-nas.sh 전체 런타임 디렉토리에 chown+chmod 일괄 적용
서비스별 개별 처리 대신 $DST 전체에 대해 chown -R + chmod -R 755 수행.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 16:55:19 +09:00
e3d5eaf6f3 refactor: portfolio → personal 리네이밍 + Blog/Todo 통합
- portfolio/ 디렉토리를 personal/로 리네이밍
- lotto-backend의 Blog/Todo 라우트·CRUD를 personal 서비스로 이전
- lotto-backend에서 Blog/Todo 코드 제거 (DB 테이블 스키마는 유지)
- nginx: /api/todos, /api/blog/ 라우팅을 personal로 추가
- docker-compose: portfolio → personal 서비스 변��
- deploy 스크립트: portfolio → personal 반영

데이터 마이그레이션은 배포 후 NAS에서 별도 수행 필요:
1. cp data/portfolio/portfolio.db data/personal/personal.db
2. sqlite3 data/lotto.db ".dump todos" | sqlite3 data/personal/personal.db
3. sqlite3 data/lotto.db ".dump blog_posts" | sqlite3 data/personal/personal.db

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 16:32:55 +09:00
6004bcf66d fix(deployer): git pull 후 파일 소유권을 PUID:PGID로 복원
deployer 컨테이너가 root로 git pull을 실행하면 새 파일이
root:root 소유로 생성되어 다른 컨테이너에서 권한 문제 발생.
pull 직후 chown -R로 원래 소유권(bgg8988:users)을 복원.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 15:05:39 +09:00
a5a9337838 fix(nginx): portfolio 프록시를 변수 기반으로 변경
직접 proxy_pass로 portfolio:8000 참조 시 컨테이너 미실행 상태에서
nginx DNS 해석 실패 → 재시작 루프 발생. resolver + 변수 패턴 적용.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 14:56:21 +09:00
4d6296bce3 docs: CLAUDE.md에 portfolio 서비스 추가 (9개 서비스)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 14:39:05 +09:00
c6366ad238 feat(portfolio): 백엔드 서비스 + 인프라 설정
- FastAPI 앱: DB(5테이블), Pydantic 모델, 토큰 인증, 전체 API 라우트
- Docker Compose: portfolio 서비스 (포트 18850)
- Nginx: /api/profile/ → portfolio:8000
- 배포 스크립트: portfolio 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 14:33:34 +09:00
b671d275eb docs: portfolio 서비스 구현 계획 (15 tasks)
백엔드 DB/API + 프론트 3탭 + 인프라 + 홈 연동 전체 구현 계획.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 14:31:11 +09:00
bb97aa3ec8 docs: portfolio 서비스 설계 스�� 문서
백엔드(portfolio 서비스 18850) + 프론트(/portfolio 페이지) 전체 설계.
프로필·경력·프로젝트·기술·자기소개(다중버전) CRUD + 비밀번호 인증 + PDF 내보내기.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 14:20:33 +09:00
335ea012cc docs: Agent Office v2 구현 계획 (24 tasks, 6 phases)
캔버스 엔진, 에이전트 시스템, 오버레이, 사이드 패널, 페이지 통합, 최종 검증.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 08:21:55 +09:00
c168656fe1 docs: Agent Office v2 pixel office UX 대규모 업데이트 설계 스펙
전체 화면 캔버스 중심 UX, BFS 배회, 3테마, 사이드 패널 4탭 구조 설계.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 08:13:04 +09:00
955fc4ee1e docs: CLAUDE.md·README.md 최신 상태 반영 (8서비스·travel 지역관리·RTX 정정)
- CLAUDE.md: 서비스 8개 정정, RTX 5070 Ti 정정, travel-proxy 지역 관리 API 추가
- README.md: travel-proxy SQLite DB 구조 반영, travel.db·lotto_briefings 추가, 스케줄러 보완

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 07:34:27 +09:00
1c255152d7 feat(travel-proxy): 앨범 지역 변경 API + 좌표 메타 API + region_map_extra 리팩토링
- PUT /api/travel/albums/{album}/region: 앨범의 지역 변경 (extra 파일 기반)
- PUT /api/travel/regions/{region_id}: 커스텀 지역 이름/좌표 수정 (Phase 2 준비)
- _load_extra/_save_extra 헬퍼 분리, _removes 키로 원본 오버라이드 지원
- regions API: 모든 커스텀 지역 동적 병합 + Point geometry 지원

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-25 12:51:59 +09:00
728428ce95 feat(travel-proxy): albums API에 region/regionName 필드 추가
앨범 커버 지정이 프론트에 반영되도록 albums API 응답에
region, regionName 포함. region_map 역인덱스 + GeoJSON name 매핑.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-25 12:32:37 +09:00
00a610c374 fix(travel-proxy): logging.basicConfig 추가 + 동기화 진행 로그 강화
- main.py에 basicConfig(level=INFO) 추가 — 기존엔 기본 WARNING이라 info 로그 무시됨
- indexer: 앨범별 변경사항 로그, 썸네일 100개 단위 진행률 로그

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-25 12:24:19 +09:00
496646fb32 feat(travel-proxy): 신규 폴더 자동 탐색 + region_map 오버라이드 분리
- indexer: travel_root 전체 서브디렉토리 스캔하여 region_map에 없는 폴더도 자동 인덱싱
- RO 원본 대신 RW thumb_root에 region_map_extra.json으로 오버라이드 저장
- regions API: 미분류 지역 동적 추가
- sync 응답에 discovered 필드 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-25 12:13:19 +09:00
cb6e2d992a perf(travel-proxy): 배치 DB 연결 + nginx sync timeout 600s
- db.py: batch_sync_album, batch_mark_thumbs_done 추가
- indexer.py: 앨범 단위 배치 동기화로 전환
- nginx: /api/travel/ proxy_read_timeout 600s 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 09:15:21 +09:00
7011d3ef3a docs: CLAUDE.md travel-proxy 섹션 — DB·API·파일 구조 업데이트
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 09:10:46 +09:00
eb322b7450 chore(travel-proxy): __init__.py 추가 + docker-compose TRAVEL_DB_PATH 환경변수 반영
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 09:10:08 +09:00
4fde9e6f58 fix(travel-proxy): 온디맨드 썸네일 폴백 시 has_thumb DB 동기화 + 미사용 import 정리
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 09:09:34 +09:00
7d78fae77f refactor(travel-proxy): main.py DB 기반 전환 — 메모리 캐시 제거 + 신규 API 2026-04-24 09:06:27 +09:00
e82ff83a5f fix(travel-proxy): indexer.py stat() 에러 핸들링 + updated 카운터 + 로깅 개선
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 09:05:12 +09:00
fac2e65ed8 feat(travel-proxy): indexer.py — 폴더 동기화 + 썸네일 일괄 생성 2026-04-24 09:02:42 +09:00
42242f86eb fix(travel-proxy): db.py 중복 쿼리 제거 + 타입 힌트 개선
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 09:01:49 +09:00
c5682e07a7 feat(travel-proxy): db.py — SQLite 스키마 + 쿼리 헬퍼 2026-04-24 08:59:19 +09:00
8f0b1fbbfa docs: travel-proxy 성능 개선 구현 계획 — 5 Tasks
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 08:57:10 +09:00
e88989d3c1 docs: travel-proxy 성능 개선 설계 — SQLite 인덱스 DB + 앨범 커버 + 썸네일 사전 생성
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 08:49:08 +09:00
f38631cdae docs: Travel 갤러리 리디자인 구현 계획 (10 tasks)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 01:10:09 +09:00
b2accba65a docs: Travel 갤러리 리디자인 설계 스펙
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 01:03:23 +09:00
8d92e50009 docs: 반응형 웹 UI/UX 구현 계획 23개 태스크
Phase 1a: breakpoint 통일 (Task 1-4)
Phase 1b: 공통 컴포넌트 + 앱 셸 (Task 5-12)
Phase 2: 주요 4페이지 (Task 13-16)
Phase 3: 나머지 페이지 (Task 17-22)
Phase 4: 검증 (Task 23)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 14:24:22 +09:00
bd7875b36a docs: 반응형 설계 리뷰 피드백 반영
- 라우트 경로 수정 (/lab/music→/music, /blog-marketing→/blog-lab 등)
- /realestate/property 미등록 라우트 제외, 실제 14개 뷰로 정정
- breakpoint 예외 목록 명시 (420/520/700px)
- 사이드바→바텀네비 마이그레이션 상세 계획 추가
- react-swipeable 경량 라이브러리 활용 명시
- 미니플레이어+바텀네비 스태킹 사양 추가
- viewport-fit=cover, prefers-reduced-motion, 테스트 뷰포트 명시
- Phase 1을 1a(breakpoint 정리) + 1b(컴포넌트)로 세분화

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 14:17:07 +09:00
5ac5cce0fe docs: 반응형 웹 UI/UX 전면 개선 설계 문서
13개 페이지 모바일 대응 + 공통 모바일 인프라 설계.
바텀 네비, 풀다운 리프레시, 스와이프, FAB, 바텀시트 포함.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 14:10:28 +09:00
ae4f0d4270 fix(agent-office/blog): generate/market/review 비동기 task 폴링 추가
blog-lab의 generate/market/review 엔드포인트는 task_id만 즉시 반환하고
BackgroundTask로 실제 작업을 수행한다. 기존 코드는 응답에서 바로
post_id를 꺼내려 해 항상 'generate did not return post_id' 실패.

공통 폴링 헬퍼 _await_task로 research처럼 status=succeeded 대기하도록
수정. 점수는 review 완료 후 post를 다시 읽어 review_score로 판정.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 00:12:24 +09:00
447c6babc3 fix(deployer): chown 경고 제거 — numeric UID/GID + idempotent 처리
Synology ACL이 username 기반 chown을 거부하고 컨테이너 내부에 bgg8988
사용자가 없어 매 배포마다 WARN 로그가 쏟아지던 문제 해결.

- PUID/PGID 환경변수를 deployer 컨테이너에 전달
- find로 소유자가 다른 항목만 골라 numeric chown (idempotent)
- 실패는 silent — 어차피 파일 기능엔 영향 없음

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 20:33:16 +09:00
6f62b34b12 fix(agent-office): docker-compose 환경변수 passthrough 누락 수정
.env에 값이 있어도 docker-compose.yml의 environment 블록에 선언되지
않으면 컨테이너에 전달되지 않음. ANTHROPIC_API_KEY 미전달로 로또
큐레이터가 schema validation 직전 CuratorError로 실패하던 문제 해결.

추가된 passthrough: ANTHROPIC_API_KEY, LOTTO_BACKEND_URL,
LOTTO_CURATOR_MODEL, TELEGRAM_WIFE_CHAT_ID, CONVERSATION_MODEL,
CONVERSATION_HISTORY_LIMIT, CONVERSATION_RATE_PER_MIN.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 20:26:44 +09:00
af3df87672 docs: lotto 큐레이터 API·테이블·스케줄 반영
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 08:36:05 +09:00
c6de615271 feat(agent-office): lotto 큐레이터 월요일 07:00 스케줄 2026-04-15 08:28:22 +09:00
7c4d7b4534 feat(agent-office): LottoAgent 등록 + seed + 텔레그램 메타 2026-04-15 08:28:10 +09:00
cc17c29266 feat(agent-office): 큐레이터 파이프라인(fetch→claude→validate→save) 2026-04-15 08:27:43 +09:00
889dc417a9 feat(agent-office): 큐레이터 system 프롬프트 2026-04-15 08:27:23 +09:00
e16cf8f817 feat(agent-office): 큐레이터 응답 검증 스키마 + 테스트 2026-04-15 08:27:07 +09:00
d4a4849943 feat(agent-office): service_proxy lotto 메서드 2026-04-15 08:26:37 +09:00
21721d34a0 feat(agent-office): lotto 큐레이터 환경변수 2026-04-15 08:26:29 +09:00
86be8c2a53 feat(lotto): curator/briefing 라우터 마운트 2026-04-15 08:24:14 +09:00
753ecdbbf2 feat(lotto): briefing CRUD + 큐레이터 사용량 라우터 2026-04-15 08:24:06 +09:00
1ec45acb95 feat(lotto): curator candidates/context 라우터 2026-04-15 08:23:53 +09:00
d1fec71bdc fix(lotto): curator_helpers 시그니처 정합 (recommender/analyzer/strategy_evolver 실제 시그니처에 맞춤)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 08:22:56 +09:00
4a8b0092d7 feat(lotto): curator_helpers — 후보 병합·피처·맥락
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 08:20:51 +09:00
e1ae0f7501 feat(lotto): lotto_briefings 테이블 + CRUD 함수
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 08:18:20 +09:00
adb5cdb54e docs: lotto AI curator 설계/구현 계획 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 03:45:14 +09:00
e691ed9a7d docs(lotto): AI 큐레이터 설계 스펙 추가
- 주간 AI 큐레이터: 월요일 07:00 자동 생성, Claude Sonnet 4.5
- lotto-backend = 엔진·저장소, agent-office = AI 판단 분리
- 브리핑 중심 프론트 재배치(3탭), 토큰·비용 노출
- 최종 미사용 DB/코드 정리 패스 포함
2026-04-15 03:35:29 +09:00
c019ab1681 feat(agent-office): 텔레그램 자연어 대화 + 프롬프트 캐싱 + 평가 지표
- 슬래시 명령이 아닌 메시지를 Claude Haiku 4.5로 응답
- system 프롬프트 + 히스토리 끝 블록에 cache_control:ephemeral 적용
- conversation_messages 테이블에 토큰·캐시·latency 기록
- chat_id 화이트리스트 + 분당 rate limit
- GET /api/agent-office/conversation/stats 로 캐시 히트율·토큰 확인
2026-04-15 03:10:19 +09:00
c15ea96e2f fix(stock-lab): 총 매입 금액을 종목별 매입가의 단순 합계로 수정
수량을 곱하지 않고 purchase_price 값만 그대로 합산.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 02:10:17 +09:00
de015a2440 feat(stock-lab): 포트폴리오 매입가(purchase_price) 컬럼 분리 + 원달러 환율 부호 보정
원달러 환율:
- 네이버 환율 change_value에 부호가 없어 프론트에서 항상 상승으로 인식되던 문제
- direction(red/blue) 기반으로 +/- 부호 prepend

포트폴리오:
- portfolio 테이블에 purchase_price 컬럼 추가 (기존 row는 avg_price로 백필)
- avg_price(평균단가): 손익률 계산 기준 (cost_basis)
- purchase_price(매입가): 총 매입 금액 요약 표시 기준
- API: PortfolioItemRequest/UpdateRequest에 purchase_price(Optional) 추가
- GET /api/portfolio 응답 holdings에 purchase_price 포함, summary.total_buy는 매입가 합계, total_profit_rate는 평균단가 기준

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 01:58:02 +09:00
7acc1979c8 docs: agent-office 주식 뉴스 스케줄 표기 08:00 → 07:30
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 00:59:23 +09:00
3152bc23f4 chore(agent-office): 주식 뉴스 브리핑 스케줄 08:00 → 07:30
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 00:58:43 +09:00
b23346143f feat(agent-office): 주식 브리핑 본문에 주요 뉴스 헤드라인+링크 추가
- stock-lab /news/summarize 응답에 top 8 기사(title/link/press) 포함
- agent-office stock.py: _build_briefing_body() 헬퍼 분리 — LLM 요약 + 📰 주요 뉴스 섹션(HTML <a> 링크). 향후 본문 고도화 시 이 함수만 수정
- telegram 포맷터/메시징에 body_is_html 플래그 추가 (링크 포함 메시지는 이중 escape 회피)
- 아내 전송도 동일 본문 재사용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 00:56:20 +09:00
b867b8ce13 feat(agent-office): 아침 시장 브리핑 아내 텔레그램 추가 전송
- TELEGRAM_WIFE_CHAT_ID 환경변수 추가 (빈 값이면 비활성)
- send_raw()에 chat_id override 파라미터 추가
- 주식 에이전트 브리핑 전송 후, 아내 chat에 제목+본문만 간결 포맷으로 추가 전송
  (기술 메타데이터/버튼 없음, 읽기 전용)

NAS .env에 TELEGRAM_WIFE_CHAT_ID 추가 필요.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 00:49:55 +09:00
f3c7ce72de fix(agent-office): blog 리서치 응답 필드명 수정 (result.keyword_id → result_id)
blog-lab /task 응답은 최상위 result_id 필드를 사용하지만 중첩 result.keyword_id를 읽고 있어 리서치 성공 직후 None으로 빠져나와 "research timeout"으로 오보고되던 문제 수정. 실제 타임아웃과 파싱 실패 경로를 분리해 에러 메시지 구분.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 00:37:23 +09:00
57b7a4921d feat(realestate-lab): 종료(완료) 청약 공고 일괄 삭제 API
- db.delete_closed_announcements(): status='완료' 공고 일괄 삭제
- DELETE /api/realestate/announcements/closed 엔드포인트 추가
- {ann_id} 라우트보다 먼저 등록 (FastAPI prefix 매칭 순서)
- 반환: {"ok": True, "deleted": <count>}

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 04:14:47 +09:00
916b04af6a feat(agent-office): Blog + Realestate 에이전트 추가
기존 Stock/Music 에이전트 패턴을 따라 2개 신규 에이전트 도입.

- Blog 에이전트 (10:00 매일): 트렌드 키워드 1개 자동 선택
  → blog-lab 파이프라인 전체 (research→generate→market→review) 자동 실행
  → 평가 점수와 본문 요약을 텔레그램 승인 요청으로 푸시
  → 승인 시 published 전환, 거절 시 작업 종료
- Realestate 에이전트 (09:15 매일): realestate-lab 수집 트리거
  → 신규 매칭 상위 5건 + 대시보드를 텔레그램 리포트
  → 조회한 매칭은 자동 읽음 처리
- service_proxy: blog-lab/realestate-lab REST 호출 래퍼 추가
- agents 레지스트리 + DB 시드 + 스케줄러 3개 잡 등록
- docker-compose: agent-office에 BLOG_LAB_URL/REALESTATE_LAB_URL 주입
- README: 에이전트 구성 표 + 명령어 + 스케줄러 잡 정리

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 03:06:14 +09:00
43ee920617 docs: README 전면 업데이트 — music/blog/realestate/agent-office 추가
- 누락된 4개 서비스 (music-lab, blog-lab, realestate-lab, agent-office) 섹션 추가
- agent-office FSM + WebSocket + 텔레그램 양방향 구조 설명
- LLM provider 추상화 (claude/ollama 전환) 반영
- 환경변수 섹션에 Anthropic/Suno/Naver/Telegram/공공데이터 키 추가
- Windows AI 서버 스펙 정정 (RTX 3070 Ti → RTX 5070 Ti 16GB)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 02:56:32 +09:00
d11aadce8a feat(stock-lab): LLM provider 전환 구조 + Claude Haiku 4.5 기본 전환
PC 메모리 부하 해소를 위해 뉴스 요약 기본 provider를 Ollama qwen3:14b
→ Claude Haiku 4.5로 변경. LLM_PROVIDER 환경변수로 언제든 ollama 롤백 가능.

- ai_summarizer.py: provider 분리 (_summarize_with_claude / _summarize_with_ollama)
- OllamaError는 LLMError alias로 유지 (main.py 수정 불필요)
- Anthropic Messages API 직접 호출 (httpx, 의존성 추가 없음)
- docker-compose + .env.example: LLM_PROVIDER, ANTHROPIC_MODEL 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 02:52:48 +09:00
5dd7b6d601 fix: Ollama 타임아웃 60s->180s + 에러 타입 로깅 + 텔레그램 chat.id 디버그 2026-04-13 02:33:55 +09:00
1d535519ef debug: webhook 수신 chat.id 로깅 + Telegram HTML escape 수정 2026-04-13 02:24:42 +09:00
de80ebd707 fix(agent-office): Telegram HTML escape + 4096자 제한 + 실패 원인 로깅 2026-04-13 02:14:29 +09:00
6e18782d3b fix(deploy): chown silent failure 제거 + 에러 로그 출력
- 2>/dev/null || true 조합이 Synology ACL 실패를 완전히 숨겨
  신규 생성 디렉토리가 root:root로 남는 문제 해결
- chown/chmod 실패 시 WARN 로그 출력 및 CHOWN_FAILED 플래그
- trailing slash 제거로 디렉토리 자체도 재귀 chown 대상에 포함

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 01:51:41 +09:00
86e7f727eb feat: Ollama qwen3:14b 기반 AI 뉴스 요약 + 텔레그램 통합 허브
- stock-lab: POST /api/stock/news/summarize 추가 (Ollama /api/generate 호출, 토큰/duration 추적)
- agent-office: telegram 패키지 분해 (client/formatter/messaging/webhook/router/agent_registry)
- send_agent_message 통합 API로 에이전트 중립 메시지 포맷 표준화
- 텔레그램 → 에이전트 명령 라우터 (/status, /stock news, /music credits 등)
- 토큰 사용량 집계 API 및 GET /agents/{id}/token-usage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 01:44:27 +09:00
de91f424a3 feat(agent-office): notification broadcast + telegram tracking + activity feed API
- Add WebSocket notification messages for task_assigned/task_completed
- Structure telegram send_message return value with ok/message_id
- Track telegram delivery status in task result_data
- Add test_telegram command to stock agent
- Add GET /api/agent-office/activity unified feed endpoint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 15:15:01 +09:00
cce84de8be fix(deploy): 서비스 목록 변수화 + rsync 전 권한 확보 + healthcheck 전서비스 추가
- deploy.sh / deploy-nas.sh: 서비스 목록을 변수로 통합하여 누락 방지
- deploy-nas.sh: rsync 전 chmod u+rwX로 Docker root 소유 파일 권한 확보
- healthcheck.sh: music-lab, blog-lab, realestate-lab, agent-office 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 14:27:36 +09:00
678440a2bd fix(deploy): agent-office 배포 누락 수정 + 백업 삭제 권한 처리
- deploy.sh의 BUILD_TARGETS, 고아 컨테이너 정리, 헬스체크, data 디렉토리에
  agent-office 추가
- .releases 오래된 백업 삭제 시 chmod u+rwX로 권한 확보 후 삭제

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 14:25:26 +09:00
a3f9f1cb39 fix(deploy): agent-office를 배포 rsync 대상에 추가
deploy-nas.sh의 rsync/chown 루프에 agent-office 디렉토리가
누락되어 NAS 런타임에 복사되지 않던 문제 수정.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 14:23:52 +09:00
9a02ed1fd3 Merge branch 'main' of https://gitea.gahusb.synology.me/gahusb/web-page-backend 2026-04-11 13:36:35 +09:00
6f8b199548 feat: Agent Office — AI 에이전트 가상 오피스 (#2)
## Summary
- 2D 픽셀아트 가상 오피스에서 AI 에이전트(Stock, Music)가 실제 작업 수행
- FastAPI + WebSocket 실시간 상태 동기화 + 텔레그램 봇 양방향 알림/승인
- BaseAgent FSM (idle/working/waiting/reporting/break), 서비스 프록시 패턴
- Docker Compose 서비스 (port 18900) + Nginx WebSocket 프록시

## Changes (13 commits)
- Backend scaffold: config, db, models, Dockerfile
- WebSocket manager + Service proxy
- BaseAgent FSM + StockAgent + MusicAgent
- Telegram bot + Scheduler
- FastAPI main (REST + WS endpoints)
- Infrastructure: docker-compose + nginx
- Code review fixes: HTTPException, async polling, input validation

Reviewed-on: https://gitea.gahusb.synology.me/gahusb/web-page-backend/pulls/2
2026-04-11 13:35:24 +09:00
c3b8794621 docs: Agent Office 구현 계획서 작성
17개 태스크: 백엔드 scaffold → FSM → 에이전트 → 텔레그램 → 인프라 → 프론트엔드 Canvas → UI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 08:22:49 +09:00
e33219af0b docs: Agent Office 설계 문서 작성
2D 픽셀아트 AI 에이전트 사무실 시각화 기능 설계.
MVP: StockAgent + MusicAgent, 텔레그램 양방향, Canvas 렌더링.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 08:10:44 +09:00
eb9bd65033 feat(music-lab): Suno API 전체 기능 확장 — Phase 1~3 (생성 강화, 후처리, 고급 크리에이티브) 2026-04-09 07:34:20 +09:00
a6fd44c697 fix(music-lab): DB 업데이트 함수 연결 — 커버이미지/WAV/스템/비디오 결과 영구 저장
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 09:19:47 +09:00
ad939dde40 docs: music-lab API 목록 업데이트 — Phase 1~3 신규 엔드포인트 반영
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 09:15:19 +09:00
26997a7dc7 feat(music-lab): Phase 3 백엔드 — 업로드커버, 업로드확장, 보컬추가, 인스트추가, 뮤직비디오
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 09:10:07 +09:00
94969f97a8 feat(music-lab): Phase 2 백엔드 — WAV 변환, 12스템 분리, 타임스탬프 가사, 스타일 부스트
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 08:58:37 +09:00
3e46cc41ca refactor(music-lab): 공통 폴링 헬퍼 추출 + V5_5 모델 + 신규 파라미터 + 커버이미지 2026-04-08 08:42:36 +09:00
214eb320fa feat(music-lab): Phase 1 DB 마이그레이션 + GenerateRequest 확장 + 커버이미지 엔드포인트 2026-04-08 08:42:14 +09:00
c8ee3bb95b docs: music-lab Suno API 전체 기능 확장 구현 계획
10개 Task, 3 Phase 구조의 상세 구현 계획.
Phase 1(생성 강화), Phase 2(후처리), Phase 3(리믹스).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 03:26:40 +09:00
6ffa04f847 docs: music-lab Suno API 전체 기능 확장 설계 스펙
Suno API 미사용 기능 분석 후 3단계 점진 확장 설계.
Phase 1(생성 강화), Phase 2(후처리), Phase 3(리믹스) 구조.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 03:13:41 +09:00
262c088c8a feat(realestate-lab): 청약 가점제 계산 (84점 만점)
- calculate_subscription_points(): 무주택기간(32) + 부양가족(35) + 통장기간(17)
- 프로필 GET/PUT 응답에 subscription_points 포함
- 매칭 결과 API에 my_points 포함 (가점 비교용)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 00:27:53 +09:00
074dd4041f feat(realestate-lab): 공고 목록에 매칭 점수 포함
- _enrich_items()로 통합: 가격 범위 + match_score/reasons/eligible_types
- 프로필 기반 매칭 점수가 공고 카드에 바로 표시됨

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 23:51:06 +09:00
243c101981 feat(realestate-lab): 즐겨찾기 + 가격 표시 + 일정 없는 공고 필터링
- announcements 테이블에 is_bookmarked 컬럼 추가 (마이그레이션 포함)
- PATCH /announcements/{id}/bookmark 토글 API 추가
- 공고 목록에 모델 기반 가격 범위(min_price, max_price_display) 포함
- 대시보드에 즐겨찾기 목록 + 개별 이벤트 일정 형식 반환
- 지역 검색을 LIKE 부분 매칭으로 변경
- 수집 시 일정 정보 없는 공고 건너뛰기

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 23:38:57 +09:00
011eac7682 fix(realestate-lab): 매칭 재계산 DB lock 오류 수정
- sqlite3.connect timeout=10 추가 (기본 0초 → 즉시 실패 방지)
- run_matching() 단일 connection으로 통합 (프로필 조회~매칭~저장)
- matches/refresh 엔드포인트 에러 핸들링 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 04:51:06 +09:00
535ffea45a refactor: 전체 코드베이스 감사 기반 리팩토링 — 버그 수정, 데드코드 제거, 보안 강화
P0 버그 수정:
- stock-lab: trade 엔드포인트 NameError 수정 (resp 미정의)
- deployer: 동시 배포 시 HTTP 200 → 503 반환

P1 데드코드 제거:
- stock-lab: fetch_overseas_news(), get_broker_cash() 제거
- blog-lab: 미사용 urlparse import 제거
- lotto-lab: 중복 inline import json 7곳 제거

P2 성능/효율 개선:
- lotto-lab: 가중 샘플링 3중 복사 → utils.weighted_sample_6() 통합
- lotto-lab: DB 인덱스 3개 추가 (recommendations, purchase_history)
- stock-lab: Pydantic .dict() → .model_dump() 호환
- blog-lab: 페이지네이션 상한(le=100) 추가

P3 보안/인프라:
- nginx: X-Frame-Options, X-Content-Type-Options, Referrer-Policy 헤더 추가
- docker-compose: travel-proxy CORS 와일드카드 → localhost 전용
- Dockerfile: music-lab, blog-lab, realestate-lab에 PYTHONUNBUFFERED 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 04:10:14 +09:00
9d5583935d docs: pet-lab 구현 계획서 추가
5개 Task: config → eye_tracker → pet_widget → interaction → main

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 03:27:41 +09:00
a2bd26682e feat(music-lab): 파일 해시 기반 라이브러리 동기화 — rename 시 태그 보존
- music_library에 file_hash(MD5) 컬럼 추가
- _sync_library_with_disk를 3단계로 변경:
  1. 파일명 매칭 (빠른 경로)
  2. 해시 비교로 rename 감지 → 기존 레코드 업데이트 (태그 보존)
  3. 나머지 → 삭제/추가
- 파일명 변경 시 audio_url 업데이트 → 다운로드도 새 이름 적용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 03:26:41 +09:00
a588a26144 docs: pet-lab 데스크톱 펫 애플리케이션 설계 문서 추가
PyQt5 기반 Windows 데스크톱 펫 — 화면 하단 고정, 마우스 시선 추적,
클릭/우클릭 상호작용. 독립 프로젝트(workspace/pet-lab)로 분리.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 03:24:00 +09:00
14674c4e9a fix(blog-lab): AI 생성 콘텐츠에 현재 날짜 컨텍스트 추가
Claude API 호출 시 시스템 프롬프트에 현재 날짜를 포함하여
2024년이 아닌 실제 날짜 기준으로 콘텐츠가 생성되도록 수정.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 02:09:49 +09:00
74891eaa60 docs: CLAUDE.md에 blog-lab 파이프라인 변경사항 반영 2026-04-07 01:03:53 +09:00
4cc802ed95 test(blog-lab): 4단계 파이프라인 통합 테스트
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 01:02:40 +09:00
b82a10e580 feat(blog-lab): 평가자 단계 — 6기준 60점 체계 + link_natural 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 01:00:21 +09:00
4646b79e6e feat(blog-lab): 마케터 단계 — 전환율 강화 + 링크 삽입
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 00:57:50 +09:00
786033f202 feat(blog-lab): 작가 단계 — 크롤링 본문 + 브랜드 링크 참조 글 생성
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 00:54:48 +09:00
25f4f1f98b feat(blog-lab): 브랜드커넥트 링크 CRUD API 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 00:51:01 +09:00
336bc90b4e feat(blog-lab): 리서치 단계에 블로그 본문 크롤링 통합
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 00:48:55 +09:00
2980807587 feat(blog-lab): brand_links 테이블 및 CRUD 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 00:47:03 +09:00
7c7093d67c test(blog-lab): _extract_text 직접 테스트 추가 2026-04-07 00:44:47 +09:00
2603c7ce20 feat(blog-lab): 네이버 블로그 본문 크롤링 모듈 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 00:42:55 +09:00
4f68b568a7 fix(compose): 프로젝트명 'webpage' 고정 — deployer/호스트 간 불일치 해결
deployer 내부(/runtime)와 NAS 호스트(/volume1/docker/webpage)에서 docker compose
실행 시 디렉토리명 기반 프로젝트명이 달라져 컨테이너 관리가 불가능한 문제 수정.
name: webpage으로 고정하여 어디서 실행해도 동일한 프로젝트로 인식.
deprecated version: "3.8" 제거.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 22:26:40 +09:00
fdb2fedd40 fix(deploy): compose stop/rm 후 재빌드로 컨테이너 충돌 근본 해결
docker ps --filter 방식이 Synology에서 불안정하여
docker compose stop/rm으로 compose 관리 컨테이너를 먼저 정리하고,
이름 기반 docker rm으로 고아 컨테이너도 추가 정리하는 2단계 방식으로 변경.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 22:22:32 +09:00
b0f12ba6c6 deployer: TZ 환경변수 추가 + 로그 시간대 표기 개선
- docker-compose.yml: deployer에 TZ=Asia/Seoul 환경변수 추가
- deployer/app.py: 로그 datefmt에 %Z 추가하여 KST 시간대 명시
- deployer 재시작 필요: docker compose up -d --build deployer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 22:17:27 +09:00
aee3937625 fix(compose): data 볼륨 상대 경로 → RUNTIME_PATH 절대 경로로 통일
deployer 컨테이너 내부에서 docker compose up 실행 시 상대 경로(./data/music)가
/runtime/data/music으로 해석되어 호스트에서 bind mount 실패하는 문제 수정.

${RUNTIME_PATH}/data/<service> 패턴으로 통일하여 NAS 환경에서도 호스트 절대
경로(/volume1/docker/webpage/data/*)가 정확히 전달되도록 함.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 22:14:47 +09:00
d9bfd04c76 deployer: 배포 안정성 개선 — 헬스체크 실패 exit 1 + rsync 에러 핸들링 수정
- deploy.sh: 헬스체크 실패 시 exit 1 반환 (성공/실패 로그 추적 가능)
- deploy.sh: 릴리즈 백업에서 data/ 디렉토리 제외 (디스크 절약)
- deploy-nas.sh: rsync || [...] && true 셸 구문 오류 수정 (올바른 에러 핸들링)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 22:12:04 +09:00
cd292b2632 fix(deploy): --force-recreate로 컨테이너 이름 충돌 해결
docker rm -f가 deployer 내부에서 동작하지 않는 문제.
docker compose up --force-recreate로 기존 컨테이너를 자동 교체.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:58:06 +09:00
80ccb20f99 fix(deploy): docker rm -f로 컨테이너 강제 제거 후 빌드
docker compose down은 다른 프로젝트명으로 생성된 컨테이너를 인식 못함.
개별 docker rm -f로 확실하게 이름 충돌 제거.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:56:46 +09:00
ce4f7b3ef6 fix(deploy): 빌드 전 docker compose down으로 컨테이너 충돌 방지
신규 서비스 추가 시 기존 고아 컨테이너와 이름 충돌하는 문제 해결.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:55:03 +09:00
1b368e9896 fix(deploy): deploy.sh에 realestate-lab 빌드/헬스체크 추가
docker compose up --build 목록과 헬스체크 대상에 realestate-lab 누락 수정.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:47:52 +09:00
a542b1af7d fix(deployer): chown/chmod 실패 시 에러 무시 — 비root 사용자 호환
호스트에서 bgg8988으로 실행 시 root 소유 파일 chown 불가 허용.
deployer 컨테이너(root)에서는 정상 동작.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:39:18 +09:00
3ce93149d5 fix(deployer): rsync 타임스탬프 보존 제거 + non-critical 에러 허용
-t 플래그 제거 (Operation not permitted 방지), exit code 23 허용.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:38:03 +09:00
5530402604 fix(deployer): rsync에서 소유자/그룹 보존 비활성화 — chgrp 권한 오류 해결
-a 대신 -rlpt --no-owner --no-group 사용. 소유권은 이후 chown으로 설정.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:36:59 +09:00
cb750f888b fix(deployer): 배포 후 파일 권한 bgg8988:users 755로 설정
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:34:56 +09:00
598adcbeb5 fix(lotto-lab): 코드 리뷰 이슈 수정 — update_purchase JSON 직렬화, EMA 피드백 루프 연결
- update_purchase에서 numbers/is_real 타입 변환 추가 (런타임 에러 방지)
- purchase_manager에서 evolve_after_check 호출하여 EMA 피드백 루프 활성화
- checker.py 중복 recalculate_weights 호출 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:25:27 +09:00
d67e1fcd67 docs: CLAUDE.md 신규 API + 테이블 + 파일 구조 업데이트
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:20:41 +09:00
7eda717326 lotto-lab: 구매/전략/스마트추천 API 엔드포인트 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:19:28 +09:00
28e3af12ec lotto-lab: checker 연동 — 추첨 결과 시 purchase 자동 체크 + 가중치 재계산
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:17:52 +09:00
c9f10aca4a lotto-lab: strategy_evolver — EMA/Softmax 가중치 진화 + 스마트 추천
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 21:15:42 +09:00
706ca410ca feat(lotto-lab): purchase_manager — 구매 결과 자동 체크 + 전략 성과 집계
- backend/app/purchase_manager.py 신규 생성
  - check_purchases_for_draw(): 회차별 미채점 구매 건 자동 채점
  - checker._calc_rank 재사용, RANK_PRIZE 상수 정의
  - 채점 후 strategy_performance 자동 upsert (전략별 집계)
- backend/tests/test_purchase_manager.py에 통합 테스트 2건 추가
  - test_check_purchases_for_draw: 1등/낙첨 결과 검증
  - test_check_purchases_updates_strategy_performance: 성과 테이블 갱신 검증

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 21:12:43 +09:00
4c6e96d59c lotto-lab: 구매 CRUD 확장 + strategy_performance/weights CRUD 추가
- _purchase_row_to_dict: numbers/is_real/source_detail/results/total_prize 신규 컬럼 포함
- add_purchase: numbers, is_real, source_strategy, source_detail 파라미터 추가
- get_purchases: is_real, strategy, checked 필터 추가
- get_purchase_stats: total/real/virtual/by_strategy 분리 통계 + 하위호환 필드 유지
- update_purchase: allowed 셋에 numbers/is_real/source_strategy 추가
- 신규: upsert_strategy_performance, get_strategy_performance
- 신규: get_strategy_weights, update_strategy_weight
- 신규: update_purchase_results (체커 연동용)
- 테스트 5건 추가 (TDD)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 21:09:59 +09:00
7cf4784c08 lotto-lab: DB 스키마 확장 — purchase_history ALTER + strategy 테이블 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:07:08 +09:00
afc159c84d fix(realestate-lab): 최종 리뷰 이슈 수정 — FK CASCADE, 단일 연결, 동시성 가드
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 08:49:05 +09:00
bdfcdee5fd fix(realestate-lab): 코드 리뷰 이슈 수정 — 신규 추적, 보안, 비동기, 매칭 상태 보존
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 08:43:27 +09:00
3b118725ca docs: CLAUDE.md에 realestate-lab 서비스 정보 추가
서비스 목록, Docker 포트, Nginx 라우팅, 로컬 URL, API 목록 추가.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 08:35:35 +09:00
6344f957fa refactor(lotto-backend): 청약 관련 코드 완전 제거 — realestate-lab으로 이관
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 08:34:12 +09:00
0be5693aee infra: realestate-lab Docker/Nginx/배포 스크립트 통합
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 08:32:08 +09:00
5a493664f2 feat(realestate-lab): FastAPI 앱 + 스케줄러 + 전체 API 라우트
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 08:31:08 +09:00
c6328f7b04 Merge branch 'worktree-agent-a36803ff' 2026-04-06 08:30:11 +09:00
d6d6faf5c7 Merge branch 'worktree-agent-a395667a' 2026-04-06 08:29:55 +09:00
437838c28b feat(realestate-lab): 프로필 기반 매칭 엔진
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 08:29:36 +09:00
4cb6296a3d feat(realestate-lab): 공공데이터포털 API 수집기
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 08:29:19 +09:00
9e7efc3f12 feat(realestate-lab): DB 레이어 — 테이블 생성 + 전체 CRUD
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 08:27:11 +09:00
6b95c1e5a0 feat(realestate-lab): Pydantic 요청 모델 정의
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 08:26:01 +09:00
7d20527a17 feat(realestate-lab): 프로젝트 스캐폴딩 — Dockerfile, requirements, init
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 08:25:43 +09:00
e91a5e6be6 docs: realestate-lab 구현 계획서 작성
10개 Task — 스캐폴딩, 모델, DB, 수집기, 매칭, API, 인프라, lotto-backend 정리, 문서, 검증

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 22:16:43 +09:00
c4406b9ecd lotto-lab: 구매 연동 + 전략 진화 시스템 구현 계획 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 22:10:16 +09:00
65ffdec7d2 docs: realestate-lab 설계 스펙 문서 추가
청약 공고 자동 수집 + 프로필 기반 자격 매칭 서비스 설계.
공공데이터포털 API 연동, 독립 서비스 분리, 매칭 엔진 정의.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 20:10:31 +09:00
8b916194aa deployer: blog-lab 서비스 배포 스크립트에 추가
rsync 대상, docker compose up, 헬스체크에 blog-lab 포함

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 20:05:14 +09:00
caeb72d310 lotto-lab: 구매 연동 + 전략 진화 시스템 설계 문서 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 20:02:27 +09:00
ba33e00ce3 blog-lab: 블로그 마케팅 수익화 서비스 추가
네이버 검색 API 키워드 분석 + Claude AI 글 생성 + 품질 리뷰 + 수익 추적
- blog-lab/ 서비스 전체 (FastAPI, SQLite 5테이블, 18 엔드포인트)
- docker-compose.yml: blog-lab 서비스 (port 18700)
- nginx: /api/blog-marketing/ 라우팅 추가
- .env.example: NAVER_CLIENT_ID/SECRET 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 19:59:25 +09:00
bb76e62774 music-lab: 가사 저장/수정/삭제 CRUD API 추가
- saved_lyrics 테이블 (id, title, text, prompt, created_at, updated_at)
- GET /api/music/lyrics/library — 저장된 가사 목록 조회
- POST /api/music/lyrics/library — 가사 저장
- PUT /api/music/lyrics/library/:id — 가사 수정
- DELETE /api/music/lyrics/library/:id — 가사 삭제

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 19:11:39 +09:00
649b99d143 music-lab: 서비스 고도화 — duration 수정 + 모델/크레딧/연장/분리 API 추가
Phase 1A:
- mutagen으로 MP3 실제 재생시간 추출 (sync + startup backfill)
- update_track_duration() DB 헬퍼 추가

Phase 2:
- GET /api/music/models — Suno 모델 목록 (V4~V5)
- GET /api/music/credits — 잔여 크레딧 조회
- POST /api/music/extend — 곡 연장 (continueAt 지점부터)
- POST /api/music/vocal-removal — 보컬/인스트루멘탈 분리
- GenerateRequest에 model 필드 추가 (하드코딩 V4 제거)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 14:36:52 +09:00
4b339d9d4f deployer: Docker CLI 설치 방식 개선 + 헬스체크 수정
- Dockerfile: docker.io → docker-ce-cli + docker-compose-plugin (Docker 공식 저장소)
  - python:3.12-slim에서 docker.io가 제대로 동작하지 않던 문제 해결
  - root 유저로 실행하여 Docker 소켓 접근 보장
- deploy.sh: 헬스체크 URL을 서비스명:내부포트로 변경
  - 컨테이너 내부에서 localhost:18000 접근 불가 문제 해결

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 14:03:58 +09:00
d2606d7317 music-lab: 라이브러리 파일시스템 동기화 + v2 파일명 중복 수정
- GET /api/music/library 호출 시 디스크 .mp3 파일과 DB 자동 동기화
  - 디스크에 없는 트랙 → DB에서 삭제
  - DB에 없는 .mp3 → 새 트랙으로 자동 등록
- NAS에서 파일명 변경 시 웹에 자동 반영
- _v2_v2 파일명 중복 버그 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 13:56:42 +09:00
33a011a086 music-lab: 두 번째 변형 파일명 _v2_v2 중복 수정
task_id에 이미 _v2가 붙으므로 filename_suffix를 빈 문자열로 변경.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 13:14:35 +09:00
e04c000a3e music-lab: MUSIC_DATA_DIR 경로 수정 (/app/data/music → /app/data)
볼륨 마운트가 ./data/music → /app/data 이므로,
/app/data/music/ 에 저장하면 호스트에서 ./data/music/music/ 이 되어
nginx 서빙 경로와 불일치. /app/data 로 통일.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 13:07:48 +09:00
1a251cae24 music-lab: Suno 응답 파싱 수정 — data.response.sunoData 경로로 트랙 추출
디버그 로그 제거.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 13:01:01 +09:00
2d98c4176b music-lab: Suno 디버깅 로그를 print()로 변경 (uvicorn 로그에 확실히 출력)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 12:50:46 +09:00
f7c583b806 music-lab: Suno SUCCESS 응답 디버깅 로깅 추가
실제 응답 구조 파악을 위해 keys/body 로깅 추가.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 12:47:14 +09:00
a618544823 music-lab: Suno generate 요청에 callBackUrl 필수 파라미터 추가
sunoapi.org는 callBackUrl이 필수. 폴링 방식이므로 더미 URL 사용.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 12:31:58 +09:00
2a1d8716c7 music-lab: Suno API를 sunoapi.org 래퍼로 전환 (URL·요청·응답 형식 수정)
- Base URL: apicast.suno.ai → api.sunoapi.org/api/v1
- 생성: POST /generate (customMode, model, instrumental 필드)
- 폴링: GET /generate/record-info?taskId=xxx (PENDING→SUCCESS)
- 가사: /lyrics 비동기 폴링 방식으로 변경
- 응답 필드: camelCase (audioUrl, imageUrl, sunoData) 대응

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 08:38:20 +09:00
f5c58a5aa5 music-lab: Suno API + MusicGen 듀얼 프로바이더 구조 구현
- suno_provider.py: Suno REST API 클라이언트 (곡 생성, 가사, 2변형 저장)
- local_provider.py: 기존 MusicGen 로직 분리
- main.py: provider 라우팅, /providers·/lyrics 엔드포인트 추가
- db.py: provider, lyrics, image_url, suno_id 컬럼 마이그레이션
- docker-compose.yml: SUNO_API_KEY 환경변수 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 08:23:29 +09:00
9ac142e1de deployer: flock용 util-linux 추가, 헬스체크 URL localhost 포트로 수정
- Dockerfile: util-linux 패키지 추가 (flock 명령어 제공)
- deploy.sh: 헬스체크 URL을 Docker 서비스명 → localhost 호스트 포트로 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 01:50:04 +09:00
819c35adfc P2: print→logging 전환, 포트폴리오 중복 제거, Docker healthcheck 추가
- backend/main.py: logging 모듈 도입, print() 제거
- stock-lab/main.py: print() → logger 전환, _calc_portfolio_totals 공용 함수 추출
- stock-lab/scraper.py: logging 모듈 도입, print() 제거
- docker-compose.yml: 전 서비스 healthcheck 블록 추가 (30s 간격, 3회 재시도)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 01:45:39 +09:00
6a1a2c4552 CI/CD 안정성 강화: 동시 배포 방지, 자기 재빌드 제거, 헬스체크 추가
- deploy.sh: flock으로 동시 배포 방지, deployer를 빌드 대상에서 제외
- deploy.sh: 배포 후 헬스체크 (4개 서비스 /health 확인)
- deploy.sh: 릴리즈 백업 최근 5개만 유지, 원자적 백업 (mv)
- deploy-nas.sh: .env 동기화 제거 (운영 시크릿 보호), __pycache__ 제외
- deployer: threading.Lock으로 동시 배포 방어, TimeoutExpired 개별 처리
- docker-compose: deployer 포트 localhost 바인딩, stock-lab 환경변수 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 01:20:25 +09:00
ff975defbd AI Coach 백엔드 프록시 추가 및 trade 엔드포인트 인증 적용
- POST /api/stock/ai-coach: Anthropic API 프록시 (API 키 서버 보관)
- trade/balance, trade/order: ADMIN_API_KEY 헤더 인증 추가
- print() → logging 모듈 전환 (stock-lab)
- .env.example: ADMIN_API_KEY, ANTHROPIC_API_KEY, CORS_ALLOW_ORIGINS 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 01:12:31 +09:00
bc9ba3901e 보안 강화: CORS 제한, Path Traversal 방어, 헬스체크 추가
- travel-proxy: get_thumb NameError 수정 및 경로 조작 방어
- stock-lab, music-lab: CORS allow_origins=* → 환경변수 기반 도메인 제한
- travel-proxy, deployer: /health 엔드포인트 추가
- 전 서비스 .dockerignore 추가 (.git, __pycache__, .env 제외)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 01:08:39 +09:00
c9737b380f 로또 종합 추론 API 추가 (5가지 통계 기법 가중 투표)
- analyzer.py: generate_combined_recommendation() 함수 추가
  빈도Z(25%)·조합지문(30%)·갭(20%)·공동출현(15%)·다양성(10%) 가중 투표
- main.py: GET /api/lotto/recommend/combined 엔드포인트 추가
  결과를 태그 "종합추론"으로 recommendations 테이블에 저장
- main.py: GET /api/lotto/recommend/combined/history 엔드포인트 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:40:53 +09:00
09e5ab4e30 AI 포트폴리오 분석 엔드포인트 및 Gemini 연동 제거
프롬프트 생성/복사 방식으로 전환하여 더 이상 불필요한
/api/stock/ai-analysis 엔드포인트, ai_analyst.py, google-generativeai 패키지 제거

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 04:47:42 +09:00
4f854c5540 stock-lab: Gemini 모델 gemini-1.5-flash로 변경 (무료 할당량)
2.5 Pro는 결제 설정 필요. 1.5 Flash는 무료 1500 RPD.
결제 설정 후 GEMINI_MODEL 환경변수로 원하는 모델 지정 가능.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 04:20:40 +09:00
0aa12d94c5 deployer: docker-compose → docker compose (v2) 수정
NAS Docker v2에서 docker-compose 명령어 없음 오류 수정.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 04:14:06 +09:00
2265da49c6 stock-lab: Gemini 모델 gemini-2.5-pro로 변경
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 04:11:43 +09:00
c11aa2a9cb stock-lab: GEMINI_API_KEY 환경변수 컨테이너에 주입
docker-compose.yml stock-lab environment에 GEMINI_API_KEY, GEMINI_MODEL 추가.
.env에 값이 있어도 컨테이너에 전달 안 됐던 문제 수정.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 04:09:56 +09:00
021f682be5 stock-lab: Gemini Pro AI 포트폴리오 분석 기능 추가
- ai_analyst.py 신규: Gemini Pro 연동 포트폴리오 분석 모듈
  - 보유 종목 현재가 + 뉴스 기반 프롬프트 생성
  - 종목별 매도/매수/분할매도 행동 지침 포함
  - 5분 메모리 캐시 (force 파라미터로 강제 갱신 가능)
- GET /api/stock/ai-analysis 엔드포인트 추가
- requirements.txt: google-generativeai>=0.8.0 추가

환경변수 필요: GEMINI_API_KEY (Google AI Studio)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 03:55:06 +09:00
5e06adea3d stock 실현손익에 세금란 추가 2026-03-24 08:12:47 +09:00
e6df50bbb1 stock 실현손익에 세금란 추가 2026-03-24 07:53:39 +09:00
57ad1fd67d MUSIC-lab generate 요청 후 대기 시간 추가 2026-03-24 07:53:22 +09:00
4589592b67 nginx: music-lab proxy_pass $request_uri 로 수정
변수 기반 proxy_pass는 하위 경로(library, generate 등)를 자동 치환하지
않으므로 $request_uri로 전체 경로를 그대로 전달하도록 수정.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 23:19:34 +09:00
c7e12ea9fe deploy: music-lab rsync 항목 추가
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 23:03:03 +09:00
438aba1dd1 nginx: music-lab upstream DNS 조회 실패 방지
- frontend depends_on music-lab 추가 (시작 순서 보장)
- /api/music/ location에 resolver 127.0.0.11 + 변수 proxy_pass 적용
  (Nginx 시작 시점에 music-lab이 미준비여도 기동 가능)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 23:01:40 +09:00
7ab0733400 NAS 기본 설정 변경 2026-03-21 10:46:09 +09:00
14236f355a music-lab: payload title 우선 사용 (없으면 자동 생성 폴백)
GenerateRequest에 title 필드 추가.
프론트가 "Lo-Fi — Chill Mix"를 보내면 그대로 저장,
미전송 시 "{genre} — {mood} Mix" 자동 생성.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:30 +09:00
f1e72e2829 music-lab 프론트엔드 대조 수정 (이중저장·title·audio_url·status shape)
- 이중 저장 방지: auto-register 유지, Save 버튼 제거는 프론트 담당 (방식 A)
- title 자동 생성: "{genre} — {mood} Mix" 형식으로 개선
- audio_url 절대경로 제거: 항상 /media/music/{task_id}.mp3 상대경로 반환
- status succeeded 시 track 메타데이터 포함 (프론트 Save 버튼 없이 즉시 UI 반영 가능)
- get_track_by_task_id() 함수 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:40:37 +09:00
868020f7ed music-lab 신규 서비스 추가 (AI 음악 생성 + 라이브러리 관리)
- music-lab/ 신규 서비스 (포트 18600)
  - POST /api/music/generate     비동기 음악 생성 (task_id 반환)
  - GET  /api/music/status/:id   폴링 (queued→processing→succeeded/failed)
  - GET  /api/music/library      라이브러리 조회
  - POST /api/music/library      트랙 수동 추가
  - DELETE /api/music/library/:id 트랙 삭제 (파일 포함)
- SQLite: music_tasks + music_library 테이블
- 생성 완료 시 라이브러리 자동 등록
- AI 서버 응답: binary audio / JSON audio_url 모두 지원
- nginx: /api/music/ 프록시 + /media/music/ 오디오 파일 직접 서빙
- docker-compose: music-lab 서비스 + frontend 볼륨 마운트 추가
- CLAUDE.md 업데이트

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:32:26 +09:00
f1eab292a2 성과 통계 인메모리 캐시 추가 (GET /api/lotto/stats/performance)
매 요청마다 전체 recommendations 조회하던 구조를 캐시로 개선.
갱신 시점: 새 회차 채점 직후(_sync_and_check) + TTL 1시간 만료 폴백

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 02:00:54 +09:00
732d78becc 로또 프리미엄 Phase 2 — 구매 이력 + 개인 패턴 분석 + 주간 리포트 캐싱
- purchase_history 테이블 추가 (draw_no, amount, sets, prize, note)
- weekly_reports 캐시 테이블 추가 (drw_no UNIQUE, report JSON)
- GET  /api/lotto/purchase         구매 이력 조회 (draw_no, days 필터)
- POST /api/lotto/purchase         구매 이력 추가
- PUT  /api/lotto/purchase/:id     구매 이력 수정 (당첨금 업데이트)
- DELETE /api/lotto/purchase/:id   구매 이력 삭제
- GET  /api/lotto/purchase/stats   투자 수익률 통계
- GET  /api/lotto/analysis/personal 개인 패턴 분석 (top/least picks, 홀짝/구간/연속번호)
- GET  /api/lotto/report/history   저장된 주간 리포트 목록
- GET  /api/lotto/report/:drw_no   캐시 우선 조회 + cached 플래그
- 스케줄러: 토요일 09:00 주간 리포트 자동 생성 및 DB 캐싱

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:59:07 +09:00
2ce118baba 로또 프리미엄 Phase 1 — 추천 성과 통계 + 회차 공략 리포트 API
- GET /api/lotto/stats/performance: 채점 이력 기반 성과 통계
  (평균 일치 수, 등수 분포, 무작위 대비 개선율)
- GET /api/lotto/report/latest: 다음 회차 공략 리포트 자동 생성
- GET /api/lotto/report/{drw_no}: 특정 회차 공략 리포트
  (과출현/냉각/오버듀 번호, 최근 패턴, 3가지 전략 추천, 신뢰도 점수)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:48:28 +09:00
05e7ffdfd9 매도 히스토리 수정 API 추가 (PUT /api/portfolio/sell-history/:id)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:02:11 +09:00
c7401c5d9f 주식 매도 히스토리 API 추가 (/api/portfolio/sell-history)
- sell_history 테이블 신규 생성 (db.py init_db)
- CRUD 함수 추가: add_sell_history, get_sell_history, delete_sell_history
- GET /api/portfolio/sell-history (broker, days 필터)
- POST /api/portfolio/sell-history (id 포함 저장된 레코드 반환)
- DELETE /api/portfolio/sell-history/{record_id}

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 22:31:17 +09:00
5d6fe2f04b 청약 관리 API 추가 (/api/subscription)
- subscription_items 테이블: 청약 목록 CRUD (GET/POST/PUT/DELETE)
- subscription_profile 테이블: 내 청약 조건 프로필 싱글톤 (GET/PUT, upsert)
- specialQuals JSON 배열, bool → int SQLite 변환 처리

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 02:18:06 +09:00
2926770d6f 부동산 청약 단지 관리 API 추가
- realestate_complexes 테이블 추가 (lotto.db)
- CRUD 엔드포인트 4개: GET/POST /api/realestate/complexes, PUT/DELETE /api/realestate/complexes/:id
- status: 청약예정|청약중|결과발표|완료, priority: high|normal|low 검증

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 01:23:28 +09:00
197d451d5f README.md 수정 2026-03-11 08:30:47 +09:00
f45041d46c 블로그 글 작성 api 추가 2026-03-11 08:07:24 +09:00
483963b463 자산관리 효율 증가 api 추가 2026-03-07 03:44:14 +09:00
11423e5106 todo List 작성 api 추가 2026-03-05 01:19:57 +09:00
de7468b256 stock 실계좌 예수금 정보 추가 2026-03-02 19:00:51 +09:00
d6d2eb0787 stock 실계좌 예수금 정보 추가 2026-03-02 18:44:51 +09:00
136aea8aee fix: portfolio API nginx 라우팅 수정 (trailing slash 없이도 매칭)
/api/portfolio/ → /api/portfolio 로 변경하여
trailing slash 미포함 요청도 stock-lab으로 정상 프록시되도록 수정

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 00:50:59 +09:00
ea9eb749aa stock 실계좌 정보 표출 추가 2026-02-25 23:49:28 +09:00
71d9d7a571 lotto lab 추천 알고리즘 및 시뮬레이션 강화 2026-02-23 22:32:14 +09:00
c96815c2e3 stock-lab 오류 수정, lotto-lab 히트맵 기반 추천 기능 추가 2026-02-05 01:26:20 +09:00
4035432c54 stock-lab 의미없이 남겨진 소스로 오류 발생의 해결 2026-01-28 01:22:52 +09:00
d28c291a55 stock-lab 자동 매매 요청 삭제, 수동 매매 요청 추가 2026-01-28 01:20:31 +09:00
21a8173963 주요지수 해외 오류 수정 및 원달러환율 추가 2026-01-28 00:55:47 +09:00
f6fcff0faf 주식 해외 지수 응답 추가 2026-01-28 00:44:09 +09:00
55863d7744 자동매매 응답 로그 출력 추가 2026-01-27 03:12:50 +09:00
a330a5271c /api/trade/auto 요청 오류 스펙에 맞게 수정 2026-01-27 03:06:59 +09:00
e27fbfada1 요청에 대한 명시적 로그 표출 2026-01-27 03:00:07 +09:00
7fb55a7be7 api/trade 미동작 문제 해결: CORS 허용 추가 2026-01-27 02:53:44 +09:00
9a8df4908a NAS 앞단에 있는 nginx가 요청을 Docker 컨테이너로 넘겨줄 수 있도록 config api/trade 설정 2026-01-27 02:28:56 +09:00
a8cbef75db window pc AI server 구축 및 NAS 중계 서버 연결 설정 2026-01-27 01:26:23 +09:00
b6fd444dba 서비스 구체화 현 상태 README 정리 2026-01-27 01:08:05 +09:00
f2e23c1241 windows pc, how to set up ollama server 2026-01-26 23:39:08 +09:00
c6850da4ac 주식 증권 api 연동 및 window pc AI 연동 기능 구현 시작 2026-01-26 22:31:56 +09:00
8283dab0de fix: use correct mainnews endpoint for overseas news 2026-01-26 04:05:30 +09:00
9faa1c5715 fix: update overseas news API url and key mapping 2026-01-26 04:03:09 +09:00
0e2d241e18 fix: handle list response from Naver API in scraper 2026-01-26 04:00:48 +09:00
84c5877207 fix: rewrite scraper.py to fix syntax errors and use mobile API 2026-01-26 03:57:30 +09:00
cbafc1f959 scraper.py 오류 수정 2026-01-26 03:51:08 +09:00
dce6b3e692 scraper.py 오류 수정 2026-01-26 03:48:51 +09:00
3d0dd24f27 feat: add overseas financial news and indices support 2026-01-26 03:45:19 +09:00
2fafce0327 fix: change manual scrap endpoint to /api/stock/scrap 2026-01-26 03:31:26 +09:00
25ede4f478 fix: import Any in stock-lab scraper 2026-01-26 03:23:54 +09:00
2493bc72fb stock_lab 배포 경로 추가될 수 있게 추가 2026-01-26 03:19:25 +09:00
dd6435eb86 기타 추가 설정 2026-01-26 03:17:59 +09:00
94db1da045 feat: add stock indices scraping and update healthcheck 2026-01-26 03:14:46 +09:00
d8e4e0461c feat: add stock-lab service for financial news scraping and analysis 2026-01-26 02:56:52 +09:00
421e52b205 refactor: extract utils to fix circular import and enable smart generator 2026-01-26 01:21:57 +09:00
526d6a53e5 파일 권한 설정 추가 2026-01-26 01:17:40 +09:00
432840a38d feat: smart recommendation generator with feedback loop and result checker 2026-01-26 01:15:49 +09:00
597353e6d4 feat: auto-sync full lotto history on stats api access 2026-01-26 00:45:32 +09:00
bd43c99221 fix: revert deployer to root and fix permissions in script 2026-01-26 00:35:26 +09:00
2c95fe49f3 fix: healthcheck url 2026-01-26 00:28:13 +09:00
8ccfc32749 healthcheck(api test) 스크립트 추가 2026-01-26 00:20:36 +09:00
67ef3c4bbf git 배포 시 파일 권한 변경되는 부분 해결 2026-01-26 00:12:51 +09:00
ee54458bf0 fix: deployer webhook timeout - implement async background task 2026-01-26 00:05:17 +09:00
e1c3168d5c fix: deploy.sh path detection for host execution 2026-01-26 00:01:08 +09:00
2d5972c25d lotto lab 전 차수 로또 당첨 번호 그래프 시각화 api 추가 2026-01-25 23:56:00 +09:00
1ddbd4ad0e lotto 추천 결과 통계 시각화 (분포, 합계, 홀짝) 를 구현 2026-01-25 22:40:39 +09:00
f75bf5d3e5 deployer 스크립트 권한 오류 수정 2026-01-25 21:37:33 +09:00
0fde916120 travel-proxy 이미지 썸네일 로딩 최적화, 페이지네이션 추가 2026-01-25 19:40:26 +09:00
64c526488a fix: deploy-nas.sh path detection for host execution 2026-01-25 19:10:23 +09:00
c655b655c9 deploy-nas.sh 스크립트 수행 오류 수정 2026-01-25 19:09:04 +09:00
005c0261c2 Local PC 개발 및 테스트 - git 배포 - gitea webhook 단계별 설정 2026-01-25 19:00:22 +09:00
879bb2f25d README.md 현 스펙 정리 2026-01-25 17:42:19 +09:00
82cbae7ae2 webhook 설정 오류 수정
- deployer 배포 webhook 오류 설정 수정
2026-01-25 17:28:58 +09:00
a8b661b304 rename script folder 2026-01-25 15:44:39 +09:00
b815c37064 webhook 자동 배포 설정 2026-01-25 11:51:39 +09:00
367 changed files with 73121 additions and 880 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

@@ -1,17 +1,129 @@
# timezone
# ---------------------------------------------------------------------------
# [Environment Configuration]
# 이 파일을 복사하여 .env 파일을 생성하고, 환경에 맞게 주석을 해제/수정하여 사용하세요.
# ---------------------------------------------------------------------------
# [COMMON]
APP_VERSION=dev
TZ=Asia/Seoul
COMPOSE_PROJECT_NAME=webpage
# backend lotto collector sources
LOTTO_ALL_URL=https://smok95.github.io/lotto/results/all.json
LOTTO_LATEST_URL=https://smok95.github.io/lotto/results/latest.json
# travel-proxy
TRAVEL_ROOT=/data/travel
TRAVEL_THUMB_ROOT=/data/thumbs
TRAVEL_MEDIA_BASE=/media/travel
TRAVEL_CACHE_TTL=300
# [SECURITY]
WEBHOOK_SECRET=change_this_secret_in_prod
# CORS (travel-proxy)
CORS_ALLOW_ORIGINS=*
# [PATHS]
# 1. 런타임 데이터 루트 (docker-compose.yml이 실행되는 위치)
# NAS: /volume1/docker/webpage
# Local: . (현재 프로젝트 루트)
RUNTIME_PATH=.
# 2. Git 저장소 루트
# NAS: /volume1/workspace/web-page-backend
# Local: .
REPO_PATH=.
# 3. Frontend 정적 파일 경로
# NAS: /volume1/docker/webpage/frontend (업로드된 파일)
# Local: ./frontend/dist (빌드된 결과물)
FRONTEND_PATH=./frontend/dist
# 4. 여행 사진 원본 경로
# NAS: /volume1/web/images/webPage/travel
# Local: ./mock_data/photos
PHOTO_PATH=./mock_data/photos
# 5. 주식 데이터 저장 경로
# NAS: /volume1/docker/webpage/data/stock
# Local: ./data/stock
STOCK_DATA_PATH=./data/stock
# [PERMISSIONS]
# NAS: 1026:100
# Local: 1000:1000 (Windows Docker Desktop의 경우 크게 중요하지 않음)
PUID=1000
PGID=1000
# [STOCK LAB]
# NAS는 Windows AI Server로 요청을 중계(Proxy)하는 역할만 수행합니다.
# 실제 KIS API 호출 및 AI 분석은 Windows PC에서 수행됩니다.
# Windows AI Server (NAS 입장에서 바라본 Windows PC IP)
WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
# 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
# 뉴스 요약 provider 전환: claude (기본) | ollama
LLM_PROVIDER=claude
# Ollama 서버 (LLM_PROVIDER=ollama 일 때만 사용)
OLLAMA_URL=http://192.168.45.59:11435
OLLAMA_MODEL=qwen3:14b
# [BLOG LAB]
# Naver Search API (https://developers.naver.com 에서 발급)
NAVER_CLIENT_ID=
NAVER_CLIENT_SECRET=
# 블로그 데이터 저장 경로
# BLOG_DATA_PATH=./data/blog
# [MUSIC LAB]
# Suno API Key (https://suno.com 에서 발급, 미설정 시 Suno provider 비활성화)
SUNO_API_KEY=
# 로컬 MusicGen AI Server URL (미설정 시 Local provider 비활성화)
# MUSIC_AI_SERVER_URL=http://192.168.45.59:8765
# CORS 허용 도메인 (콤마 구분)
CORS_ALLOW_ORIGINS=https://gahusb.synology.me,http://localhost:3007,http://localhost:8080
# [REALESTATE LAB — agent-office push notify]
AGENT_OFFICE_URL=http://agent-office:8000
REALESTATE_LAB_URL=http://realestate-lab:8000
REALESTATE_DASHBOARD_URL=http://localhost:8080/realestate
REALESTATE_NOTIFY_TIMEOUT=15
# [MUSIC LAB — YouTube Video Generation]
PEXELS_API_KEY=
YOUTUBE_DATA_API_KEY=
# VIDEO_DATA_DIR=/app/data/videos # 기본값, 재정의 필요 시만 설정
# ─── packs-lab — NAS 자료 다운로드 자동화 ────────────────────────────
# Synology DSM 7.x 인증 (공유 링크 발급용)
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=
# Supabase pack_files 테이블 접근 (service_role 키, RLS 우회)
SUPABASE_URL=https://<project>.supabase.co
SUPABASE_SERVICE_KEY=
# admin upload 토큰 TTL (초). default 1800 = 30분
UPLOAD_TOKEN_TTL_SEC=1800
# 호스트 마운트 경로 (로컬 ./data/packs, NAS /volume1/docker/webpage/media/packs)
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

11
.gitignore vendored
View File

@@ -63,3 +63,14 @@ uploads/
################################
tmp/
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건. 진단 커맨드.

744
CLAUDE.md Normal file
View File

@@ -0,0 +1,744 @@
# CLAUDE.md — web-backend 프로젝트 가이드
> Claude Code가 이 프로젝트를 작업할 때 참조하는 설정 및 구조 문서.
---
## 1. 프로젝트 개요
Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
- **서비스**: lotto-lab, stock, travel-proxy, music-lab, insta-lab, realestate-lab, agent-office, personal, packs-lab, deployer (10개)
- **프론트엔드**: 별도 레포 (React + Vite SPA), 빌드 산출물만 NAS에 배포
- **인프라**: Docker Compose (10컨테이너) + Nginx(리버스 프록시) + Gitea Webhook 자동 배포
---
## 2. NAS 환경
| 항목 | 값 |
|------|----|
| 장비 | Synology NAS |
| CPU | Intel Celeron J4025 (2 Core, 2.0 GHz) |
| 메모리 | 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 |
---
## 3. NAS 디렉토리 구조
```
/volume1
├── docker/webpage/ # 운영 런타임 (Docker Compose 실행 위치)
│ ├── lotto/ # lotto 소스 (rsync 동기화)
│ ├── stock/ # stock 소스 (rsync 동기화)
│ ├── travel-proxy/ # travel-proxy 소스 (rsync 동기화)
│ ├── deployer/ # deployer 소스 (rsync 동기화)
│ ├── nginx/default.conf # Nginx 설정
│ ├── scripts/deploy.sh # Webhook 트리거 배포 스크립트
│ ├── docker-compose.yml
│ ├── .env # 운영 환경변수
│ ├── data/lotto.db # SQLite DB
│ └── data/music/ # 생성된 오디오 파일 (music-lab)
├── workspace/web-page-backend/ # Git 레포 클론 위치 (REPO_PATH)
└── web/images/webPage/travel/ # 원본 여행 사진 (RO 마운트)
```
---
## 4. Docker 서비스 & 포트
| 컨테이너 | 포트 | 역할 |
|---------|------|------|
| `lotto` | 18000 | 로또 데이터 수집·분석·추천 API |
| `stock` | 18500 | 주식 뉴스·AI 분석·KIS API 연동 |
| `music-lab` | 18600 | AI 음악 생성·라이브러리 관리 API |
| `insta-lab` | 18700 | 인스타 카드 피드 자동 생성 (뉴스→키워드→10페이지 카드) |
| `realestate-lab` | 18800 | 부동산 청약 자동 수집·매칭 API |
| `agent-office` | 18900 | AI 에이전트 오피스 (실시간 WebSocket + 텔레그램 연동) |
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (DSM 공유 링크 + 5GB 업로드, Vercel SaaS와 HMAC 통신) |
| `personal` | 18850 | 개인 서비스 (포트폴리오·블로그·투두 통합) |
| `travel-proxy` | 19000 | 여행 사진 API + 썸네일 생성 |
| `frontend` (nginx) | 8080 | 정적 SPA 서빙 + API 리버스 프록시 |
| `webpage-deployer` | 19010 | Gitea Webhook 수신 → 자동 배포 |
---
## 5. Nginx 라우팅 규칙
| 경로 | 프록시 대상 | 비고 |
|------|------------|------|
| `/api/` | `lotto:8000` | lotto API (기본) |
| `/api/travel/` | `travel-proxy:8000` | travel API |
| `/api/stock/` | `stock:8000` | stock API |
| `/api/trade/` | `stock:8000` | KIS 실계좌 API |
| `/api/portfolio` | `stock:8000` | trailing slash 유무 모두 매칭 |
| `/api/music/` | `music-lab:8000` | AI 음악 생성·라이브러리 API |
| `/api/insta/` | `insta-lab:8000` | 인스타 카드 자동 생성 API |
| `/api/realestate/` | `realestate-lab:8000` | 부동산 청약 API |
| `/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) |
| `/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`) | |
---
## 6. 기술 스택
| 레이어 | 기술 |
|--------|------|
| Backend 언어 | Python 3.12 |
| API 프레임워크 | FastAPI |
| DB | SQLite (`/app/data/*.db`) |
| 스케줄러 | APScheduler |
| 컨테이너 | Docker (`python:3.12-slim` 기반) |
| AI 연동 | Ollama (Llama 3.1) — Windows PC (192.168.45.59) |
| 주식 API | KIS (한국투자증권) Open API |
---
## 7. 자동 배포 흐름
```
개발자 git push → Gitea → Webhook (HMAC SHA256 검증)
→ deployer 컨테이너 → /scripts/deploy.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/` 디렉토리에 자동 백업
---
## 8. 로컬 개발 환경
```bash
# .env 기본값으로 즉시 실행 가능 (RUNTIME_PATH=., PHOTO_PATH=./mock_data/photos)
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 |
| Insta Lab | http://localhost:18700 |
| Realestate Lab | http://localhost:18800 |
| Packs Lab | http://localhost:18950 |
---
## 9. 서비스별 핵심 정보
### 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`
**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 서비스로 이전됨, 레거시 테이블 유지 |
| `weight_trials` | 주별 6일치 후보 가중치 (4 perturb + 2 dirichlet) |
| `auto_picks` | 매일 N=5 시도 번호 + 채점 결과 |
| `weight_base_history` | base 갱신 이력 (winner_4plus / ema_blend / unchanged / cold_start) |
**스케줄러 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개 교체)
- 월요일 09:00 — weight_evolver_weekly (6개 후보 생성 + 그날 N=5 추출)
- 매일 09:00 — weight_evolver_daily (월요일 제외, 오늘 W로 N=5 추출)
- 토요일 22:00 — weight_evolver_eval (회고 + 다음주 base 갱신)
**lotto-lab API 목록**
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/lotto/latest` | 최신 당첨번호 |
| GET | `/api/lotto/{drw_no}` | 특정 회차 |
| GET | `/api/lotto/stats` | 번호 빈도 통계 |
| GET | `/api/lotto/analysis` | 5가지 통계 분석 리포트 |
| GET | `/api/lotto/best` | 시뮬레이션 최적 번호 (기본 20쌍) |
| GET | `/api/lotto/simulation` | 시뮬레이션 상세 결과 |
| GET | `/api/lotto/recommend` | 통계 기반 추천 |
| GET | `/api/lotto/recommend/heatmap` | 히트맵 기반 추천 |
| GET | `/api/lotto/recommend/batch` | 배치 추천 |
| 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 | `/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` | 당첨번호 수동 동기화 |
| 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` | 큐레이터 토큰·비용 집계 |
| POST | `/api/lotto/briefing` | AI 브리핑 저장 |
| GET | `/api/lotto/briefing/latest` | 최신 브리핑 |
| GET | `/api/lotto/briefing/{draw_no}` | 특정 회차 브리핑 |
| GET | `/api/lotto/briefing` | 브리핑 이력 |
| GET | `/api/lotto/evolver/status` | weight_evolver 이번주 trials + current_base + 진행 상황 |
| GET | `/api/lotto/evolver/history?weeks=12` | base 변경 이력 |
| GET | `/api/lotto/evolver/trials/{week_start}` | 특정 주 6 trials + 채점 결과 |
| POST | `/api/lotto/evolver/generate-now` | 수동 트리거 — 이번주 후보 생성 |
| POST | `/api/lotto/evolver/evaluate-now` | 수동 회고 + 다음주 base 갱신 |
### stock (stock/)
- Windows AI 서버 연동: `WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000`
- KIS API 연동으로 실계좌 잔고·거래 조회
- 뉴스 스크래핑: 네이버 증권 + 해외 사이트
- DB: `/app/data/stock.db` (articles, portfolio, broker_cash, asset_snapshots, sell_history 테이블)
- 파일 구조: `main.py`, `db.py`, `scraper.py`, `price_fetcher.py`, `holidays.json`
**stock API 목록**
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/stock/news` | 뉴스 조회 (`limit`, `category` 파라미터) |
| GET | `/api/stock/indices` | 주요 지표 실시간 조회 |
| 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) |
| 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`)
### 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 목록**
| 메서드 | 경로 | 설명 |
|--------|------|------|
| 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/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) |
**환경변수**
- `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_projects 테이블**
- format: `visualizer` | `slideshow`
- status: `pending``rendering``done` | `failed`
- target_countries: JSON 배열 (예: `["BR","US"]`)
- render_params: JSON 객체 (FFmpeg 파라미터 캐시)
**revenue_records 테이블**
- UNIQUE(yt_video_id, record_month, country)
- avg_rpm 계산: 가중평균 `SUM(revenue_usd)/SUM(views)*1000` (단순 AVG 아님)
**market_trends 테이블**
- source: `youtube` | `google_trends` | `billboard`
- metadata: JSON 객체 (원본 API 응답 부분)
- 인덱스: `idx_mt_country_source` ON (country, source, collected_at DESC)
**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개)
**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]`
### 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 목록**
| 메서드 | 경로 | 설명 |
|--------|------|------|
| 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` 포함) |
| 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}` | 커스텀 지역 이름/좌표 수정 (지도 핀 표시용) |
### insta-lab (insta-lab/)
- 인스타그램 카드 피드 자동 생성 — 뉴스 모니터링 → 키워드 추출 → 10페이지 카드 카피 + PNG 렌더 → 텔레그램 푸시 → 사용자 수동 업로드
- DB: `/app/data/insta.db` (news_articles, trending_keywords, card_slates, card_assets, generation_tasks, prompt_templates)
- 카드 사이즈: 1080×1350 (인스타 4:5 세로)
- 카드 렌더: Jinja2 템플릿 → Playwright headless Chromium 스크린샷
- 파일 구조: `app/main.py`, `config.py`, `db.py`, `news_collector.py`, `keyword_extractor.py`, `card_writer.py`, `card_renderer.py`, `templates/default/card.html.j2`
**환경변수**
- `NAVER_CLIENT_ID` / `NAVER_CLIENT_SECRET`: 네이버 검색 API
- `ANTHROPIC_API_KEY`: Claude API (Haiku=키워드 정제, Sonnet=카드 카피)
- `ANTHROPIC_MODEL_HAIKU` / `ANTHROPIC_MODEL_SONNET`: 모델명 오버라이드
- `INSTA_DATA_PATH`: SQLite + 카드 PNG 저장 경로 (기본 `/app/data`)
- `CARD_TEMPLATE_DIR`: HTML 템플릿 디렉토리 (기본 `/app/app/templates`)
- `INSTA_DEFAULT_THEME`: 카드 렌더에 사용할 theme 디렉토리명 (기본 `default`). `templates/<theme>/card.html.j2`가 없으면 자동으로 default 폴백
- `NEWS_PER_CATEGORY` / `KEYWORDS_PER_CATEGORY`: 수집·추출 limit 튜닝
**카테고리 시드 키워드**
- 기본 economy / psychology / celebrity 3종 (config.DEFAULT_CATEGORY_SEEDS)
- `prompt_templates.name='category_seeds'`에 JSON으로 오버라이드 가능
**카드 슬레이트 (`card_slates`)**
- status: `draft``rendered``sent` (또는 `failed`)
- cover_copy / body_copies (8개) / cta_copy / suggested_caption / hashtags JSON 컬럼
- accent_color는 카테고리별 기본값 (economy=#0F62FE, psychology=#A66CFF, celebrity=#FF5C8A)
**스케줄러 job (agent-office)**
- 09:30 매일 — `_run_insta_schedule` (insta_pipeline) → 뉴스 수집 → 키워드 추출 → 텔레그램 후보 푸시
- `agent_config.custom_config.auto_select=True`이면 카테고리당 1위 키워드 자동 슬레이트 생성·발송
**디자인 import (사용자 디자인 PNG → Claude Vision → Jinja HTML 자동 생성)**
- `insta-lab/app/templates/<theme>/pages/*.png` (10장, 4:5 비율 권장 1080×1350, placeholder 텍스트 박혀있는 형태) → Claude Sonnet Vision → `templates/<theme>/card.html.j2` 자동 생성
- 파일명 자동 매핑: `cover`/`start`/`intro` → page 1, `cta`/`outro`/`finish`/`end` → page 10, 나머지 알파벳 순 → page 2~9
- 매핑 override: `pages/_order.json``{filename: page_no}` 명시 (10장 + page 1~10 완전 매핑일 때만 적용)
- Vision prompt에 placeholder 마스킹 요구 포함 (2-layer: 마스킹 박스 + 동적 텍스트 layer)
- 기존 HTML 자동 백업 (`card.html.j2.bak.YYYYMMDD-HHMMSS`)
- Jinja 문법 깨진 응답은 `card.html.j2.error.txt`로 보존 + ValueError
- 활성화: `.env``INSTA_DEFAULT_THEME=<theme>` 추가 + `docker compose restart insta-lab` (테마 디렉토리에 `card.html.j2` 없으면 렌더러가 default로 폴백)
- 토큰 비용: 1회당 ~15K tokens (~$0.05 Sonnet 기준)
**⚠️ 실행 위치 — 로컬 권장, NAS docker exec 금지**
- docker-compose의 insta-lab volume은 `/app/data`만 마운트. **`/app/app/templates`는 컨테이너 ephemeral state**.
- NAS에서 `docker exec insta-lab python -m app.design_importer <theme>`로 돌리면 `card.html.j2`가 컨테이너 안에만 생성되고 다음 image rebuild(다른 push의 webhook이라도) 때 사라짐 → 렌더러가 default로 폴백.
- **로컬 실행** (host repo working tree에 영속화 → git push → 자동 배포):
```bash
cd insta-lab
pip install anthropic Pillow jinja2 # 이미 있으면 skip
export ANTHROPIC_API_KEY=sk-ant-...
python -m app.design_importer <theme> --templates-dir ./app/templates
git add app/templates/<theme>/card.html.j2
git commit -m "feat(insta-lab): <theme> 디자인 import"
git push # → Gitea webhook → NAS rebuild → 영구 활성화
```
- 응급 hotfix로 NAS에서 돌렸다면 `docker cp insta-lab:/app/app/templates/<theme>/card.html.j2 ./` 후 즉시 host repo에 commit + push 필요
**insta-lab API 목록**
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/insta/status` | 서비스 상태 (NAVER/ANTHROPIC 키 여부) |
| POST | `/api/insta/news/collect` | 뉴스 수집 트리거 (BackgroundTask) |
| GET | `/api/insta/news/articles` | 수집 기사 목록 (category, days) |
| POST | `/api/insta/keywords/extract` | 키워드 추출 트리거 (BackgroundTask) |
| GET | `/api/insta/keywords` | 트렌딩 키워드 목록 (category, used) |
| POST | `/api/insta/slates` | 슬레이트 생성 (keyword, category) |
| GET | `/api/insta/slates` | 슬레이트 목록 |
| GET | `/api/insta/slates/{id}` | 슬레이트 상세 + 자산 |
| POST | `/api/insta/slates/{id}/render` | 카드 렌더 재시도 |
| GET | `/api/insta/slates/{id}/assets/{page}` | 카드 PNG 다운로드 (1~10) |
| DELETE | `/api/insta/slates/{id}` | 슬레이트 삭제 (자산 파일 포함) |
| GET | `/api/insta/tasks/{task_id}` | BackgroundTask 상태 폴링 |
| GET/PUT | `/api/insta/templates/prompts/{name}` | 프롬프트 템플릿 CRUD |
### agent-office (agent-office/)
- AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 에이전트가 실제 작업 수행
- stock/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_URL`: stock 내부 URL (기본 `http://stock:8000`)
- `MUSIC_LAB_URL`: music-lab 내부 URL (기본 `http://music-lab:8000`)
- `REALESTATE_LAB_URL`: realestate-lab 내부 URL (기본 `http://realestate-lab:8000`) — 북마크 콜백 프록시 대상
- `REALESTATE_DASHBOARD_URL`: 텔레그램 [전체 보기] 버튼 URL (기본 `http://localhost:8080/realestate`)
- `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)
- `LOTTO_SIGNAL_WINDOW`: baseline 윈도우 크기 (기본 8)
- `LOTTO_Z_NORMAL`: normal fire 임계치 (기본 1.5)
- `LOTTO_Z_URGENT`: urgent fire 임계치 (기본 2.5)
- `LOTTO_THROTTLE_HOURS`: 같은 메트릭 재발화 throttle (기본 6시간)
- `LOTTO_URGENT_DAILY_MAX`: urgent 하루 cap (기본 3통)
**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`)
- 09:15 매일 — 로또 light_check (시뮬·전략 가중치 평가)
- 매 4시간 :15 — 로또 sim_check (00/04/08/12/16/20시)
- 일/수 21:15 — 로또 deep_check (큐레이션 후 confidence 포함 평가)
- 09:25 매일 — 로또 daily_digest (지난 24h 발화 텔레그램 1통)
- 토요일 22:15 — 로또 weight_evolver 주간 텔레그램 리포트
**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 목록**
| 메서드 | 경로 | 설명 |
|--------|------|------|
| WS | `/api/agent-office/ws` | WebSocket (init, agent_state, task_complete, command_result) |
| GET | `/api/agent-office/agents` | 에이전트 목록 |
| GET | `/api/agent-office/agents/{id}` | 에이전트 상세 (설정 + 상태) |
| 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` | 에이전트에 명령 전송 |
| 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` | 마지막 수집 작업 상태 |
| GET | `/api/agent-office/lotto/signals?days=7` | 로또 능동 시그널 이력 (모든 fire_level) |
| GET | `/api/agent-office/lotto/baselines` | 로또 메트릭별 baseline μ/σ + 윈도우 상태 |
| POST | `/api/agent-office/lotto/signal-check?source=light` | 로또 시그널 평가 수동 트리거 (light/sim/deep) |
### 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 목록**
| 메서드 | 경로 | 설명 |
|--------|------|------|
| 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}` | 자기소개 삭제 (인증) |
| 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}` | 블로그 글 삭제 |
### 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`
- 경로 3분리: `PACK_DATA_PATH`(호스트 OS path, docker volume 좌측) → `PACK_BASE_DIR`(컨테이너 내부, upload 저장 target) → `PACK_HOST_DIR`(DSM API path, Supabase에 저장). 운영 NAS에서 `PACK_HOST_DIR` 미설정 시 sign-link가 컨테이너 경로를 DSM에 전달해 파일을 못 찾음.
- ⚠️ **DSM API path 형식**: Synology DSM API는 일반 사용자 권한일 때 `/<shared_folder>/...` 형식만 인식하고 `/volume1/...` 절대경로는 거부(error 408). 운영 NAS는 반드시 `PACK_HOST_DIR=/docker/webpage/media/packs` (shared folder 시점) 설정. admin 사용자만 `/volume1/...` 사용 가능하나 보안상 권장 안 함.
**환경변수**
- `DSM_HOST` / `DSM_USER` / `DSM_PASS`: Synology DSM 7.x 인증 (공유 링크 발급용)
- `DSM_VERIFY_SSL`: SSL 검증 (default `true`). LAN IP + self-signed cert 환경에서 IP mismatch 시 `false` 설정 (LAN 내부 통신이라 허용)
- `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_HOST_DIR`: DSM API용 path. **운영 NAS는 `/docker/webpage/media/packs` (shared folder 시점)**. 미설정 시 `PACK_BASE_DIR`로 fallback (DSM 호출 X 환경에서만 안전)
- `PACK_DATA_PATH`: docker-compose volume 마운트의 호스트 측 OS 경로 (로컬 `./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 목록**
| 메서드 | 경로 | 설명 |
|--------|------|------|
| 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 (single-shot) → multipart 5GB 저장 + Supabase INSERT |
| POST | `/api/packs/upload/init` | Bearer token → chunked upload 세션 초기화 (`session_id = jti`, `chunk_max_size` 반환). init만 jti consume |
| PUT | `/api/packs/upload/{session_id}/chunk?offset=N` | 동일 Bearer token → 부분파일 append (offset 불일치 시 409 + `X-Current-Offset` 헤더) |
| GET | `/api/packs/upload/{session_id}/status` | 동일 Bearer token → `{written, expected_size}` 조회 (재개용) |
| POST | `/api/packs/upload/{session_id}/complete` | 동일 Bearer token → 부분파일 rename + Supabase INSERT |
| DELETE | `/api/packs/upload/{session_id}` | 동일 Bearer token → 세션 중단 + 부분파일 정리 |
| GET | `/api/packs/list` | Vercel HMAC → 활성 pack_files 목록 (deleted_at IS NULL) |
| DELETE | `/api/packs/{file_id}` | Vercel HMAC → soft delete (DSM 공유는 자동 만료) |
**Chunked upload 흐름 (5GB+ 안정성)**
- 같은 mint-token을 init·chunk·status·complete·abort 전체에서 Bearer로 재사용 (jti consume은 init에서만)
- 세션 state: 컨테이너 내부 `PACK_BASE_DIR/.uploads/{jti}/meta.json + data.part`
- chunk 재시도: 클라이언트는 PUT 응답 헤더 `X-Current-Offset` 또는 `GET /status`로 재개 지점 확인
- 환경변수 `PACK_CHUNK_MAX_SIZE` (기본 64MB) — 너무 크면 nginx buffering 부담, 너무 작으면 RTT 비용
### deployer (deployer/)
- Webhook 검증: `X-Gitea-Signature` (HMAC SHA256, `compare_digest` 사용)
- `WEBHOOK_SECRET` 환경변수로 시크릿 관리
- Webhook 수신 즉시 `{"ok": True}` 응답 후 BackgroundTask로 배포 실행
- 배포 타임아웃: 10분 (`scripts/deploy.sh`)
---
## 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/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`으로 비활성화 후 신규 입력
- **insta-lab Playwright**: NAS에서 chromium 빌드는 가능하지만 +500MB 이미지. 메모리 부족 시 카드 렌더 실패 가능 — 한 번에 1슬레이트만 렌더하도록 직렬화됨

393
README.md
View File

@@ -0,0 +1,393 @@
# web-backend
Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
로또 분석, 주식 포트폴리오, AI 음악 생성, 인스타 카드 피드, 부동산 청약, AI 에이전트 오피스, 여행 앨범, 개인 서비스(포트폴리오·블로그·투두), NAS 자료 다운로드 자동화를 하나의 Docker Compose 스택으로 운영한다.
---
## 서비스 구성
```
┌──────────────────────────────────────────────────────────────────────┐
│ frontend (Nginx:8080) │
│ ├── 정적 SPA 서빙 (React + Vite) │
│ └── API 리버스 프록시 │
│ ├── /api/ → lotto:8000 (로또) │
│ ├── /api/stock/, /trade/ → stock:8000 │
│ ├── /api/portfolio → stock:8000 │
│ ├── /api/music/ → music-lab:8000 │
│ ├── /api/insta/ → insta-lab:8000 │
│ ├── /api/realestate/ → realestate-lab:8000 │
│ ├── /api/agent-office/ → agent-office:8000 (+ WebSocket) │
│ ├── /api/profile/, /todos, /blog/ → personal:8000 │
│ ├── /api/packs/ → packs-lab:8000 (HMAC + 5GB upload) │
│ ├── /api/travel/ → travel-proxy:8000 │
│ ├── /media/music/, /media/videos/ (nginx 직접 서빙, 미디어) │
│ ├── /media/travel/… (nginx 직접 서빙, 사진/썸네일) │
│ └── /webhook → deployer:9000 │
└──────────────────────────────────────────────────────────────────────┘
```
| 컨테이너 | 포트 | 역할 |
|---------|------|------|
| `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 + 온디맨드 썸네일 |
| `frontend` | 8080 | SPA 서빙 + 리버스 프록시 |
| `webpage-deployer` | 19010 | Gitea Webhook → 자동 배포 |
---
## 디렉토리 구조
```
web-backend/
├── 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 + 캐시
├── scripts/ # deploy.sh, deploy-nas.sh, healthcheck.sh
├── docker-compose.yml
├── .env.example
└── CLAUDE.md # Claude Code 작업용 상세 컨텍스트
```
---
## 빠른 시작 (로컬 개발)
```bash
cp .env.example .env
docker compose up -d
curl http://localhost:18000/health
curl http://localhost:18500/health
```
| 서비스 | 로컬 URL |
|--------|----------|
| Frontend + API | http://localhost:8080 |
| lotto | http://localhost:18000 |
| stock | http://localhost:18500 |
| music-lab | http://localhost:18600 |
| 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 |
---
## 서비스별 기능
### 1. lotto-backend (`/api/`)
로또 당첨번호 수집·통계 분석·몬테카를로 시뮬레이션 기반 추천 + 투두·블로그 CRUD.
- **로또**: 당첨번호 조회, 5종 통계 분석, 시뮬레이션 최적 번호(`best_picks` 20쌍), 통계/히트맵/스마트/배치 추천, 전략 가중치(EMA+Softmax), 구매 이력 관리
- **추천 이력**: 즐겨찾기·태그·메모 관리
- **투두리스트**: UUID PK, 상태(todo/in_progress/done)
- **블로그**: 일기형 포스트 (tags JSON 배열, date DESC)
**스케줄러**
- 09:10 / 21:10 — 당첨번호 동기화 + 추천 채점
- 00:05, 04:05, 08:05, 12:05, 16:05, 20:05 — 몬테카를로 시뮬레이션 (후보 20,000 → 상위 100 → best_picks 20쌍 교체)
### 2. stock (`/api/stock/`, `/api/trade/`, `/api/portfolio`)
주식 뉴스 스크래핑 + LLM 요약 + KIS 실계좌 연동 + 포트폴리오·자산 스냅샷.
- **뉴스**: 네이버 증권 + 해외 사이트 크롤링, LLM 기반 한국어 요약
- **실계좌**: Windows AI 서버(192.168.45.59:8000) 프록시 → KIS Open API (잔고/주문)
- **포트폴리오**: 종목·예수금·매도 히스토리 관리, 현재가 자동 조회
- **자산 스냅샷**: 평일 15:40 자동 저장 (KRX 공휴일 판별, `holidays.json` 매년 갱신)
**LLM provider 전환**`LLM_PROVIDER` 환경변수
- `claude` (기본): Anthropic Messages API (`claude-haiku-4-5`)
- `ollama`: Windows AI 서버 Ollama (`qwen3:14b`)
**현재가 조회**: 네이버 모바일 API → HTML 파싱 폴백, 3분 TTL 메모리 캐시
### 3. music-lab (`/api/music/`)
듀얼 프로바이더 AI 음악 생성.
- **Suno** (`suno`): REST API 연동, 보컬·가사·인스트루멘탈. 1회 요청 시 2개 variation 생성, 곡 연장, 보컬 분리, WAV 변환, 12스템 분리, 뮤직비디오, AI Cover 등 풀 스위트 지원
- **로컬 MusicGen** (`local`): Windows AI PC(RTX 5070 Ti, 16GB VRAM) 인스트루멘탈 전용
- **라이브러리**: 생성 파일은 `/app/data/music/`에 저장되고 Nginx가 `/media/music/`으로 직접 서빙
- **가사 도구**: 저장·편집·타임스탬프 기반 가라오케 동기
### 4. insta-lab (`/api/insta/`)
인스타그램 카드 피드 자동 생성 — 뉴스 모니터링 → 키워드 추출 → 10페이지 카드 카피·PNG 렌더 → 텔레그램 푸시 → 사용자 수동 업로드.
```
NAVER 뉴스 + YouTube 인기 (외부 트렌드)
→ 카테고리별 빈도 + Claude Haiku 정제 → 트렌딩 키워드
→ 사용자가 키워드 선택
→ Claude Sonnet으로 10페이지 카피 추론 (커버 1 + 본문 8 + CTA 1)
→ Jinja2 + Playwright 1080×1350 PNG 10장 렌더
→ 텔레그램 미디어 그룹 + 추천 캡션·해시태그
```
- **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/`)
공공데이터포털 청약홈 API 연동 + 프로필 기반 자동 매칭.
- **공고 수집**: 09:00 매일 자동 (`DATA_GO_KR_API_KEY` 필요)
- **상태 갱신 + 재매칭**: 00:00 매일 자동
- **프로필 매칭**: 지역·주택형·소득·부양가족 등으로 점수화, 신규 매칭 알림
- **대시보드**: 진행 중 공고수, 신규 매칭수, 다가오는 일정 요약
### 6. agent-office (`/api/agent-office/`)
AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 4명의 에이전트가 실제 작업을 수행한다.
- **아키텍처**: 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)
- **텔레그램 연동**: 양방향 알림 + 인라인 키보드 승인
- 봇이 작업 결과를 텔레그램으로 푸시, 명령은 텔레그램에서 바로 에이전트에 전달
- Webhook 검증 후 `chat.id` 기준 라우팅
#### 에이전트 구성
| 에이전트 | 스케줄 | 승인 | 주요 기능 |
|---------|--------|-----|----------|
| 📈 **주식 트레이더** (`stock`) | 08:00 매일 | — | 뉴스 요약 (LLM) → 텔레그램 아침 브리핑, 종목 알람 등록 |
| 🎵 **음악 프로듀서** (`music`) | 수동 트리거 | ✅ 작곡 | 프롬프트 수신 → 승인 → Suno API 작곡 → 트랙 푸시 |
| 🎴 **인스타 큐레이터** (`insta`) | 09:00 / 09:30 매일 | — | 09:00 외부 트렌드(NAVER + YouTube) 수집 → 09:30 가중치 기반 키워드 추출 → 텔레그램 후보 5개씩 카테고리당 인라인 버튼 푸시 → 사용자 선택 시 카드 10장 미디어 그룹 |
| 🏢 **청약 애널리스트** (`realestate`) | realestate-lab push trigger | — | realestate-lab이 신규 매칭 발견 시 push → 인라인 [북마크] 버튼 포함 텔레그램 알림 |
| 🎬 **YouTube 리서처** (`youtube`) | 09:00 매일 | — | 한국 YouTube 트렌딩 + Google Trends + Billboard → music-lab market_trends push |
#### 에이전트별 명령
**Stock**`fetch_news`, `list_alerts`, `add_alert`, `test_telegram`
**Music**`compose` (승인 필요), `credits`
**Insta**`extract`, `render <keyword_id>`, `collect_trends`
**Realestate**`fetch_matches`, `dashboard`
**YouTube**`research {countries: [...]}`
#### 스케줄러 잡
- 07:00 월요일 — Lotto: AI 큐레이터 브리핑 (5세트 + 내러티브)
- 07:30 — Stock: 뉴스 요약
- 08:00 평일 — Stock: AI 뉴스 sentiment 분석
- 09:00 — YouTube: 한국 트렌딩 수집
- 09:00 — Insta: 외부 트렌드 수집 (NAVER 인기 + YouTube mostPopular)
- 09:30 — Insta: 키워드 추출 (가중치 적용) + 텔레그램 후보 푸시
- 15:40 평일 — Stock: 총 자산 스냅샷
- 16:30 평일 — Stock: 스크리너 실행
- 60초 interval — 유휴 에이전트 휴식 체크
### 7. travel-proxy (`/api/travel/`)
여행 사진 API + SQLite 인덱스 + 온디맨드 썸네일 + 지역 관리.
- 원본: `/data/travel/` (RO 마운트)
- 썸네일: 480×480 Pillow 리사이징, `/data/thumbs/` 영구 캐시 (tmp → rename 원자성 보장)
- DB: `/data/thumbs/travel.db` (photos, album_covers 테이블)
- 메타: `region_map.json` (RO) + `region_map_extra.json` (RW 오버라이드) + `regions.geojson`
- 지역 관리: 앨범 지역 변경, 커스텀 지역 생성, 지도 핀 좌표 지정
- 데이터 흐름: 수동 sync → 폴더 스캔 → SQLite 인덱싱 + 썸네일 일괄 생성
### 8. deployer (`/webhook`)
Gitea Webhook 수신 → NAS 자동 배포.
- HMAC SHA256 서명 검증 (`compare_digest`, `WEBHOOK_SECRET`)
- 수신 즉시 200 응답 후 BackgroundTask로 배포
- 배포 스크립트: `git pull``.releases/` 백업 → `rsync``docker compose up -d --build``chown PUID:PGID`
- 타임아웃 10분
---
## 핵심 로직
### 몬테카를로 시뮬레이션 (lotto-backend)
```
역대 당첨번호 분석 → 번호별 가중치 산출
→ 가중 확률 샘플링으로 후보 20,000개 생성
→ 5가지 기법으로 각 조합 점수화
→ 상위 100개 DB 저장 → best_picks 20개 교체
```
| 기법 | 가중치 | 내용 |
|------|--------|------|
| 빈도 Z-score | 25% | 번호 출현 빈도의 표준편차 |
| 조합 지문 | 30% | 합계 정규분포 + 홀짝 비율 + 구간분포 |
| 갭 분석 | 20% | 마지막 출현 이후 경과 회차 |
| 공동 출현 | 15% | 번호 쌍 동시 출현 빈도 |
| 다양성 | 10% | 연속번호·범위·구간 커버리지 |
### LLM 요약 provider 추상화 (stock)
`ai_summarizer.py`는 provider 분리 구조. `summarize_news(articles)` 시그니처는 provider와 무관하게 고정.
- `_summarize_with_claude`: Anthropic Messages API 직접 호출 (httpx, SDK 의존성 없음)
- `_summarize_with_ollama`: Ollama `/api/generate` (타임아웃 180s, qwen3:14b 첫 로드 대응)
- 실패 시 `LLMError` (구 `OllamaError` alias 유지)
### 총 자산 스냅샷 (stock)
평일 15:40 자동 실행 → `holidays.json`으로 공휴일 스킵 → 포트폴리오 현재가 조회 + 예수금 합계 → `asset_snapshots` upsert (date UNIQUE).
### 에이전트 FSM + WS 동기화 (agent-office)
DB에 저장된 에이전트 상태가 바뀔 때마다 `websocket_manager`가 전체 클라이언트에 브로드캐스트. 텔레그램 봇은 `waiting` 상태 작업에 인라인 키보드를 붙여 승인 요청. 승인/거부 결과가 DB → WS → 프론트로 전파.
---
## 자동 배포
```
git push → Gitea → X-Gitea-Signature (HMAC SHA256)
→ deployer:9000/webhook (서명 검증, compare_digest)
→ BackgroundTask: scripts/deploy.sh (10분 타임아웃)
1. git pull
2. .releases/{timestamp}/ 백업
3. rsync (repo → runtime)
4. docker compose up -d --build
5. chown PUID:PGID
```
> 프론트엔드는 **자동 배포 안 됨** — 로컬 빌드 후 NAS에 수동 업로드 (`scripts/deploy.bat --frontend`)
---
## 데이터베이스
각 서비스는 독립 SQLite DB를 `/app/data/` 볼륨에 저장.
| DB | 소유 서비스 | 주요 테이블 |
|----|------------|-----------|
| `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 |
---
## 환경변수
```env
# 경로
RUNTIME_PATH=.
REPO_PATH=.
FRONTEND_PATH=./frontend/dist
PHOTO_PATH=./mock_data/photos
# NAS 파일 권한
PUID=1000
PGID=1000
# 외부 서비스
WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
WEBHOOK_SECRET=your_secret_here
# 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
# insta-lab + agent-office (NAVER 검색 + YouTube Data API 공유)
NAVER_CLIENT_ID=
NAVER_CLIENT_SECRET=
YOUTUBE_DATA_API_KEY=
# realestate-lab
DATA_GO_KR_API_KEY=
# packs-lab (DSM + Supabase)
DSM_HOST=
DSM_USER=
DSM_PASS=
BACKEND_HMAC_SECRET=
SUPABASE_URL=
SUPABASE_SERVICE_KEY=
PACK_HOST_DIR=/docker/webpage/media/packs # shared folder 시점 (CLAUDE.md F5)
# agent-office
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
TELEGRAM_WEBHOOK_URL=
STOCK_URL=http://stock:8000
MUSIC_LAB_URL=http://music-lab:8000
INSTA_LAB_URL=http://insta-lab:8000
REALESTATE_LAB_URL=http://realestate-lab:8000
# personal (포트폴리오 편집 인증)
PORTFOLIO_EDIT_PASSWORD=
```
---
## 인프라
| 항목 | 값 |
|------|----|
| 장비 | Synology NAS (Intel Celeron J4025, 18GB RAM) |
| Docker | Synology Container Manager |
| Git 서버 | Gitea (NAS 내부 self-hosted, `gahusb.synology.me`) |
| AI 서버 | Windows PC (192.168.45.59) — RTX 5070 Ti (16GB VRAM) + Ollama + MusicGen |
| Python | 3.12 (`slim` 기반 이미지) |
| DB | SQLite (볼륨 마운트로 영속 저장) |
---
## 주의사항
- **`.env` 파일** — 절대 커밋 금지. `.env.example`만 레포에 포함
- **Nginx trailing slash** — `/api/portfolio`는 두 location 블록으로 처리 (trailing slash 유무 모두 매칭)
- **라우트 순서** — `DELETE /api/todos/done``/api/todos/{id}` 보다 먼저 등록 필수 (FastAPI prefix 매칭)
- **캐시 전략** — `index.html`: no-store / `assets/`: 1년 immutable
- **PUID/PGID** — travel-proxy는 NAS 파일 권한을 위해 환경변수 주입 필수
- **공휴일 목록** — `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`
- **시뮬레이션 교체 방식** — `best_picks`는 교체형 (`is_active=0` 비활성화 후 신규 입력)
---
## 참고 문서
- `CLAUDE.md` — Claude Code 작업용 상세 컨텍스트 (API 전체 목록, 테이블 스키마 등)
- `docs/` — 서비스별 기획·설계 문서

111
STATUS.md Normal file
View File

@@ -0,0 +1,111 @@
# web-backend — 구현 현황 & 로드맵
> 최종 갱신: 2026-05-17
> 자세한 서비스·환경변수·DB 표는 [CLAUDE.md](./CLAUDE.md), 설계는 `docs/superpowers/specs/`, 실행 계획은 `docs/superpowers/plans/` 참조.
---
## 1. 서비스 구현 현황
### 1-1. 운영 중인 컨테이너 (11개)
| 서비스 | 포트 | 상태 | 핵심 기능 |
|--------|------|------|-----------|
| `lotto` | 18000 | ✅ | 로또 추천·통계·리포트·구매내역·AI 큐레이터 |
| `stock` | 18500 | ✅ | 주식 뉴스·지수·트레이딩·포트폴리오·자산 스냅샷·스크리너 |
| `music-lab` | 18600 | ✅ | Suno + MusicGen + YouTube 수익화 + 컴파일 |
| `insta-lab` | 18700 | ✅ | 인스타 카드 피드 자동 생성 (NAVER + YouTube 트렌드 → 10페이지 카드, Playwright) |
| `realestate-lab` | 18800 | ✅ | 청약 수집·5티어 매칭·매칭 알림 push |
| `personal` | 18850 | ✅ | 포트폴리오·블로그·투두 통합 (개인 서비스) |
| `agent-office` | 18900 | ✅ | AI 에이전트 (WebSocket + 텔레그램 + InstaAgent + YouTubeResearcher) |
| `packs-lab` | 18950 | ✅ | NAS 자료 다운로드 자동화 (HMAC + Supabase + 5GB chunked upload) |
| `travel-proxy` | 19000 | ✅ | 여행 사진 API + 썸네일 + 지역 관리 |
| `frontend` (nginx) | 8080 | ✅ | SPA + 리버스 프록시 (5GB body limit, 인스타 라우팅 포함) |
| `webpage-deployer` | 19010 | ✅ | Gitea Webhook 자동 배포 (BUILDKIT timeout 600s, healthcheck via docker inspect) |
### 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축 점수·알림 대상 카운트, 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-15 | lotto | AI 큐레이터 (Claude 기반 주간 브리핑 자동 생성) |
### 1-3. 인프라 / DX
| 항목 | 상태 |
|------|------|
| docker-compose 통합 (10 서비스) | ✅ |
| Gitea Webhook → deployer rsync 자동 배포 | ✅ |
| nginx 라우팅 표 (/api/* 서비스별) | ✅ |
| 배포 환경변수 (PEXELS·YOUTUBE_DATA·VIDEO_DATA_DIR 등) | ✅ |
---
## 2. 진행 중 / 향후 계획
### 2-1. 로또 프리미엄 (Phase 3) — 구독 모델
> 출처: [docs/lotto-premium-roadmap.md](./docs/lotto-premium-roadmap.md)
- [ ] 회원 시스템 (JWT 인증, `users` 테이블)
- [ ] 구독 플랜 (`subscription_plans`, `user_subscriptions`)
- [ ] 결제 연동 (Toss Payments 또는 Stripe)
- [ ] 이메일 발송 자동화 (SendGrid)
- [ ] 소셜 증거 데이터 집계 API (가장 많이 선택된 번호 TOP 10 등)
Phase 1·2 (성과 통계 / 회차별 공략 리포트 / 개인 분석 / 구매 추적)는 이미 완료.
### 2-2. Pet Lab (신규 서비스) — 설계 단계
> 출처: `docs/superpowers/specs/2026-04-07-pet-lab-design.md`, `plans/2026-04-07-pet-lab.md`
- [ ] 컨테이너 추가 + 포트 배정
- [ ] 핵심 도메인 모델 (반려동물 등록·기록·일정)
- [ ] 프론트 페이지 신설
### 2-3. Music YouTube 자동화 후속
- [ ] VideoProjects 실제 렌더링 잡 큐 (현재 스켈레톤)
- [ ] 시장 트렌드 → 자동 음악 생성 트리거 연결
- [ ] Revenue 트래킹 정확도 개선 (YouTube Analytics API)
### 2-4. Travel 영상 지원
- [ ] `travel-proxy`에 영상 메타·썸네일 API 추가
- [ ] `/media/travel/.video-thumb/` 처리
- [ ] `/api/travel/videos` 엔드포인트
### 2-5. 청약 (realestate-lab) 후속
- [ ] 알림 dry-run API (사용자가 사전 시뮬레이션 가능)
- [ ] 신규 매칭 텔레그램 알림 노이즈 필터링 (이미 본 공고 제외)
- [ ] 백오피스용 공고 수동 보정 API
### 2-6. packs-lab 후속
- [ ] 사용자별 다운로드 쿼터 제어
- [ ] 만료된 토큰/링크 정리 스케줄러
- [ ] Vercel SaaS 측 UI 연결 검증
### 2-7. 인프라 일반
- [ ] APScheduler 잡 모니터링 대시보드 (현재 로그 의존)
- [ ] 백업 자동화 (lotto.db / stock.db / 사진 메타)
- [ ] OpenAPI 스펙 통합 (서비스별 자동 수집)
---
## 3. 참고 문서
- 서비스·포트·API 전체 표: [CLAUDE.md](./CLAUDE.md)
- 워크스페이스 통합 가이드: `../CLAUDE.md`
- 프론트엔드 상태: `../web-ui/STATUS.md`
- 설계 스펙: `docs/superpowers/specs/`
- 실행 계획: `docs/superpowers/plans/`
- 로또 프리미엄 로드맵: `docs/lotto-premium-roadmap.md`

10
agent-office/Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM python:3.12-alpine
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

View File

@@ -0,0 +1 @@
# agent-office/app/__init__.py

View File

@@ -0,0 +1,27 @@
from .stock import StockAgent
from .music import MusicAgent
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["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)
def get_all_agent_states() -> list:
return [
{"agent_id": aid, "state": agent.state, "detail": agent.state_detail}
for aid, agent in AGENT_REGISTRY.items()
]

View File

@@ -0,0 +1,60 @@
import time
from typing import Optional
from ..db import add_log
VALID_STATES = ("idle", "working", "waiting", "reporting")
class BaseAgent:
agent_id: str = ""
display_name: str = ""
state: str = "idle"
state_detail: str = ""
_idle_since: float = 0.0
_ws_manager = None
def __init__(self):
self._idle_since = time.time()
def set_ws_manager(self, manager):
self._ws_manager = manager
async def transition(self, new_state: str, detail: str = "", task_id: str = None) -> None:
if new_state not in VALID_STATES:
return
old = self.state
self.state = new_state
self.state_detail = detail
if new_state == "idle":
self._idle_since = time.time()
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)
if new_state == "working" and old != "working":
await self._ws_manager.send_notification(
self.agent_id, "task_assigned", task_id, detail or "새 작업 시작"
)
elif new_state == "idle" and old in ("working", "reporting"):
await self._ws_manager.send_notification(
self.agent_id, "task_completed", task_id, detail or "작업 완료"
)
async def on_schedule(self) -> None:
raise NotImplementedError
async def on_command(self, command: str, params: dict) -> dict:
raise NotImplementedError
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
raise NotImplementedError
async def get_status(self) -> dict:
return {
"agent_id": self.agent_id,
"display_name": self.display_name,
"state": self.state,
"detail": self.state_detail,
}

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,170 @@
"""인스타 카드 에이전트 — 매일 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__)
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:
by_cat: Dict[str, List[Dict[str, Any]]] = {}
for k in keywords:
by_cat.setdefault(k["category"], []).append(k)
if not by_cat:
await messaging.send_raw("📰 [인스타 큐레이터] 오늘은 추천할 키워드가 없습니다.")
return
rows: List[List[Dict[str, Any]]] = []
text_lines = ["📰 <b>[인스타 큐레이터]</b> 오늘의 키워드 후보"]
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

@@ -0,0 +1,267 @@
from .base import BaseAgent
from ..db import create_task, update_task_status, add_log
from ..curator.pipeline import curate_weekly, CuratorError
class LottoAgent(BaseAgent):
agent_id = "lotto"
display_name = "로또 큐레이터"
async def on_schedule(self) -> None:
if self.state != "idle":
return
await self._run(source="auto")
async def on_command(self, action: str, params: dict) -> dict:
if action in ("curate_now", "curate_weekly"):
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()
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_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={
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, **{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)
await self.transition("idle", "오류")
return {"ok": False, "message": str(e)}
except Exception 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)
await self.transition("idle", "오류")
return {"ok": False, "message": f"{type(e).__name__}: {e}"}

View File

@@ -0,0 +1,124 @@
import asyncio
from .base import BaseAgent
from ..db import create_task, update_task_status, approve_task, reject_task, add_log
from .. import service_proxy
from .. import telegram_bot
class MusicAgent(BaseAgent):
agent_id = "music"
display_name = "음악 프로듀서"
async def on_schedule(self) -> None:
pass
async def on_command(self, command: str, params: dict) -> dict:
if command == "compose":
prompt = params.get("prompt", "")
style = params.get("style", "")
model = params.get("model", "V4")
instrumental = params.get("instrumental", False)
if not prompt:
return {"ok": False, "message": "프롬프트를 입력해주세요"}
task_id = create_task(self.agent_id, "compose", {
"prompt": prompt, "style": style,
"model": model, "instrumental": instrumental,
}, requires_approval=True)
await self.transition("waiting", "프롬프트 승인 대기", task_id)
detail = f"프롬프트: {prompt}"
if style:
detail += f"\n스타일: {style}"
detail += f"\n모델: {model}"
await telegram_bot.send_approval_request(
self.agent_id, task_id,
"🎵 [음악 에이전트] 작곡 요청", detail,
)
return {"ok": True, "task_id": task_id, "message": "승인 대기 중"}
if command == "credits":
credits = await service_proxy.get_music_credits()
return {"ok": True, "credits": credits}
return {"ok": False, "message": f"Unknown command: {command}"}
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
if not approved:
reject_task(task_id)
await self.transition("idle", "작곡 거절됨")
await telegram_bot.send_task_result(
self.agent_id, "🎵 [음악 에이전트] 작곡 취소",
"사용자가 거절했습니다.",
)
return
from ..db import get_task
task = get_task(task_id)
if not task:
return
approve_task(task_id, via="telegram")
await self.transition("working", "작곡 중...", task_id)
asyncio.create_task(self._poll_composition(task_id, task))
async def _poll_composition(self, task_id: str, task: dict) -> None:
try:
input_data = task["input_data"]
payload = {
"provider": "suno",
"model": input_data.get("model", "V4"),
"prompt": input_data.get("prompt", ""),
"style": input_data.get("style", ""),
"instrumental": input_data.get("instrumental", False),
"custom_mode": True,
}
result = await service_proxy.generate_music(payload)
music_task_id = result.get("task_id")
if not music_task_id:
raise Exception("music-lab did not return task_id")
for _ in range(60):
await asyncio.sleep(5)
status = await service_proxy.get_music_status(music_task_id)
state = status.get("status", "")
if state == "succeeded":
tracks = status.get("tracks", [])
update_task_status(task_id, "succeeded", {
"music_task_id": music_task_id,
"tracks": tracks,
})
await self.transition("reporting", "작곡 완료!")
track_info = ""
for t in tracks:
title = t.get("title", "Untitled")
url = t.get("audio_url", "")
track_info += f"🎶 {title}\n{url}\n"
await telegram_bot.send_task_result(
self.agent_id, "🎵 [음악 에이전트] 작곡 완료",
track_info or "트랙 생성 완료",
)
await self.transition("idle", "작곡 완료")
return
if state == "failed":
raise Exception(status.get("message", "Generation failed"))
raise Exception("Timeout: 5분 초과")
except Exception as e:
add_log(self.agent_id, f"Compose failed: {e}", "error", task_id)
update_task_status(task_id, "failed", {"error": str(e)})
await self.transition("idle", f"오류: {e}")
await telegram_bot.send_task_result(
self.agent_id, "🎵 [음악 에이전트] 작곡 실패",
f"오류: {e}",
)

View File

@@ -0,0 +1,77 @@
from .base import BaseAgent
from ..db import create_task, update_task_status, add_log
from .. import service_proxy
from ..telegram import messaging
from ..telegram.realestate_message import format_realestate_matches, build_match_keyboard
class RealestateAgent(BaseAgent):
"""부동산 청약 에이전트.
realestate-lab이 신규 매칭 발견 시 /realestate/notify로 push해 트리거됨.
on_new_matches가 메인 진입점. on_schedule은 사용하지 않음(cron 폐기).
"""
agent_id = "realestate"
display_name = "청약 애널리스트"
async def on_new_matches(self, matches: list[dict]) -> dict:
"""신규 매칭 N건을 텔레그램 1통으로 푸시.
성공 시 sent_ids 반환 → realestate-lab이 notified_at 마킹.
실패 시 sent=0, sent_ids=[] 반환 → 다음 사이클 재시도.
"""
if not matches:
return {"sent": 0, "sent_ids": []}
task_id = create_task(self.agent_id, "notify_matches", {"count": len(matches)})
try:
text = format_realestate_matches(matches)
keyboard = build_match_keyboard(matches)
await self.transition("reporting", f"매칭 {len(matches)}건 알림", task_id)
tg = await messaging.send_raw(text, reply_markup=keyboard)
if not tg.get("ok"):
update_task_status(task_id, "failed", {"error": tg.get("description")})
await self.transition("idle", "알림 실패")
return {"sent": 0, "sent_ids": [], "error": tg.get("description")}
sent_ids = [m["id"] for m in matches if "id" in m]
update_task_status(task_id, "succeeded", {
"sent": len(matches),
"telegram_message_id": tg.get("message_id"),
})
await self.transition("idle", f"매칭 {len(matches)}건 알림 완료")
return {
"sent": len(matches),
"sent_ids": sent_ids,
"message_id": tg.get("message_id"),
}
except Exception as e:
add_log(self.agent_id, f"on_new_matches failed: {e}", "error", task_id)
update_task_status(task_id, "failed", {"error": str(e)})
await self.transition("idle", f"오류: {e}")
return {"sent": 0, "sent_ids": [], "error": str(e)}
async def on_command(self, command: str, params: dict) -> dict:
if command == "fetch_matches":
try:
matches = await service_proxy.realestate_matches(limit=20)
if not matches:
return {"ok": True, "message": "매칭 없음"}
result = await self.on_new_matches(matches)
return {"ok": True, "result": result}
except Exception as e:
return {"ok": False, "message": str(e)}
if command == "dashboard":
try:
data = await service_proxy.realestate_dashboard()
return {"ok": True, "dashboard": data}
except Exception as e:
return {"ok": False, "message": str(e)}
return {"ok": False, "message": f"Unknown command: {command}"}
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
pass

View File

@@ -0,0 +1,391 @@
import asyncio
import html
from typing import Optional
from .base import BaseAgent
from ..db import create_task, update_task_status, get_agent_config, add_log
from .. import service_proxy
def _build_briefing_body(result: dict, max_headlines: int = 5) -> str:
"""아침 시장 브리핑 본문 조립.
LLM 요약 + 주요 뉴스 헤드라인(링크) 섹션을 합친다.
향후 본문 고도화 시 이 함수만 수정하면 됨 (텔레그램 HTML parse_mode).
"""
summary = (result.get("summary") or "").strip()
articles = result.get("articles") or []
# body_is_html=True 로 보낼 예정이므로 LLM 요약(plain text)도 escape
parts = [html.escape(summary)] if summary else []
headlines = []
for a in articles[:max_headlines]:
title = (a.get("title") or "").strip()
if not title:
continue
title_esc = html.escape(title)
link = (a.get("link") or "").strip()
press = (a.get("press") or "").strip()
press_suffix = f"{html.escape(press)}" if press else ""
if link:
headlines.append(f'• <a href="{html.escape(link, quote=True)}">{title_esc}</a>{press_suffix}')
else:
headlines.append(f"{title_esc}{press_suffix}")
if headlines:
parts.append("📰 <b>주요 뉴스</b>\n" + "\n".join(headlines))
return "\n\n".join(parts)
class StockAgent(BaseAgent):
agent_id = "stock"
display_name = "주식 트레이더"
async def on_schedule(self) -> None:
if self.state != "idle":
return
task_id = create_task(self.agent_id, "news_summary", {"limit": 15})
await self.transition("working", "최신 뉴스 수집 중...", task_id)
try:
# 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", "뉴스 요약 전송 중...")
body = _build_briefing_body(result)
# 새 통합 텔레그램 API 사용
from ..telegram import send_agent_message
tg_result = await send_agent_message(
agent_id=self.agent_id,
kind="report",
title="아침 시장 브리핑",
body=body,
body_is_html=True,
task_id=task_id,
metadata={
"tokens": result["tokens"]["total"],
"duration_ms": result["duration_ms"],
"model": result["model"],
},
)
# 아내 chat 추가 전송 (설정된 경우) — 제목 + 본문만 간결하게
from ..config import TELEGRAM_WIFE_CHAT_ID
if TELEGRAM_WIFE_CHAT_ID:
from ..telegram.messaging import send_raw
wife_text = f"📈 <b>아침 시장 브리핑</b>\n\n{body}"
wife_result = await send_raw(wife_text, chat_id=TELEGRAM_WIFE_CHAT_ID)
if not wife_result.get("ok"):
desc = wife_result.get("description") or "unknown"
add_log(self.agent_id, f"Wife telegram send failed: {desc}", "warning", task_id)
update_task_status(task_id, "succeeded", {
"summary": result["summary"],
"article_count": result.get("article_count", 0),
"tokens": result["tokens"],
"model": result["model"],
"duration_ms": result["duration_ms"],
"telegram_sent": tg_result.get("ok", False),
"telegram_message_id": tg_result.get("message_id"),
})
if not tg_result.get("ok"):
desc = tg_result.get("description") or "unknown"
code = tg_result.get("error_code")
add_log(self.agent_id, f"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", "뉴스 요약 완료")
except Exception as e:
add_log(self.agent_id, f"News summary failed: {e}", "error", task_id)
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 on_command(self, command: str, params: dict) -> dict:
if command == "run_screener":
await self.on_screener_schedule()
return {"ok": True, "message": "스크리너 실행 트리거 완료"}
if command == "run_ai_news":
await self.on_ai_news_schedule()
return {"ok": True, "message": "AI 뉴스 분석 트리거 완료"}
if command == "test_telegram":
from ..telegram import send_agent_message
result = await send_agent_message(
agent_id=self.agent_id,
kind="info",
title="연결 테스트",
body="텔레그램 연동이 정상적으로 동작합니다.",
)
return {
"ok": result.get("ok", False),
"message": "텔레그램 전송 성공" if result.get("ok") else "텔레그램 전송 실패",
"telegram_message_id": result.get("message_id"),
}
if command == "fetch_news":
await self.on_schedule()
return {"ok": True, "message": "뉴스 수집 시작"}
if command == "add_alert":
symbol = params.get("symbol")
target_price = params.get("target_price")
if not symbol or target_price is None:
return {"ok": False, "message": "symbol과 target_price는 필수입니다"}
config = get_agent_config(self.agent_id)
alerts = config["custom_config"].get("alerts", [])
alerts.append({
"symbol": symbol,
"name": params.get("name", symbol),
"target_price": target_price,
"direction": params.get("direction", "above"),
})
from ..db import update_agent_config
update_agent_config(self.agent_id, custom_config={**config["custom_config"], "alerts": alerts})
return {"ok": True, "message": f"알람 추가: {params['symbol']}"}
if command == "list_alerts":
config = get_agent_config(self.agent_id)
alerts = config["custom_config"].get("alerts", [])
return {"ok": True, "alerts": alerts}
return {"ok": False, "message": f"Unknown command: {command}"}
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
pass

View File

@@ -0,0 +1,93 @@
# agent-office/app/agents/youtube.py
import asyncio
import logging
from datetime import date
import httpx
from .base import BaseAgent
from ..db import add_youtube_research_job, update_youtube_research_job, add_log
from ..youtube_researcher import (
TARGET_COUNTRIES, TREND_KEYWORDS, MUSIC_LAB_URL,
fetch_youtube_trending, fetch_google_trends, fetch_billboard_top20,
push_to_music_lab,
)
logger = logging.getLogger(__name__)
class YouTubeResearchAgent(BaseAgent):
agent_id = "youtube"
display_name = "YouTube 리서치"
async def on_schedule(self) -> None:
await self._run_research(TARGET_COUNTRIES)
async def on_command(self, command: str, params: dict) -> dict:
if command == "research":
if self.state == "working":
return {"ok": False, "message": "이미 수집 중"}
countries = params.get("countries", TARGET_COUNTRIES)
asyncio.create_task(self._run_research(countries))
return {"ok": True, "message": f"리서치 시작: {countries}"}
return {"ok": False, "message": f"Unknown command: {command}"}
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
pass
async def _run_research(self, countries: list) -> None:
job_id = add_youtube_research_job(countries)
await self.transition("working", f"트렌드 수집 중 ({','.join(countries)})", str(job_id))
all_trends = []
try:
for country in countries:
trends = await fetch_youtube_trending(country)
all_trends.extend(trends)
gt = await fetch_google_trends(TREND_KEYWORDS, countries)
all_trends.extend(gt)
bb = await fetch_billboard_top20()
all_trends.extend(bb)
ok = await push_to_music_lab(all_trends, date.today().isoformat())
if not ok:
raise RuntimeError("music-lab push 실패")
update_youtube_research_job(job_id, "completed", len(all_trends))
await self.transition("reporting", f"수집 완료: {len(all_trends)}", str(job_id))
except Exception as e:
update_youtube_research_job(job_id, "failed", len(all_trends), str(e))
await self.transition("idle", f"수집 실패: {e}")
return
await self.transition("idle", "리서치 완료")
async def send_weekly_report(self) -> None:
"""매주 월요일 08:00 — 주간 인사이트 텔레그램 발송."""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(f"{MUSIC_LAB_URL}/api/music/market/report/latest")
if resp.status_code != 200:
return
report = resp.json()
except Exception as e:
add_log(self.agent_id, f"주간 리포트 조회 실패: {e}", level="error")
logger.error("send_weekly_report: music-lab 조회 실패: %s", e)
return
top = report.get("top_genres", [])[:3]
insights = report.get("insights", "")
text = "📊 *YouTube 시장 주간 리포트*\n\n🔥 인기 장르:\n"
for g in top:
text += f"{g['genre']} (score: {g['score']:.2f})\n"
if insights:
text += f"\n💡 {insights[:300]}"
try:
from ..telegram_bot import send_message
await send_message(text)
except (ImportError, Exception) as e:
add_log(self.agent_id, f"주간 리포트 텔레그램 발송 실패: {e}", level="error")
logger.error("send_weekly_report: 텔레그램 발송 실패: %s", e)

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

@@ -0,0 +1,40 @@
import os
# Service URLs (Docker internal network)
STOCK_URL = os.getenv("STOCK_URL", "http://localhost:18500")
MUSIC_LAB_URL = os.getenv("MUSIC_LAB_URL", "http://localhost:18600")
INSTA_LAB_URL = os.getenv("INSTA_LAB_URL", "http://localhost:18700")
REALESTATE_LAB_URL = os.getenv("REALESTATE_LAB_URL", "http://localhost:18800")
# Telegram
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "")
TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "")
TELEGRAM_WEBHOOK_URL = os.getenv("TELEGRAM_WEBHOOK_URL", "")
TELEGRAM_WIFE_CHAT_ID = os.getenv("TELEGRAM_WIFE_CHAT_ID", "")
# Anthropic (conversational)
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
CONVERSATION_MODEL = os.getenv("CONVERSATION_MODEL", "claude-haiku-4-5-20251001")
CONVERSATION_HISTORY_LIMIT = int(os.getenv("CONVERSATION_HISTORY_LIMIT", "20"))
CONVERSATION_RATE_PER_MIN = int(os.getenv("CONVERSATION_RATE_PER_MIN", "6"))
# Database
DB_PATH = os.getenv("AGENT_OFFICE_DB_PATH", "/app/data/agent_office.db")
# CORS
CORS_ALLOW_ORIGINS = os.getenv(
"CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080"
)
# 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"))

View File

View File

@@ -0,0 +1,132 @@
"""큐레이터 파이프라인 — fetch → claude → validate → save."""
import json
import time
from typing import Any, Dict
import httpx
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"
class CuratorError(Exception):
pass
async def _call_claude(user_text: str, feedback: str = "") -> tuple[dict, dict]:
if not ANTHROPIC_API_KEY:
raise CuratorError("ANTHROPIC_API_KEY missing")
headers = {
"x-api-key": ANTHROPIC_API_KEY,
"anthropic-version": "2023-06-01",
"anthropic-beta": "prompt-caching-2024-07-31",
"content-type": "application/json",
}
system_blocks = [{
"type": "text",
"text": SYSTEM_PROMPT,
"cache_control": {"type": "ephemeral"},
}]
if feedback:
user_text = f"이전 응답이 다음 이유로 거절됨: {feedback}\n올바른 스키마로 다시 응답.\n\n{user_text}"
payload = {
"model": LOTTO_CURATOR_MODEL,
"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=180) as client: # 큰 응답 → 시간 여유
r = await client.post(API_URL, headers=headers, json=payload)
r.raise_for_status()
resp = r.json()
latency_ms = int((time.monotonic() - started) * 1000)
text = "".join(
b.get("text", "") for b in resp.get("content", []) if b.get("type") == "text"
).strip()
if text.startswith("```"):
text = text.strip("`")
if text.startswith("json"):
text = text[4:]
text = text.strip()
parsed = json.loads(text)
usage = resp.get("usage", {}) or {}
return parsed, {
"input": int(usage.get("input_tokens", 0) or 0),
"output": int(usage.get("output_tokens", 0) or 0),
"cache_read": int(usage.get("cache_read_input_tokens", 0) or 0),
"cache_write": int(usage.get("cache_creation_input_tokens", 0) or 0),
"latency_ms": latency_ms,
}
async def curate_weekly(source: str = "auto") -> Dict[str, Any]:
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]
usage_total = {"input": 0, "output": 0, "cache_read": 0, "cache_write": 0, "latency_ms": 0}
last_error = None
validated = None
for attempt in (0, 1):
try:
raw, usage = await _call_claude(user_text, feedback=last_error or "")
for k in usage_total:
usage_total[k] += usage[k]
validated = validate_response(raw, candidate_numbers)
break
except Exception as e:
last_error = f"{type(e).__name__}: {e}"
if validated is None:
raise CuratorError(f"schema validation failed after retry: {last_error}")
payload = {
"draw_no": draw_no,
"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"],
"tokens_output": usage_total["output"],
"cache_read": usage_total["cache_read"],
"cache_write": usage_total["cache_write"],
"latency_ms": usage_total["latency_ms"],
"source": source,
}
await service_proxy.lotto_save_briefing(payload)
return {
"ok": True,
"draw_no": draw_no,
"confidence": validated.confidence,
"tokens": {"input": usage_total["input"], "output": usage_total["output"]},
"payload": payload, # 텔레그램 알림용
}

View File

@@ -0,0 +1,64 @@
"""큐레이터 system/user 프롬프트. system은 정적이므로 캐시 대상."""
import json
SYSTEM_PROMPT = """당신은 로또 번호 큐레이터입니다.
주어진 후보 30세트 중 4계층(코어 5, 보너스 5, 확장 5, 풀 5) 총 20세트를 선별합니다.
계층별 큐레이션 규칙:
- 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: 주의사항 없으면 빈 문자열.
- retrospective: 회고 한 줄 또는 빈 문자열.
출력은 반드시 JSON 하나, 그 외 어떤 텍스트도 금지. 스키마:
{
"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,
"retrospective": str
},
"confidence": int (0~100)
}
"""
def build_user_message(draw_no: int, candidates: list, context: dict) -> str:
payload = {
"draw_no": draw_no,
"context": context, # hot_numbers, cold_numbers, last_draw_summary, my_recent_performance, retrospective
"candidates": candidates,
}
return (
f"이번 회차: {draw_no}\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

@@ -0,0 +1,58 @@
from typing import List, Literal
from pydantic import BaseModel, Field, field_validator
class Pick(BaseModel):
numbers: List[int] = Field(min_length=6, max_length=6)
risk_tag: Literal["안정", "균형", "공격"]
reason: str = Field(max_length=80)
@field_validator("numbers")
@classmethod
def _check_numbers(cls, v):
if len(set(v)) != 6:
raise ValueError("numbers must be 6 unique integers")
if any(n < 1 or n > 45 for n in v):
raise ValueError("numbers must be within 1..45")
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):
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)
candidate_set = {tuple(sorted(c)) for c in candidate_numbers}
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"

768
agent-office/app/db.py Normal file
View File

@@ -0,0 +1,768 @@
import os
import json
import sqlite3
import uuid
from typing import Any, Dict, List, Optional
from .config import DB_PATH
def _conn() -> sqlite3.Connection:
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = sqlite3.connect(DB_PATH, timeout=120.0)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=120000")
return conn
def init_db() -> None:
with _conn() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS agent_config (
agent_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
schedule_config TEXT NOT NULL DEFAULT '{}',
custom_config 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 TABLE IF NOT EXISTS agent_tasks (
id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL,
task_type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
input_data TEXT NOT NULL DEFAULT '{}',
result_data TEXT,
requires_approval INTEGER NOT NULL DEFAULT 0,
approved_at TEXT,
approved_via TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
completed_at TEXT
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_tasks_agent
ON agent_tasks(agent_id, created_at DESC)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS agent_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id TEXT NOT NULL,
task_id TEXT,
level TEXT NOT NULL DEFAULT 'info',
message TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS telegram_state (
callback_id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
agent_id TEXT NOT NULL,
action TEXT,
responded INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS conversation_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
model TEXT,
tokens_input INTEGER DEFAULT 0,
tokens_output INTEGER DEFAULT 0,
cache_read INTEGER DEFAULT 0,
cache_write INTEGER DEFAULT 0,
latency_ms INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_conv_chat
ON conversation_messages(chat_id, created_at DESC)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS youtube_research_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
status TEXT NOT NULL DEFAULT 'running',
countries TEXT NOT NULL DEFAULT '[]',
trends_collected INTEGER NOT NULL DEFAULT 0,
error TEXT,
started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
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'))
)
""")
# Seed default agent configs
for agent_id, name in [
("stock", "주식 트레이더"),
("music", "음악 프로듀서"),
("blog", "블로그 마케터"),
("realestate", "청약 애널리스트"),
("lotto", "로또 큐레이터"),
("youtube", "YouTube 리서치"),
]:
conn.execute(
"INSERT OR IGNORE INTO agent_config(agent_id, display_name) VALUES(?,?)",
(agent_id, name),
)
# --- agent_config CRUD ---
def get_all_agents() -> List[Dict[str, Any]]:
with _conn() as conn:
rows = conn.execute("SELECT * FROM agent_config ORDER BY agent_id").fetchall()
return [_config_to_dict(r) for r in rows]
def get_agent_config(agent_id: str) -> Optional[Dict[str, Any]]:
with _conn() as conn:
r = conn.execute("SELECT * FROM agent_config WHERE agent_id=?", (agent_id,)).fetchone()
return _config_to_dict(r) if r else None
def update_agent_config(agent_id: str, **kwargs) -> None:
sets, vals = [], []
for k in ("enabled", "schedule_config", "custom_config"):
if k in kwargs and kwargs[k] is not None:
if k in ("schedule_config", "custom_config"):
sets.append(f"{k}=?")
vals.append(json.dumps(kwargs[k]))
else:
sets.append(f"{k}=?")
vals.append(kwargs[k])
if not sets:
return
sets.append("updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')")
vals.append(agent_id)
with _conn() as conn:
conn.execute(f"UPDATE agent_config SET {','.join(sets)} WHERE agent_id=?", vals)
def _config_to_dict(r) -> Dict[str, Any]:
return {
"agent_id": r["agent_id"],
"display_name": r["display_name"],
"enabled": bool(r["enabled"]),
"schedule_config": json.loads(r["schedule_config"]),
"custom_config": json.loads(r["custom_config"]),
"created_at": r["created_at"],
"updated_at": r["updated_at"],
}
# --- agent_tasks CRUD ---
def create_task(agent_id: str, task_type: str, input_data: dict, requires_approval: bool = False) -> str:
task_id = str(uuid.uuid4())
status = "pending" if requires_approval else "working"
with _conn() as conn:
conn.execute(
"INSERT INTO agent_tasks(id,agent_id,task_type,status,input_data,requires_approval) VALUES(?,?,?,?,?,?)",
(task_id, agent_id, task_type, status, json.dumps(input_data), int(requires_approval)),
)
return task_id
def update_task_status(task_id: str, status: str, result_data: dict = None) -> None:
with _conn() as conn:
if result_data is not None:
conn.execute(
"UPDATE agent_tasks SET status=?, result_data=?, completed_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?",
(status, json.dumps(result_data), task_id),
)
else:
conn.execute("UPDATE agent_tasks SET status=? WHERE id=?", (status, task_id))
def approve_task(task_id: str, via: str = "web") -> None:
with _conn() as conn:
conn.execute(
"UPDATE agent_tasks SET status='approved', approved_at=strftime('%Y-%m-%dT%H:%M:%fZ','now'), approved_via=? WHERE id=?",
(via, task_id),
)
def reject_task(task_id: str) -> None:
with _conn() as conn:
conn.execute(
"UPDATE agent_tasks SET status='rejected', completed_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?",
(task_id,),
)
def get_task(task_id: str) -> Optional[Dict[str, Any]]:
with _conn() as conn:
r = conn.execute("SELECT * FROM agent_tasks WHERE id=?", (task_id,)).fetchone()
return _task_to_dict(r) if r else None
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(sql, params).fetchall()
return [_task_to_dict(r) for r in rows]
def get_pending_approvals() -> List[Dict[str, Any]]:
with _conn() as conn:
rows = conn.execute(
"SELECT * FROM agent_tasks WHERE status='pending' AND requires_approval=1 ORDER BY created_at DESC"
).fetchall()
return [_task_to_dict(r) for r in rows]
def _task_to_dict(r) -> Dict[str, Any]:
return {
"id": r["id"],
"agent_id": r["agent_id"],
"task_type": r["task_type"],
"status": r["status"],
"input_data": json.loads(r["input_data"]) if r["input_data"] else {},
"result_data": json.loads(r["result_data"]) if r["result_data"] else None,
"requires_approval": bool(r["requires_approval"]),
"approved_at": r["approved_at"],
"approved_via": r["approved_via"],
"created_at": r["created_at"],
"completed_at": r["completed_at"],
}
# --- agent_logs ---
def add_log(agent_id: str, message: str, level: str = "info", task_id: str = None) -> None:
with _conn() as conn:
conn.execute(
"INSERT INTO agent_logs(agent_id,task_id,level,message) VALUES(?,?,?,?)",
(agent_id, task_id, level, message),
)
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 ?",
(agent_id, limit),
).fetchall()
return [
{
"id": r["id"],
"agent_id": r["agent_id"],
"task_id": r["task_id"],
"level": r["level"],
"message": r["message"],
"created_at": r["created_at"],
}
for r in rows
]
# --- telegram_state ---
def save_telegram_callback(callback_id: str, task_id: str, agent_id: str) -> None:
with _conn() as conn:
conn.execute(
"INSERT OR REPLACE INTO telegram_state(callback_id,task_id,agent_id) VALUES(?,?,?)",
(callback_id, task_id, agent_id),
)
def get_telegram_callback(callback_id: str) -> Optional[Dict[str, Any]]:
with _conn() as conn:
r = conn.execute(
"SELECT * FROM telegram_state WHERE callback_id=? AND responded=0",
(callback_id,),
).fetchone()
if not r:
return None
return {
"callback_id": r["callback_id"],
"task_id": r["task_id"],
"agent_id": r["agent_id"],
"responded": bool(r["responded"]),
}
def mark_telegram_responded(callback_id: str, action: str) -> None:
with _conn() as conn:
conn.execute(
"UPDATE telegram_state SET responded=1, action=? WHERE callback_id=?",
(action, callback_id),
)
def get_token_usage_stats(agent_id: str, days: int = 1) -> dict:
"""지정 에이전트의 최근 N일 토큰 사용량 집계.
agent_tasks 테이블의 result_data JSON에서 tokens.total을 합산.
반환: {"total_tokens": int, "task_count": int, "by_day": [{"date": "YYYY-MM-DD", "tokens": int}]}
"""
with _conn() as conn:
rows = conn.execute(
"""
SELECT completed_at, result_data
FROM agent_tasks
WHERE agent_id = ?
AND status = 'succeeded'
AND completed_at IS NOT NULL
AND completed_at >= strftime('%Y-%m-%dT%H:%M:%fZ','now', ?)
""",
(agent_id, f"-{int(days)} days"),
).fetchall()
total_tokens = 0
task_count = 0
by_day_map: Dict[str, int] = {}
for r in rows:
result_data = r["result_data"]
if not result_data:
continue
try:
parsed = json.loads(result_data)
except Exception:
continue
tokens = parsed.get("tokens") if isinstance(parsed, dict) else None
total = 0
if isinstance(tokens, dict):
total = int(tokens.get("total", 0) or 0)
if total <= 0:
continue
total_tokens += total
task_count += 1
completed_at = r["completed_at"] or ""
day = completed_at[:10] if completed_at else "unknown"
by_day_map[day] = by_day_map.get(day, 0) + total
by_day = [
{"date": d, "tokens": t}
for d, t in sorted(by_day_map.items())
]
return {
"total_tokens": total_tokens,
"task_count": task_count,
"by_day": by_day,
}
def save_conversation_message(
chat_id: str,
role: str,
content: str,
model: Optional[str] = None,
tokens_input: int = 0,
tokens_output: int = 0,
cache_read: int = 0,
cache_write: int = 0,
latency_ms: int = 0,
) -> None:
with _conn() as conn:
conn.execute(
"""
INSERT INTO conversation_messages
(chat_id, role, content, model, tokens_input, tokens_output,
cache_read, cache_write, latency_ms)
VALUES (?,?,?,?,?,?,?,?,?)
""",
(str(chat_id), role, content, model, tokens_input, tokens_output,
cache_read, cache_write, latency_ms),
)
def get_conversation_history(chat_id: str, limit: int = 20) -> List[Dict[str, Any]]:
"""최근 N개를 시간순(오래된 → 최신)으로 반환."""
with _conn() as conn:
rows = conn.execute(
"""
SELECT role, content FROM conversation_messages
WHERE chat_id=? ORDER BY id DESC LIMIT ?
""",
(str(chat_id), limit),
).fetchall()
return [{"role": r["role"], "content": r["content"]} for r in reversed(rows)]
def count_recent_user_messages(chat_id: str, seconds: int = 60) -> int:
with _conn() as conn:
r = conn.execute(
"""
SELECT COUNT(*) AS c FROM conversation_messages
WHERE chat_id=? AND role='user'
AND created_at >= strftime('%Y-%m-%dT%H:%M:%fZ','now', ?)
""",
(str(chat_id), f"-{int(seconds)} seconds"),
).fetchone()
return r["c"] if r else 0
def get_conversation_stats(days: int = 7) -> Dict[str, Any]:
with _conn() as conn:
rows = conn.execute(
"""
SELECT chat_id,
COUNT(*) AS msg_count,
SUM(tokens_input) AS in_tokens,
SUM(tokens_output) AS out_tokens,
SUM(cache_read) AS cache_read,
SUM(cache_write) AS cache_write,
AVG(latency_ms) AS avg_latency
FROM conversation_messages
WHERE role='assistant'
AND created_at >= strftime('%Y-%m-%dT%H:%M:%fZ','now', ?)
GROUP BY chat_id
""",
(f"-{int(days)} days",),
).fetchall()
by_chat = []
tot_in = tot_out = tot_r = tot_w = tot_msgs = 0
for r in rows:
ci = int(r["in_tokens"] or 0)
co = int(r["out_tokens"] or 0)
cr = int(r["cache_read"] or 0)
cw = int(r["cache_write"] or 0)
mc = int(r["msg_count"] or 0)
hit_rate = (cr / (cr + cw)) if (cr + cw) > 0 else 0.0
by_chat.append({
"chat_id": r["chat_id"],
"message_count": mc,
"tokens_input": ci,
"tokens_output": co,
"cache_read": cr,
"cache_write": cw,
"cache_hit_rate": round(hit_rate, 3),
"avg_latency_ms": round(float(r["avg_latency"] or 0), 1),
})
tot_in += ci; tot_out += co; tot_r += cr; tot_w += cw; tot_msgs += mc
overall_hit = (tot_r / (tot_r + tot_w)) if (tot_r + tot_w) > 0 else 0.0
return {
"days": days,
"total_messages": tot_msgs,
"tokens_input": tot_in,
"tokens_output": tot_out,
"cache_read": tot_r,
"cache_write": tot_w,
"cache_hit_rate": round(overall_hit, 3),
"by_chat": by_chat,
}
def get_activity_feed(limit: int = 50, offset: int = 0) -> dict:
with _conn() as conn:
total_row = conn.execute("""
SELECT (SELECT COUNT(*) FROM agent_tasks) + (SELECT COUNT(*) FROM agent_logs) AS total
""").fetchone()
total = total_row["total"] if total_row else 0
rows = conn.execute("""
SELECT 'task' AS type, agent_id, id AS task_id, task_type,
status, NULL AS level,
COALESCE(
json_extract(result_data, '$.summary'),
task_type
) AS message,
created_at, completed_at,
result_data
FROM agent_tasks
UNION ALL
SELECT 'log' AS type, agent_id, task_id, NULL AS task_type,
NULL AS status, level,
message,
created_at, NULL AS completed_at,
NULL AS result_data
FROM agent_logs
ORDER BY created_at DESC
LIMIT ? OFFSET ?
""", (limit, offset)).fetchall()
items = []
for r in rows:
item = {
"type": r["type"],
"agent_id": r["agent_id"],
"task_id": r["task_id"],
"message": r["message"],
"created_at": r["created_at"],
}
if r["type"] == "task":
item["task_type"] = r["task_type"]
item["status"] = r["status"]
item["completed_at"] = r["completed_at"]
if r["created_at"] and r["completed_at"]:
try:
from datetime import datetime
start = datetime.fromisoformat(r["created_at"].replace("Z", "+00:00"))
end = datetime.fromisoformat(r["completed_at"].replace("Z", "+00:00"))
item["duration_seconds"] = round((end - start).total_seconds())
except Exception:
item["duration_seconds"] = None
else:
item["duration_seconds"] = None
result_data = json.loads(r["result_data"]) if r["result_data"] else None
if result_data and "telegram_sent" in result_data:
item["telegram_sent"] = result_data["telegram_sent"]
else:
item["level"] = r["level"]
items.append(item)
return {"items": items, "total": total}
# ── youtube_research_jobs CRUD ────────────────────────────────────────────────
def add_youtube_research_job(countries: list) -> int:
with _conn() as conn:
conn.execute(
"INSERT INTO youtube_research_jobs (countries) VALUES (?)",
(json.dumps(countries),),
)
return conn.execute("SELECT last_insert_rowid()").fetchone()[0]
def update_youtube_research_job(
job_id: int, status: str, trends_collected: int, error: Optional[str] = None
) -> None:
with _conn() as conn:
conn.execute(
"""UPDATE youtube_research_jobs
SET status=?, trends_collected=?, error=?,
completed_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')
WHERE id=?""",
(status, trends_collected, error, job_id),
)
def get_latest_youtube_research_job() -> Optional[Dict[str, Any]]:
with _conn() as conn:
row = conn.execute(
"SELECT * FROM youtube_research_jobs ORDER BY id DESC LIMIT 1"
).fetchone()
if not row:
return None
return {
"id": row["id"],
"status": row["status"],
"countries": json.loads(row["countries"]),
"trends_collected": row["trends_collected"],
"error": row["error"],
"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]

264
agent-office/app/main.py Normal file
View File

@@ -0,0 +1,264 @@
import os
import json
from typing import Optional
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from .config import CORS_ALLOW_ORIGINS
from .db import init_db, get_all_agents, get_agent_config, update_agent_config, get_agent_tasks, get_pending_approvals, get_task, get_logs, get_activity_feed, get_latest_youtube_research_job
from .models import CommandRequest, ApprovalRequest, AgentConfigUpdate
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(
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")
async def on_startup():
init_db()
os.makedirs("/app/data", exist_ok=True)
init_agents()
for agent in AGENT_REGISTRY.values():
agent.set_ws_manager(ws_manager)
init_scheduler()
@app.get("/health")
def health():
return {"ok": True}
# --- WebSocket ---
@app.websocket("/api/agent-office/ws")
async def websocket_endpoint(ws: WebSocket):
await ws_manager.connect(ws)
try:
await ws.send_text(json.dumps({
"type": "init",
"agents": get_all_agent_states(),
"pending": [t["id"] for t in get_pending_approvals()],
}, ensure_ascii=False))
while True:
data = await ws.receive_text()
try:
msg = json.loads(data)
except json.JSONDecodeError:
continue
await _handle_ws_message(msg)
except WebSocketDisconnect:
pass
finally:
await ws_manager.disconnect(ws)
async def _handle_ws_message(msg: dict):
msg_type = msg.get("type")
agent_id = msg.get("agent")
agent = get_agent(agent_id) if agent_id else None
if msg_type == "command" and agent:
action = msg.get("action", "")
params = msg.get("params", {})
result = await agent.on_command(action, params)
await ws_manager.broadcast({"type": "command_result", "agent": agent_id, "result": result})
elif msg_type == "approval" and agent:
task_id = msg.get("task_id")
approved = msg.get("approved", False)
if task_id:
await agent.on_approval(task_id, approved)
elif msg_type == "query" and agent:
status = await agent.get_status()
await ws_manager.broadcast({"type": "agent_status", "agent": agent_id, "status": status})
# --- REST Endpoints ---
@app.get("/api/agent-office/agents")
def list_agents():
return {"agents": get_all_agents()}
@app.get("/api/agent-office/agents/{agent_id}")
def agent_detail(agent_id: str):
config = get_agent_config(agent_id)
if not config:
raise HTTPException(status_code=404, detail="Agent not found")
agent = get_agent(agent_id)
state_info = {"state": agent.state, "detail": agent.state_detail} if agent else {}
return {**config, **state_info}
@app.put("/api/agent-office/agents/{agent_id}")
def update_agent(agent_id: str, body: AgentConfigUpdate):
update_agent_config(agent_id, enabled=body.enabled,
schedule_config=body.schedule_config,
custom_config=body.custom_config)
return {"ok": True}
@app.get("/api/agent-office/agents/{agent_id}/tasks")
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)}
@app.get("/api/agent-office/tasks/pending")
def pending_tasks():
return {"tasks": get_pending_approvals()}
@app.get("/api/agent-office/tasks/{task_id}")
def task_detail(task_id: str):
task = get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task
@app.post("/api/agent-office/command")
async def send_command(body: CommandRequest):
agent = get_agent(body.agent)
if not agent:
return {"error": f"Agent '{body.agent}' not found"}
result = await agent.on_command(body.action, body.params or {})
return result
@app.post("/api/agent-office/approve")
async def approve(body: ApprovalRequest):
agent = get_agent(body.agent)
if not agent:
return {"error": f"Agent '{body.agent}' not found"}
await agent.on_approval(body.task_id, body.approved, body.feedback or "")
return {"ok": True}
# --- Telegram Webhook ---
async def _agent_dispatcher(agent_id: str, command: str, params: dict) -> dict:
"""텔레그램 라우터가 호출하는 에이전트 디스패처."""
# 전역 상태 조회
if agent_id == "__global__" and command == "status":
result = {}
for aid, agent in AGENT_REGISTRY.items():
result[aid] = {"state": agent.state, "detail": agent.state_detail}
return result
agent = AGENT_REGISTRY.get(agent_id)
if agent is None:
return {"ok": False, "message": f"Unknown agent: {agent_id}"}
return await agent.on_command(command, params or {})
@app.post("/api/agent-office/telegram/webhook")
async def telegram_webhook(data: dict):
result = await telegram_bot.handle_webhook(data, agent_dispatcher=_agent_dispatcher)
# callback_query (승인/거절) → 기존 승인 흐름
if result and "approved" in result:
agent = get_agent(result["agent_id"])
if agent:
await agent.on_approval(result["task_id"], result["approved"])
return {"ok": True}
@app.get("/api/agent-office/states")
def all_states():
return {"agents": get_all_agent_states()}
@app.get("/api/agent-office/agents/{agent_id}/token-usage")
def agent_token_usage(agent_id: str, days: int = 1):
from .db import get_token_usage_stats
return get_token_usage_stats(agent_id, days)
@app.get("/api/agent-office/conversation/stats")
def conversation_stats(days: int = 7):
from .db import get_conversation_stats
return get_conversation_stats(days)
@app.get("/api/agent-office/activity")
def activity_feed(limit: int = 50, offset: int = 0):
return get_activity_feed(limit, offset)
# --- Realestate Agent Push Endpoint ---
from pydantic import BaseModel
from typing import List, Dict, Any, Optional
class RealestateNotifyBody(BaseModel):
matches: List[Dict[str, Any]]
@app.post("/api/agent-office/realestate/notify")
async def realestate_notify(body: RealestateNotifyBody):
agent = get_agent("realestate")
if agent is None:
from fastapi import HTTPException
raise HTTPException(status_code=503, detail="RealestateAgent not initialized")
return await agent.on_new_matches(body.matches)
# --- YouTube Research Agent Endpoints ---
class YouTubeResearchBody(BaseModel):
countries: List[str] = []
@app.post("/api/agent-office/youtube/research")
async def trigger_youtube_research(body: Optional[YouTubeResearchBody] = None):
agent = get_agent("youtube")
if not agent:
raise HTTPException(status_code=503, detail="YouTubeResearchAgent 없음")
params = {}
if body and body.countries:
params["countries"] = body.countries
result = await agent.on_command("research", params)
return result
@app.get("/api/agent-office/youtube/research/status")
def youtube_research_status():
job = get_latest_youtube_research_job()
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

@@ -0,0 +1,35 @@
from pydantic import BaseModel
from typing import Optional
class CommandRequest(BaseModel):
agent: str
action: str
params: Optional[dict] = None
class ApprovalRequest(BaseModel):
agent: str
task_id: str
approved: bool
feedback: Optional[str] = None
class AgentConfigUpdate(BaseModel):
enabled: Optional[bool] = None
schedule_config: Optional[dict] = None
custom_config: Optional[dict] = None
class PriceAlertConfig(BaseModel):
symbol: str
name: str
target_price: float
direction: str # "above" or "below"
class ComposeCommand(BaseModel):
prompt: str
style: Optional[str] = None
model: Optional[str] = "V4"
instrumental: Optional[bool] = False

View File

View File

@@ -0,0 +1,227 @@
"""로또 큐레이션·당첨 알림 — 텔레그램 푸시."""
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}")

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

@@ -0,0 +1,120 @@
import asyncio
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from .agents import AGENT_REGISTRY
scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
async def _run_stock_schedule():
agent = AGENT_REGISTRY.get("stock")
if agent:
await agent.on_schedule()
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_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_youtube_research():
agent = AGENT_REGISTRY.get("youtube")
if agent:
await agent.on_schedule()
async def _send_youtube_weekly_report():
agent = AGENT_REGISTRY.get("youtube")
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 init_scheduler():
scheduler.add_job(_run_stock_schedule, "cron", hour=7, minute=30, id="stock_news")
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_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_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(_poll_pipelines, "interval", seconds=30, id="pipeline_poll")
scheduler.start()

View File

@@ -0,0 +1,396 @@
import httpx
from typing import Any, Dict, List, Optional
from .config import STOCK_URL, MUSIC_LAB_URL, INSTA_LAB_URL, REALESTATE_LAB_URL
_client = httpx.AsyncClient(timeout=30.0)
async def fetch_stock_news(limit: int = 10, category: str = None) -> List[Dict[str, Any]]:
params = {"limit": limit}
if category:
params["category"] = category
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_URL}/api/stock/indices")
resp.raise_for_status()
return resp.json()
async def summarize_stock_news(limit: int = 15) -> Dict[str, Any]:
"""stock의 AI 요약 엔드포인트 호출.
반환: {"summary": str, "tokens": {...}, "model": str, "duration_ms": int, "article_count": int}
"""
# stock 내부 Ollama 호출이 180s까지 가능하므로 여유있게 200s
async with httpx.AsyncClient(timeout=200.0) as client:
resp = await client.post(
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 generate_music(payload: dict) -> Dict[str, Any]:
resp = await _client.post(f"{MUSIC_LAB_URL}/api/music/generate", json=payload)
resp.raise_for_status()
return resp.json()
async def get_music_status(task_id: str) -> Dict[str, Any]:
resp = await _client.get(f"{MUSIC_LAB_URL}/api/music/status/{task_id}")
resp.raise_for_status()
return resp.json()
async def get_music_credits() -> Dict[str, Any]:
resp = await _client.get(f"{MUSIC_LAB_URL}/api/music/credits")
resp.raise_for_status()
return resp.json()
# --- insta-lab ---
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"{INSTA_LAB_URL}/api/insta/slates",
json={"keyword": keyword, "category": category, "keyword_id": keyword_id},
)
resp.raise_for_status()
return resp.json()
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 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()
# --- realestate-lab ---
async def realestate_collect() -> Dict[str, Any]:
"""청약 공고 수동 수집 트리거"""
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.post(f"{REALESTATE_LAB_URL}/api/realestate/collect")
resp.raise_for_status()
return resp.json()
async def realestate_matches(limit: int = 20) -> List[Dict[str, Any]]:
"""realestate-lab의 GET /api/realestate/matches 호출."""
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
f"{REALESTATE_LAB_URL}/api/realestate/matches",
params={"size": limit},
)
resp.raise_for_status()
data = resp.json()
return data.get("items", [])
async def realestate_dashboard() -> Dict[str, Any]:
resp = await _client.get(f"{REALESTATE_LAB_URL}/api/realestate/dashboard")
resp.raise_for_status()
return resp.json()
async def realestate_mark_read(match_id: int) -> Dict[str, Any]:
resp = await _client.patch(f"{REALESTATE_LAB_URL}/api/realestate/matches/{match_id}/read")
resp.raise_for_status()
return resp.json()
async def realestate_bookmark_toggle(announcement_id: int) -> Dict[str, Any]:
"""realestate-lab의 PATCH /api/realestate/announcements/{id}/bookmark 호출."""
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.patch(
f"{REALESTATE_LAB_URL}/api/realestate/announcements/{announcement_id}/bookmark"
)
resp.raise_for_status()
return resp.json()
# --- lotto-backend ---
async def lotto_candidates(n: int = 20) -> Dict[str, Any]:
from .config import LOTTO_BACKEND_URL
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/curator/candidates", params={"n": n})
resp.raise_for_status()
return resp.json()
async def lotto_context() -> Dict[str, Any]:
from .config import LOTTO_BACKEND_URL
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/curator/context")
resp.raise_for_status()
return resp.json()
async def lotto_save_briefing(payload: dict) -> Dict[str, Any]:
from .config import LOTTO_BACKEND_URL
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()

View File

@@ -0,0 +1,19 @@
"""Telegram 통합 메시지 패키지."""
from .agent_registry import AGENT_META, get_agent_meta, register_agent
from .messaging import send_agent_message, send_approval_request, send_raw
from .router import parse_command, resolve_agent_command, HELP_TEXT
from .webhook import handle_webhook, setup_webhook
__all__ = [
"send_agent_message",
"send_approval_request",
"send_raw",
"handle_webhook",
"setup_webhook",
"get_agent_meta",
"register_agent",
"AGENT_META",
"parse_command",
"resolve_agent_command",
"HELP_TEXT",
]

View File

@@ -0,0 +1,39 @@
"""에이전트 메타 등록소."""
AGENT_META = {
"stock": {
"display_name": "주식 트레이더",
"emoji": "📈",
"color": "#4488cc",
},
"music": {
"display_name": "음악 프로듀서",
"emoji": "🎵",
"color": "#44aa88",
},
"lotto": {
"emoji": "🎱",
"display_name": "로또 큐레이터",
},
"realestate": {
"display_name": "청약 애널리스트",
"emoji": "🏢",
"color": "#f43f5e",
},
}
def get_agent_meta(agent_id: str) -> dict:
return AGENT_META.get(
agent_id,
{"display_name": agent_id, "emoji": "🤖", "color": "#888"},
)
def register_agent(agent_id: str, display_name: str, emoji: str, color: str = "#888"):
"""향후 에이전트 동적 등록용"""
AGENT_META[agent_id] = {
"display_name": display_name,
"emoji": emoji,
"color": color,
}

View File

@@ -0,0 +1,18 @@
"""Telegram Bot API 저수준 래퍼."""
import httpx
from ..config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, TELEGRAM_WEBHOOK_URL
_BASE = "https://api.telegram.org/bot"
def _enabled() -> bool:
return bool(TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID)
async def api_call(method: str, payload: dict) -> dict:
if not _enabled():
return {"ok": False, "description": "Telegram not configured"}
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(f"{_BASE}{TELEGRAM_BOT_TOKEN}/{method}", json=payload)
return resp.json()

View File

@@ -0,0 +1,182 @@
"""텔레그램 자연어 대화 핸들러 — Claude + 프롬프트 캐싱.
구조:
- system prompt(정적) + 최근 대화 이력 + 마지막 user turn
- system과 history 끝 블록에 cache_control=ephemeral 적용 → 5분 TTL 프롬프트 캐시
- 평가를 위해 토큰·캐시·latency를 DB에 기록
"""
import asyncio
import time
from typing import Optional
import httpx
from ..config import (
ANTHROPIC_API_KEY,
CONVERSATION_MODEL,
CONVERSATION_HISTORY_LIMIT,
CONVERSATION_RATE_PER_MIN,
TELEGRAM_CHAT_ID,
TELEGRAM_WIFE_CHAT_ID,
)
from ..db import (
save_conversation_message,
get_conversation_history,
count_recent_user_messages,
)
API_URL = "https://api.anthropic.com/v1/messages"
SYSTEM_PROMPT = """당신은 'gahusb' 개인 웹 플랫폼의 AI 비서입니다. 텔레그램을 통해 CEO(주인)와 그의 가족과 대화합니다.
역할과 성격:
- 따뜻하지만 간결합니다. 텔레그램에서 읽기 쉽게 2~5문장 위주로 답합니다.
- 농담과 위트를 섞되 공손하게. 이모지는 상황에 맞게 1~2개만.
- 모르는 것은 솔직히 모른다고 하고, 추측은 명시합니다.
플랫폼 컨텍스트(대답에 자연스럽게 참고):
- 주식 에이전트: 뉴스 요약·시장 브리핑·포트폴리오 관리
- 음악 에이전트: AI 음악 생성(Suno/MusicGen)
- 블로그 에이전트: 키워드 리서치·포스트 생성·품질 리뷰
- 청약 에이전트: 부동산 청약 공고 수집·매칭
- 명령은 `/help`, `/agents`, `/status`, `/stock.brief` 같은 슬래시 형식이 있습니다. 사용자가 요청을 설명만 하면 해당 명령을 안내해 주세요.
응답 규칙:
- 장문 설명 금지. 스크롤을 넘기지 않을 분량.
- 에이전트 실행을 부탁받으면 지금 이 채널은 '대화'만 가능함을 알리고, 정확한 슬래시 명령을 한 줄로 제시하세요.
- HTML·마크다운 태그 없이 평문으로 답합니다."""
_rate_lock = asyncio.Lock()
def is_whitelisted(chat_id: str) -> bool:
allowed = {str(x) for x in (TELEGRAM_CHAT_ID, TELEGRAM_WIFE_CHAT_ID) if x}
return str(chat_id) in allowed
async def _check_rate_limit(chat_id: str) -> bool:
async with _rate_lock:
count = count_recent_user_messages(chat_id, seconds=60)
return count < CONVERSATION_RATE_PER_MIN
async def _call_claude(messages: list) -> dict:
"""Anthropic Messages API 호출 (prompt caching beta)."""
headers = {
"x-api-key": ANTHROPIC_API_KEY,
"anthropic-version": "2023-06-01",
"anthropic-beta": "prompt-caching-2024-07-31",
"content-type": "application/json",
}
# system: cache_control 적용하여 정적 프롬프트 캐싱
system_blocks = [
{
"type": "text",
"text": SYSTEM_PROMPT,
"cache_control": {"type": "ephemeral"},
}
]
payload = {
"model": CONVERSATION_MODEL,
"max_tokens": 1024,
"system": system_blocks,
"messages": messages,
}
async with httpx.AsyncClient(timeout=60) as client:
r = await client.post(API_URL, headers=headers, json=payload)
r.raise_for_status()
return r.json()
def _build_messages(history: list, user_text: str) -> list:
"""history: [{role, content(str)}, ...]. 가장 오래된 턴을 제외한 나머지 히스토리 끝 블록에
cache_control을 추가하여 누적 이력을 캐시한다."""
msgs: list = []
for h in history:
msgs.append({"role": h["role"], "content": [{"type": "text", "text": h["content"]}]})
# 히스토리 마지막 블록에 cache_control → 이전 대화를 캐시
if msgs:
last = msgs[-1]["content"][-1]
last["cache_control"] = {"type": "ephemeral"}
msgs.append({"role": "user", "content": [{"type": "text", "text": user_text}]})
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:
return None # 기능 비활성
if not is_whitelisted(chat_id):
return None # 모르는 사용자 무시
if not await _check_rate_limit(chat_id):
return "⏳ 잠시만요, 너무 빠릅니다. 분당 몇 번만 대화해 주세요."
history = get_conversation_history(chat_id, limit=CONVERSATION_HISTORY_LIMIT)
messages = _build_messages(history, user_text)
started = time.monotonic()
try:
resp = await _call_claude(messages)
except httpx.HTTPStatusError as e:
body = e.response.text[:200] if e.response is not None else ""
return f"⚠️ Claude 호출 실패: {e.response.status_code} {body}"
except Exception as e:
return f"⚠️ 응답 생성 중 오류: {type(e).__name__}"
latency_ms = int((time.monotonic() - started) * 1000)
try:
reply = "".join(
blk.get("text", "") for blk in resp.get("content", []) if blk.get("type") == "text"
).strip()
except Exception:
reply = ""
if not reply:
reply = "(빈 응답)"
usage = resp.get("usage", {}) or {}
t_in = int(usage.get("input_tokens", 0) or 0)
t_out = int(usage.get("output_tokens", 0) or 0)
c_read = int(usage.get("cache_read_input_tokens", 0) or 0)
c_write = int(usage.get("cache_creation_input_tokens", 0) or 0)
# 기록: user 먼저, assistant 나중 (순서 보존)
save_conversation_message(chat_id, "user", user_text)
save_conversation_message(
chat_id, "assistant", reply,
model=CONVERSATION_MODEL,
tokens_input=t_in, tokens_output=t_out,
cache_read=c_read, cache_write=c_write,
latency_ms=latency_ms,
)
return reply

View File

@@ -0,0 +1,51 @@
"""에이전트 메시지 포맷팅."""
from html import escape as _h
from typing import Literal, Optional
from .agent_registry import get_agent_meta
MessageKind = Literal["report", "alert", "approval", "error", "info"]
KIND_ICONS = {
"report": "📊",
"alert": "🔔",
"approval": "",
"error": "⚠️",
"info": "",
}
def format_agent_message(
agent_id: str,
kind: MessageKind,
title: str,
body: str,
metadata: Optional[dict] = None,
body_is_html: bool = False,
) -> str:
meta = get_agent_meta(agent_id)
icon = KIND_ICONS.get(kind, "")
header = f"{icon} <b>[{_h(meta['emoji'])} {_h(meta['display_name'])}]</b> {_h(title)}"
# Telegram 단일 메시지 4096자 제한 대응 (헤더/푸터 여유 512자 확보)
# body_is_html=True 면 호출자가 이미 HTML-safe하게 구성한 것으로 간주 (예: <a> 링크 포함)
safe_body = body if body_is_html else _h(body)
if len(safe_body) > 3500:
safe_body = safe_body[:3500] + "\n…(생략)"
lines = [header, "" * 20, safe_body]
if metadata:
footer_parts = []
if "tokens" in metadata:
footer_parts.append(f"🧮 {metadata['tokens']:,} tokens")
if "duration_ms" in metadata:
seconds = metadata["duration_ms"] / 1000
footer_parts.append(f"{seconds:.1f}s")
if "model" in metadata:
footer_parts.append(f"🤖 {metadata['model']}")
if footer_parts:
lines.append("")
lines.append(f"<i>{_h(' · '.join(footer_parts))}</i>")
return "\n".join(lines)

View File

@@ -0,0 +1,83 @@
"""고수준 메시지 전송 API."""
import uuid
from typing import Optional
from ..config import TELEGRAM_CHAT_ID
from ..db import save_telegram_callback
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,
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": parse_mode,
}
if reply_markup:
payload["reply_markup"] = reply_markup
result = await api_call("sendMessage", payload)
ok = result.get("ok", False)
return {
"ok": ok,
"message_id": result.get("result", {}).get("message_id") if ok else None,
"description": result.get("description") if not ok else None,
"error_code": result.get("error_code") if not ok else None,
}
async def send_agent_message(
agent_id: str,
kind: MessageKind,
title: str,
body: str,
task_id: Optional[str] = None,
actions: Optional[list] = None,
metadata: Optional[dict] = None,
body_is_html: bool = False,
) -> dict:
"""통합 에이전트 메시지 API. 모든 에이전트가 이걸 씀.
body_is_html=True: 호출자가 이미 HTML-safe 포맷(링크 <a> 등) 구성한 경우.
"""
text = format_agent_message(agent_id, kind, title, body, metadata, body_is_html=body_is_html)
reply_markup = None
if actions:
buttons = []
for action in actions:
cb_id = f"{action['action']}_{uuid.uuid4().hex[:8]}"
save_telegram_callback(cb_id, task_id or "", agent_id)
buttons.append({"text": action["label"], "callback_data": cb_id})
reply_markup = {"inline_keyboard": [buttons]}
return await send_raw(text, reply_markup)
async def send_approval_request(
agent_id: str,
task_id: str,
title: str,
detail: str,
) -> dict:
"""승인/거절 단축 헬퍼."""
return await send_agent_message(
agent_id=agent_id,
kind="approval",
title=title,
body=detail,
task_id=task_id,
actions=[
{"label": "✅ 승인", "action": "approve"},
{"label": "❌ 거절", "action": "reject"},
],
)

View File

@@ -0,0 +1,93 @@
"""청약 매칭 알림 — 텔레그램 메시지 포맷터 + 인라인 키보드 빌더."""
import os
from html import escape as _h
from typing import Optional
DASHBOARD_URL = os.getenv("REALESTATE_DASHBOARD_URL", "https://example.com/realestate")
def _format_one_compact(m: dict) -> str:
score = m.get("match_score", 0)
name = _h(m.get("house_nm") or "(제목 없음)")
district = m.get("district") or ""
region = m.get("region_name") or ""
where = f"{region.split()[0] if region else ''} {district}".strip() or "위치 미상"
rstart = m.get("receipt_start") or ""
rend = m.get("receipt_end") or ""
return (
f"{score}점 — <b>{name}</b>\n"
f"📍 {_h(where)} 📅 {_h(rstart)} ~ {_h(rend)}"
)
def _format_one_full(m: dict) -> str:
score = m.get("match_score", 0)
name = _h(m.get("house_nm") or "(제목 없음)")
district = m.get("district") or ""
region = m.get("region_name") or ""
flags = []
if m.get("is_speculative_area") == "Y":
flags.append("투기과열")
if m.get("is_price_cap") == "Y":
flags.append("분양가상한제")
flag_str = f" ({', '.join(flags)})" if flags else ""
rstart = m.get("receipt_start") or ""
rend = m.get("receipt_end") or ""
elig = m.get("eligible_types") or []
reasons = m.get("match_reasons") or []
where = f"{region.split()[0] if region else ''} {district}".strip() or "위치 미상"
lines = [
f"{score}점 — <b>{name}</b>",
f"📍 {_h(where)}{_h(flag_str)}",
f"📅 청약 {_h(rstart)} ~ {_h(rend)}",
]
if elig:
lines.append(f"✓ 자격: {_h(', '.join(elig))}")
if reasons:
lines.append(f"💡 {_h(' / '.join(reasons[:4]))}")
return "\n".join(lines)
def format_realestate_matches(matches: list[dict]) -> str:
"""매칭 목록을 텔레그램 HTML 메시지로 변환.
1~2건은 풀 카드, 3건 이상은 묶음 카드(상위 5건).
"""
if not matches:
return "🏢 새 청약 매칭이 없습니다."
if len(matches) <= 2:
body = "\n\n".join(_format_one_full(m) for m in matches)
return f"🏢 <b>새 청약 매칭 {len(matches)}건</b>\n━━━━━━━━━━\n\n{body}"
top = matches[:5]
body = "\n\n".join(_format_one_compact(m) for m in top)
suffix = f"\n\n…외 {len(matches) - 5}" if len(matches) > 5 else ""
return f"🏢 <b>새 청약 매칭 {len(matches)}건</b>\n━━━━━━━━━━\n\n{body}{suffix}"
def build_match_keyboard(matches: list[dict]) -> Optional[dict]:
"""1~2건: 매치별 [북마크][공고 보기] 행. 3건 이상: [전체 보기] 단일 행."""
if not matches:
return None
if len(matches) <= 2:
rows = []
for m in matches:
buttons = [{
"text": "🔖 북마크",
"callback_data": f"realestate_bookmark_{m['id']}",
}]
url = m.get("pblanc_url")
if url:
buttons.append({"text": "📄 공고 보기", "url": url})
rows.append(buttons)
return {"inline_keyboard": rows}
return {
"inline_keyboard": [[
{"text": "📋 전체 보기", "url": DASHBOARD_URL},
]],
}

View File

@@ -0,0 +1,95 @@
"""텔레그램 메시지 명령 → 에이전트 라우팅.
새 명령을 추가하려면 AGENT_COMMAND_MAP에 등록만 하면 됨."""
from typing import Optional
def parse_command(text: str) -> Optional[tuple]:
"""슬래시 명령 파싱.
반환: (agent_id_or_None, command, args_list) 또는 None
예시:
/stock news -> ("stock", "news", [])
/status -> (None, "status", [])
/music compose 잔잔한 피아노 -> ("music", "compose", ["잔잔한 피아노"])
"""
if not text:
return None
text = text.strip()
if not text.startswith("/"):
return None
parts = text[1:].split(maxsplit=2)
if not parts:
return None
first = parts[0].lower()
# 전역 명령
if first in ("status", "agents", "help"):
return (None, first, parts[1:] if len(parts) > 1 else [])
# 에이전트 명령: /<agent> <command> [args...]
if len(parts) < 2:
return None
agent_id = first
command = parts[1].lower()
args = [parts[2]] if len(parts) > 2 else []
return (agent_id, command, args)
# 에이전트별 텔레그램 → 내부 command 매핑
# 텔레그램에서 친숙한 이름 -> (실제 on_command의 command, 기본 params)
AGENT_COMMAND_MAP = {
"stock": {
"news": ("fetch_news", {}),
"alerts": ("list_alerts", {}),
"test": ("test_telegram", {}),
},
"music": {
"credits": ("credits", {}),
# compose는 인자 필요 — 아래 특수 케이스에서 처리
},
"realestate": {
"matches": ("fetch_matches", {}),
"dashboard": ("dashboard", {}),
},
}
def resolve_agent_command(agent_id: str, command: str, args: list) -> Optional[tuple]:
"""(internal_command, params) 반환. 매핑 없으면 None."""
mapping = AGENT_COMMAND_MAP.get(agent_id, {}).get(command)
if mapping is None:
# 특수 케이스: music compose <prompt>
if agent_id == "music" and command == "compose" and args:
return ("compose", {"prompt": " ".join(args)})
return None
internal_cmd, base_params = mapping
params = dict(base_params)
if args:
# args가 있으면 첫 번째(합쳐진 나머지)를 message로 자동 주입
params["message"] = " ".join(args)
return (internal_cmd, params)
HELP_TEXT = """<b>🤖 Agent Office 텔레그램 명령</b>
<b>전역</b>
/status — 모든 에이전트 상태
/agents — 에이전트 목록
/help — 이 도움말
<b>📈 주식 트레이더</b>
/stock news — 뉴스 AI 요약 실행
/stock alerts — 알람 목록
/stock test — 텔레그램 테스트
<b>🎵 음악 프로듀서</b>
/music credits — Suno 크레딧 조회
/music compose &lt;프롬프트&gt; — 작곡 시작
<b>🏢 청약 애널리스트</b>
/realestate matches — 신규 매칭 조회 후 알림 전송
/realestate dashboard — 청약 현황 요약
"""

View File

@@ -0,0 +1,239 @@
"""텔레그램 Webhook 이벤트 처리."""
from typing import Optional
from ..db import get_telegram_callback, mark_telegram_responded
from .client import _enabled, api_call
async def handle_webhook(data: dict, agent_dispatcher=None) -> Optional[dict]:
"""텔레그램에서 들어오는 이벤트 처리.
- callback_query(인라인 버튼)는 항상 처리 → 승인/거절 dict 반환
- message(텍스트 슬래시 명령)는 `agent_dispatcher`가 주입된 경우에만 처리
agent_dispatcher: async (agent_id, command, params) -> dict
- agent_id == "__global__", command == "status" 특수 케이스는
{agent_id: {state, detail}} dict를 반환해야 함.
"""
callback_query = data.get("callback_query")
if callback_query:
return await _handle_callback(callback_query)
message = data.get("message")
if message:
chat = message.get("chat", {})
print(f"[TG-WEBHOOK] chat.id={chat.get('id')} type={chat.get('type')} text={message.get('text')!r}", flush=True)
if message and message.get("text") and agent_dispatcher is not None:
return await _handle_message(message, agent_dispatcher)
return None
async def _handle_callback(callback_query: dict) -> Optional[dict]:
"""승인/거절 및 realestate 북마크 콜백 처리."""
callback_id = callback_query.get("data", "")
# realestate 북마크 토글 콜백 — DB 조회 없이 직접 처리
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
action = callback_id.split("_")[0]
mark_telegram_responded(callback_id, action)
feedback_text = {
"approve": "승인됨 ✅",
"reject": "거절됨 ❌",
}.get(action, f"처리됨: {action}")
await api_call(
"answerCallbackQuery",
{
"callback_query_id": callback_query["id"],
"text": feedback_text,
},
)
return {
"task_id": cb["task_id"],
"agent_id": cb["agent_id"],
"action": action,
"approved": action == "approve",
}
async def _handle_realestate_bookmark(callback_query: dict, callback_id: str) -> dict:
"""realestate_bookmark_{announcement_id} 콜백 처리."""
from .. import service_proxy
from .messaging import send_raw
# answerCallbackQuery 먼저 — 텔레그램 로딩 스피너 해제
await api_call(
"answerCallbackQuery",
{"callback_query_id": callback_query["id"], "text": "처리 중..."},
)
try:
ann_id = int(callback_id.removeprefix("realestate_bookmark_"))
except ValueError:
await send_raw("⚠️ 잘못된 북마크 콜백 데이터")
return {"ok": False, "error": "invalid_callback_data"}
try:
result = await service_proxy.realestate_bookmark_toggle(ann_id)
is_on = result.get("is_bookmarked")
if is_on == 1:
await send_raw(f"🔖 북마크 추가 완료 (#{ann_id})")
elif is_on == 0:
await send_raw(f"🔖 북마크 해제 완료 (#{ann_id})")
else:
await send_raw(f"🔖 북마크 토글 완료 (#{ann_id})")
return {"ok": True, "announcement_id": ann_id}
except Exception as e:
await send_raw(f"⚠️ 북마크 처리 실패: {e}")
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)
if not parsed:
# 슬래시 명령이 아니면 자연어 대화로 라우팅
chat_id = str(message.get("chat", {}).get("id", ""))
if not chat_id:
return None
from .conversational import respond_to_message
reply = await respond_to_message(chat_id, text)
if reply:
import html as _html
await send_raw(_html.escape(reply), chat_id=chat_id)
return {"handled": "chat"}
return None
agent_id, command, args = parsed
# 전역 명령
if agent_id is None:
if command == "help":
await send_raw(HELP_TEXT)
return {"handled": "help"}
if command == "agents":
lines = ["<b>📋 등록된 에이전트</b>", ""]
for aid, meta in AGENT_META.items():
lines.append(
f"{meta['emoji']} <b>{meta['display_name']}</b> <code>/{aid}</code>"
)
await send_raw("\n".join(lines))
return {"handled": "agents"}
if command == "status":
try:
result = await agent_dispatcher("__global__", "status", {})
body_lines = []
if isinstance(result, dict):
for aid, info in result.items():
meta = AGENT_META.get(
aid, {"emoji": "🤖", "display_name": aid}
)
state = info.get("state", "unknown") if isinstance(info, dict) else "unknown"
body_lines.append(
f"{meta['emoji']} <b>{meta['display_name']}</b>: <code>{state}</code>"
)
detail = info.get("detail") if isinstance(info, dict) else None
if detail:
body_lines.append(f"{detail}")
await send_raw("<b>📊 전체 상태</b>\n\n" + "\n".join(body_lines))
except Exception as e:
await send_raw(f"⚠️ 상태 조회 실패: {e}")
return {"handled": "status"}
return None
# 에이전트 명령
if agent_id not in AGENT_META:
await send_raw(
f"⚠️ 알 수 없는 에이전트: <code>{agent_id}</code>\n/help 로 사용 가능한 명령 확인"
)
return {"handled": "unknown_agent"}
resolved = resolve_agent_command(agent_id, command, args)
if resolved is None:
await send_raw(
f"⚠️ <code>{agent_id}</code>에서 <code>{command}</code> 명령은 지원하지 않습니다."
)
return {"handled": "unknown_command"}
internal_cmd, params = resolved
try:
result = await agent_dispatcher(agent_id, internal_cmd, params)
ok = result.get("ok", False) if isinstance(result, dict) else False
msg = result.get("message", "") if isinstance(result, dict) else str(result)
await send_agent_message(
agent_id=agent_id,
kind="info" if ok else "error",
title=f"{internal_cmd} 실행 결과",
body=msg or str(result),
)
except Exception as e:
await send_raw(f"⚠️ 명령 실행 실패: {e}")
return {"handled": "command", "agent_id": agent_id, "command": internal_cmd}
async def setup_webhook() -> dict:
from ..config import TELEGRAM_WEBHOOK_URL
if not _enabled() or not TELEGRAM_WEBHOOK_URL:
return {"ok": False, "description": "Webhook URL not configured"}
return await api_call("setWebhook", {"url": TELEGRAM_WEBHOOK_URL})

View File

@@ -0,0 +1,27 @@
"""Deprecated: app.telegram 패키지 사용 권장. 하위 호환용 re-export."""
from .telegram import handle_webhook, send_approval_request, send_raw, setup_webhook
from .telegram.messaging import send_agent_message
# 기존 호출자가 쓰던 이름들
async def send_message(text: str, reply_markup: dict = None) -> dict:
return await send_raw(text, reply_markup)
async def send_stock_summary(summary: str) -> dict:
return await send_raw(summary)
async def send_task_result(agent_id: str, title: str, result: str) -> dict:
return await send_agent_message(agent_id, "report", title, result)
__all__ = [
"send_message",
"send_stock_summary",
"send_task_result",
"send_approval_request",
"send_agent_message",
"handle_webhook",
"setup_webhook",
]

110
agent-office/app/test_db.py Normal file
View File

@@ -0,0 +1,110 @@
import os
import sys
import tempfile
# Override DB_PATH before importing db
_tmp = tempfile.mktemp(suffix=".db")
os.environ["AGENT_OFFICE_DB_PATH"] = _tmp
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from app.db import (
init_db, get_all_agents, get_agent_config, update_agent_config,
create_task, update_task_status, approve_task, get_task, get_agent_tasks,
get_pending_approvals, add_log, get_logs,
save_telegram_callback, get_telegram_callback, mark_telegram_responded,
)
def test_init_and_seed():
init_db()
agents = get_all_agents()
assert len(agents) == 2, f"Expected 2 agents, got {len(agents)}"
ids = {a["agent_id"] for a in agents}
assert ids == {"stock", "music"}, f"Unexpected agent ids: {ids}"
print(" [PASS] test_init_and_seed")
def test_agent_config_update():
init_db()
update_agent_config("stock", custom_config={"watch": ["AAPL"]})
cfg = get_agent_config("stock")
assert cfg["custom_config"] == {"watch": ["AAPL"]}, f"Unexpected config: {cfg['custom_config']}"
print(" [PASS] test_agent_config_update")
def test_task_lifecycle():
init_db()
# Create task with approval
tid = create_task("music", "compose", {"prompt": "test"}, requires_approval=True)
task = get_task(tid)
assert task["status"] == "pending", f"Expected pending, got {task['status']}"
assert task["requires_approval"] is True
# Approve
approve_task(tid, via="telegram")
task = get_task(tid)
assert task["status"] == "approved", f"Expected approved, got {task['status']}"
assert task["approved_via"] == "telegram"
# Complete
update_task_status(tid, "succeeded", {"url": "/media/music/test.mp3"})
task = get_task(tid)
assert task["status"] == "succeeded", f"Expected succeeded, got {task['status']}"
assert task["result_data"]["url"] == "/media/music/test.mp3"
print(" [PASS] test_task_lifecycle")
def test_task_no_approval():
init_db()
tid = create_task("stock", "news_summary", {"limit": 10})
task = get_task(tid)
assert task["status"] == "working", f"Expected working, got {task['status']}"
print(" [PASS] test_task_no_approval")
def test_pending_approvals():
init_db()
create_task("music", "compose", {"prompt": "a"}, requires_approval=True)
create_task("music", "compose", {"prompt": "b"}, requires_approval=True)
create_task("stock", "news_summary", {})
pending = get_pending_approvals()
assert len(pending) == 2, f"Expected 2 pending, got {len(pending)}"
print(" [PASS] test_pending_approvals")
def test_logs():
init_db()
add_log("stock", "News fetched", "info", "task-1")
add_log("stock", "API error", "error")
logs = get_logs("stock")
assert len(logs) == 2, f"Expected 2 logs, got {len(logs)}"
assert logs[0]["level"] == "error", f"Expected error first (DESC), got {logs[0]['level']}"
print(" [PASS] test_logs")
def test_telegram_state():
init_db()
save_telegram_callback("cb-1", "task-1", "music")
cb = get_telegram_callback("cb-1")
assert cb["task_id"] == "task-1"
mark_telegram_responded("cb-1", "approve")
cb = get_telegram_callback("cb-1")
assert cb is None, f"Expected None after responded=1, got {cb}"
print(" [PASS] test_telegram_state")
if __name__ == "__main__":
test_init_and_seed()
test_agent_config_update()
test_task_lifecycle()
test_task_no_approval()
test_pending_approvals()
test_logs()
test_telegram_state()
print("All DB tests passed!")
# Cleanup temp DB (best-effort; WAL mode may keep files open on Windows)
for ext in ("", "-wal", "-shm"):
try:
os.unlink(_tmp + ext)
except OSError:
pass

View File

@@ -0,0 +1,55 @@
import asyncio
import json
from typing import Any, Dict, Set
from fastapi import WebSocket
class WebSocketManager:
def __init__(self):
self._connections: Set[WebSocket] = set()
self._lock = asyncio.Lock()
async def connect(self, ws: WebSocket) -> None:
await ws.accept()
async with self._lock:
self._connections.add(ws)
async def disconnect(self, ws: WebSocket) -> None:
async with self._lock:
self._connections.discard(ws)
async def broadcast(self, message: Dict[str, Any]) -> None:
payload = json.dumps(message, ensure_ascii=False)
async with self._lock:
dead = set()
for ws in self._connections:
try:
await ws.send_text(payload)
except Exception:
dead.add(ws)
self._connections -= dead
async def send_agent_state(self, agent_id: str, state: str, detail: str = "", task_id: str = None) -> None:
msg = {"type": "agent_state", "agent": agent_id, "state": state, "detail": detail}
if task_id:
msg["task_id"] = task_id
await self.broadcast(msg)
async def send_task_complete(self, agent_id: str, task_id: str, result: dict) -> None:
await self.broadcast({
"type": "task_complete", "agent": agent_id,
"task_id": task_id, "result": result,
})
async def send_agent_move(self, agent_id: str, target: str) -> None:
await self.broadcast({"type": "agent_move", "agent": agent_id, "target": target})
async def send_notification(self, agent_id: str, event: str, task_id: str = None, message: str = "") -> None:
await self.broadcast({
"type": "notification",
"agent": agent_id,
"event": event,
"task_id": task_id,
"message": message,
})
ws_manager = WebSocketManager()

View File

@@ -0,0 +1,142 @@
import os
import re
import asyncio
from typing import List, Dict, Any
import httpx
YOUTUBE_DATA_API_KEY = os.getenv("YOUTUBE_DATA_API_KEY", "")
MUSIC_LAB_URL = os.getenv("MUSIC_LAB_URL", "http://music-lab:8000")
TARGET_COUNTRIES = ["BR", "ID", "MX", "US", "KR"]
TREND_KEYWORDS = ["lofi music", "phonk", "ambient music", "chill beats", "study music"]
YOUTUBE_MUSIC_CAT = "10"
GENRE_TAGS = {
"lo-fi": ["lofi", "lo-fi", "lo fi", "chill", "study"],
"phonk": ["phonk", "drift", "memphis"],
"ambient": ["ambient", "relaxing", "meditation"],
"pop": ["pop", "kpop", "k-pop"],
"funk": ["funk", "baile funk"],
"latin": ["latin", "reggaeton", "sertanejo"],
}
def _tags_to_genre(tags: list) -> str:
joined = " ".join(t.lower() for t in tags)
for genre, kws in GENRE_TAGS.items():
if any(kw in joined for kw in kws):
return genre
return "general"
async def fetch_youtube_trending(country: str, max_results: int = 50) -> List[Dict[str, Any]]:
"""YouTube Data API v3 — 국가별 트렌딩 음악 영상 (categoryId=10)."""
if not YOUTUBE_DATA_API_KEY:
return []
async with httpx.AsyncClient(timeout=10.0) as client:
try:
resp = await client.get(
"https://www.googleapis.com/youtube/v3/videos",
params={
"part": "snippet,statistics",
"chart": "mostPopular",
"regionCode": country,
"videoCategoryId": YOUTUBE_MUSIC_CAT,
"maxResults": max_results,
"key": YOUTUBE_DATA_API_KEY,
},
)
if resp.status_code != 200:
return []
items = resp.json().get("items", [])
except Exception:
return []
results = []
for i, item in enumerate(items):
snippet = item.get("snippet", {})
stats = item.get("statistics", {})
genre = _tags_to_genre(snippet.get("tags") or [])
results.append({
"source": "youtube",
"country": country,
"genre": genre,
"keyword": snippet.get("title", "")[:100],
"score": round(1.0 - i / max_results, 3),
"rank": i + 1,
"metadata": {
"video_id": item["id"],
"view_count": int(stats.get("viewCount", 0)),
"channel": snippet.get("channelTitle", ""),
},
})
return results
async def fetch_google_trends(keywords: List[str], countries: List[str]) -> List[Dict[str, Any]]:
"""pytrends — 키워드별 Google 관심도 (sync → threadpool)."""
try:
from pytrends.request import TrendReq
except ImportError:
return []
def _sync_fetch(kw: str) -> List[Dict[str, Any]]:
try:
pt = TrendReq(hl="en-US", tz=0, timeout=(5, 15))
pt.build_payload([kw], timeframe="now 7-d")
df = pt.interest_over_time()
if df.empty or kw not in df.columns:
return []
score = round(float(df[kw].mean()) / 100.0, 3)
return [
{"source": "google_trends", "country": c, "genre": "",
"keyword": kw, "score": score, "rank": None, "metadata": {}}
for c in countries
]
except Exception:
return []
loop = asyncio.get_running_loop()
results = []
for kw in keywords[:5]:
rows = await loop.run_in_executor(None, _sync_fetch, kw)
results.extend(rows)
await asyncio.sleep(1.0)
return results
async def fetch_billboard_top20() -> List[Dict[str, Any]]:
"""Billboard Hot 100 스크래핑 — 상위 20위."""
async with httpx.AsyncClient(
timeout=10.0,
headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"},
follow_redirects=True,
) as client:
try:
resp = await client.get("https://www.billboard.com/charts/hot-100/")
if resp.status_code != 200:
return []
titles = re.findall(
r'class="c-title[^"]*"[^>]*>\s*([^<\n]{3,80})\s*<', resp.text
)[:20]
return [
{"source": "billboard", "country": "US", "genre": "pop",
"keyword": t.strip(), "score": round(1.0 - i / 20, 3),
"rank": i + 1, "metadata": {}}
for i, t in enumerate(titles) if t.strip()
]
except Exception:
return []
async def push_to_music_lab(trends: List[Dict[str, Any]], report_date: str) -> bool:
"""수집한 트렌드를 music-lab /api/music/market/ingest로 push."""
async with httpx.AsyncClient(timeout=15.0) as client:
try:
resp = await client.post(
f"{MUSIC_LAB_URL}/api/music/market/ingest",
json={"trends": trends, "report_date": report_date},
)
return resp.status_code == 200
except Exception:
return False

View File

@@ -0,0 +1,8 @@
fastapi==0.115.6
uvicorn[standard]==0.30.6
apscheduler==3.10.4
websockets>=12.0
httpx>=0.27
respx>=0.21
google-api-python-client>=2.100.0
pytrends>=4.9.2

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

@@ -0,0 +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
def _pick(nums, role="안정"):
return {"numbers": nums, "risk_tag": role, "reason": "x"}
def _make_payload(core, bonus, ext, pool):
return {
"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": ["1", "2", "3"],
"retrospective": "지난주 평균 1.8",
},
"confidence": 70,
}
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_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(_make_payload(cores, bonus, ext, pl), pool)

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,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,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,99 @@
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 test_on_new_matches_returns_empty_when_no_matches():
from app.agents.realestate import RealestateAgent
agent = RealestateAgent()
result = asyncio.run(agent.on_new_matches([]))
assert result == {"sent": 0, "sent_ids": []}
def test_on_new_matches_sends_telegram_and_returns_ids():
from app.agents.realestate import RealestateAgent
from app.telegram import messaging
matches = [{
"id": 7, "match_score": 80, "house_nm": "단지A",
"region_name": "서울특별시", "district": "강남구",
"receipt_start": "2026-05-01", "receipt_end": "2026-05-05",
"match_reasons": [], "eligible_types": [], "pblanc_url": "https://x.test/7",
}]
fake_send = AsyncMock(return_value={"ok": True, "message_id": 123})
with patch.object(messaging, "send_raw", fake_send):
agent = RealestateAgent()
result = asyncio.run(agent.on_new_matches(matches))
assert result["sent"] == 1
assert result["sent_ids"] == [7]
assert result["message_id"] == 123
fake_send.assert_awaited_once()
args, kwargs = fake_send.call_args
text = args[0]
assert "단지A" in text
def test_on_new_matches_telegram_failure_returns_zero():
from app.agents.realestate import RealestateAgent
from app.telegram import messaging
matches = [{
"id": 8, "match_score": 80, "house_nm": "단지B",
"region_name": "서울", "district": "송파구",
"receipt_start": "", "receipt_end": "",
"match_reasons": [], "eligible_types": [], "pblanc_url": "",
}]
fake_send = AsyncMock(return_value={"ok": False, "description": "401"})
with patch.object(messaging, "send_raw", fake_send):
agent = RealestateAgent()
result = asyncio.run(agent.on_new_matches(matches))
assert result["sent"] == 0
assert result["sent_ids"] == []
assert "error" in result
def test_endpoint_calls_agent_on_new_matches():
from fastapi.testclient import TestClient
from app.main import app
from app.agents.realestate import RealestateAgent
fake = AsyncMock(return_value={"sent": 1, "sent_ids": [99], "message_id": 1})
with patch.object(RealestateAgent, "on_new_matches", fake):
with TestClient(app) as client:
resp = client.post(
"/api/agent-office/realestate/notify",
json={"matches": [{"id": 99, "match_score": 80}]},
)
assert resp.status_code == 200
body = resp.json()
assert body["sent"] == 1
assert body["sent_ids"] == [99]

View File

@@ -0,0 +1,133 @@
import os
import sys
import tempfile
import gc
from unittest.mock import AsyncMock, patch
_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
import pytest
@pytest.fixture(autouse=True)
def _init_db():
gc.collect()
if os.path.exists(_TMP):
try:
os.remove(_TMP)
except PermissionError:
pass
from app.db import init_db
init_db()
yield
def test_callback_realestate_bookmark_calls_proxy():
"""callback_data 'realestate_bookmark_42' 가 service_proxy.realestate_bookmark_toggle(42) 를 호출하고
is_bookmarked=1 이면 '추가 완료' 메시지를 전송한다."""
from app import service_proxy
from app.telegram import webhook
fake_toggle = AsyncMock(return_value={"is_bookmarked": 1})
fake_send = AsyncMock(return_value={"ok": True})
fake_api_call = AsyncMock(return_value={"ok": True})
update = {
"callback_query": {
"id": "cb1",
"from": {"id": 1},
"data": "realestate_bookmark_42",
}
}
with patch.object(service_proxy, "realestate_bookmark_toggle", fake_toggle), \
patch("app.telegram.messaging.send_raw", fake_send), \
patch("app.telegram.webhook.api_call", fake_api_call):
result = asyncio.run(webhook.handle_webhook(update))
fake_toggle.assert_awaited_once_with(42)
assert result == {"ok": True, "announcement_id": 42}
args, _ = fake_send.call_args
assert "추가" in args[0]
def test_callback_realestate_bookmark_invalid_id():
"""callback_data 'realestate_bookmark_abc' 는 ValueError를 처리하고 에러 응답 반환."""
from app import service_proxy
from app.telegram import webhook
fake_toggle = AsyncMock(return_value={"bookmarked": True})
fake_send = AsyncMock(return_value={"ok": True})
fake_api_call = AsyncMock(return_value={"ok": True})
update = {
"callback_query": {
"id": "cb2",
"from": {"id": 1},
"data": "realestate_bookmark_abc",
}
}
with patch.object(service_proxy, "realestate_bookmark_toggle", fake_toggle), \
patch("app.telegram.messaging.send_raw", fake_send), \
patch("app.telegram.webhook.api_call", fake_api_call):
result = asyncio.run(webhook.handle_webhook(update))
fake_toggle.assert_not_awaited()
assert result is not None
assert result.get("ok") is False
assert result.get("error") == "invalid_callback_data"
def test_callback_realestate_bookmark_proxy_error():
"""service_proxy 가 예외를 던질 때 에러 응답 반환."""
from app import service_proxy
from app.telegram import webhook
fake_toggle = AsyncMock(side_effect=Exception("connection refused"))
fake_send = AsyncMock(return_value={"ok": True})
fake_api_call = AsyncMock(return_value={"ok": True})
update = {
"callback_query": {
"id": "cb3",
"from": {"id": 1},
"data": "realestate_bookmark_99",
}
}
with patch.object(service_proxy, "realestate_bookmark_toggle", fake_toggle), \
patch("app.telegram.messaging.send_raw", fake_send), \
patch("app.telegram.webhook.api_call", fake_api_call):
result = asyncio.run(webhook.handle_webhook(update))
fake_toggle.assert_awaited_once_with(99)
assert result is not None
assert result.get("ok") is False
assert "connection refused" in result.get("error", "")
def test_non_realestate_callback_uses_db_path():
"""approve_*/reject_* 콜백은 기존 DB 조회 경로를 사용 (realestate 분기를 타지 않음)."""
from app.telegram import webhook
fake_api_call = AsyncMock(return_value={"ok": True})
update = {
"callback_query": {
"id": "cb4",
"from": {"id": 1},
"data": "approve_abcd1234",
}
}
# DB에 등록되지 않은 콜백이므로 None 반환 — 기존 로직 진입 확인
with patch("app.telegram.webhook.api_call", fake_api_call):
result = asyncio.run(webhook.handle_webhook(update))
assert result is None # DB에 없으면 None 반환 (기존 동작 유지)

View File

@@ -0,0 +1,59 @@
def test_format_realestate_match_full_card_single():
from app.telegram.realestate_message import format_realestate_matches
matches = [{
"id": 1,
"match_score": 90,
"house_nm": "디에이치 강남",
"region_name": "서울특별시",
"district": "강남구",
"is_speculative_area": "Y",
"is_price_cap": "Y",
"receipt_start": "2026-05-15",
"receipt_end": "2026-05-19",
"match_reasons": ["광역 일치", "자치구 S티어: 강남구 (+25)", "예산 범위"],
"eligible_types": ["일반1순위", "특별-신혼부부"],
"pblanc_url": "https://example.com/p/1",
}]
text = format_realestate_matches(matches)
assert "디에이치 강남" in text
assert "90점" in text
assert "강남구" in text
assert "2026-05-15" in text
def test_format_realestate_match_compact_when_three_or_more():
from app.telegram.realestate_message import format_realestate_matches
matches = [
{"id": i, "match_score": 90 - i, "house_nm": f"단지{i}", "district": "강남구",
"region_name": "서울특별시", "receipt_start": "2026-05-15", "receipt_end": "2026-05-19",
"match_reasons": [], "eligible_types": [], "pblanc_url": ""}
for i in range(3)
]
text = format_realestate_matches(matches)
assert "3건" in text or "3" in text
for i in range(3):
assert f"단지{i}" in text
def test_build_keyboard_single_match_has_bookmark_and_url():
from app.telegram.realestate_message import build_match_keyboard
matches = [{"id": 42, "pblanc_url": "https://example.com/p/42"}]
kb = build_match_keyboard(matches)
rows = kb["inline_keyboard"]
flat = [b for row in rows for b in row]
assert any(b.get("callback_data", "").startswith("realestate_bookmark_42") for b in flat)
assert any(b.get("url") == "https://example.com/p/42" for b in flat)
def test_build_keyboard_multi_matches_uses_dashboard_link():
from app.telegram.realestate_message import build_match_keyboard
matches = [{"id": i, "pblanc_url": ""} for i in range(3)]
kb = build_match_keyboard(matches)
flat = [b for row in kb["inline_keyboard"] for b in row]
# 3건 이상이면 [전체 보기] 단일 URL 버튼
assert any("전체" in b.get("text", "") for b in flat)
def test_build_keyboard_empty_returns_none():
from app.telegram.realestate_message import build_match_keyboard
assert build_match_keyboard([]) is None

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,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,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,55 +0,0 @@
import requests
from typing import Dict, Any
from .db import get_draw, upsert_draw
def _normalize_item(item: dict) -> dict:
# smok95 all.json / latest.json 구조
# - draw_no: int
# - numbers: [n1..n6]
# - bonus_no: int
# - date: "YYYY-MM-DD ..."
numbers = item["numbers"]
return {
"drw_no": int(item["draw_no"]),
"drw_date": (item.get("date") or "")[:10],
"n1": int(numbers[0]),
"n2": int(numbers[1]),
"n3": int(numbers[2]),
"n4": int(numbers[3]),
"n5": int(numbers[4]),
"n6": int(numbers[5]),
"bonus": int(item["bonus_no"]),
}
def sync_all_from_json(all_url: str) -> Dict[str, Any]:
r = requests.get(all_url, timeout=60)
r.raise_for_status()
data = r.json() # list[dict]
inserted = 0
skipped = 0
for item in data:
row = _normalize_item(item)
if get_draw(row["drw_no"]):
skipped += 1
continue
upsert_draw(row)
inserted += 1
return {"mode": "all_json", "url": all_url, "inserted": inserted, "skipped": skipped, "total": len(data)}
def sync_latest(latest_url: str) -> Dict[str, Any]:
r = requests.get(latest_url, timeout=30)
r.raise_for_status()
item = r.json()
row = _normalize_item(item)
before = get_draw(row["drw_no"])
upsert_draw(row)
return {"mode": "latest_json", "url": latest_url, "was_new": (before is None), "drawNo": row["drw_no"]}

View File

@@ -1,239 +0,0 @@
# backend/app/db.py
import os
import sqlite3
import json
import hashlib
from typing import Any, Dict, Optional, List
DB_PATH = "/app/data/lotto.db"
def _conn() -> sqlite3.Connection:
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def _ensure_column(conn: sqlite3.Connection, table: str, col: str, ddl: str) -> None:
cols = {r["name"] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
if col not in cols:
conn.execute(ddl)
def init_db() -> None:
with _conn() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS draws (
drw_no INTEGER PRIMARY KEY,
drw_date TEXT NOT NULL,
n1 INTEGER NOT NULL,
n2 INTEGER NOT NULL,
n3 INTEGER NOT NULL,
n4 INTEGER NOT NULL,
n5 INTEGER NOT NULL,
n6 INTEGER NOT NULL,
bonus INTEGER NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_draws_date ON draws(drw_date);")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS recommendations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
based_on_draw INTEGER,
numbers TEXT NOT NULL,
params TEXT NOT NULL
);
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_reco_created ON recommendations(created_at DESC);")
# ✅ 확장 컬럼들(기존 DB에도 자동 추가)
_ensure_column(conn, "recommendations", "numbers_sorted",
"ALTER TABLE recommendations ADD COLUMN numbers_sorted TEXT;")
_ensure_column(conn, "recommendations", "dedup_hash",
"ALTER TABLE recommendations ADD COLUMN dedup_hash TEXT;")
_ensure_column(conn, "recommendations", "favorite",
"ALTER TABLE recommendations ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0;")
_ensure_column(conn, "recommendations", "note",
"ALTER TABLE recommendations ADD COLUMN note TEXT NOT NULL DEFAULT '';")
_ensure_column(conn, "recommendations", "tags",
"ALTER TABLE recommendations ADD COLUMN tags TEXT NOT NULL DEFAULT '[]';")
# ✅ UNIQUE 인덱스(중복 저장 방지)
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS uq_reco_dedup ON recommendations(dedup_hash);")
def upsert_draw(row: Dict[str, Any]) -> None:
with _conn() as conn:
conn.execute(
"""
INSERT INTO draws (drw_no, drw_date, n1, n2, n3, n4, n5, n6, bonus)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(drw_no) DO UPDATE SET
drw_date=excluded.drw_date,
n1=excluded.n1, n2=excluded.n2, n3=excluded.n3,
n4=excluded.n4, n5=excluded.n5, n6=excluded.n6,
bonus=excluded.bonus,
updated_at=datetime('now')
""",
(
int(row["drw_no"]),
str(row["drw_date"]),
int(row["n1"]), int(row["n2"]), int(row["n3"]),
int(row["n4"]), int(row["n5"]), int(row["n6"]),
int(row["bonus"]),
),
)
def get_latest_draw() -> Optional[Dict[str, Any]]:
with _conn() as conn:
r = conn.execute("SELECT * FROM draws ORDER BY drw_no DESC LIMIT 1").fetchone()
return dict(r) if r else None
def get_draw(drw_no: int) -> Optional[Dict[str, Any]]:
with _conn() as conn:
r = conn.execute("SELECT * FROM draws WHERE drw_no = ?", (drw_no,)).fetchone()
return dict(r) if r else None
def count_draws() -> int:
with _conn() as conn:
r = conn.execute("SELECT COUNT(*) AS c FROM draws").fetchone()
return int(r["c"])
def get_all_draw_numbers():
with _conn() as conn:
rows = conn.execute(
"SELECT drw_no, n1, n2, n3, n4, n5, n6 FROM draws ORDER BY drw_no ASC"
).fetchall()
return [(int(r["drw_no"]), [int(r["n1"]), int(r["n2"]), int(r["n3"]), int(r["n4"]), int(r["n5"]), int(r["n6"])]) for r in rows]
# ---------- ✅ recommendation helpers ----------
def _canonical_params(params: dict) -> str:
return json.dumps(params, sort_keys=True, separators=(",", ":"))
def _numbers_sorted_str(numbers: List[int]) -> str:
return ",".join(str(x) for x in sorted(numbers))
def _dedup_hash(based_on_draw: Optional[int], numbers: List[int], params: dict) -> str:
s = f"{based_on_draw or ''}|{_numbers_sorted_str(numbers)}|{_canonical_params(params)}"
return hashlib.sha1(s.encode("utf-8")).hexdigest()
def save_recommendation_dedup(based_on_draw: Optional[int], numbers: List[int], params: dict) -> Dict[str, Any]:
"""
✅ 동일 추천(번호+params+based_on_draw)이면 중복 저장 없이 기존 id 반환
"""
ns = _numbers_sorted_str(numbers)
h = _dedup_hash(based_on_draw, numbers, params)
with _conn() as conn:
# 이미 있으면 반환
r = conn.execute("SELECT id FROM recommendations WHERE dedup_hash = ?", (h,)).fetchone()
if r:
return {"id": int(r["id"]), "saved": False, "deduped": True}
cur = conn.execute(
"""
INSERT INTO recommendations (based_on_draw, numbers, params, numbers_sorted, dedup_hash)
VALUES (?, ?, ?, ?, ?)
""",
(based_on_draw, json.dumps(numbers), json.dumps(params), ns, h),
)
return {"id": int(cur.lastrowid), "saved": True, "deduped": False}
def list_recommendations_ex(
limit: int = 30,
offset: int = 0,
favorite: Optional[bool] = None,
tag: Optional[str] = None,
q: Optional[str] = None,
sort: str = "id_desc", # id_desc|created_desc|favorite_desc
) -> List[Dict[str, Any]]:
import json
where = []
args: list[Any] = []
if favorite is not None:
where.append("favorite = ?")
args.append(1 if favorite else 0)
if q:
where.append("note LIKE ?")
args.append(f"%{q}%")
# tags는 JSON 문자열이므로 단순 LIKE로 처리(가볍게 시작)
if tag:
where.append("tags LIKE ?")
args.append(f"%{tag}%")
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
if sort == "created_desc":
order = "created_at DESC"
elif sort == "favorite_desc":
# favorite(1)이 먼저, 그 다음 최신
order = "favorite DESC, id DESC"
else:
order = "id DESC"
sql = f"""
SELECT id, created_at, based_on_draw, numbers, params, favorite, note, tags
FROM recommendations
{where_sql}
ORDER BY {order}
LIMIT ? OFFSET ?
"""
args.extend([int(limit), int(offset)])
with _conn() as conn:
rows = conn.execute(sql, args).fetchall()
out = []
for r in rows:
out.append({
"id": int(r["id"]),
"created_at": r["created_at"],
"based_on_draw": r["based_on_draw"],
"numbers": json.loads(r["numbers"]),
"params": json.loads(r["params"]),
"favorite": bool(r["favorite"]) if r["favorite"] is not None else False,
"note": r["note"],
"tags": json.loads(r["tags"]) if r["tags"] else [],
})
return out
def update_recommendation(rec_id: int, favorite: Optional[bool] = None, note: Optional[str] = None, tags: Optional[List[str]] = None) -> bool:
fields = []
args: list[Any] = []
if favorite is not None:
fields.append("favorite = ?")
args.append(1 if favorite else 0)
if note is not None:
fields.append("note = ?")
args.append(note)
if tags is not None:
fields.append("tags = ?")
args.append(json.dumps(tags))
if not fields:
return False
args.append(rec_id)
with _conn() as conn:
cur = conn.execute(
f"UPDATE recommendations SET {', '.join(fields)} WHERE id = ?",
args,
)
return cur.rowcount > 0
def delete_recommendation(rec_id: int) -> bool:
with _conn() as conn:
cur = conn.execute("DELETE FROM recommendations WHERE id = ?", (rec_id,))
return cur.rowcount > 0

View File

@@ -1,344 +0,0 @@
import os
from typing import Optional, List, Dict, Any, Tuple
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from apscheduler.schedulers.background import BackgroundScheduler
from .db import (
init_db, get_draw, get_latest_draw, get_all_draw_numbers,
save_recommendation_dedup, list_recommendations_ex, delete_recommendation,
update_recommendation,
)
from .recommender import recommend_numbers
from .collector import sync_latest
app = FastAPI()
scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
ALL_URL = os.getenv("LOTTO_ALL_URL", "https://smok95.github.io/lotto/results/all.json")
LATEST_URL = os.getenv("LOTTO_LATEST_URL", "https://smok95.github.io/lotto/results/latest.json")
def calc_metrics(numbers: List[int]) -> Dict[str, Any]:
nums = sorted(numbers)
s = sum(nums)
odd = sum(1 for x in nums if x % 2 == 1)
even = len(nums) - odd
mn, mx = nums[0], nums[-1]
rng = mx - mn
# 1-10, 11-20, 21-30, 31-40, 41-45
buckets = {
"1-10": 0,
"11-20": 0,
"21-30": 0,
"31-40": 0,
"41-45": 0,
}
for x in nums:
if 1 <= x <= 10:
buckets["1-10"] += 1
elif 11 <= x <= 20:
buckets["11-20"] += 1
elif 21 <= x <= 30:
buckets["21-30"] += 1
elif 31 <= x <= 40:
buckets["31-40"] += 1
else:
buckets["41-45"] += 1
return {
"sum": s,
"odd": odd,
"even": even,
"min": mn,
"max": mx,
"range": rng,
"buckets": buckets,
}
def calc_recent_overlap(numbers: List[int], draws: List[Tuple[int, List[int]]], last_k: int) -> Dict[str, Any]:
"""
draws: [(drw_no, [n1..n6]), ...] 오름차순
last_k: 최근 k회 기준 중복
"""
if last_k <= 0:
return {"last_k": 0, "repeats": 0, "repeated_numbers": []}
recent = draws[-last_k:] if len(draws) >= last_k else draws
recent_set = set()
for _, nums in recent:
recent_set.update(nums)
repeated = sorted(set(numbers) & recent_set)
return {
"last_k": len(recent),
"repeats": len(repeated),
"repeated_numbers": repeated,
}
@app.on_event("startup")
def on_startup():
init_db()
scheduler.add_job(lambda: sync_latest(LATEST_URL), "cron", hour="9,21", minute=10)
scheduler.start()
@app.get("/health")
def health():
return {"ok": True}
@app.get("/api/lotto/latest")
def api_latest():
row = get_latest_draw()
if not row:
raise HTTPException(status_code=404, detail="No data yet")
return {
"drawNo": row["drw_no"],
"date": row["drw_date"],
"numbers": [row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]],
"bonus": row["bonus"],
}
@app.get("/api/lotto/{drw_no:int}")
def api_draw(drw_no: int):
row = get_draw(drw_no)
if not row:
raise HTTPException(status_code=404, detail="Not found")
return {
"drwNo": row["drw_no"],
"date": row["drw_date"],
"numbers": [row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]],
"bonus": row["bonus"],
}
@app.post("/api/admin/sync_latest")
def admin_sync_latest():
return sync_latest(LATEST_URL)
# ---------- ✅ recommend (dedup save) ----------
@app.get("/api/lotto/recommend")
def api_recommend(
recent_window: int = 200,
recent_weight: float = 2.0,
avoid_recent_k: int = 5,
# ---- optional constraints (Lotto Lab) ----
sum_min: Optional[int] = None,
sum_max: Optional[int] = None,
odd_min: Optional[int] = None,
odd_max: Optional[int] = None,
range_min: Optional[int] = None,
range_max: Optional[int] = None,
max_overlap_latest: Optional[int] = None, # 최근 avoid_recent_k 회차와 중복 허용 개수
max_try: int = 200, # 조건 맞는 조합 찾기 재시도
):
draws = get_all_draw_numbers()
if not draws:
raise HTTPException(status_code=404, detail="No data yet")
latest = get_latest_draw()
params = {
"recent_window": recent_window,
"recent_weight": float(recent_weight),
"avoid_recent_k": avoid_recent_k,
"sum_min": sum_min,
"sum_max": sum_max,
"odd_min": odd_min,
"odd_max": odd_max,
"range_min": range_min,
"range_max": range_max,
"max_overlap_latest": max_overlap_latest,
"max_try": int(max_try),
}
def _accept(nums: List[int]) -> bool:
m = calc_metrics(nums)
if sum_min is not None and m["sum"] < sum_min:
return False
if sum_max is not None and m["sum"] > sum_max:
return False
if odd_min is not None and m["odd"] < odd_min:
return False
if odd_max is not None and m["odd"] > odd_max:
return False
if range_min is not None and m["range"] < range_min:
return False
if range_max is not None and m["range"] > range_max:
return False
if max_overlap_latest is not None:
ov = calc_recent_overlap(nums, draws, last_k=avoid_recent_k)
if ov["repeats"] > max_overlap_latest:
return False
return True
chosen = None
explain = None
tries = 0
while tries < max_try:
tries += 1
result = recommend_numbers(
draws,
recent_window=recent_window,
recent_weight=recent_weight,
avoid_recent_k=avoid_recent_k,
)
nums = result["numbers"]
if _accept(nums):
chosen = nums
explain = result["explain"]
break
if chosen is None:
raise HTTPException(
status_code=400,
detail=f"Constraints too strict. No valid set found in max_try={max_try}. "
f"Try relaxing sum/odd/range/overlap constraints.",
)
# ✅ dedup save
saved = save_recommendation_dedup(
latest["drw_no"] if latest else None,
chosen,
params,
)
metrics = calc_metrics(chosen)
overlap = calc_recent_overlap(chosen, draws, last_k=avoid_recent_k)
return {
"id": saved["id"],
"saved": saved["saved"],
"deduped": saved["deduped"],
"based_on_latest_draw": latest["drw_no"] if latest else None,
"numbers": chosen,
"explain": explain,
"params": params,
"metrics": metrics,
"recent_overlap": overlap,
"tries": tries,
}
# ---------- ✅ history list (filter/paging) ----------
@app.get("/api/history")
def api_history(
limit: int = 30,
offset: int = 0,
favorite: Optional[bool] = None,
tag: Optional[str] = None,
q: Optional[str] = None,
sort: str = "id_desc",
):
items = list_recommendations_ex(
limit=limit,
offset=offset,
favorite=favorite,
tag=tag,
q=q,
sort=sort,
)
draws = get_all_draw_numbers()
out = []
for it in items:
nums = it["numbers"]
out.append({
**it,
"metrics": calc_metrics(nums),
"recent_overlap": calc_recent_overlap(
nums, draws, last_k=int(it["params"].get("avoid_recent_k", 0) or 0)
),
})
return {
"items": out,
"limit": limit,
"offset": offset,
"filters": {"favorite": favorite, "tag": tag, "q": q, "sort": sort},
}
@app.delete("/api/history/{rec_id:int}")
def api_history_delete(rec_id: int):
ok = delete_recommendation(rec_id)
if not ok:
raise HTTPException(status_code=404, detail="Not found")
return {"deleted": True, "id": rec_id}
# ---------- ✅ history update (favorite/note/tags) ----------
class HistoryUpdate(BaseModel):
favorite: Optional[bool] = None
note: Optional[str] = None
tags: Optional[List[str]] = None
@app.patch("/api/history/{rec_id:int}")
def api_history_patch(rec_id: int, body: HistoryUpdate):
ok = update_recommendation(rec_id, favorite=body.favorite, note=body.note, tags=body.tags)
if not ok:
raise HTTPException(status_code=404, detail="Not found or no changes")
return {"updated": True, "id": rec_id}
# ---------- ✅ batch recommend ----------
def _batch_unique(draws, count: int, recent_window: int, recent_weight: float, avoid_recent_k: int, max_try: int = 200):
items = []
seen = set()
tries = 0
while len(items) < count and tries < max_try:
tries += 1
r = recommend_numbers(draws, recent_window=recent_window, recent_weight=recent_weight, avoid_recent_k=avoid_recent_k)
key = tuple(sorted(r["numbers"]))
if key in seen:
continue
seen.add(key)
items.append(r)
return items
@app.get("/api/lotto/recommend/batch")
def api_recommend_batch(
count: int = 5,
recent_window: int = 200,
recent_weight: float = 2.0,
avoid_recent_k: int = 5,
):
count = max(1, min(count, 20))
draws = get_all_draw_numbers()
if not draws:
raise HTTPException(status_code=404, detail="No data yet")
latest = get_latest_draw()
params = {
"recent_window": recent_window,
"recent_weight": float(recent_weight),
"avoid_recent_k": avoid_recent_k,
"count": count,
}
items = _batch_unique(draws, count, recent_window, float(recent_weight), avoid_recent_k)
return {
"based_on_latest_draw": latest["drw_no"] if latest else None,
"count": count,
"items": [{"numbers": it["numbers"], "explain": it["explain"]} for it in items],
"params": params,
}
class BatchSave(BaseModel):
items: List[List[int]]
params: dict
@app.post("/api/lotto/recommend/batch")
def api_recommend_batch_save(body: BatchSave):
latest = get_latest_draw()
based = latest["drw_no"] if latest else None
created, deduped = [], []
for nums in body.items:
saved = save_recommendation_dedup(based, nums, body.params)
(created if saved["saved"] else deduped).append(saved["id"])
return {"saved": True, "created_ids": created, "deduped_ids": deduped}

View File

@@ -1,68 +0,0 @@
import random
from collections import Counter
from typing import Dict, Any, List, Tuple
def recommend_numbers(
draws: List[Tuple[int, List[int]]],
*,
recent_window: int = 200,
recent_weight: float = 2.0,
avoid_recent_k: int = 5,
seed: int | None = None,
) -> Dict[str, Any]:
"""
가벼운 통계 기반 추천:
- 전체 빈도 + 최근(recent_window) 빈도에 가중치를 더한 가중 샘플링
- 최근 avoid_recent_k 회차에 나온 번호는 확률을 낮춤(완전 제외는 아님)
"""
if seed is not None:
random.seed(seed)
# 전체 빈도
all_nums = [n for _, nums in draws for n in nums]
freq_all = Counter(all_nums)
# 최근 빈도
recent = draws[-recent_window:] if len(draws) >= recent_window else draws
recent_nums = [n for _, nums in recent for n in nums]
freq_recent = Counter(recent_nums)
# 최근 k회차 번호(패널티)
last_k = draws[-avoid_recent_k:] if len(draws) >= avoid_recent_k else draws
last_k_nums = set(n for _, nums in last_k for n in nums)
# 가중치 구성
weights = {}
for n in range(1, 46):
w = freq_all[n] + recent_weight * freq_recent[n]
if n in last_k_nums:
w *= 0.6 # 최근에 너무 방금 나온 건 살짝 덜 뽑히게
weights[n] = max(w, 0.1)
# 중복 없이 6개 뽑기(가중 샘플링)
chosen = []
pool = list(range(1, 46))
for _ in range(6):
total = sum(weights[n] for n in pool)
r = random.random() * total
acc = 0.0
for n in pool:
acc += weights[n]
if acc >= r:
chosen.append(n)
pool.remove(n)
break
chosen_sorted = sorted(chosen)
explain = {
"recent_window": recent_window,
"recent_weight": recent_weight,
"avoid_recent_k": avoid_recent_k,
"top_all": [n for n, _ in freq_all.most_common(10)],
"top_recent": [n for n, _ in freq_recent.most_common(10)],
"last_k_draws": [d for d, _ in last_k],
}
return {"numbers": chosen_sorted, "explain": explain}

6
deployer/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
.git
__pycache__
*.pyc
.env
.env.*
*.md

24
deployer/Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
FROM python:3.12-slim
# Docker CE CLI + Compose Plugin (공식 저장소에서 설치)
RUN apt-get update && apt-get install -y --no-install-recommends \
git rsync ca-certificates curl util-linux gnupg \
&& install -m 0755 -d /etc/apt/keyrings \
&& curl -fsSL https://download.docker.com/linux/debian/gpg \
| gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/debian $(. /etc/os-release && echo $VERSION_CODENAME) stable" \
> /etc/apt/sources.list.d/docker.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py /app/app.py
ENV PYTHONUNBUFFERED=1
EXPOSE 9000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "9000"]

79
deployer/app.py Normal file
View File

@@ -0,0 +1,79 @@
import os, hmac, hashlib, subprocess, threading
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
from fastapi.responses import JSONResponse
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S %Z",
)
logger = logging.getLogger("deployer")
app = FastAPI()
SECRET = os.getenv("WEBHOOK_SECRET", "")
if not SECRET:
logger.warning("WEBHOOK_SECRET is not set! All webhooks will be rejected.")
_deploy_lock = threading.Lock()
def verify(sig: str, body: bytes) -> bool:
if not SECRET or not sig:
return False
mac = hmac.new(SECRET.encode(), msg=body, digestmod=hashlib.sha256).hexdigest()
candidates = {mac, f"sha256={mac}"}
return any(hmac.compare_digest(sig, c) for c in candidates)
def run_deploy_script():
"""배포 스크립트를 백그라운드에서 실행 (동시 실행 방지)"""
if not _deploy_lock.acquire(blocking=False):
logger.info("Deploy already in progress, skipping")
return
try:
logger.info("Starting deployment script...")
p = subprocess.run(["/bin/bash", "/scripts/deploy.sh"], capture_output=True, text=True, timeout=600)
if p.returncode == 0:
logger.info(f"Deployment SUCCESS:\n{p.stdout}")
else:
logger.error(f"Deployment FAILED ({p.returncode}):\n{p.stdout}\n{p.stderr}")
except subprocess.TimeoutExpired:
logger.error("Deployment TIMEOUT (10 min exceeded)")
except Exception as e:
logger.exception(f"Exception during deployment: {e}")
finally:
_deploy_lock.release()
@app.get("/health")
def health():
return {"status": "healthy", "service": "deployer"}
@app.post("/webhook")
async def webhook(req: Request, background_tasks: BackgroundTasks):
body = await req.body()
sig = (
req.headers.get("X-Gitea-Signature")
or req.headers.get("X-Hub-Signature-256")
or ""
)
if not verify(sig, body):
raise HTTPException(401, "bad signature")
# 동시 배포 방지: 이미 진행 중이면 503 반환
if _deploy_lock.locked():
return JSONResponse(
status_code=503,
content={"ok": False, "message": "Deploy already in progress"},
)
# ✅ 비동기 실행: Gitea에게는 즉시 OK 응답을 주고, 배포는 뒤에서 실행
background_tasks.add_task(run_deploy_script)
return {"ok": True, "message": "Deployment started in background"}

View File

@@ -0,0 +1,2 @@
fastapi
uvicorn

View File

@@ -1,9 +1,12 @@
version: "3.8"
name: webpage
services:
backend:
build: ./backend
container_name: lotto-backend
lotto:
build:
context: ./lotto
args:
APP_VERSION: ${APP_VERSION:-dev}
container_name: lotto
restart: unless-stopped
ports:
- "18000:8000"
@@ -12,36 +15,327 @@ services:
- 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}
volumes:
- /volume1/docker/webpage/data:/app/data
- ${RUNTIME_PATH}/data:/app/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 60s
timeout: 5s
retries: 3
stock:
build:
context: ./stock
args:
APP_VERSION: ${APP_VERSION:-dev}
container_name: stock
restart: unless-stopped
ports:
- "18500:8000"
environment:
- TZ=${TZ:-Asia/Seoul}
- WINDOWS_AI_SERVER_URL=${WINDOWS_AI_SERVER_URL:-http://192.168.0.5:8000}
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
- GEMINI_MODEL=${GEMINI_MODEL:-gemini-1.5-flash}
- ADMIN_API_KEY=${ADMIN_API_KEY:-}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- ANTHROPIC_MODEL=${ANTHROPIC_MODEL:-claude-haiku-4-5-20251001}
- LLM_PROVIDER=${LLM_PROVIDER:-claude}
- OLLAMA_URL=${OLLAMA_URL:-http://192.168.45.59:11435}
- OLLAMA_MODEL=${OLLAMA_MODEL:-qwen3:14b}
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
- WEBAI_API_KEY=${WEBAI_API_KEY:-}
volumes:
- ${RUNTIME_PATH}/data/stock:/app/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 60s
timeout: 5s
retries: 3
music-lab:
build:
context: ./music-lab
container_name: music-lab
restart: unless-stopped
ports:
- "18600:8000"
environment:
- TZ=${TZ:-Asia/Seoul}
- MUSIC_AI_SERVER_URL=${MUSIC_AI_SERVER_URL:-}
- 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}
volumes:
- ${RUNTIME_PATH}/data/music:/app/data
- ${RUNTIME_PATH:-.}/data/videos:/app/data/videos
depends_on:
- redis
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 60s
timeout: 5s
retries: 3
video-lab:
build:
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
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:-}
volumes:
- ${RUNTIME_PATH}/data/insta:/app/data
depends_on:
- redis
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 60s
timeout: 5s
retries: 3
realestate-lab:
build:
context: ./realestate-lab
container_name: realestate-lab
restart: unless-stopped
ports:
- "18800:8000"
environment:
- TZ=${TZ:-Asia/Seoul}
- 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}
volumes:
- ${RUNTIME_PATH}/data/realestate:/app/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 60s
timeout: 5s
retries: 3
agent-office:
build:
context: ./agent-office
container_name: agent-office
restart: unless-stopped
ports:
- "18900:8000"
environment:
- TZ=${TZ:-Asia/Seoul}
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
- STOCK_URL=http://stock:8000
- MUSIC_LAB_URL=http://music-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:-}
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
- 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}
- CONVERSATION_HISTORY_LIMIT=${CONVERSATION_HISTORY_LIMIT:-20}
- CONVERSATION_RATE_PER_MIN=${CONVERSATION_RATE_PER_MIN:-6}
- YOUTUBE_DATA_API_KEY=${YOUTUBE_DATA_API_KEY:-}
volumes:
- ${RUNTIME_PATH:-.}/data/agent-office:/app/data
depends_on:
- stock
- music-lab
- insta-lab
- realestate-lab
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 60s
timeout: 5s
retries: 3
personal:
build:
context: ./personal
container_name: personal
restart: unless-stopped
ports:
- "18850:8000"
environment:
- TZ=${TZ:-Asia/Seoul}
- PORTFOLIO_EDIT_PASSWORD=${PORTFOLIO_EDIT_PASSWORD:-}
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
volumes:
- ${RUNTIME_PATH:-.}/data/personal:/app/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 60s
timeout: 5s
retries: 3
packs-lab:
build:
context: ./packs-lab
container_name: packs-lab
restart: unless-stopped
ports:
- "18950:8000"
environment:
- TZ=${TZ:-Asia/Seoul}
- 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: 60s
timeout: 5s
retries: 3
travel-proxy:
build: ./travel-proxy
container_name: travel-proxy
restart: unless-stopped
user: "1026:100"
user: "${PUID}:${PGID}"
ports:
- "19000:8000" # 내부 확인용
- "19000:8000"
environment:
- TZ=${TZ:-Asia/Seoul}
- TRAVEL_ROOT=${TRAVEL_ROOT:-/data/travel}
- TRAVEL_THUMB_ROOT=${TRAVEL_THUMB_ROOT:-/data/thumbs}
- TRAVEL_MEDIA_BASE=${TRAVEL_MEDIA_BASE:-/media/travel}
- TRAVEL_CACHE_TTL=${TRAVEL_CACHE_TTL:-300}
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-*}
- TRAVEL_DB_PATH=${TRAVEL_DB_PATH:-/data/thumbs/travel.db}
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
volumes:
- /volume1/web/images/webPage/travel:/data/travel:ro
- /volume1/docker/webpage/travel-thumbs:/data/thumbs:rw
- ${PHOTO_PATH}:/data/travel:ro
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:rw
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 60s
timeout: 5s
retries: 3
frontend:
image: nginx:alpine
container_name: lotto-frontend
container_name: frontend
restart: unless-stopped
depends_on:
- lotto
- stock
- music-lab
- insta-lab
- realestate-lab
- agent-office
- personal
- packs-lab
- travel-proxy
- video-lab
ports:
- "8080:80"
volumes:
- /volume1/docker/webpage/frontend:/usr/share/nginx/html:ro
- /volume1/docker/webpage/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- /volume1/web/images/webPage/travel:/data/travel:ro
- /volume1/docker/webpage/travel-thumbs:/data/thumbs:ro
- ${FRONTEND_PATH}:/usr/share/nginx/html:ro
- ${RUNTIME_PATH}/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ${PHOTO_PATH}:/data/travel:ro
- ${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: 60s
timeout: 5s
retries: 3
deployer:
build: ./deployer
container_name: webpage-deployer
restart: unless-stopped
ports:
- "127.0.0.1:19010:9000"
environment:
- TZ=${TZ:-Asia/Seoul}
- WEBHOOK_SECRET=${WEBHOOK_SECRET}
- PUID=${PUID:-1026}
- PGID=${PGID:-100}
volumes:
- ${REPO_PATH}:/repo:rw
- ${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()`

View File

@@ -0,0 +1,672 @@
# Pet Lab Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Windows 데스크톱 펫 애플리케이션 — 화면 하단에 고정된 캐릭터가 마우스 시선을 추적하고 클릭/우클릭 상호작용을 지원한다.
**Architecture:** PyQt5 투명 프레임리스 윈도우에 캐릭터 이미지를 표시. QTimer 루프로 마우스 좌표를 폴링하여 이미지 기울기/반전으로 시선을 표현. 좌클릭(점프)/더블클릭(흔들기) 애니메이션과 우클릭 컨텍스트 메뉴 제공.
**Tech Stack:** Python 3.12, PyQt5
**Project Path:** `C:\Users\jaeoh\Desktop\workspace\pet-lab`
---
## File Structure
| 파일 | 역할 | 생성/수정 |
|------|------|-----------|
| `app/config.py` | 상수 정의 (크기, 위치, 애니메이션, 경로) | Create |
| `app/eye_tracker.py` | 마우스→기울기 각도/반전 계산 (순수 함수) | Create |
| `app/pet_widget.py` | 투명 윈도우 + 캐릭터 렌더링 + QTimer 루프 | Create |
| `app/interaction.py` | 클릭 애니메이션 + 우클릭 메뉴 | Create |
| `app/main.py` | 엔트리포인트 (QApplication 초기화) | Create |
| `assets/characters/박뚱냥.png` | 캐릭터 이미지 | Copy |
| `requirements.txt` | PyQt5 의존성 | Create |
| `tests/test_eye_tracker.py` | eye_tracker 단위 테스트 | Create |
| `tests/test_config.py` | config 상수 검증 테스트 | Create |
---
### Task 1: 프로젝트 초기화 + config.py
**Files:**
- Create: `C:\Users\jaeoh\Desktop\workspace\pet-lab\requirements.txt`
- Create: `C:\Users\jaeoh\Desktop\workspace\pet-lab\app\config.py`
- Create: `C:\Users\jaeoh\Desktop\workspace\pet-lab\tests\test_config.py`
- Copy: `Z:\homes\jaeoh\캐릭터\박뚱냥.jpg``C:\Users\jaeoh\Desktop\workspace\pet-lab\assets\characters\박뚱냥.png`
- [ ] **Step 1: 프로젝트 디렉토리 생성 및 git 초기화**
```bash
mkdir -p "C:\Users\jaeoh\Desktop\workspace\pet-lab"/{app,assets/characters,tests}
cd "C:\Users\jaeoh\Desktop\workspace\pet-lab"
git init
```
- [ ] **Step 2: 캐릭터 이미지 복사**
```bash
cp "Z:\homes\jaeoh\캐릭터\박뚱냥.jpg" "C:\Users\jaeoh\Desktop\workspace\pet-lab\assets\characters\박뚱냥.png"
```
참고: 원본이 .jpg이지만 투명 배경이 있는 이미지이므로 그대로 사용. 파일명은 .png으로 저장하되, 실제 포맷이 JPG라면 PyQt5의 QPixmap이 자동 감지하므로 문제없음.
- [ ] **Step 3: requirements.txt 생성**
```
PyQt5>=5.15,<6.0
pytest>=7.0
```
- [ ] **Step 4: 가상환경 생성 및 의존성 설치**
```bash
cd "C:\Users\jaeoh\Desktop\workspace\pet-lab"
python -m venv venv
venv\Scripts\activate
pip install -r requirements.txt
```
- [ ] **Step 5: config.py 작성**
```python
"""pet-lab 설정 상수."""
import os
# 캐릭터 크기 (높이 기준 px, 너비는 비율 유지)
SIZES = {"small": 100, "medium": 150, "large": 200}
DEFAULT_SIZE = "medium"
# 수평 위치 프리셋 (화면 너비 비율)
POSITIONS = {"left": 0.1, "center": 0.5, "right": 0.9}
DEFAULT_POSITION = "right"
# 시선 추적
TIMER_INTERVAL_MS = 30
MAX_TILT_ANGLE = 15.0
# 태스크바
TASKBAR_HEIGHT = 48
# 애니메이션
JUMP_HEIGHT = 30
JUMP_DURATION_MS = 300
SHAKE_OFFSET = 10
SHAKE_DURATION_MS = 400
# 에셋 경로
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CHARACTER_DIR = os.path.join(BASE_DIR, "assets", "characters")
DEFAULT_CHARACTER = "박뚱냥.png"
```
- [ ] **Step 6: test_config.py 작성**
```python
"""config 상수 검증."""
from app.config import SIZES, POSITIONS, DEFAULT_SIZE, DEFAULT_POSITION
from app.config import TIMER_INTERVAL_MS, MAX_TILT_ANGLE, CHARACTER_DIR
import os
def test_sizes_has_three_presets():
assert set(SIZES.keys()) == {"small", "medium", "large"}
assert all(isinstance(v, int) and v > 0 for v in SIZES.values())
def test_default_size_is_valid():
assert DEFAULT_SIZE in SIZES
def test_positions_has_three_presets():
assert set(POSITIONS.keys()) == {"left", "center", "right"}
assert all(0.0 < v < 1.0 for v in POSITIONS.values())
def test_default_position_is_valid():
assert DEFAULT_POSITION in POSITIONS
def test_timer_interval_is_reasonable():
assert 10 <= TIMER_INTERVAL_MS <= 100
def test_max_tilt_angle_is_reasonable():
assert 5.0 <= MAX_TILT_ANGLE <= 45.0
def test_character_dir_exists():
assert os.path.isdir(CHARACTER_DIR)
```
- [ ] **Step 7: 테스트 실행**
```bash
cd "C:\Users\jaeoh\Desktop\workspace\pet-lab"
python -m pytest tests/test_config.py -v
```
Expected: 7 passed
- [ ] **Step 8: .gitignore 생성 및 커밋**
`.gitignore`:
```
venv/
__pycache__/
*.pyc
.pytest_cache/
dist/
build/
*.spec
```
```bash
git add .
git commit -m "feat: 프로젝트 초기화 — config, 캐릭터 에셋, 테스트"
```
---
### Task 2: eye_tracker.py — 시선 계산 모듈
**Files:**
- Create: `C:\Users\jaeoh\Desktop\workspace\pet-lab\app\eye_tracker.py`
- Create: `C:\Users\jaeoh\Desktop\workspace\pet-lab\tests\test_eye_tracker.py`
- [ ] **Step 1: test_eye_tracker.py 작성**
```python
"""eye_tracker 시선 계산 테스트."""
import math
from app.eye_tracker import compute_gaze
def test_mouse_right_of_character():
"""마우스가 캐릭터 오른쪽 → 양수 기울기, flip=False."""
angle, flip = compute_gaze(
char_center_x=500, char_center_y=900,
mouse_x=800, mouse_y=500,
max_angle=15.0,
)
assert 0 < angle <= 15.0
assert flip is False
def test_mouse_left_of_character():
"""마우스가 캐릭터 왼쪽 → 음수 기울기, flip=True."""
angle, flip = compute_gaze(
char_center_x=500, char_center_y=900,
mouse_x=200, mouse_y=500,
max_angle=15.0,
)
assert -15.0 <= angle < 0
assert flip is True
def test_mouse_directly_above():
"""마우스가 캐릭터 바로 위 → 기울기 0, flip=False."""
angle, flip = compute_gaze(
char_center_x=500, char_center_y=900,
mouse_x=500, mouse_y=100,
max_angle=15.0,
)
assert angle == 0.0
assert flip is False
def test_mouse_at_character_position():
"""마우스가 캐릭터 위치와 동일 → 기울기 0, flip=False."""
angle, flip = compute_gaze(
char_center_x=500, char_center_y=500,
mouse_x=500, mouse_y=500,
max_angle=15.0,
)
assert angle == 0.0
assert flip is False
def test_angle_clamped_to_max():
"""기울기가 max_angle을 초과하지 않아야 한다."""
angle, flip = compute_gaze(
char_center_x=500, char_center_y=500,
mouse_x=10000, mouse_y=500,
max_angle=15.0,
)
assert abs(angle) <= 15.0
def test_mouse_far_left():
"""마우스가 매우 왼쪽 → 기울기 -max_angle에 근접."""
angle, flip = compute_gaze(
char_center_x=500, char_center_y=500,
mouse_x=0, mouse_y=500,
max_angle=15.0,
)
assert angle < 0
assert flip is True
```
- [ ] **Step 2: 테스트 실행 — 실패 확인**
```bash
python -m pytest tests/test_eye_tracker.py -v
```
Expected: FAIL with `ModuleNotFoundError: No module named 'app.eye_tracker'`
- [ ] **Step 3: eye_tracker.py 구현**
```python
"""마우스 위치 기반 시선/기울기 계산 — 순수 함수 모듈."""
import math
def compute_gaze(
char_center_x: float,
char_center_y: float,
mouse_x: float,
mouse_y: float,
max_angle: float = 15.0,
) -> tuple[float, bool]:
"""캐릭터 중심과 마우스 위치로 기울기 각도와 좌우 반전 여부를 계산한다.
Returns:
(tilt_angle, flip_horizontal)
- tilt_angle: -max_angle ~ +max_angle (도). 양수=우측 기울기, 음수=좌측 기울기.
- flip_horizontal: True면 이미지를 좌우 반전 (마우스가 캐릭터 왼쪽).
"""
dx = mouse_x - char_center_x
dy = mouse_y - char_center_y
if dx == 0 and dy == 0:
return 0.0, False
# dx 방향의 비율로 기울기 결정 (atan2로 각도 → 비율 변환)
angle_rad = math.atan2(abs(dx), max(abs(dy), 1))
ratio = angle_rad / (math.pi / 2) # 0~1 범위
tilt = ratio * max_angle
if dx < 0:
tilt = -tilt
# max_angle 클램핑
tilt = max(-max_angle, min(max_angle, tilt))
flip = dx < 0
return tilt, flip
```
- [ ] **Step 4: 테스트 실행 — 통과 확인**
```bash
python -m pytest tests/test_eye_tracker.py -v
```
Expected: 6 passed
- [ ] **Step 5: 커밋**
```bash
git add app/eye_tracker.py tests/test_eye_tracker.py
git commit -m "feat: eye_tracker — 마우스 시선 기울기 계산 모듈"
```
---
### Task 3: pet_widget.py — 투명 윈도우 + 캐릭터 렌더링
**Files:**
- Create: `C:\Users\jaeoh\Desktop\workspace\pet-lab\app\pet_widget.py`
- [ ] **Step 1: pet_widget.py 작성**
```python
"""투명 윈도우 위에 캐릭터를 렌더링하고 시선을 추적하는 메인 위젯."""
from PyQt5.QtWidgets import QWidget, QLabel, QApplication
from PyQt5.QtCore import Qt, QTimer, QPoint
from PyQt5.QtGui import QPixmap, QCursor, QTransform
import os
from app.config import (
SIZES, DEFAULT_SIZE, POSITIONS, DEFAULT_POSITION,
TIMER_INTERVAL_MS, MAX_TILT_ANGLE, TASKBAR_HEIGHT,
CHARACTER_DIR, DEFAULT_CHARACTER,
)
from app.eye_tracker import compute_gaze
class PetWidget(QWidget):
def __init__(self):
super().__init__()
self._size_key = DEFAULT_SIZE
self._position_key = DEFAULT_POSITION
self._always_on_top = True
self._last_mouse_pos = None
self._base_y = 0
self._init_window()
self._load_character()
self._position_on_screen()
self._start_tracking()
def _init_window(self):
flags = Qt.FramelessWindowHint | Qt.Tool
if self._always_on_top:
flags |= Qt.WindowStaysOnTopHint
self.setWindowFlags(flags)
self.setAttribute(Qt.WA_TranslucentBackground)
def _load_character(self):
path = os.path.join(CHARACTER_DIR, DEFAULT_CHARACTER)
self._original_pixmap = QPixmap(path)
self._label = QLabel(self)
self._apply_size()
def _apply_size(self):
height = SIZES[self._size_key]
scaled = self._original_pixmap.scaledToHeight(height, Qt.SmoothTransformation)
self._label.setPixmap(scaled)
self._label.setFixedSize(scaled.size())
self.setFixedSize(scaled.size())
def _position_on_screen(self):
screen = QApplication.primaryScreen().geometry()
char_height = SIZES[self._size_key]
self._base_y = screen.height() - TASKBAR_HEIGHT - char_height
x_ratio = POSITIONS[self._position_key]
x = int(screen.width() * x_ratio) - self.width() // 2
self.move(x, self._base_y)
def _start_tracking(self):
self._timer = QTimer(self)
self._timer.timeout.connect(self._update_gaze)
self._timer.start(TIMER_INTERVAL_MS)
def _update_gaze(self):
mouse_pos = QCursor.pos()
if self._last_mouse_pos == mouse_pos:
return
self._last_mouse_pos = mouse_pos
center = self.geometry().center()
tilt, flip = compute_gaze(
center.x(), center.y(),
mouse_pos.x(), mouse_pos.y(),
MAX_TILT_ANGLE,
)
height = SIZES[self._size_key]
scaled = self._original_pixmap.scaledToHeight(height, Qt.SmoothTransformation)
transform = QTransform()
if flip:
transform.scale(-1, 1)
transform.rotate(tilt)
rotated = scaled.transformed(transform, Qt.SmoothTransformation)
self._label.setPixmap(rotated)
self._label.setFixedSize(rotated.size())
self.setFixedSize(rotated.size())
# ── 크기/위치 변경 (interaction.py에서 호출) ──
def set_size(self, size_key: str):
self._size_key = size_key
self._apply_size()
self._position_on_screen()
def set_position(self, position_key: str):
self._position_key = position_key
self._position_on_screen()
def toggle_always_on_top(self):
self._always_on_top = not self._always_on_top
flags = Qt.FramelessWindowHint | Qt.Tool
if self._always_on_top:
flags |= Qt.WindowStaysOnTopHint
self.setWindowFlags(flags)
self.show()
@property
def always_on_top(self) -> bool:
return self._always_on_top
@property
def base_y(self) -> int:
return self._base_y
```
- [ ] **Step 2: 수동 테스트 — 투명 윈도우에 캐릭터 표시 확인**
임시 실행 스크립트:
```bash
cd "C:\Users\jaeoh\Desktop\workspace\pet-lab"
python -c "
import sys
from PyQt5.QtWidgets import QApplication
from app.pet_widget import PetWidget
app = QApplication(sys.argv)
pet = PetWidget()
pet.show()
sys.exit(app.exec_())
"
```
Expected: 화면 우하단에 박뚱냥이 표시되고, 마우스 이동 시 기울기/반전이 바뀜.
- [ ] **Step 3: 커밋**
```bash
git add app/pet_widget.py
git commit -m "feat: pet_widget — 투명 윈도우 + 시선 추적 렌더링"
```
---
### Task 4: interaction.py — 클릭 반응 + 우클릭 메뉴
**Files:**
- Create: `C:\Users\jaeoh\Desktop\workspace\pet-lab\app\interaction.py`
- Modify: `C:\Users\jaeoh\Desktop\workspace\pet-lab\app\pet_widget.py` (마우스 이벤트 연결)
- [ ] **Step 1: interaction.py 작성**
```python
"""클릭 애니메이션 + 우클릭 컨텍스트 메뉴."""
from PyQt5.QtWidgets import QMenu, QAction, QApplication
from PyQt5.QtCore import QPropertyAnimation, QEasingCurve, QPoint, QSequentialAnimationGroup
from app.config import (
JUMP_HEIGHT, JUMP_DURATION_MS,
SHAKE_OFFSET, SHAKE_DURATION_MS,
SIZES, POSITIONS,
)
def play_jump(widget):
"""좌클릭 — 위로 점프 후 복귀."""
start = widget.pos()
top = QPoint(start.x(), start.y() - JUMP_HEIGHT)
anim = QPropertyAnimation(widget, b"pos")
anim.setDuration(JUMP_DURATION_MS)
anim.setStartValue(start)
anim.setKeyValueAt(0.4, top)
anim.setEndValue(start)
anim.setEasingCurve(QEasingCurve.OutBounce)
# prevent garbage collection
widget._current_anim = anim
anim.start()
def play_shake(widget):
"""더블클릭 — 좌우 흔들기."""
start = widget.pos()
left = QPoint(start.x() - SHAKE_OFFSET, start.y())
right = QPoint(start.x() + SHAKE_OFFSET, start.y())
group = QSequentialAnimationGroup(widget)
for end_pos in [left, right, left, right, start]:
anim = QPropertyAnimation(widget, b"pos")
anim.setDuration(SHAKE_DURATION_MS // 5)
anim.setEndValue(end_pos)
group.addAnimation(anim)
widget._current_anim = group
group.start()
def show_context_menu(widget, global_pos):
"""우클릭 — 컨텍스트 메뉴 표시."""
menu = QMenu()
# 위치 서브메뉴
pos_menu = menu.addMenu("위치")
for key, label in [("left", ""), ("center", "중앙"), ("right", "")]:
action = pos_menu.addAction(label)
action.triggered.connect(lambda checked, k=key: widget.set_position(k))
# 크기 서브메뉴
size_menu = menu.addMenu("크기")
for key, label in [("small", "소 (100px)"), ("medium", "중 (150px)"), ("large", "대 (200px)")]:
action = size_menu.addAction(label)
action.triggered.connect(lambda checked, k=key: widget.set_size(k))
# 항상 위 토글
top_action = menu.addAction("항상 위" + ("" if widget.always_on_top else ""))
top_action.triggered.connect(widget.toggle_always_on_top)
menu.addSeparator()
# 종료
quit_action = menu.addAction("종료")
quit_action.triggered.connect(QApplication.quit)
menu.exec_(global_pos)
```
- [ ] **Step 2: pet_widget.py에 마우스 이벤트 연결**
`pet_widget.py``PetWidget` 클래스에 다음 메서드를 추가:
```python
# ── 마우스 이벤트 (파일 하단, toggle_always_on_top 뒤에 추가) ──
def mousePressEvent(self, event):
if event.button() == Qt.RightButton:
from app.interaction import show_context_menu
show_context_menu(self, event.globalPos())
def mouseDoubleClickEvent(self, event):
if event.button() == Qt.LeftButton:
from app.interaction import play_shake
play_shake(self)
def mouseReleaseEvent(self, event):
if event.button() == Qt.LeftButton:
from app.interaction import play_jump
play_jump(self)
```
파일 상단 import에 추가 필요 없음 (lazy import 사용).
- [ ] **Step 3: 수동 테스트**
```bash
cd "C:\Users\jaeoh\Desktop\workspace\pet-lab"
python -c "
import sys
from PyQt5.QtWidgets import QApplication
from app.pet_widget import PetWidget
app = QApplication(sys.argv)
pet = PetWidget()
pet.show()
sys.exit(app.exec_())
"
```
테스트 항목:
- 좌클릭 → 점프 애니메이션
- 더블클릭 → 흔들기 애니메이션
- 우클릭 → 메뉴 표시 (위치/크기/항상위/종료)
- 메뉴에서 위치 변경 → 캐릭터 이동
- 메뉴에서 크기 변경 → 캐릭터 크기 변경
- 종료 → 앱 종료
- [ ] **Step 4: 커밋**
```bash
git add app/interaction.py app/pet_widget.py
git commit -m "feat: interaction — 클릭 점프/흔들기 + 우클릭 메뉴"
```
---
### Task 5: main.py — 엔트리포인트
**Files:**
- Create: `C:\Users\jaeoh\Desktop\workspace\pet-lab\app\main.py`
- [ ] **Step 1: main.py 작성**
```python
"""pet-lab 엔트리포인트."""
import sys
from PyQt5.QtWidgets import QApplication
from app.pet_widget import PetWidget
def main():
app = QApplication(sys.argv)
app.setQuitOnLastWindowClosed(False)
pet = PetWidget()
pet.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()
```
- [ ] **Step 2: 실행 확인**
```bash
cd "C:\Users\jaeoh\Desktop\workspace\pet-lab"
python -m app.main
```
Expected: 박뚱냥이 화면 우하단에 표시되고, 시선 추적 + 클릭 반응 + 우클릭 메뉴 모두 동작.
- [ ] **Step 3: 커밋**
```bash
git add app/main.py
git commit -m "feat: main.py 엔트리포인트 — python -m app.main으로 실행"
```
---
## Self-Review Checklist
**Spec coverage:**
- [x] 투명 윈도우 (Task 3: `FramelessWindowHint`, `WA_TranslucentBackground`, `Tool`)
- [x] 바닥 고정 (Task 3: `_position_on_screen`)
- [x] 시선 추적 (Task 2: `compute_gaze`, Task 3: `_update_gaze`)
- [x] 좌클릭 점프 (Task 4: `play_jump`)
- [x] 더블클릭 흔들기 (Task 4: `play_shake`)
- [x] 우클릭 메뉴 — 위치/크기/항상위/종료 (Task 4: `show_context_menu`)
- [x] config 상수 (Task 1: `config.py`)
- [x] 성능 최적화 — 마우스 변화 없으면 스킵 (Task 3: `_last_mouse_pos`)
**Placeholder scan:** 없음. 모든 step에 실제 코드 포함.
**Type consistency:** `compute_gaze` 시그니처 — Task 2 구현과 Task 3 호출 일치. `set_size`/`set_position` — Task 3 정의와 Task 4 호출 일치.

View File

@@ -0,0 +1,977 @@
# packs-lab 인프라 통합 + admin mint-token Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** packs-lab을 운영 가능 상태로 만든다 — admin upload 토큰 발급 endpoint + Supabase 스키마 + docker-compose/nginx/env 통합 + 통합 테스트 + 문서 갱신.
**Architecture:** 기존 코드(HMAC + DSM client + 4 라우트)는 그대로 유지하고, 신규 라우트 1개(`POST /api/packs/admin/mint-token`)를 routes.py에 추가한다. Supabase `pack_files` DDL 파일과 인프라(docker-compose 18950, nginx 5GB streaming, .env.example 6+1 환경변수)를 신설하고, 통합 테스트(routes + dsm_client mock)와 CLAUDE.md 5+1곳을 갱신한다.
**Tech Stack:** Python 3.12 / FastAPI / pytest + unittest.mock / Supabase(PostgreSQL) / Synology DSM 7.x API / nginx / Docker Compose
**스펙 참조:** `docs/superpowers/specs/2026-05-05-packs-lab-infra-integration-design.md`
**작업 디렉토리:** `C:\Users\jaeoh\Desktop\workspace\web-backend` (기존 web-backend repo)
---
## Task 1: 테스트 인프라 — `tests/conftest.py`
기존 `tests/test_auth.py``BACKEND_HMAC_SECRET=secret` 같은 fixture가 없어 환경변수 의존. 모든 테스트가 동일한 secret으로 동작하도록 autouse fixture를 conftest에 정리.
**Files:**
- Create: `packs-lab/tests/conftest.py`
- [ ] **Step 1: conftest.py 생성**
`packs-lab/tests/conftest.py`:
```python
"""packs-lab 테스트 공통 fixture."""
import pytest
@pytest.fixture(autouse=True)
def _hmac_secret(monkeypatch):
"""모든 테스트에서 동일한 HMAC secret 사용. auth._SECRET 모듈 캐시까지 갱신."""
monkeypatch.setenv("BACKEND_HMAC_SECRET", "test-secret-do-not-use-in-prod")
# auth.py 모듈은 import 시점에 _SECRET을 캐시하므로 monkeypatch로 함께 갱신
from app import auth
monkeypatch.setattr(auth, "_SECRET", "test-secret-do-not-use-in-prod")
```
- [ ] **Step 2: 기존 test_auth.py 회귀 검증**
```bash
cd C:\Users\jaeoh\Desktop\workspace\web-backend\packs-lab
python -m pytest tests/test_auth.py -v
```
Expected: 기존 테스트 모두 PASS (conftest 영향 없거나 PASS 그대로 유지). 만약 secret 인코딩 차이로 실패 시 해당 테스트의 secret 사용 부분을 conftest 값과 일치시킨다.
- [ ] **Step 3: 커밋**
```bash
git add packs-lab/tests/conftest.py
git commit -m "test(packs-lab): conftest로 HMAC secret 통일"
```
---
## Task 2: admin mint-token 라우트 (스키마 + 구현 + 테스트)
`POST /api/packs/admin/mint-token` 신규. Pydantic 스키마 추가 + 라우트 구현 + 통합 테스트.
**Files:**
- Modify: `packs-lab/app/models.py` (스키마 2개 추가)
- Modify: `packs-lab/app/routes.py` (import 보강 + 라우트 추가)
- Create: `packs-lab/tests/test_routes.py` (mint-token 관련 테스트만 우선)
- [ ] **Step 1: failing 테스트 작성**
`packs-lab/tests/test_routes.py`:
```python
"""packs-lab 라우트 통합 테스트.
DSM·Supabase는 mock. HMAC 검증·토큰 발급·검증은 실제 코드 사용.
"""
import hashlib
import hmac
import json
import time
from unittest.mock import patch, MagicMock
from fastapi.testclient import TestClient
from app.main import app
SECRET = "test-secret-do-not-use-in-prod"
def _hmac_headers(body_bytes: bytes) -> dict:
"""body에 대한 X-Timestamp + X-Signature 헤더 생성."""
ts = str(int(time.time()))
sig = hmac.new(SECRET.encode(), ts.encode() + b"." + body_bytes, hashlib.sha256).hexdigest()
return {"X-Timestamp": ts, "X-Signature": sig}
def test_mint_token_hmac_required():
"""HMAC 헤더 누락 → 401."""
client = TestClient(app)
body = {"tier": "pro", "label": "샘플", "filename": "x.zip", "size_bytes": 1024}
resp = client.post("/api/packs/admin/mint-token", json=body)
assert resp.status_code == 401
def test_mint_token_returns_valid_token():
"""발급된 token이 verify_upload_token으로 통과해야 한다."""
from app.auth import verify_upload_token
body = {"tier": "pro", "label": "샘플", "filename": "test.zip", "size_bytes": 2048}
body_bytes = json.dumps(body).encode()
headers = _hmac_headers(body_bytes)
headers["Content-Type"] = "application/json"
client = TestClient(app)
resp = client.post("/api/packs/admin/mint-token", content=body_bytes, headers=headers)
assert resp.status_code == 200
data = resp.json()
assert "token" in data and "expires_at" in data and "jti" in data
payload = verify_upload_token(data["token"])
assert payload["tier"] == "pro"
assert payload["label"] == "샘플"
assert payload["filename"] == "test.zip"
assert payload["size_bytes"] == 2048
assert payload["jti"] == data["jti"]
def test_mint_token_invalid_filename():
"""허용 외 확장자 → 400."""
body = {"tier": "pro", "label": "샘플", "filename": "x.exe", "size_bytes": 1024}
body_bytes = json.dumps(body).encode()
headers = _hmac_headers(body_bytes)
headers["Content-Type"] = "application/json"
client = TestClient(app)
resp = client.post("/api/packs/admin/mint-token", content=body_bytes, headers=headers)
assert resp.status_code == 400
```
- [ ] **Step 2: 실패 확인**
```bash
cd packs-lab
python -m pytest tests/test_routes.py -v
```
Expected: 모든 테스트 FAIL — `/api/packs/admin/mint-token` 라우트 없음 (404 또는 405).
- [ ] **Step 3: models.py에 스키마 추가**
`packs-lab/app/models.py` 끝부분에 추가:
```python
class MintTokenRequest(BaseModel):
"""Vercel → backend: admin upload 토큰 발급 요청."""
tier: PackTier
label: str = Field(..., max_length=200)
filename: str = Field(..., max_length=255)
size_bytes: int = Field(..., gt=0, le=5 * 1024 * 1024 * 1024)
class MintTokenResponse(BaseModel):
token: str
expires_at: datetime
jti: str
```
- [ ] **Step 4: routes.py에 mint-token 라우트 추가**
`packs-lab/app/routes.py` 상단 import 블록에 다음을 추가:
```python
import time
from datetime import timezone
```
(이미 `import uuid`, `from datetime import datetime`은 있음)
`from .auth import` 라인을 다음과 같이 확장:
```python
from .auth import mint_upload_token, verify_request_hmac, verify_upload_token
```
`from .models import` 라인을 다음과 같이 확장:
```python
from .models import (
MintTokenRequest,
MintTokenResponse,
PackFileItem,
SignLinkRequest,
SignLinkResponse,
UploadResponse,
)
```
상수 추가 (`MAX_BYTES` 다음 줄에):
```python
UPLOAD_TOKEN_TTL_SEC = int(os.getenv("UPLOAD_TOKEN_TTL_SEC", "1800")) # 30분 default
```
라우트 추가 (`sign_link` 함수 다음, `upload` 함수 앞):
```python
@router.post("/admin/mint-token", response_model=MintTokenResponse)
async def mint_token(
request: Request,
x_timestamp: str = Header(""),
x_signature: str = Header(""),
):
body = await request.body()
verify_request_hmac(body, x_timestamp, x_signature)
payload = MintTokenRequest.model_validate_json(body)
_check_filename(payload.filename)
jti = str(uuid.uuid4())
expires_ts = int(time.time()) + UPLOAD_TOKEN_TTL_SEC
token = mint_upload_token({
"tier": payload.tier,
"label": payload.label,
"filename": payload.filename,
"size_bytes": payload.size_bytes,
"jti": jti,
"expires_at": expires_ts,
})
return MintTokenResponse(
token=token,
expires_at=datetime.fromtimestamp(expires_ts, tz=timezone.utc),
jti=jti,
)
```
- [ ] **Step 5: 테스트 통과 확인**
```bash
cd packs-lab
python -m pytest tests/test_routes.py -v
```
Expected: 3 passed.
- [ ] **Step 6: 커밋**
```bash
git add packs-lab/app/models.py packs-lab/app/routes.py packs-lab/tests/test_routes.py
git commit -m "feat(packs-lab): POST /api/packs/admin/mint-token 라우트 + 통합 테스트"
```
---
## Task 3: 기존 4 라우트 통합 테스트 (sign-link / upload / list / delete)
기존 라우트는 변경 없음. 테스트만 추가해 회귀 안전망 확보.
**Files:**
- Modify: `packs-lab/tests/test_routes.py` (테스트 8개 추가)
- [ ] **Step 1: sign-link 테스트 추가**
`tests/test_routes.py` 끝에 추가:
```python
def test_sign_link_hmac_required():
"""HMAC 헤더 없으면 401."""
client = TestClient(app)
body = {"file_path": "/volume1/docker/webpage/media/packs/pro/x.zip"}
resp = client.post("/api/packs/sign-link", json=body)
assert resp.status_code == 401
def test_sign_link_outside_base_dir():
"""PACK_BASE_DIR 외부 경로 → 400."""
body = {"file_path": "/etc/passwd"}
body_bytes = json.dumps(body).encode()
headers = _hmac_headers(body_bytes)
headers["Content-Type"] = "application/json"
client = TestClient(app)
resp = client.post("/api/packs/sign-link", content=body_bytes, headers=headers)
assert resp.status_code == 400
def test_sign_link_calls_dsm():
"""DSM client 호출되고 응답 URL 반환."""
from datetime import datetime, timezone
from unittest.mock import AsyncMock
body = {"file_path": "/volume1/docker/webpage/media/packs/pro/sample.zip"}
body_bytes = json.dumps(body).encode()
headers = _hmac_headers(body_bytes)
headers["Content-Type"] = "application/json"
fake_url = "https://gahusb.synology.me:5001/sharing/abc123"
fake_expires = datetime(2026, 5, 5, 13, 0, tzinfo=timezone.utc)
with patch("app.routes.create_share_link", new=AsyncMock(return_value=(fake_url, fake_expires))) as mock:
client = TestClient(app)
resp = client.post("/api/packs/sign-link", content=body_bytes, headers=headers)
assert resp.status_code == 200
data = resp.json()
assert data["url"] == fake_url
mock.assert_awaited_once()
```
- [ ] **Step 2: upload 테스트 추가**
```python
def _make_upload_token(tier="pro", label="샘플", filename="test.zip", size_bytes=1024, jti=None, ttl=1800):
"""테스트용 upload token 생성. mint_token endpoint 거치지 않고 직접."""
import uuid
from app.auth import mint_upload_token
return mint_upload_token({
"tier": tier,
"label": label,
"filename": filename,
"size_bytes": size_bytes,
"jti": jti or str(uuid.uuid4()),
"expires_at": int(time.time()) + ttl,
})
def test_upload_token_required():
"""Authorization Bearer 누락 → 401."""
client = TestClient(app)
resp = client.post("/api/packs/upload", files={"file": ("x.zip", b"hello")})
assert resp.status_code == 401
def test_upload_size_mismatch(tmp_path, monkeypatch):
"""토큰 size_bytes ≠ 실제 → 400 + 파일 정리됨."""
monkeypatch.setattr("app.routes.PACK_BASE_DIR", tmp_path)
token = _make_upload_token(size_bytes=999) # 실제 5바이트지만 토큰엔 999
client = TestClient(app)
resp = client.post(
"/api/packs/upload",
files={"file": ("test.zip", b"hello")},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 400
assert "크기" in resp.json()["detail"]
def test_upload_jti_replay(tmp_path, monkeypatch):
"""같은 jti 토큰 두 번 → 두 번째 409."""
monkeypatch.setattr("app.routes.PACK_BASE_DIR", tmp_path)
fake_supabase = MagicMock()
fake_supabase.table.return_value.insert.return_value.execute.return_value = MagicMock(
data=[{"uploaded_at": "2026-05-05T12:00:00+00:00"}]
)
token = _make_upload_token(filename="replay.zip", size_bytes=5, jti="replay-jti-1")
with patch("app.routes._supabase", return_value=fake_supabase):
client = TestClient(app)
# 1차: 성공
resp1 = client.post(
"/api/packs/upload",
files={"file": ("replay.zip", b"hello")},
headers={"Authorization": f"Bearer {token}"},
)
assert resp1.status_code == 200
# 2차: 동일 토큰 재사용 — 두 번째 파일은 다른 이름으로 보내 파일명 충돌 회피
resp2 = client.post(
"/api/packs/upload",
files={"file": ("replay.zip", b"world")},
headers={"Authorization": f"Bearer {token}"},
)
assert resp2.status_code == 409
```
- [ ] **Step 3: list / delete 테스트 추가**
```python
def test_list_returns_active_only():
"""mock supabase가 deleted_at IS NULL 행만 반환하는지 (쿼리 빌더 호출 검증)."""
fake_rows = [
{
"id": "11111111-1111-1111-1111-111111111111",
"min_tier": "pro",
"label": "샘플",
"file_path": "/volume1/docker/webpage/media/packs/pro/a.zip",
"filename": "a.zip",
"size_bytes": 1024,
"sort_order": 0,
"uploaded_at": "2026-05-05T12:00:00+00:00",
}
]
fake_supabase = MagicMock()
chain = fake_supabase.table.return_value.select.return_value
chain.is_.return_value.order.return_value.order.return_value.execute.return_value = MagicMock(data=fake_rows)
body_bytes = b""
headers = _hmac_headers(body_bytes)
with patch("app.routes._supabase", return_value=fake_supabase):
client = TestClient(app)
resp = client.get("/api/packs/list", headers=headers)
assert resp.status_code == 200
items = resp.json()
assert len(items) == 1
assert items[0]["filename"] == "a.zip"
fake_supabase.table.return_value.select.return_value.is_.assert_called_with("deleted_at", "null")
def test_delete_soft_deletes():
"""DELETE 시 supabase update에 deleted_at ISO timestamp가 들어가야 한다."""
fake_supabase = MagicMock()
fake_supabase.table.return_value.update.return_value.eq.return_value.execute.return_value = MagicMock(
data=[{"id": "abc"}]
)
body_bytes = b""
headers = _hmac_headers(body_bytes)
with patch("app.routes._supabase", return_value=fake_supabase):
client = TestClient(app)
resp = client.delete("/api/packs/abc", headers=headers)
assert resp.status_code == 200
update_call = fake_supabase.table.return_value.update.call_args
update_kwargs = update_call.args[0]
assert "deleted_at" in update_kwargs
# ISO 8601 timestamp 형식 검증 (예: 2026-05-05T12:00:00+00:00)
assert "T" in update_kwargs["deleted_at"]
```
- [ ] **Step 4: 테스트 실행**
```bash
cd packs-lab
python -m pytest tests/test_routes.py -v
```
Expected: 11 passed (3 from Task 2 + 3 sign-link + 3 upload + 2 list/delete).
- [ ] **Step 5: 커밋**
```bash
git add packs-lab/tests/test_routes.py
git commit -m "test(packs-lab): 기존 4 라우트 통합 테스트 (sign-link, upload, list, delete)"
```
---
## Task 4: `tests/test_dsm_client.py` — DSM client mock 테스트
**Files:**
- Create: `packs-lab/tests/test_dsm_client.py`
- [ ] **Step 1: DSM client 테스트 작성**
`packs-lab/tests/test_dsm_client.py`:
```python
"""DSM 7.x API client 테스트 — httpx mock으로 외부 호출 차단."""
import asyncio
from unittest.mock import patch, MagicMock
import pytest
import httpx
from app.dsm_client import create_share_link, DSMError, _login, _logout
@pytest.fixture(autouse=True)
def _dsm_env(monkeypatch):
monkeypatch.setenv("DSM_HOST", "https://test-nas:5001")
monkeypatch.setenv("DSM_USER", "test-user")
monkeypatch.setenv("DSM_PASS", "test-pass")
# 모듈 캐시도 갱신
from app import dsm_client
monkeypatch.setattr(dsm_client, "DSM_HOST", "https://test-nas:5001")
monkeypatch.setattr(dsm_client, "DSM_USER", "test-user")
monkeypatch.setattr(dsm_client, "DSM_PASS", "test-pass")
def _make_response(json_data, status_code=200):
"""httpx.Response mock."""
mock = MagicMock(spec=httpx.Response)
mock.json.return_value = json_data
mock.status_code = status_code
mock.raise_for_status = MagicMock()
return mock
def test_create_share_link_login_logout():
"""login → Sharing.create → logout 순서가 보장되어야 한다."""
call_order = []
async def fake_get(self, url, *, params=None, **kw):
api = (params or {}).get("api", "")
method = (params or {}).get("method", "")
call_order.append(f"{api}.{method}")
if api == "SYNO.API.Auth" and method == "login":
return _make_response({"success": True, "data": {"sid": "fake-sid"}})
if api == "SYNO.API.Auth" and method == "logout":
return _make_response({"success": True})
if api == "SYNO.FileStation.Sharing" and method == "create":
return _make_response({
"success": True,
"data": {"links": [{"url": "https://test-nas:5001/sharing/abc"}]},
})
return _make_response({"success": False, "error": "unexpected"})
with patch.object(httpx.AsyncClient, "get", new=fake_get):
url, expires_at = asyncio.run(create_share_link("/volume1/test/file.zip", expires_in_sec=3600))
assert url == "https://test-nas:5001/sharing/abc"
assert call_order == [
"SYNO.API.Auth.login",
"SYNO.FileStation.Sharing.create",
"SYNO.API.Auth.logout",
]
def test_create_share_link_returns_url_and_expiry():
"""응답 파싱 — links[0].url 사용."""
async def fake_get(self, url, *, params=None, **kw):
method = (params or {}).get("method", "")
if method == "login":
return _make_response({"success": True, "data": {"sid": "sid"}})
if method == "create":
return _make_response({
"success": True,
"data": {"links": [{"url": "https://nas/sharing/xyz"}]},
})
return _make_response({"success": True})
with patch.object(httpx.AsyncClient, "get", new=fake_get):
url, expires_at = asyncio.run(create_share_link("/volume1/test/file.zip", expires_in_sec=7200))
assert url == "https://nas/sharing/xyz"
assert expires_at is not None
def test_dsm_login_failure_raises():
"""login API success=False → DSMError."""
async def fake_get(self, url, *, params=None, **kw):
return _make_response({"success": False, "error": {"code": 400}})
with patch.object(httpx.AsyncClient, "get", new=fake_get):
with pytest.raises(DSMError, match="login 실패"):
asyncio.run(create_share_link("/volume1/test/file.zip"))
def test_dsm_share_failure_logs_out():
"""Sharing.create 실패해도 logout 호출 (try/finally)."""
call_order = []
async def fake_get(self, url, *, params=None, **kw):
method = (params or {}).get("method", "")
call_order.append(method)
if method == "login":
return _make_response({"success": True, "data": {"sid": "sid"}})
if method == "create":
return _make_response({"success": False, "error": {"code": 401}})
if method == "logout":
return _make_response({"success": True})
return _make_response({"success": False})
with patch.object(httpx.AsyncClient, "get", new=fake_get):
with pytest.raises(DSMError, match="Sharing.create 실패"):
asyncio.run(create_share_link("/volume1/test/file.zip"))
assert "login" in call_order
assert "logout" in call_order, "logout이 호출되지 않음 (finally 누락 의심)"
```
- [ ] **Step 2: 테스트 실행**
```bash
cd packs-lab
python -m pytest tests/test_dsm_client.py -v
```
Expected: 4 passed.
- [ ] **Step 3: 커밋**
```bash
git add packs-lab/tests/test_dsm_client.py
git commit -m "test(packs-lab): DSM client mock 테스트 (login/share/logout 순서)"
```
---
## Task 5: DELETE 라우트 docstring 수정
`routes.py` 모듈 docstring의 한 줄 변경.
**Files:**
- Modify: `packs-lab/app/routes.py:1-7` (모듈 docstring)
- [ ] **Step 1: docstring 수정**
`packs-lab/app/routes.py` 첫 docstring을 다음으로 변경:
```python
"""packs-lab API 엔드포인트.
- POST /api/packs/sign-link — Vercel HMAC 인증 → DSM 공유 링크
- POST /api/packs/admin/mint-token — Vercel HMAC 인증 → 일회성 upload 토큰
- POST /api/packs/upload — 일회성 토큰 인증 → multipart 저장 + supabase INSERT
- GET /api/packs/list — Vercel HMAC 인증 → pack_files 전체 조회
- DELETE /api/packs/{file_id} — Vercel HMAC 인증 → soft delete (DSM 공유는 자동 만료)
"""
```
(변경: `정리``자동 만료`, mint-token 줄 추가)
- [ ] **Step 2: 회귀 검증**
```bash
cd packs-lab
python -m pytest tests/ -v
```
Expected: 모든 테스트 그대로 통과 (15 passed).
- [ ] **Step 3: 커밋**
```bash
git add packs-lab/app/routes.py
git commit -m "docs(packs-lab): routes 모듈 docstring 정리 (mint-token 추가, DSM 자동 만료 명시)"
```
---
## Task 6: Supabase `pack_files` DDL
운영 적용 시 Supabase SQL editor에서 실행할 SQL 파일.
**Files:**
- Create: `packs-lab/supabase/pack_files.sql`
- [ ] **Step 1: SQL 파일 생성**
`packs-lab/supabase/pack_files.sql`:
```sql
-- pack_files: NAS에 저장된 다운로드 가능한 패키지 파일 메타
-- 운영 적용: Supabase Dashboard → SQL editor에서 실행
create table if not exists public.pack_files (
id uuid primary key default gen_random_uuid(),
min_tier text not null check (min_tier in ('starter','pro','master')),
label text not null,
file_path text not null unique,
filename text not null,
size_bytes bigint not null check (size_bytes > 0),
sort_order integer not null default 0,
uploaded_at timestamptz not null default now(),
deleted_at timestamptz
);
-- list 라우트 hot path: deleted_at IS NULL + tier/order 정렬
create index if not exists pack_files_active_idx
on public.pack_files (min_tier, sort_order)
where deleted_at is null;
-- soft-deleted 통계 / cleanup 잡 대비
create index if not exists pack_files_deleted_at_idx
on public.pack_files (deleted_at)
where deleted_at is not null;
```
- [ ] **Step 2: 커밋**
```bash
git add packs-lab/supabase/pack_files.sql
git commit -m "feat(packs-lab): Supabase pack_files DDL + 활성/삭제 인덱스"
```
---
## Task 7: 인프라 통합 — docker-compose / nginx / .env.example / deploy-nas.sh
**Files:**
- Modify: `docker-compose.yml` (packs-lab 서비스 추가, env에 PACK_BASE_DIR/PACK_HOST_DIR 포함)
- Modify: `nginx/default.conf` (`/api/packs/` 라우팅)
- 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 서비스 추가**
`docker-compose.yml`에서 다른 lab 서비스(예: `realestate-lab`) 정의 다음에 추가:
```yaml
packs-lab:
build:
context: ./packs-lab
dockerfile: Dockerfile
container_name: packs-lab
restart: unless-stopped
ports:
- "18950:8000"
environment:
TZ: Asia/Seoul
DSM_HOST: ${DSM_HOST}
DSM_USER: ${DSM_USER}
DSM_PASS: ${DSM_PASS}
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}
volumes:
- ${PACK_DATA_PATH:-./data/packs}:/volume1/docker/webpage/media/packs
```
- [ ] **Step 2: nginx/default.conf — /api/packs/ 라우팅**
기존 `location /api/agent-office/ { ... }` 다음(또는 다른 `/api/...` 라우트들 근처)에 추가:
```nginx
location /api/packs/ {
proxy_pass http://packs-lab:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 5GB 멀티파트 업로드 대응
client_max_body_size 5G;
proxy_request_buffering off;
proxy_read_timeout 1800s;
proxy_send_timeout 1800s;
}
```
- [ ] **Step 3: .env.example — 6+1 환경변수 추가**
`.env.example` 끝에 추가:
```bash
# ─── packs-lab — NAS 자료 다운로드 자동화 ────────────────────────────
# Synology DSM 7.x 인증 (공유 링크 발급용)
DSM_HOST=https://gahusb.synology.me:5001
DSM_USER=
DSM_PASS=
# Vercel SaaS ↔ backend HMAC 시크릿 (양쪽 동일 값)
BACKEND_HMAC_SECRET=
# Supabase pack_files 테이블 접근 (service_role 키, RLS 우회)
SUPABASE_URL=https://<project>.supabase.co
SUPABASE_SERVICE_KEY=
# admin upload 토큰 TTL (초). default 1800 = 30분
UPLOAD_TOKEN_TTL_SEC=1800
# 로컬 개발: ./data/packs / NAS 운영: /volume1/docker/webpage/media/packs
PACK_DATA_PATH=./data/packs
```
- [ ] **Step 4: docker compose config 검증**
```bash
cd C:\Users\jaeoh\Desktop\workspace\web-backend
docker compose config 2>&1 | grep -A 10 "packs-lab:"
```
Expected: packs-lab 서비스 정의가 정상 출력 (port mapping, environment 변수, volumes 모두 보임). 환경변수가 비어있어도 docker compose config는 통과.
> ⚠️ Docker가 로컬에 설치되어 있어야 검증 가능. 실제 실행은 NAS에서. 로컬 docker가 없으면 step skip하고 nginx config 문법만 별도 검증.
- [ ] **Step 5: 커밋**
```bash
git add docker-compose.yml nginx/default.conf .env.example
git commit -m "chore(infra): packs-lab 서비스 통합 (compose 18950 + nginx 5GB streaming + env 7개)"
```
---
## Task 8: NAS 디렉토리 준비 가이드 + 문서 갱신
**Files:**
- Modify: `web-backend/CLAUDE.md` (5곳 갱신)
- Modify: `workspace/CLAUDE.md` (1줄 추가)
- [ ] **Step 1: web-backend/CLAUDE.md — 1.프로젝트 개요**
찾을 위치 (1.프로젝트 개요 섹션):
```
- **서비스**: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, personal, deployer (9개)
```
다음으로 수정:
```
- **서비스**: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, personal, packs-lab, deployer (10개)
```
같은 섹션의 인프라 줄도:
```
- **인프라**: Docker Compose (10컨테이너) + Nginx(리버스 프록시) + Gitea Webhook 자동 배포
```
- [ ] **Step 2: web-backend/CLAUDE.md — 4.Docker 서비스 표**
표 마지막에 신규 행 추가 (deployer 행 직전 또는 personal 행 다음 — 알파벳 순):
```
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (DSM 공유 링크 + 5GB 업로드, Vercel SaaS와 HMAC 통신) |
```
- [ ] **Step 3: web-backend/CLAUDE.md — 5.Nginx 라우팅 표**
표 적절한 위치에 신규 행 추가:
```
| `/api/packs/` | `packs-lab:8000` | 5GB 업로드 대응 (`client_max_body_size 5G`, `proxy_request_buffering off`, 1800s timeout) |
```
- [ ] **Step 4: web-backend/CLAUDE.md — 8.로컬 개발 표**
표 끝에 신규 행 추가:
```
| Packs Lab | http://localhost:18950 |
```
- [ ] **Step 5: web-backend/CLAUDE.md — 9.서비스별 packs-lab 신규 섹션**
`### deployer (deployer/)` 섹션 직전에 추가 (또는 personal 다음):
```
### 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`
- 운영 디렉토리: `/volume1/docker/webpage/media/packs/{starter,pro,master}/` (NAS PUID:PGID 권한 필요)
**환경변수**
- `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_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 목록**
| 메서드 | 경로 | 설명 |
|--------|------|------|
| 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 공유는 자동 만료) |
```
- [ ] **Step 6: workspace/CLAUDE.md — 컨테이너 표 한 줄 추가**
`workspace/CLAUDE.md`의 "Docker 서비스 & 포트" 표에 추가:
```
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (Vercel SaaS와 HMAC 통신) |
```
(personal 행 다음 또는 적절한 위치)
- [ ] **Step 7: 커밋 (web-backend repo의 CLAUDE.md만)**
작업 디렉토리는 `C:\Users\jaeoh\Desktop\workspace\web-backend`. 그 안의 `CLAUDE.md`만 git 추적 대상.
```bash
git add CLAUDE.md
git commit -m "docs(claude): packs-lab 10번째 서비스로 등록 (포트/라우팅/API 표 + 신규 섹션)"
```
> `workspace/CLAUDE.md`(상위 디렉토리의 워크스페이스 메모)는 git repo가 아님. 텍스트 편집만 하고 commit 대상에서 제외.
---
## Task 9: 회귀 검증 + NAS 디렉토리 가이드
전체 테스트 + docker compose config + NAS 배포 전 가이드.
**Files:**
- (검증만)
- [ ] **Step 1: 전체 pytest**
```bash
cd packs-lab
python -m pytest tests/ -v
```
Expected: 모든 테스트 통과 (test_auth + test_routes + test_dsm_client = 약 15+ tests).
- [ ] **Step 2: docker compose config 검증**
```bash
cd C:\Users\jaeoh\Desktop\workspace\web-backend
docker compose config 2>&1 | tail -30
```
Expected: error 없이 packs-lab 포함된 전체 config 출력.
> ⚠️ Docker 미설치 시 skip. NAS에서 git push 후 webhook 배포 시점에 검증됨.
- [ ] **Step 3: NAS 배포 전 가이드 출력**
배포 전 NAS에서 SSH로 1회 실행할 명령들을 README 또는 NAS 배포 노트로 정리. 본 task에서는 명령만 제시 (실행은 사용자):
```bash
# NAS SSH로 접속 후
mkdir -p /volume1/docker/webpage/media/packs/{starter,pro,master}
chown -R PUID:PGID /volume1/docker/webpage/media/packs # PUID/PGID는 .env 값 사용
# .env에 신규 환경변수 추가 (DSM_*, BACKEND_HMAC_SECRET, SUPABASE_*, UPLOAD_TOKEN_TTL_SEC, PACK_DATA_PATH=/volume1/docker/webpage/media/packs)
# Supabase에서 packs-lab/supabase/pack_files.sql 실행
# git push 후 webhook이 자동 배포
```
- [ ] **Step 4: 최종 commit (검증 결과 빈 commit으로 마일스톤 표시 — 선택)**
```bash
# 만약 위 step에서 어떤 자동 수정이 있었으면 commit. 없으면 skip.
git status
```
회귀 검증으로 변경 사항 없으면 별도 commit 없이 종료.
---
## 완료 기준
- 모든 task의 step 통과 (체크박스 모두 체크)
- `cd packs-lab && python -m pytest tests/ -v` — 통과 (test_auth + test_routes + test_dsm_client)
- `docker compose config` — packs-lab 포함된 전체 config 정상
- web-backend/CLAUDE.md 5곳 갱신 + workspace/CLAUDE.md 1줄
- Supabase DDL 파일 존재 (운영 적용은 사용자가 NAS에서 SQL editor로)
- NAS 디렉토리 준비 명령은 사용자가 SSH로 실행 (배포 전 1회)
---
## 배포
git push → Gitea webhook → deployer rsync → docker compose up -d --build (자동).
**배포 전 사용자 액션 (1회)**:
1. Supabase에서 `pack_files` 테이블 생성 (DDL 실행)
2. NAS SSH로 `/volume1/docker/webpage/media/packs/{starter,pro,master}` 디렉토리 생성 + 권한
3. NAS `.env`에 신규 7개 환경변수 입력 (DSM 인증, HMAC secret, Supabase 키 등)
---
## 참고 — 후속 별도 plan (스코프 외)
- Vercel SaaS-side admin UI / 사용자 다운로드 UI / Supabase user 테이블
- DSM 공유 추적 (즉시 차단 필요 시)
- deleted_at + N일 후 실제 파일 삭제 cron
- multi-admin 토큰 발급 권한 분리
- resumable multipart 업로드 (5GB tus 등)
- pack_files sort_order 편집 endpoint
- 모니터링 (업로드 실패율, DSM API latency)

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에 명시함.

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

@@ -0,0 +1,635 @@
# Plan-B-Base — NAS Redis 컨테이너 + Windows WSL2/Docker/Tailscale/SMB Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 분산 아키텍처 base 인프라 셋업 — NAS에 24/7 Redis 컨테이너 신설 + Windows AI 머신에 WSL2 + Docker Engine + Tailscale + NAS SMB 마운트 구성. 후속 Plan-B-Insta/Music/Video/Infra 트랙의 prerequisite.
**Architecture:** SP-1 (NAS Redis) = docker-compose service 추가 + deployer auto-rebuild. SP-2 (Windows) = 박재오 머신 192.168.45.59에서 직접 셋업 (WSL2 Ubuntu 22.04 + Docker Engine + Tailscale + cifs-utils로 NAS SMB 마운트). 두 SP가 모두 끝나야 후속 트랙의 worker가 NAS ↔ Windows 양방향 통신 가능.
**Tech Stack:** Redis 7-alpine, WSL2, Ubuntu 22.04, Docker Engine 24+, Tailscale, cifs-utils (SMB 3.0). PowerShell (관리자) + bash (WSL2 내부).
**Spec:** `web-backend/docs/superpowers/specs/2026-05-18-nas-windows-distributed-architecture-design.md` §4 SP-1·SP-2, §10 SP-1·SP-2 상세
---
## 사전 확인 사항
- **박재오 자격증명 필요**: NAS SMB 마운트용 user/password (Synology DSM 사용자, SMB 권한 보유)
- **Windows AI 머신 직접 접근 필요**: WSL2 설치는 관리자 PowerShell + 재부팅 1회. Claude는 별도 머신이라 명령 직접 실행 불가 — **Task 4~7은 박재오가 콘솔에서 직접 수행**. 명령어와 검증 방법 명시.
- **NAS deployer 사용자**: Gitea webhook으로 docker compose up -d 자동 실행. 새 redis 서비스도 추가 시 자동 startup.
## File Structure
### SP-1 — NAS 측 (Modify)
| 파일 | 변경 | 책임 |
|------|------|------|
| `web-backend/docker-compose.yml` | `redis:` 서비스 블록 추가 | 컨테이너 정의 (image, volume, healthcheck) |
### SP-2 — Windows 측 (Create, 박재오 머신 로컬)
| 파일/위치 | 변경 | 책임 |
|----------|------|------|
| (Windows AI) WSL2 Ubuntu-22.04 | install | Linux 런타임 |
| WSL2 `/etc/apt/keyrings/docker.gpg` | install | Docker Engine apt key |
| WSL2 `/etc/apt/sources.list.d/docker.list` | install | Docker Engine apt source |
| (Windows AI) Tailscale | install + auth | 사설망 100.x.x.x |
| WSL2 `/etc/nas-smb-credentials` (신규) | NAS user/password | SMB 자격증명 (chmod 600) |
| WSL2 `/etc/fstab` (수정) | SMB 마운트 항목 추가 | 부팅 시 자동 마운트 |
| WSL2 `/mnt/nas` | mkdir | 마운트 포인트 |
---
## Task 1: NAS docker-compose.yml에 redis 서비스 추가
**Files:**
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml`
- [ ] **Step 1: 현재 docker-compose.yml 끝부분 확인 (deployer 위치)**
Run: `tail -20 C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml`
Expected: `deployer` 서비스가 마지막. line ~277-293 영역.
- [ ] **Step 2: redis 서비스 블록 추가**
`C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml` 파일 **끝**에 (deployer 서비스 다음, volumes 블록 있다면 그 전에) 다음 블록 추가. 들여쓰기는 다른 서비스(`lotto:`, `stock:` 등)와 동일하게 services 아래 2칸 들여쓰기:
```yaml
redis:
image: redis:7-alpine
container_name: redis
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- ${RUNTIME_PATH}/redis-data:/data
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 60s
timeout: 5s
retries: 3
networks:
- default
```
**주의:**
- 파일 끝에 추가하되, 만약 `networks:` / `volumes:` top-level 블록이 services 다음에 있다면 그 블록들 **앞에** 삽입
- 첫 줄에 빈 줄 1개 두기 (deployer와 분리)
- `${RUNTIME_PATH}` 환경변수는 다른 서비스에서도 사용 중. 자동 적용됨
- [ ] **Step 3: yaml 문법 검증**
Run:
```bash
python -c "import yaml; yaml.safe_load(open('C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml'))" && echo "yaml OK"
```
Expected: `yaml OK`
만약 실패하면 indent 또는 trailing space 확인.
- [ ] **Step 4: redis 서비스가 services dict에 들어갔는지 확인**
Run:
```bash
python -c "import yaml; d=yaml.safe_load(open('C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml')); print(sorted(d['services'].keys()))"
```
Expected: 리스트에 `'redis'` 포함. 다른 서비스(`lotto`, `stock`, `music-lab`, `insta-lab`, `realestate-lab`, `agent-office`, `personal`, `packs-lab`, `travel-proxy`, `frontend`, `deployer`)도 모두 그대로.
- [ ] **Step 5: 커밋**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git add docker-compose.yml
git commit -m "$(cat <<'EOF'
feat(infra): add redis container as 24/7 queue + cache base (SP-1)
redis:7-alpine, 256MB maxmemory, AOF appendonly ON, allkeys-lru.
docker volume ${RUNTIME_PATH}/redis-data로 영속화.
Plan-B 후속 트랙(insta-render/music-render/video-render Windows
워커)의 BLPOP 큐 + NAS↔Windows pub/sub의 base.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
- [ ] **Step 6: push (Gitea webhook → NAS deployer 자동 적용)**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git push origin main
```
자격증명 prompt 시 입력. 1회 실패 시 1회 재시도 패턴.
Expected: push 성공. NAS deployer가 webhook 수신 → `git pull``docker compose up -d redis` 자동 실행.
---
## Task 2: NAS Redis 컨테이너 헬스 확인
**Files:** 없음 (NAS 검증)
- [ ] **Step 1: deployer 완료까지 대기 (통상 30초~2분)**
Run (Windows 로컬에서):
```bash
for i in 1 2 3 4 5 6 7 8 9 10; do
code=$(curl -s -o /dev/null -w "%{http_code}" https://gahusb.synology.me/api/stock/news -m 5)
echo "[try $i] HTTP $code"
if [ "$code" = "200" ]; then break; fi
sleep 15
done
```
Expected: HTTP 200 응답 — NAS 컨테이너 안정 상태. redis 컨테이너는 별도 endpoint 없으나 deployer가 build 완료했음을 시사.
- [ ] **Step 2: NAS에서 redis 컨테이너 확인 (박재오 SSH)**
NAS bash:
```bash
ssh -p 22 박재오@gahusb.synology.me
cd /volume1/docker/webpage
docker compose ps redis
```
또는 한 번에:
```bash
ssh -p 22 박재오@gahusb.synology.me "cd /volume1/docker/webpage && docker compose ps redis && docker exec redis redis-cli PING"
```
Expected:
- `docker compose ps redis``redis ... healthy` 또는 `Up X seconds (health: starting)` 후 곧 healthy
- `redis-cli PING``PONG`
만약 `docker compose ps`에 redis가 안 보이면:
```bash
cd /volume1/docker/webpage && docker compose up -d redis
```
수동 실행해서 startup 확인.
- [ ] **Step 3: redis-data 볼륨 생성 확인 (Z: drive로)**
Run (Windows):
```powershell
Test-Path "Z:\webpage\redis-data"
```
또는 NAS bash:
```bash
ls -la /volume1/docker/webpage/redis-data/
```
Expected: 디렉토리 존재. 그 안에 `appendonlydir/` 또는 `dump.rdb` 등의 redis 데이터 파일.
- [ ] **Step 4: AOF append-only 작동 확인 (선택, 데이터 영속성 검증)**
```bash
ssh -p 22 박재오@gahusb.synology.me 'docker exec redis redis-cli SET test_key "hello"'
ssh -p 22 박재오@gahusb.synology.me 'docker exec redis redis-cli RESTART' # 또는 docker restart
# 잠시 대기
ssh -p 22 박재오@gahusb.synology.me 'docker exec redis redis-cli GET test_key'
```
Expected: `"hello"` — 재시작 후에도 값 유지 (AOF 영속화 작동).
테스트 후 정리: `docker exec redis redis-cli DEL test_key`
---
## Task 3: Windows AI에 WSL2 + Ubuntu 22.04 설치
**Files:** 없음 (Windows AI 머신 192.168.45.59에서 박재오 직접 실행)
**전제:** Windows 10 build 19041+ 또는 Windows 11. 박재오 9800X3D 머신 충족.
- [ ] **Step 1: 관리자 PowerShell 실행**
박재오 Windows AI 머신에서 시작 메뉴 → "PowerShell" 우클릭 → "관리자 권한으로 실행".
- [ ] **Step 2: WSL2 + Ubuntu 22.04 설치**
```powershell
wsl --install -d Ubuntu-22.04
```
Expected: 다운로드 progress + "Ubuntu-22.04 has been installed". **재부팅 필요할 수 있음.**
- [ ] **Step 3: 재부팅 (필요 시)**
설치 완료 메시지에 "재시작이 필요합니다"가 보이면 재부팅. 자동 재부팅 안 됨.
- [ ] **Step 4: Ubuntu 초기 설정 (재부팅 후 자동 실행 또는 시작 메뉴에서 "Ubuntu" 클릭)**
새 콘솔이 열리고 다음 입력 요청됨:
- 새 UNIX username: `jaeoh` 또는 박재오 선호 username (이후 모든 sudo에 사용)
- 비밀번호: 박재오가 정하는 값. 잘 기억할 것.
Expected: `jaeoh@<hostname>:~$` 프롬프트 표시 → WSL2 진입 성공.
- [ ] **Step 5: WSL 버전 확인**
WSL2 내부에서 PowerShell로 잠시 돌아와서:
```powershell
wsl -l -v
```
Expected:
```
NAME STATE VERSION
* Ubuntu-22.04 Running 2
```
VERSION=2 확인. 만약 1이면:
```powershell
wsl --set-version Ubuntu-22.04 2
```
- [ ] **Step 6: WSL2 안 진입 (이후 작업)**
```powershell
wsl -d Ubuntu-22.04
```
이후 Task 4~7은 모두 WSL2 안 bash에서 실행.
---
## Task 4: WSL2 안 Docker Engine 설치 (Docker Desktop 사용 X)
**Files:** (WSL2 내부) `/etc/apt/keyrings/docker.gpg`, `/etc/apt/sources.list.d/docker.list`
**위치:** WSL2 Ubuntu-22.04 bash 프롬프트.
- [ ] **Step 1: 패키지 인덱스 + 기본 의존성 설치**
```bash
sudo apt update
sudo apt install -y ca-certificates curl gnupg lsb-release
```
Expected: 에러 없이 완료.
- [ ] **Step 2: Docker apt key 등록**
```bash
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
```
Expected: 에러 없이 완료. `/etc/apt/keyrings/docker.gpg` 파일 생성.
- [ ] **Step 3: Docker repository 추가**
```bash
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
```
Expected: `Hit:N https://download.docker.com/linux/ubuntu jammy InRelease` 라인 보임.
- [ ] **Step 4: Docker Engine + Compose 설치**
```bash
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
```
Expected: 설치 완료. 용량 ~400MB.
- [ ] **Step 5: 현재 사용자를 docker 그룹에 추가**
```bash
sudo usermod -aG docker $USER
```
Expected: 출력 없음 (정상). **새 셸 열어야 적용됨.**
- [ ] **Step 6: Docker 서비스 시작 + 자동 시작 설정**
```bash
sudo systemctl enable docker
sudo systemctl start docker
sudo systemctl status docker | head -5
```
Expected: `Active: active (running)`.
만약 `systemctl: command not found` 또는 systemd 미지원 시:
```bash
sudo service docker start
```
WSL2 systemd 활성화는 `/etc/wsl.conf``[boot]\nsystemd=true` 추가 후 PowerShell에서 `wsl --shutdown` 후 재진입. (Ubuntu-22.04는 보통 기본 활성)
- [ ] **Step 7: docker 명령 동작 확인**
새 셸로 (PowerShell에서 다시 `wsl -d Ubuntu-22.04` 또는 현재 셸 종료 후 재진입):
```bash
docker version
docker run --rm hello-world
```
Expected:
- `docker version`: Client + Server 둘 다 표시 (Server에 Engine version)
- `hello-world`: "Hello from Docker!" 출력
---
## Task 5: WSL2 안 Tailscale 설치 + 가입
**Files:** Tailscale은 systemd service 등록 (별도 path 신경 안 써도 됨)
- [ ] **Step 1: Tailscale 설치**
WSL2 bash:
```bash
curl -fsSL https://tailscale.com/install.sh | sh
```
Expected: 패키지 install 후 "Installation complete!" 출력.
- [ ] **Step 2: Tailscale 가입 (브라우저 OAuth)**
```bash
sudo tailscale up
```
Expected: `To authenticate, visit: https://login.tailscale.com/a/...` URL 표시.
브라우저에서 그 URL 열기 → Google/Microsoft/GitHub 등으로 로그인 → 박재오 Tailscale 네트워크에 가입 (기존 계정 없으면 생성).
- [ ] **Step 3: 가입 완료 확인**
```bash
tailscale status
```
Expected:
- 첫 줄에 Windows AI 머신의 100.x.x.x IP 표시
- (이미 가입된) NAS도 같은 네트워크에 있다면 NAS의 100.x.x.x IP도 표시
- [ ] **Step 4: NAS와 Tailscale ping (양방향 사설망 확인)**
NAS의 Tailscale IP를 `tailscale status` 출력에서 찾아 (예: `100.64.0.10`):
```bash
tailscale ping 100.64.0.10
```
Expected: `pong from <NAS hostname>` (직접 LAN 또는 DERP 중계). 만약 NAS가 Tailscale 미가입이면 별도로 NAS DSM Tailscale 패키지 셋업 필요 — 이는 박재오 결정 사항이라 plan 외.
> **참고:** Tailscale은 spec §3 sense의 사설망 layer 보조. LAN(192.168.45.0/24) 안에서만 작업한다면 Tailscale 없이도 작동. 외부 출장 등에서 NAS↔Windows 통신을 위해 권장.
---
## Task 6: WSL2 안 NAS SMB 자격증명 파일 + 마운트 포인트 준비
**Files:** `/etc/nas-smb-credentials`, `/mnt/nas`
- [ ] **Step 1: cifs-utils 설치 (SMB 마운트 패키지)**
```bash
sudo apt install -y cifs-utils
```
Expected: 설치 완료.
- [ ] **Step 2: SMB 자격증명 파일 생성**
박재오 NAS 계정의 username과 password를 사용. 파일 위치는 system-wide `/etc/`.
```bash
sudo bash -c 'cat > /etc/nas-smb-credentials <<EOF
username=박재오NAS사용자명
password=박재오NAS비밀번호
domain=
EOF'
```
**위 명령 실행 전 `박재오NAS사용자명` / `박재오NAS비밀번호`를 실제 값으로 교체.** Synology DSM Control Panel → User & Group 에서 SMB 접근 권한 있는 계정 사용. 비밀번호에 특수문자 있을 시 escape 필요 (특히 `!`, `$`, `\`).
- [ ] **Step 3: 자격증명 파일 권한 보호**
```bash
sudo chmod 600 /etc/nas-smb-credentials
sudo chown root:root /etc/nas-smb-credentials
```
Expected: 출력 없음.
```bash
ls -la /etc/nas-smb-credentials
```
Expected: `-rw------- 1 root root ... /etc/nas-smb-credentials`
- [ ] **Step 4: 마운트 포인트 생성**
```bash
sudo mkdir -p /mnt/nas
```
---
## Task 7: NAS SMB 마운트 (수동 마운트 + fstab 자동화)
**Files:** `/etc/fstab` (수정)
- [ ] **Step 1: 수동 마운트 시도 (자격증명·경로 검증)**
```bash
sudo mount -t cifs //gahusb.synology.me/docker /mnt/nas \
-o credentials=/etc/nas-smb-credentials,vers=3.0,uid=1000,gid=1000,_netdev
```
Expected: 출력 없음 (성공). 만약 `mount error(13)` (permission) → 자격증명 오류. `mount error(2)` (no such file) → share name `docker` 확인.
> **share name 변형:** 박재오 NAS는 메모리(`feedback_nas_deploy_paths.md`)에 따르면 SMB 매핑이 `/volume1/docker/`를 share `docker`로 노출. 만약 다른 share name(예: `webpage`)이라면 그것으로 교체.
- [ ] **Step 2: 마운트 결과 확인**
```bash
ls /mnt/nas/
```
Expected: `webpage/` 디렉토리 + 다른 share 내 디렉토리 보임.
```bash
ls /mnt/nas/webpage/data/
```
Expected: `insta/`, `music/` 등 후속 트랙에서 사용할 디렉토리. 없으면 후속 트랙에서 생성됨.
- [ ] **Step 3: 마운트 해제 후 fstab으로 자동화**
```bash
sudo umount /mnt/nas
```
Expected: 출력 없음.
`/etc/fstab` 끝에 다음 라인 추가:
```bash
sudo bash -c 'cat >> /etc/fstab <<EOF
# NAS Synology SMB mount for web-ai-services workers (2026-05-18)
//gahusb.synology.me/docker /mnt/nas cifs credentials=/etc/nas-smb-credentials,vers=3.0,uid=1000,gid=1000,_netdev,nofail 0 0
EOF'
```
`nofail` 옵션은 부팅 시 NAS 미접속이어도 boot 진행 (production 안전).
- [ ] **Step 4: fstab 적용 + 검증**
```bash
sudo mount -a
ls /mnt/nas/webpage/data/ 2>&1 | head -5
mount | grep cifs
```
Expected:
- `mount -a` 출력 없음 (성공)
- `ls /mnt/nas/webpage/data/` 디렉토리 내용 표시
- `mount | grep cifs` 라인에 마운트 정보 보임
- [ ] **Step 5: WSL2 재시작 시 자동 마운트 확인**
PowerShell에서 (관리자 권한 불필요):
```powershell
wsl --shutdown
wsl -d Ubuntu-22.04
```
WSL2 다시 진입 후:
```bash
ls /mnt/nas/webpage/data/
```
Expected: 정상 디렉토리 목록. 자동 마운트 성공.
만약 마운트 안 됨:
- `dmesg | grep cifs` 확인
- `nofail` 때문에 boot은 통과했으나 마운트 실패 가능. 수동 `sudo mount -a` 후 동작 확인 → fstab syntax 재검토
---
## Task 8: 통합 검증 — base 인프라 동작 확인
**Files:** 없음 (검증)
- [ ] **Step 1: NAS Redis 외부 ping (Windows 로컬에서)**
```powershell
# Windows AI 또는 박재오 PC에서
Test-NetConnection -ComputerName 192.168.45.54 -Port 6379
```
Expected: `TcpTestSucceeded : True`
> 외부 6379 노출은 LAN 한정. 가능하면 NAS firewall (DSM Control Panel)에서 6379 LAN-only allowed로 한정 권장. (이번 plan에 포함 안 됨, 별도 사용자 작업)
- [ ] **Step 2: WSL2에서 NAS Redis 접속**
WSL2 bash:
```bash
docker run --rm redis:7-alpine redis-cli -h 192.168.45.54 PING
```
또는 Tailscale 사용 시:
```bash
docker run --rm redis:7-alpine redis-cli -h <NAS_TAILSCALE_IP> PING
```
Expected: `PONG`
- [ ] **Step 3: NAS volume 쓰기 테스트 (Windows→NAS 양방향)**
WSL2 bash:
```bash
echo "Plan-B-Base test $(date)" | sudo tee /mnt/nas/webpage/data/.plan-b-test.txt
cat /mnt/nas/webpage/data/.plan-b-test.txt
sudo rm /mnt/nas/webpage/data/.plan-b-test.txt
```
Expected:
- `tee` 출력에 같은 내용 + 파일 생성됨
- `cat` 으로 확인 성공
- 파일 삭제 성공
`sudo` 필요 시 chmod로 uid 1000 쓰기 권한 확인. 또는 mount option `uid=1000,gid=1000` 적용 후 일반 사용자도 쓰기 가능. 만약 안 되면 NAS DSM에서 SMB user의 write 권한 확인.
- [ ] **Step 4: WSL2 Docker로 hello-world 한 번 더 (재진입 후 상태 확인)**
```bash
docker run --rm hello-world
```
Expected: "Hello from Docker!"
- [ ] **Step 5: 모든 검증 완료 후 보고 — 후속 트랙으로 진입 가능 상태**
다음 plan(Plan-B-Insta 등)이 가정하는 상태:
- ✅ NAS `redis:6379` PING/PONG 성공
- ✅ Windows WSL2 Ubuntu-22.04 작동 + Docker Engine 실행
-`/mnt/nas/webpage/data/` 양방향 read·write 성공
- ✅ Tailscale 가입 (선택, 외부 출장 시 필요)
---
## Self-Review
### Spec 커버리지
| Spec 요구사항 | 구현 Task |
|---------------|-----------|
| §4 SP-1: NAS Redis 컨테이너 | Task 1 (compose 추가) + Task 2 (헬스 검증) |
| §10 SP-1: redis:7-alpine + 256MB + AOF + healthcheck | Task 1 Step 2 |
| §4 SP-2: Windows WSL2 + Docker Engine | Task 3 (WSL2) + Task 4 (Docker) |
| §10 SP-2: Tailscale | Task 5 |
| §10 SP-2: NAS SMB mount `/mnt/nas` | Task 6 (자격증명·포인트) + Task 7 (마운트+fstab) |
| §10 SP-2: 검증 (docker ps, tailscale status, ls /mnt/nas) | Task 8 |
| §6 Redis 키 컨벤션 사용 가능 | Task 2 Step 2 (PING) — 컨벤션 자체는 후속 트랙에서 RPUSH로 시작 |
### Placeholder 스캔
- TBD/TODO 없음 ✓
- 모든 명령어가 그대로 실행 가능한 형태 ✓
- 한 가지 예외: Task 6 Step 2 — `박재오NAS사용자명/박재오NAS비밀번호`는 사용자 자격증명이라 placeholder가 의도된 것. 실행 전 교체 명시 ✓
- Task 5 Step 4 — `<NAS 의 Tailscale IP>``tailscale status` 출력에서 박재오가 보고 입력. 사용자 환경에서만 결정 가능, plan에 명시 ✓
### Type/이름 consistency
- `redis` 서비스명 (Task 1, 2, 8 모두 동일) ✓
- `/mnt/nas` 마운트 포인트 (Task 6, 7, 8 모두 동일) ✓
- `/etc/nas-smb-credentials` 자격증명 파일 (Task 6, 7 동일) ✓
- share name `docker` (Task 7 Step 1, fstab 동일) ✓
- Ubuntu-22.04 (Task 3, 4 동일) ✓
### 위험·주의
| 위험 | 완화 |
|------|------|
| Windows 재부팅 시 WSL2 자동 시작 안 함 | 향후 Plan-B-Infra(SP-9)에서 NSSM으로 자동 시작 |
| WSL2 systemd 미지원 시 docker service 자동 시작 안 함 | Task 4 Step 6의 fallback `sudo service docker start` 또는 `/etc/wsl.conf` 수정 |
| SMB 마운트 자격증명 노출 | `/etc/nas-smb-credentials` chmod 600 + root:root |
| NAS firewall에서 6379 외부 노출 | 권장: LAN(192.168.45.0/24) only allow. 본 plan 외 (DSM 수동) |
| Tailscale 미가입 시 NAS↔Windows 외부 통신 불가 | LAN 내에선 작동. 외부 출장 시 필요할 때만 가입 |
| /mnt/nas 쓰기 권한 부족 | uid=1000 mount option + NAS DSM에서 SMB user의 share write 권한 확인 |
---
## 완료 후 다음 단계
Plan-B-Base 완료 후 spec §14 권장 순서대로:
1. **Plan-B-Insta** — SP-3 (insta-render Windows worker) + SP-4 (NAS insta-lab 분할)
2. **Plan-B-Music** — SP-5 + SP-6
3. **Plan-B-Video** — SP-7 + SP-8
4. **Plan-B-Infra** — SP-9 (NSSM 자동 시작) + SP-10 (task-watcher)
각 후속 plan은 본 plan이 제공한 base 인프라(Redis + WSL2/Docker + /mnt/nas)에 의존.

View File

@@ -0,0 +1,656 @@
# Track A — NAS↔Windows API 부하 캐시 강화 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** web-ai → NAS stock 호출량을 분당 12회 → 분당 3~4회로 축소하여, V2 재시작 시점부터 즉시 NAS CPU 부담 70% 감소.
**Architecture:** 2-layer cache. (1) web-ai client side: 3개 endpoint TTL 60/300/60 → 180/600/300으로 증가. (2) NAS stock server side: 동일 endpoint에 in-memory TTLCache 추가하여 web-ai 캐시 miss 시에도 KIS·LLM 재호출 차단. 두 layer가 cumulative하게 작동.
**Tech Stack:** Python 3.12 / FastAPI / pytest / `cachetools.TTLCache`. **two repos**: `web-ai` (signal_v2/) + `web-backend` (stock/).
**Spec:** `web-backend/docs/superpowers/specs/2026-05-18-nas-windows-distributed-architecture-design.md` §4 SP-A1·A2, §10 상세
---
## File Structure
### SP-A1 — web-ai 캐시 TTL (Modify)
| 파일 | 변경 | 책임 |
|------|------|------|
| `web-ai/signal_v2/stock_client.py:13-17` | `_TTL` dict 3개 값 변경 | endpoint별 client-side cache TTL |
| `web-ai/signal_v2/tests/test_stock_client_ttl.py` (Create) | TTL 값 회귀 테스트 | 미래 변경 시 의도하지 않은 회귀 방지 |
### SP-A2 — NAS stock TTLCache (Modify + Create)
| 파일 | 변경 | 책임 |
|------|------|------|
| `web-backend/stock/requirements.txt` | `cachetools>=5.3` 추가 | 의존성 |
| `web-backend/stock/app/webai_cache.py` (Create) | 3개 TTLCache + helper 함수 | server-side cache 중앙화 |
| `web-backend/stock/app/main.py:419-422` | `get_webai_portfolio()` cache 적용 | NAS portfolio 캐시 |
| `web-backend/stock/app/main.py:467-470` | `get_webai_news_sentiment(date)` cache 적용 | date별 캐시 |
| `web-backend/stock/app/screener/router.py:173` | `post_run()` cache 적용 (mode=preview만) | screener preview 캐시 |
| `web-backend/stock/app/test_webai_cache.py` (Create) | cache 동작 + TTL + key 분기 | 캐시 hit/miss 검증 |
---
## Task 1: web-ai SP-A1 — `_TTL` dict 회귀 테스트 작성
**Files:**
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/signal_v2/tests/test_stock_client_ttl.py`
- [ ] **Step 1: 실패하는 테스트 작성**
```python
# tests/test_stock_client_ttl.py
"""SP-A1 회귀 — _TTL이 NAS 부담 완화를 위한 값으로 설정되어 있어야 함."""
from signal_v2.stock_client import _TTL
def test_portfolio_ttl_is_180s():
"""portfolio TTL은 180초 이상 (3분 폴링에서 1회 fetch가 3 폴링 커버)."""
assert _TTL["portfolio"] >= 180.0
def test_news_sentiment_ttl_is_600s():
"""news-sentiment TTL은 600초 이상 (10분, 뉴스 sentiment는 자주 안 바뀜)."""
assert _TTL["news-sentiment"] >= 600.0
def test_screener_preview_ttl_is_300s():
"""screener-preview TTL은 300초 이상 (5분, Top-20은 분 단위로 거의 안 바뀜)."""
assert _TTL["screener-preview"] >= 300.0
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai && python -m pytest signal_v2/tests/test_stock_client_ttl.py -v`
Expected: FAIL — 현재 _TTL 값은 60/300/60. portfolio·screener-preview 모두 < 180/300.
- [ ] **Step 3: `_TTL` 값 변경**
`C:/Users/jaeoh/Desktop/workspace/web-ai/signal_v2/stock_client.py` line 13-17:
변경 전:
```python
_TTL = {
"portfolio": 60.0,
"news-sentiment": 300.0,
"screener-preview": 60.0,
}
```
변경 후:
```python
# Cache TTL by endpoint (seconds).
# 2026-05-18 — NAS 인바운드 호출 부담 완화 (Plan-A SP-A1).
_TTL = {
"portfolio": 180.0, # 3분 (1분 폴링 시 3 폴링당 1회 실제 fetch)
"news-sentiment": 600.0, # 10분 (뉴스 sentiment는 자주 안 바뀜)
"screener-preview": 300.0, # 5분 (Top-20은 분 단위로 거의 안 바뀜)
}
```
- [ ] **Step 4: 테스트 통과 확인**
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai && python -m pytest signal_v2/tests/test_stock_client_ttl.py -v`
Expected: PASS — 3개 모두 통과.
- [ ] **Step 5: 전체 회귀 확인 (기존 56 tests + 신규 3 tests)**
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai && python -m pytest signal_v2/tests/ -v 2>&1 | tail -5`
Expected: 59 tests 모두 PASS (기존 56 + 신규 3).
- [ ] **Step 6: 커밋**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-ai
git add signal_v2/stock_client.py signal_v2/tests/test_stock_client_ttl.py
git commit -m "$(cat <<'EOF'
perf(signal_v2): raise stock_client TTL for NAS load relief (SP-A1)
portfolio 60s → 180s (3분 폴링 → 3회당 1회 fetch)
news-sent 300s → 600s (sentiment는 자주 안 바뀜)
screener 60s → 300s (Top-20 분 단위 변화 미미)
V2 재시작 시점부터 NAS stock에 대한 인바운드 호출이
분당 12 → 분당 3~4 로 감소 예상. 캐시 hit ratio 0~50% → 66~80%.
회귀 테스트 3건 추가로 미래 의도치 않은 TTL 변경 차단.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 2: NAS SP-A2 — `cachetools` 의존성 추가
**Files:**
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/requirements.txt`
- [ ] **Step 1: 현재 requirements.txt 확인**
Run: `cat C:/Users/jaeoh/Desktop/workspace/web-backend/stock/requirements.txt`
파일 끝 확인 — 마지막 줄 newline 여부 확인 (sed/append 안전).
- [ ] **Step 2: cachetools 추가**
`C:/Users/jaeoh/Desktop/workspace/web-backend/stock/requirements.txt` 끝에 한 줄 추가:
```
cachetools>=5.3
```
(파일 마지막에 newline 없으면 newline 먼저, 그 다음 cachetools 줄.)
- [ ] **Step 3: 로컬 import 가능 여부 확인 (선택, NAS rebuild가 정본)**
Run (Windows 로컬에서 docker 외부 검증용, 선택):
```bash
python -c "import cachetools; print(cachetools.__version__)" 2>&1
```
로컬 미설치라면 skip — NAS deployer가 rebuild 시 install. 이 plan은 코드 정합성만 보장.
- [ ] **Step 4: 커밋 (단독 커밋, deps만)**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git add stock/requirements.txt
git commit -m "$(cat <<'EOF'
chore(stock): add cachetools for server-side TTLCache (SP-A2 prep)
다음 커밋에서 /api/webai/portfolio·news-sentiment·screener/run에
in-memory TTLCache 적용 예정.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 3: NAS SP-A2 — `webai_cache.py` 모듈 + 단위 테스트
**Files:**
- Create: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/webai_cache.py`
- Create: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/test_webai_cache.py`
- [ ] **Step 1: 실패하는 테스트 작성**
`C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/test_webai_cache.py`:
```python
"""SP-A2 — webai_cache module의 cache hit/miss + key 분기 검증."""
import time
import pytest
from app.webai_cache import (
PORTFOLIO_CACHE, NEWS_CACHE, SCREENER_CACHE,
cache_get_portfolio, cache_set_portfolio,
cache_get_news, cache_set_news,
cache_get_screener, cache_set_screener,
_screener_key,
)
def _clear_all():
PORTFOLIO_CACHE.clear()
NEWS_CACHE.clear()
SCREENER_CACHE.clear()
def test_portfolio_cache_miss_then_hit():
_clear_all()
assert cache_get_portfolio() is None
cache_set_portfolio({"holdings": [], "cash": 0})
assert cache_get_portfolio() == {"holdings": [], "cash": 0}
def test_news_cache_key_by_date():
"""date가 다르면 별도 캐시 슬롯."""
_clear_all()
cache_set_news("2026-05-18", {"count": 5})
cache_set_news("2026-05-17", {"count": 3})
assert cache_get_news("2026-05-18") == {"count": 5}
assert cache_get_news("2026-05-17") == {"count": 3}
assert cache_get_news("2026-05-16") is None # not cached
def test_news_cache_latest_key_normalized():
"""date=None은 'latest' 키로 정규화되어 동일 슬롯."""
_clear_all()
cache_set_news(None, {"count": 9})
assert cache_get_news(None) == {"count": 9}
def test_screener_key_includes_mode_and_top_n():
"""screener key는 mode + top_n + weights hash로 분기."""
k_preview = _screener_key("preview", 20, None)
k_preview_w = _screener_key("preview", 20, {"news": 0.3})
k_auto = _screener_key("auto", 20, None)
assert k_preview != k_preview_w
assert k_preview != k_auto
def test_screener_cache_roundtrip():
_clear_all()
payload = {"asof": "2026-05-18", "survivors_count": 17}
cache_set_screener("preview", 20, None, payload)
assert cache_get_screener("preview", 20, None) == payload
assert cache_get_screener("preview", 20, {"news": 0.3}) is None
def test_ttl_expiry_portfolio():
"""짧은 ttl로 만료 확인 — 직접 시간 조작 대신 TTLCache 내부 동작 신뢰."""
from cachetools import TTLCache
short = TTLCache(maxsize=1, ttl=0.1) # 0.1초
short["result"] = "x"
assert short.get("result") == "x"
time.sleep(0.2)
assert short.get("result") is None
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -m pytest app/test_webai_cache.py -v`
Expected: FAIL — `app.webai_cache` 모듈 존재 안 함.
- [ ] **Step 3: `webai_cache.py` 작성**
`C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/webai_cache.py`:
```python
"""SP-A2 — NAS stock의 /api/webai/* 엔드포인트 in-memory TTLCache.
web-ai 측 캐시(stock_client._TTL)가 miss됐을 때도 NAS에서 같은 데이터를
KIS·LLM 재호출 없이 즉시 반환하기 위한 2-layer 캐시의 server 측.
V1+V2가 동시 호출해도 NAS는 1회만 계산.
TTL 정책 (spec §10 SP-A2):
- portfolio: 120s (web-ai TTL 180s 보다 짧게 — 변경 감지 가능)
- news: 600s (sentiment는 일 단위)
- screener: 180s
"""
from __future__ import annotations
import hashlib
import json
from typing import Any, Optional
from cachetools import TTLCache
PORTFOLIO_CACHE: TTLCache = TTLCache(maxsize=1, ttl=120.0)
NEWS_CACHE: TTLCache = TTLCache(maxsize=10, ttl=600.0)
SCREENER_CACHE: TTLCache = TTLCache(maxsize=10, ttl=180.0)
# ----- portfolio -----
def cache_get_portfolio() -> Optional[Any]:
return PORTFOLIO_CACHE.get("result")
def cache_set_portfolio(value: Any) -> None:
PORTFOLIO_CACHE["result"] = value
# ----- news-sentiment -----
def _news_key(date: Optional[str]) -> str:
return date if date else "latest"
def cache_get_news(date: Optional[str]) -> Optional[Any]:
return NEWS_CACHE.get(_news_key(date))
def cache_set_news(date: Optional[str], value: Any) -> None:
NEWS_CACHE[_news_key(date)] = value
# ----- screener -----
def _screener_key(mode: str, top_n: int, weights: Optional[dict]) -> str:
"""mode + top_n + weights canonical hash. weights 객체 동등성을 키로."""
if weights is None:
w_repr = "none"
else:
# canonical: sorted keys → md5 hex (긴 weights도 짧은 키로)
canon = json.dumps(weights, sort_keys=True, ensure_ascii=False)
w_repr = hashlib.md5(canon.encode("utf-8")).hexdigest()[:12]
return f"{mode}:{top_n}:{w_repr}"
def cache_get_screener(mode: str, top_n: int, weights: Optional[dict]) -> Optional[Any]:
return SCREENER_CACHE.get(_screener_key(mode, top_n, weights))
def cache_set_screener(mode: str, top_n: int, weights: Optional[dict], value: Any) -> None:
SCREENER_CACHE[_screener_key(mode, top_n, weights)] = value
```
- [ ] **Step 4: 테스트 통과 확인**
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -m pytest app/test_webai_cache.py -v`
Expected: PASS — 6개 모두 통과.
- [ ] **Step 5: 커밋**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git add stock/app/webai_cache.py stock/app/test_webai_cache.py
git commit -m "$(cat <<'EOF'
feat(stock): webai_cache module (TTLCache for SP-A2)
3개의 TTLCache (portfolio 120s · news 600s · screener 180s) +
헬퍼 함수. screener key는 mode + top_n + weights canonical hash로
분기. 다음 커밋에서 /api/webai/portfolio·news-sentiment·screener/run
3 endpoint에 적용.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 4: NAS SP-A2 — `/api/webai/portfolio` 캐시 적용
**Files:**
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/main.py:419-422`
- [ ] **Step 1: 현재 endpoint 코드 확인**
`web-backend/stock/app/main.py` 419-422 line은 spec §10 SP-A2와 일치:
```python
@app.get("/api/webai/portfolio", dependencies=[Depends(verify_webai_key)])
def get_webai_portfolio():
"""web-ai 전용 portfolio (인증 필수, pnl_pct 비율 필드 추가)."""
return _augment_portfolio_with_pnl_pct(get_portfolio())
```
- [ ] **Step 2: 캐시 적용으로 교체**
`web-backend/stock/app/main.py` 419-422 line을 다음으로 교체:
```python
@app.get("/api/webai/portfolio", dependencies=[Depends(verify_webai_key)])
def get_webai_portfolio():
"""web-ai 전용 portfolio (인증 필수, pnl_pct 비율 필드 추가).
SP-A2 server-side TTLCache 적용. V1+V2 동시 호출도 NAS에서 1회 계산.
"""
cached = webai_cache.cache_get_portfolio()
if cached is not None:
return cached
result = _augment_portfolio_with_pnl_pct(get_portfolio())
webai_cache.cache_set_portfolio(result)
return result
```
- [ ] **Step 3: import 추가 (파일 상단)**
`web-backend/stock/app/main.py` 파일 상단 import 블록 (다른 `from .xxx import` 들과 같은 위치)에 추가:
```python
from . import webai_cache
```
- [ ] **Step 4: 빠른 import sanity 체크**
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -c "from app import main; print('OK')"` 2>&1 | tail -3
(`cachetools` 미설치 환경에선 ImportError 가능 → 그 경우 `pip install cachetools` 후 재시도. 실제 검증은 NAS rebuild 후.)
Expected: `OK` 또는 cachetools 누락 메시지 (의도된 상태).
---
## Task 5: NAS SP-A2 — `/api/webai/news-sentiment` 캐시 적용
**Files:**
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/main.py:467-470`
- [ ] **Step 1: 캐시 적용**
`web-backend/stock/app/main.py` 467-470 line을 다음으로 교체:
```python
@app.get("/api/webai/news-sentiment", dependencies=[Depends(verify_webai_key)])
def get_webai_news_sentiment(date: str | None = None):
"""web-ai 전용 news sentiment 일별 dump.
SP-A2 server-side TTLCache 적용. date 파라미터별로 별도 슬롯.
"""
cached = webai_cache.cache_get_news(date)
if cached is not None:
return cached
result = _fetch_news_sentiment_dump(date)
webai_cache.cache_set_news(date, result)
return result
```
- [ ] **Step 2: import sanity 체크**
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -c "from app import main; print('OK')" 2>&1 | tail -3`
Expected: `OK`
---
## Task 6: NAS SP-A2 — `/api/stock/screener/run` 캐시 적용 (preview 모드만)
**Files:**
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/screener/router.py:173-...`
- [ ] **Step 1: 현재 함수 확인 (참고)**
`web-backend/stock/app/screener/router.py:173` 시작 `def post_run(body: schemas.RunRequest):` — 함수 본체는 mode 분기 후 _conn() + KIS 호출 등. 단, `mode == "auto"` 는 휴장일/실 운영 트리거이므로 캐시하지 않음 (매 호출이 다른 의미). `mode == "preview"` 는 frontend·web-ai 폴링용 → 캐시 적용.
- [ ] **Step 2: 함수 진입부에 cache 분기 추가**
`web-backend/stock/app/screener/router.py:173` `@router.post("/run", ...)``def post_run(...)` 본체 **첫 줄들에** 다음 캐시 분기 추가:
변경 전 (line 173-179 근처):
```python
@router.post("/run", response_model=schemas.RunResponse)
def post_run(body: schemas.RunRequest):
from .registry import NODE_REGISTRY as _NR, GATE_REGISTRY as _GR
started_at = dt.datetime.utcnow().isoformat()
with _conn() as c:
asof = _resolve_asof(body.asof, c)
```
변경 후:
```python
@router.post("/run", response_model=schemas.RunResponse)
def post_run(body: schemas.RunRequest):
from .registry import NODE_REGISTRY as _NR, GATE_REGISTRY as _GR
# SP-A2 — preview 모드는 web-ai/frontend 폴링이라 캐시 적용.
# auto 모드는 실제 운영 트리거(휴장일 게이트 등)라 캐시 미적용.
if body.mode == "preview":
cached = webai_cache.cache_get_screener(body.mode, body.top_n, body.weights)
if cached is not None:
return cached
started_at = dt.datetime.utcnow().isoformat()
with _conn() as c:
asof = _resolve_asof(body.asof, c)
```
- [ ] **Step 3: 함수 끝 부분 — preview 결과를 캐시에 저장**
`post_run`의 반환부 직전에 (preview 모드일 때만) 캐시 저장. `post_run` 함수는 결과를 `schemas.RunResponse(...)` 로 만들어 return하는 구조일 것. 정확한 return 위치 확인 후, return 직전에:
`web-backend/stock/app/screener/router.py` `post_run` 함수의 마지막 return 직전에:
```python
# SP-A2 — preview 모드 결과 캐시 저장.
if body.mode == "preview":
webai_cache.cache_set_screener(body.mode, body.top_n, body.weights, response)
return response
```
(`response` 라는 변수가 없으면, 기존 return 표현식을 `response = ...` 로 binding 후 위 코드 추가.)
> **주의:** post_run의 정확한 return 라인을 먼저 확인. `grep -n "return " app/screener/router.py | head` 로 위치 파악 후 적용.
- [ ] **Step 4: import 추가 (router.py 상단)**
`web-backend/stock/app/screener/router.py` 상단 import 블록에 추가:
```python
from .. import webai_cache
```
- [ ] **Step 5: 빠른 import sanity 체크**
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -c "from app.screener import router; print('OK')" 2>&1 | tail -3`
Expected: `OK`
---
## Task 7: 통합 검증 — 기존 테스트 회귀 + SP-A2 신규 테스트
**Files:** (조회만)
- [ ] **Step 1: stock 전체 pytest 실행**
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -m pytest -v 2>&1 | tail -30`
Expected: 기존 모든 테스트 + SP-A2 신규 6 tests 모두 PASS. **0 failed**.
- [ ] **Step 2: 회귀 발견 시 처리**
회귀가 발견되면:
- import 누락 → `from . import webai_cache` 또는 `from .. import webai_cache` 위치 재확인
- screener test가 cache hit으로 fail → test가 `_clear_all()` 또는 cache fixture 통해 격리되어 있는지 확인. 필요 시 conftest에 `autouse=True` cache reset fixture 추가:
```python
# conftest.py에 추가 (선택)
import pytest
from app import webai_cache
@pytest.fixture(autouse=True)
def _reset_webai_cache():
webai_cache.PORTFOLIO_CACHE.clear()
webai_cache.NEWS_CACHE.clear()
webai_cache.SCREENER_CACHE.clear()
yield
```
- [ ] **Step 3: 커밋 (SP-A2 endpoint 통합 + 회귀 확인)**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git add stock/app/main.py stock/app/screener/router.py
# (필요 시) git add stock/app/conftest.py
git commit -m "$(cat <<'EOF'
feat(stock): apply webai_cache to portfolio/news/screener-preview (SP-A2)
3 endpoint cache 적용 — /api/webai/portfolio, /api/webai/news-sentiment,
/api/stock/screener/run (preview 모드만, auto는 캐시 미적용).
V1+V2 동시 호출도 NAS에서 1회 계산. web-ai 측 SP-A1 캐시와 2-layer로
작동하여 NAS 인바운드 부담 70% 감소 예상.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 8: 양쪽 push + NAS deploy 트리거
**Files:** 없음 (git 작업)
- [ ] **Step 1: web-ai push**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-ai
git push origin main
```
Expected: success. 인증 prompt 뜨면 자격증명 입력. 1회 실패 시 1회 재시도 (캐시 패턴).
> **참고:** web-ai는 NAS deployer가 별도 webhook 없음 (Windows 머신 코드). push는 백업/이력 동기화 목적. 실제 적용은 V2 재시작 시점.
- [ ] **Step 2: web-backend push (NAS deployer 트리거)**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git push origin main
```
Expected: success. NAS deployer가 webhook 수신 → `git pull``docker compose build stock --no-cache` (cachetools 신규 설치) → `docker compose up -d stock`. 통상 2~3분 소요.
- [ ] **Step 3: NAS stock 컨테이너 헬스 확인**
```bash
curl -s -o /dev/null -w "HTTP %{http_code}\n" https://gahusb.synology.me/api/stock/news -m 10
```
Expected: `HTTP 200`. (NAS deploy 완료 후 통상 30초 ~ 2분 대기 필요.)
- [ ] **Step 4: webai 캐시 효과 확인 (선택)**
연속 2회 호출 시 두 번째가 즉시 응답하는지 (cached):
```bash
# 인증키 필요. .env의 WEBAI_API_KEY 사용 또는 NAS에서 직접 호출.
# Windows 로컬에서:
# 첫 호출
time curl -s -H "X-WebAI-Key: $WEBAI_API_KEY" https://gahusb.synology.me/api/webai/portfolio -o /dev/null
# 즉시 두번째 (캐시 hit 기대, 첫 호출 < 1s + DB / 두번째 < 100ms)
time curl -s -H "X-WebAI-Key: $WEBAI_API_KEY" https://gahusb.synology.me/api/webai/portfolio -o /dev/null
```
Expected: 두 번째 호출이 첫 번째보다 명확히 빠름 (DB·계산 skip).
---
## Self-Review
### Spec 커버리지
| Spec 요구사항 | 구현 Task |
|---------------|-----------|
| §4 SP-A1: web-ai 캐시 TTL 증가 (180/600/300) | Task 1 |
| §4 SP-A2: NAS stock TTLCache | Task 2~7 |
| §10 SP-A2: 3 endpoint (portfolio/news/screener) 적용 | Task 4 (portfolio), Task 5 (news), Task 6 (screener preview) |
| §10 SP-A2: cachetools 의존성 | Task 2 |
| §8: X-WebAI-Key 인증 (기존 verify_webai_key 유지) | 기존 dependency 그대로, 변경 없음 |
| §6: server cache 별개 (Redis 캐시 옵션) | in-memory TTLCache 선택 (Redis는 SP-1 이후 도입 검토) |
§4의 SP-A2는 `/api/webai/portfolio`, `/api/webai/news-sentiment`, `/api/stock/screener/run` 3건만 명시. 추가 endpoint 캐시는 out of scope (별도 plan에서).
### Placeholder 스캔
- TBD/TODO/"implement later" 패턴 없음 ✓
- 모든 code step에 완전 코드 포함 ✓
- Task 6에 한 가지 conditional ("`post_run`의 정확한 return 라인을 먼저 확인") — 이건 plan 실행 시 grep 명령으로 즉시 해결 가능한 단순 lookup이라 placeholder가 아님. 그러나 안전성 위해 helper note 그대로 유지.
### Type consistency
- `webai_cache.cache_get_portfolio()` / `cache_set_portfolio(value)` — Task 3에서 정의, Task 4에서 사용. 시그니처 일치 ✓
- `cache_get_news(date)` — Task 3·5 일치 ✓
- `cache_get_screener(mode, top_n, weights)` / `cache_set_screener(mode, top_n, weights, value)` — Task 3·6 일치 ✓
- 변수명 `cached`, `result`, `payload` — 각 함수 안에서만 사용, 충돌 없음 ✓
### 위험·주의
- **NAS deployer rebuild**: `requirements.txt` 변경은 docker image rebuild 필요. deployer가 변경 감지 시 rebuild 트리거. 만약 deployer가 변경 미감지(예: requirements.txt만 변경 시 rebuild 안 함)라면 NAS에서 `docker compose build stock --no-cache && docker compose up -d stock` 수동 실행 필요.
- **Cache stale**: TTL이 충분히 짧아 stale 문제 미미. portfolio 120s = web-ai 폴링 주기(1분) 2배. 변경 감지에 최대 2분 지연.
- **Cache miss thunder herd**: V1+V2가 정확히 동시에 캐시 miss 시 KIS 동시 호출 가능. 현재 V1/V2 둘 다 정지 상태라 risk 0. 향후 재시작 시 KIS rate limit 모니터링 필요 (별도 plan 항목).
---
## 완료 후 다음 단계
Plan-A 완료 후 spec §14 "차후 plan 작성 순서 권장"대로:
1. **Plan-B-Base** — SP-1 (Redis) + SP-2 (WSL2)
2. **Plan-B-Insta** — SP-3 + SP-4
3. **Plan-B-Music** — SP-5 + SP-6
4. **Plan-B-Video** — SP-7 + SP-8
5. **Plan-B-Infra** — SP-9 + SP-10
각각은 별도 brainstorm 없이 spec §10에서 직접 plan 작성 가능 (이미 명세 충분).

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,929 @@
# Plan-B-Infra — NSSM 자동 시작 + task-watcher (시간대 큐 토글) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Windows AI 머신의 서비스(ai_trade + WSL2 Docker)를 NSSM으로 부팅 시 자동 시작 + 우선순위 설정(SP-9), 그리고 시간대 기반으로 `queue:paused`를 토글하는 task-watcher 컨테이너 신설(SP-10). 트레이딩 시간대(비휴장 평일 07:0016:30)에 무거운 render 작업을 일시정지하여 KIS 트레이딩 우선순위 보장.
**Architecture:** task-watcher는 WSL2 Docker 컨테이너로 30초마다 `current_mode()` 판정(KST 시각 + NAS `/api/stock/holidays` 조회) → 트레이딩 시간대면 `SET queue:paused 1 EX 600`, 그 외엔 `DEL queue:paused`. 모든 render worker(insta/music/video)가 BLPOP 전 `queue:paused`를 확인하므로 단일 키로 전체 일시정지. NSSM(SP-9)은 박재오 Windows 머신에서 수동 설치 — plan은 정확한 명령 + 안내 문서 제공.
**Tech Stack:** Python 3.12 / `redis>=5.0` / `httpx` (holidays fetch) / `zoneinfo` (KST) / Docker Engine in WSL2 / NSSM (Windows service manager) / FastAPI (NAS stock holidays endpoint)
**Spec:** `web-backend/docs/superpowers/specs/2026-05-18-nas-windows-distributed-architecture-design.md` §3 시간대별 우선순위 모드, §10 SP-9·SP-10. **박재오 결정 (2026-05-22): idle/게임 감지 생략 — 시간대만으로 토글** (spec §3의 "박재오 활동 감지 시 SET" → "트레이딩 시간대면 무조건 SET"). idle 감지가 없으므로 WSL2 컨테이너로 구현 가능 (Win32 input API 불필요).
**Spec 갱신 사항 (현 상태 반영):**
- `signal_v2`**`ai_trade`** (rename 완료, web-ai/ai_trade/)
- `Ubuntu-22.04`**`Ubuntu-24.04`** (Plan-B-Base에서 변경)
- `web-ai-services`**`web-ai/services`** (실제 경로)
- `/api/stock/holidays` endpoint **미존재 → 신설** (Task 1)
**Prerequisites (✅ 모두 완료):**
- Plan-A / Plan-B-Base / Plan-B-Insta / Plan-B-Music / Plan-B-Video 모두 완료
- WSL2 mirror mode + Redis chown 999:999 영구 적용
- services/.env 분기 패턴 정착 (NAS_BASE_URL service-local default)
---
## Phase 구조
| Phase | 내용 | Task |
|-------|------|------|
| **1. NAS stock holidays endpoint** | `/api/stock/holidays` GET 신설 (task-watcher가 조회) | 1 |
| **2. Windows task-watcher** | mode 판정 + Redis 토글 loop + Dockerfile + compose | 2~6 |
| **3. NSSM 안내 + 검증** | SP-9 NSSM 안내 문서 + 박재오 빌드 + end-to-end | 7~8 |
---
## File Structure
### Phase 1 — NAS web-backend
| 파일 | 변경 | 책임 |
|------|------|------|
| `web-backend/stock/app/main.py` | `GET /api/stock/holidays` endpoint 추가 | holidays.json + 주말 노출 |
| `web-backend/stock/app/test_holidays_endpoint.py` (Create) | 2 tests | TDD |
### Phase 2 — Windows web-ai/services/task-watcher
| 파일 | 변경 | 책임 |
|------|------|------|
| `web-ai/services/task-watcher/mode.py` (Create) | `current_mode(now, holidays)` 순수 함수 + `fetch_holidays()` | 시간대 판정 |
| `web-ai/services/task-watcher/watcher.py` (Create) | 30초 loop + Redis 토글 | dispatcher |
| `web-ai/services/task-watcher/main.py` (Create) | FastAPI + lifespan(watcher spawn) + /health | entry |
| `web-ai/services/task-watcher/Dockerfile` (Create) | python:3.12-slim | image |
| `web-ai/services/task-watcher/requirements.txt` (Create) | fastapi, redis, httpx, pytest | deps |
| `web-ai/services/task-watcher/.env.example` (Create) | REDIS_URL, STOCK_BASE_URL, TRADING_START, TRADING_END | secrets |
| `web-ai/services/task-watcher/tests/test_mode.py` (Create) | current_mode 6 cases | TDD |
| `web-ai/services/task-watcher/tests/__init__.py` (Create) | 빈 marker | pkg |
| `web-ai/services/docker-compose.yml` | task-watcher service 추가 (port 18713) | compose |
### Phase 3 — 안내 문서
| 파일 | 변경 | 책임 |
|------|------|------|
| `web-ai/services/task-watcher/NSSM_SETUP.md` (Create) | SP-9 NSSM 설치 안내 (ai_trade + wsl_docker + task-watcher) | 박재오 수동 가이드 |
---
## Task 1: NAS stock — `/api/stock/holidays` endpoint + tests
**Files:**
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/main.py`
- Create: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/test_holidays_endpoint.py`
### Step 1: 실패 테스트 작성
`C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/test_holidays_endpoint.py`:
```python
"""GET /api/stock/holidays — task-watcher 휴장일 조회용."""
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_holidays_returns_list():
r = client.get("/api/stock/holidays")
assert r.status_code == 200
data = r.json()
assert "holidays" in data
assert isinstance(data["holidays"], list)
def test_holidays_entries_are_iso_dates():
r = client.get("/api/stock/holidays")
holidays = r.json()["holidays"]
# 비어 있지 않다면 ISO date 형식 (YYYY-MM-DD)
if holidays:
import datetime as dt
for h in holidays[:5]:
dt.date.fromisoformat(h) # raise 안 하면 통과
```
### Step 2: 테스트 실패 확인
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -m pytest app/test_holidays_endpoint.py -v`
Expected: FAIL — endpoint 404.
### Step 3: `main.py`에 endpoint 추가
`C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/main.py`에서 `_HOLIDAYS_PATH` (현재 line 82 부근) 정의를 활용. 적절한 위치(다른 `@app.get` 근처)에 추가:
```python
@app.get("/api/stock/holidays")
def get_holidays():
"""task-watcher가 조회하는 휴장일 목록. holidays.json 그대로 노출 (인증 불필요)."""
import json
try:
with open(_HOLIDAYS_PATH, encoding="utf-8") as f:
data = json.load(f)
# holidays.json 구조가 list이거나 {"holidays": [...]} 또는 {year: [...]} 형태일 수 있음
if isinstance(data, list):
holidays = data
elif isinstance(data, dict) and "holidays" in data:
holidays = data["holidays"]
elif isinstance(data, dict):
# {year: [dates]} → flatten
holidays = [d for v in data.values() if isinstance(v, list) for d in v]
else:
holidays = []
except (OSError, ValueError):
holidays = []
return {"holidays": holidays}
```
**주의:** 작성 전 `holidays.json` 실제 구조를 확인할 것 (`Read web-backend/stock/app/holidays.json`). 위 코드는 list / `{"holidays":[]}` / `{year:[]}` 3가지 형태를 모두 처리하지만, 실제 구조에 맞게 단순화 가능.
### Step 4: 테스트 통과
Run: `python -m pytest app/test_holidays_endpoint.py -v`
Expected: 2 PASS.
### Step 5: 회귀 확인
Run: `python -m pytest app/ -v 2>&1 | tail -5`
Expected: 기존 stock 테스트 모두 통과 + 새 2개.
### Step 6: 커밋
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git add stock/app/main.py stock/app/test_holidays_endpoint.py
git commit -m "$(cat <<'EOF'
feat(stock): GET /api/stock/holidays endpoint (SP-10 task-watcher용)
holidays.json 노출. task-watcher가 휴장일 판정에 조회.
인증 불필요 (민감 정보 아님). 주말은 task-watcher가 weekday로 별도 판정.
Plan-B-Infra Phase 1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
## Context
- spec §3: "휴장일 단일 소스 — web-backend/stock/app/holidays.json 정본. NAS stock이 GET /api/stock/holidays로 노출."
- 현재 holidays.json은 `_is_holiday()` 내부 함수에서만 사용, HTTP endpoint 없음 → 신설.
- stock 컨테이너는 이미 deploy.sh BUILD_TARGETS에 등재됨 (신규 lab 아님 — deploy scripts 추가 불필요).
- 작업 디렉토리: `C:/Users/jaeoh/Desktop/workspace/web-backend`
## Report
- Status: DONE | DONE_WITH_CONCERNS | BLOCKED
- holidays.json 실제 구조 (확인 결과)
- 2 PASS + 회귀
- 커밋 SHA
---
## Task 2: Windows task-watcher — mode.py (current_mode + fetch_holidays) + tests
**Files:**
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/mode.py`
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/tests/__init__.py`
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/tests/test_mode.py`
### Step 1: 실패 테스트 작성
`tests/__init__.py`: (빈 파일)
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/tests/test_mode.py`:
```python
"""current_mode — 시간대 + 휴장일 판정 (순수 함수)."""
import datetime as dt
from zoneinfo import ZoneInfo
from mode import current_mode
KST = ZoneInfo("Asia/Seoul")
HOLIDAYS = {"2026-05-25"} # 가상 휴장일 (월요일)
def _kst(y, m, d, hh, mm):
return dt.datetime(y, m, d, hh, mm, tzinfo=KST)
def test_weekday_trading_hours_is_trading():
# 2026-05-22 금요일 10:00 — 트레이딩 시간대
assert current_mode(_kst(2026, 5, 22, 10, 0), HOLIDAYS) == "trading"
def test_weekday_before_open_is_free():
# 평일 06:00 — 장 전
assert current_mode(_kst(2026, 5, 22, 6, 0), HOLIDAYS) == "free"
def test_weekday_after_close_is_free():
# 평일 17:00 — 장 마감 후
assert current_mode(_kst(2026, 5, 22, 17, 0), HOLIDAYS) == "free"
def test_weekend_is_free():
# 2026-05-23 토요일 10:00
assert current_mode(_kst(2026, 5, 23, 10, 0), HOLIDAYS) == "free"
def test_holiday_weekday_is_free():
# 2026-05-25 월요일이지만 휴장일 → 트레이딩 시간대라도 free
assert current_mode(_kst(2026, 5, 25, 10, 0), HOLIDAYS) == "free"
def test_trading_boundary_inclusive_start_exclusive_end():
# 07:00 정각 = 트레이딩 시작, 16:30 정각 = 마감 (16:30은 free)
assert current_mode(_kst(2026, 5, 22, 7, 0), HOLIDAYS) == "trading"
assert current_mode(_kst(2026, 5, 22, 16, 29), HOLIDAYS) == "trading"
assert current_mode(_kst(2026, 5, 22, 16, 30), HOLIDAYS) == "free"
```
### Step 2: 테스트 실패 확인
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher && python -m pytest tests/test_mode.py -v`
Expected: FAIL — `mode` 모듈 미존재.
### Step 3: `mode.py` 작성
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/mode.py`:
```python
"""시간대 + 휴장일 기반 모드 판정 (idle 감지 생략 — 박재오 결정 2026-05-22).
trading: 비휴장 평일 07:0016:30 (장중) → queue:paused SET
free: 그 외 (장 전/후, 주말, 휴장) → queue:paused DEL
"""
from __future__ import annotations
import datetime as dt
import logging
import os
from typing import Set
from zoneinfo import ZoneInfo
import httpx
logger = logging.getLogger(__name__)
KST = ZoneInfo("Asia/Seoul")
STOCK_BASE_URL = os.getenv("STOCK_BASE_URL", "http://192.168.45.54:18500")
# 트레이딩 윈도우 (HH:MM, KST). .env로 조정 가능.
TRADING_START = os.getenv("TRADING_START", "07:00")
TRADING_END = os.getenv("TRADING_END", "16:30")
def _parse_hhmm(s: str) -> dt.time:
hh, mm = s.split(":")
return dt.time(int(hh), int(mm))
def current_mode(now: dt.datetime, holidays: Set[str]) -> str:
"""now(KST aware) + holidays(ISO date set) → 'trading' | 'free'."""
# 주말 (토=5, 일=6)
if now.weekday() >= 5:
return "free"
# 휴장일
if now.date().isoformat() in holidays:
return "free"
# 트레이딩 윈도우 [start, end)
start = _parse_hhmm(TRADING_START)
end = _parse_hhmm(TRADING_END)
t = now.timetz().replace(tzinfo=None)
if start <= t < end:
return "trading"
return "free"
def fetch_holidays() -> Set[str]:
"""NAS stock /api/stock/holidays 조회. 실패 시 빈 set (안전 — free로 판정)."""
try:
r = httpx.get(f"{STOCK_BASE_URL}/api/stock/holidays", timeout=10.0)
if r.status_code == 200:
return set(r.json().get("holidays", []))
logger.warning("holidays fetch returned %d", r.status_code)
except Exception:
logger.exception("holidays fetch 실패")
return set()
```
### Step 4: 테스트 통과
Run: `python -m pytest tests/test_mode.py -v`
Expected: 6 PASS.
### Step 5: 커밋
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-ai
git add services/task-watcher/mode.py services/task-watcher/tests/__init__.py services/task-watcher/tests/test_mode.py
git commit -m "$(cat <<'EOF'
feat(task-watcher): mode.py — 시간대+휴장일 판정 (SP-10)
current_mode(now, holidays): 비휴장 평일 07:0016:30 → trading, 그 외 free.
fetch_holidays(): NAS /api/stock/holidays 조회 (실패 시 빈 set = free 안전).
TRADING_START/END env로 윈도우 조정. idle 감지 생략 (박재오 결정).
6 tests (평일 장중/장전/장후, 주말, 휴장, 경계).
Plan-B-Infra Phase 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
## Context
- KST 시각 + holidays set → trading/free 순수 함수. 테스트 용이 (now를 인자로).
- holidays는 fetch_holidays()로 NAS 조회. 매 loop마다 호출하면 부하 — watcher.py에서 캐싱 (Task 3).
- 작업 디렉토리: `C:/Users/jaeoh/Desktop/workspace/web-ai`
## Report
- Status / 6 PASS / 커밋 SHA
---
## Task 3: Windows task-watcher — watcher.py (Redis 토글 loop)
**Files:**
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/watcher.py`
### Step 1: `watcher.py` 작성
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/watcher.py`:
```python
"""30초마다 current_mode 판정 → queue:paused 토글.
trading → SET queue:paused 1 EX 600 (10분 TTL — watcher 죽어도 자동 해제)
free → DEL queue:paused
holidays는 1시간마다 refresh (매 loop fetch 부하 회피).
"""
from __future__ import annotations
import asyncio
import datetime as dt
import logging
import os
from zoneinfo import ZoneInfo
import redis.asyncio as aioredis
from mode import current_mode, fetch_holidays, KST
logger = logging.getLogger(__name__)
REDIS_URL = os.getenv("REDIS_URL", "redis://192.168.45.54:6379")
PAUSED_KEY = "queue:paused"
LOOP_INTERVAL = 30 # 초
HOLIDAYS_REFRESH = 3600 # 1시간
PAUSED_TTL = 600 # 10분 (watcher 죽어도 자동 해제)
async def watcher_loop():
redis = aioredis.from_url(REDIS_URL, decode_responses=False)
holidays = fetch_holidays()
last_holiday_refresh = dt.datetime.now(KST)
last_mode = None
logger.info("task-watcher started (trading window 토글)")
while True:
try:
now = dt.datetime.now(KST)
# holidays 주기적 refresh
if (now - last_holiday_refresh).total_seconds() >= HOLIDAYS_REFRESH:
holidays = fetch_holidays()
last_holiday_refresh = now
mode = current_mode(now, holidays)
if mode == "trading":
await redis.set(PAUSED_KEY, b"1", ex=PAUSED_TTL)
else:
await redis.delete(PAUSED_KEY)
if mode != last_mode:
logger.info("mode 전환: %s%s (paused=%s)", last_mode, mode, mode == "trading")
last_mode = mode
await asyncio.sleep(LOOP_INTERVAL)
except asyncio.CancelledError:
logger.info("watcher_loop cancelled")
raise
except Exception:
logger.exception("watcher_loop iteration 실패, 30초 후 재시도")
await asyncio.sleep(LOOP_INTERVAL)
```
### Step 2: 임포트 smoke
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher && python -c "from watcher import watcher_loop; print('OK')"`
Expected: `OK`.
### Step 3: 커밋
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-ai
git add services/task-watcher/watcher.py
git commit -m "$(cat <<'EOF'
feat(task-watcher): watcher.py — 30초 loop + queue:paused 토글 (SP-10)
trading → SET queue:paused 1 EX 600 / free → DEL.
holidays 1시간마다 refresh. PAUSED_TTL 600s (watcher 죽어도 자동 해제 — 안전).
mode 전환 시에만 로그.
Plan-B-Infra Phase 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
## Context
- `PAUSED_TTL=600`이 핵심 안전장치: task-watcher가 죽어도 10분 후 자동으로 paused 해제 → 큐 영구 정지 방지.
- holidays는 1시간 캐싱 (매 30초 fetch 안 함).
- render worker들(insta/music/video)이 이미 `queue:paused` 체크 로직 보유 (Plan-B-Insta/Music/Video).
## Report
- Status / smoke 결과 / 커밋 SHA
---
## Task 4: Windows task-watcher — main.py + Dockerfile + requirements + .env.example
**Files:**
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/main.py`
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/Dockerfile`
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/requirements.txt`
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/.env.example`
### Step 1: `requirements.txt`
```
fastapi==0.115.6
uvicorn[standard]==0.34.0
redis>=5.0
httpx>=0.27
pytest>=8.0
```
### Step 2: `Dockerfile`
```dockerfile
FROM python:3.12-slim-bookworm
ENV PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates tzdata \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir --timeout 600 --retries 5 -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
```
(tzdata 추가 — zoneinfo Asia/Seoul 사용.)
### Step 3: `.env.example`
```
# Plan-B-Infra — task-watcher
# NAS Redis
REDIS_URL=redis://192.168.45.54:6379
# NAS stock holidays endpoint
STOCK_BASE_URL=http://192.168.45.54:18500
# 트레이딩 윈도우 (KST, HH:MM) — 이 시간대에만 queue:paused
TRADING_START=07:00
TRADING_END=16:30
```
### Step 4: `main.py`
```python
"""task-watcher FastAPI entry — health + lifespan (watcher loop spawn)."""
from __future__ import annotations
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
import watcher
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
watcher_task = asyncio.create_task(watcher.watcher_loop())
logger.info("task-watcher lifespan 시작")
try:
yield
finally:
watcher_task.cancel()
try:
await watcher_task
except asyncio.CancelledError:
pass
logger.info("task-watcher lifespan 종료")
app = FastAPI(lifespan=lifespan)
@app.get("/health")
def health():
return {"ok": True, "service": "task-watcher"}
```
### Step 5: smoke + 회귀
Run:
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher
python -c "from main import app; print(len(app.routes))"
python -m pytest tests/ -v 2>&1 | tail -5
```
Expected: 숫자 출력 + 6 PASS (test_mode).
### Step 6: 커밋
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-ai
git add services/task-watcher/main.py services/task-watcher/Dockerfile services/task-watcher/requirements.txt services/task-watcher/.env.example
git commit -m "$(cat <<'EOF'
feat(task-watcher): main.py + Dockerfile + requirements + env (SP-10)
FastAPI lifespan에서 watcher_loop 스폰. /health. tzdata(zoneinfo Asia/Seoul).
.env: REDIS_URL, STOCK_BASE_URL, TRADING_START/END.
Plan-B-Infra Phase 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
## Report
- Status / routes 개수 / 6 PASS / 커밋 SHA
---
## Task 5: Windows services/docker-compose — task-watcher entry
**Files:**
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/docker-compose.yml`
### Step 1: video-render service 다음에 task-watcher 추가
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/docker-compose.yml`에 추가:
```yaml
task-watcher:
build:
context: ./task-watcher
container_name: task-watcher
restart: unless-stopped
ports:
- "18713:8000"
environment:
- TZ=Asia/Seoul
- REDIS_URL=${REDIS_URL:-redis://192.168.45.54:6379}
- STOCK_BASE_URL=${STOCK_BASE_URL:-http://192.168.45.54:18500}
- TRADING_START=${TRADING_START:-07:00}
- TRADING_END=${TRADING_END:-16:30}
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 60s
timeout: 5s
retries: 3
```
### Step 2: YAML 검증
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai/services && python -c "import yaml; yaml.safe_load(open('docker-compose.yml')); print('valid YAML')"`
Expected: `valid YAML`.
### Step 3: 커밋 + push
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-ai
git add services/docker-compose.yml
git commit -m "$(cat <<'EOF'
feat(task-watcher): services/docker-compose entry (SP-10)
port 18713, REDIS_URL/STOCK_BASE_URL/TRADING_START/END env.
insta/music/video-render와 같은 services 묶음. outbound only.
Plan-B-Infra Phase 2 완료 — 박재오 빌드 대기.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
git push 2>&1 # 자격증명 실패 시 박재오 수동 push
```
## Report
- Status / YAML 검증 / 커밋 SHA / push 결과
---
## Task 6: NSSM 안내 문서 (SP-9)
**Files:**
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/NSSM_SETUP.md`
SP-9는 박재오 Windows 머신에서 NSSM 수동 설치. controller는 정확한 명령 + 안내 문서 작성. (코드 아님 — 안내 문서.)
### Step 1: `NSSM_SETUP.md` 작성
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/NSSM_SETUP.md`:
```markdown
# NSSM 자동 시작 설정 (SP-9)
Windows AI 머신 부팅 시 ai_trade(트레이딩) + WSL2 Docker(render workers + task-watcher) 자동 시작.
## 1. NSSM 다운로드
https://nssm.cc/download → nssm-2.24.zip → `C:\nssm\nssm.exe` 배치 (또는 PATH 등록).
## 2. ai_trade (Native Python, HIGH priority)
⚠️ spec의 signal_v2는 ai_trade로 rename됨. 경로/포트 확인.
```powershell
# 관리자 PowerShell
C:\nssm\nssm.exe install ai_trade "C:\Python312\python.exe" "-m uvicorn main:app --host 0.0.0.0 --port 8001"
C:\nssm\nssm.exe set ai_trade AppDirectory "C:\Users\jaeoh\Desktop\workspace\web-ai\ai_trade"
C:\nssm\nssm.exe set ai_trade Priority HIGH_PRIORITY_CLASS
C:\nssm\nssm.exe set ai_trade Start SERVICE_AUTO_START
C:\nssm\nssm.exe set ai_trade AppStdout "C:\Users\jaeoh\nssm-logs\ai_trade.log"
C:\nssm\nssm.exe set ai_trade AppStderr "C:\Users\jaeoh\nssm-logs\ai_trade.log"
```
(ai_trade의 실제 진입점이 main:app + port 8001인지 확인. 다르면 조정.)
## 3. WSL2 Docker (NORMAL priority — render workers + task-watcher)
```powershell
C:\nssm\nssm.exe install wsl_docker "C:\Windows\System32\wsl.exe" "-d Ubuntu-24.04 -- sh -c 'sudo service docker start && cd /workspace/web-ai/services && docker compose up -d'"
C:\nssm\nssm.exe set wsl_docker Priority NORMAL_PRIORITY_CLASS
C:\nssm\nssm.exe set wsl_docker Start SERVICE_AUTO_START
C:\nssm\nssm.exe set wsl_docker AppStdout "C:\Users\jaeoh\nssm-logs\wsl_docker.log"
```
⚠️ 변경점: Ubuntu-22.04 → **Ubuntu-24.04**, web-ai-services → **web-ai/services**. WSL 경로는 `/mnt/c/...` 또는 박재오 WSL 마운트 기준 (`/workspace`가 web-ai에 매핑되어 있으면 그대로).
`sudo service docker start`가 비밀번호 요구하면 sudoers에 NOPASSWD 추가:
```bash
# WSL2 안
echo "$USER ALL=(ALL) NOPASSWD: /usr/sbin/service docker start" | sudo tee /etc/sudoers.d/docker-start
```
## 4. 서비스 시작 + 확인
```powershell
C:\nssm\nssm.exe start ai_trade
C:\nssm\nssm.exe start wsl_docker
# 상태 확인
C:\nssm\nssm.exe status ai_trade
C:\nssm\nssm.exe status wsl_docker
sc query ai_trade
```
## 5. 검증
```powershell
# ai_trade
curl http://localhost:8001/health # 또는 ai_trade의 실제 health endpoint
# WSL2 docker 컨테이너 (재부팅 후 자동 시작 확인)
wsl -d Ubuntu-24.04 -- docker ps
# insta-render, music-render, video-render, task-watcher 4개 Up 확인
```
## 6. 재부팅 테스트
Windows 재부팅 → 로그인 → 수동 조작 없이:
- ai_trade 서비스 자동 시작 (HIGH priority)
- WSL2 + Docker + 4 컨테이너 자동 시작 (NORMAL priority)
- task-watcher가 trading window에 queue:paused 토글 시작
## task-watcher 동작 확인
```bash
# WSL2
docker logs task-watcher --tail 20
# 기대: "task-watcher started" + mode 전환 로그 (trading/free)
# Redis 큐 상태 (NAS 또는 LAN)
docker exec redis redis-cli GET queue:paused
# 트레이딩 시간대(평일 07:00-16:30): "1"
# 그 외: (nil)
```
```
### Step 2: 커밋 + push
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-ai
git add services/task-watcher/NSSM_SETUP.md
git commit -m "$(cat <<'EOF'
docs(task-watcher): NSSM_SETUP.md — SP-9 자동 시작 안내
ai_trade(HIGH, native python :8001) + wsl_docker(NORMAL, WSL2 Ubuntu-24.04
docker compose up). spec의 signal_v2→ai_trade, 22.04→24.04, web-ai-services
→web-ai/services 정정. sudoers NOPASSWD + 재부팅 검증 절차.
Plan-B-Infra Phase 3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
git push 2>&1
```
## Report
- Status / 커밋 SHA / push 결과
---
## Task 7: 박재오 빌드 + task-watcher 검증
**Files:** (변경 없음 — 박재오 측 작업 + 검증)
### Step 1: web-backend push (Task 1 holidays endpoint)
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-backend && git push
```
→ NAS deployer가 stock 컨테이너 rebuild. `/api/stock/holidays` 활성화.
### Step 2: 박재오 NAS 측 holidays endpoint 확인
```bash
curl https://gahusb.synology.me/api/stock/holidays
# → {"holidays": ["2026-01-01", ...]}
```
### Step 3: 박재오 Windows 측 task-watcher 빌드
```bash
cd /workspace/web-ai && git pull
cd /workspace/web-ai/services
docker compose build task-watcher
docker compose up -d task-watcher
docker logs task-watcher --tail 20
# 기대: "task-watcher lifespan 시작" + "task-watcher started" + mode 로그
curl -m 3 http://localhost:18713/health
```
### Step 4: 시간대 토글 검증
현재 KST 시각 기준:
```bash
# 트레이딩 시간대(평일 07:00-16:30)면 paused=1, 아니면 nil
docker exec task-watcher python -c "import datetime as dt; from zoneinfo import ZoneInfo; from mode import current_mode, fetch_holidays; print('now mode:', current_mode(dt.datetime.now(ZoneInfo('Asia/Seoul')), fetch_holidays()))"
# Redis 확인 (NAS 또는 LAN)
ssh nas
docker exec redis redis-cli GET queue:paused
```
기대:
- 평일 07:00-16:30 (비휴장): `current_mode` = "trading", `queue:paused` = "1"
- 그 외: "free", (nil)
### Step 5: render worker가 paused 존중하는지 (선택)
트레이딩 시간대에 video 생성 요청 → worker가 BLPOP 전 paused 확인 → 10초 대기 반복 (처리 보류). free 시간대 되면 자동 처리. (이미 Plan-B-Insta/Music/Video worker에 `queue:paused` 체크 로직 있음.)
### Step 6: 메모리 기록
`reference_plan_b_infra_complete.md` 작성 + MEMORY.md 인덱스 추가 (Task 8에서).
## Report
- holidays endpoint 응답
- task-watcher health + mode
- queue:paused 토글 확인
---
## Task 8: 메모리 기록 + 최종 정리
**Files:**
- Create: `C:/Users/jaeoh/.claude/projects/C--Users-jaeoh-Desktop-workspace-web-ui/memory/reference_plan_b_infra_complete.md`
- Modify: `C:/Users/jaeoh/.claude/projects/C--Users-jaeoh-Desktop-workspace-web-ui/memory/MEMORY.md`
### Step 1: `reference_plan_b_infra_complete.md`
```markdown
---
name: plan-b-infra-complete
description: 2026-05-22 Plan-B-Infra — NSSM 자동 시작(SP-9) + task-watcher 시간대 큐 토글(SP-10). spec 12 SP 전부 완료
metadata:
type: reference
---
Plan-B-Infra 2026-05-22 완료. spec §10 SP-9 + SP-10. 이로써 NAS↔Windows 분산 아키텍처 spec의 12 SP 전부 완료.
## SP-10 task-watcher (구현)
- web-ai/services/task-watcher/ WSL2 컨테이너 (port 18713)
- 30초 loop: current_mode(KST + holidays) → queue:paused 토글
- trading(비휴장 평일 07:00-16:30) → SET queue:paused 1 EX 600 / free → DEL
- **idle/게임 감지 생략** (박재오 결정 2026-05-22) — WSL2 컨테이너는 Win32 input API 접근 불가. 시간대만으로 판정.
- PAUSED_TTL 600s = watcher 죽어도 10분 후 자동 해제 (큐 영구정지 방지 안전장치)
- holidays는 NAS GET /api/stock/holidays (신설) 1시간 캐싱
- TRADING_START/END env로 윈도우 조정
## SP-9 NSSM (박재오 수동)
- NSSM_SETUP.md 안내 문서. ai_trade(HIGH, native :8001) + wsl_docker(NORMAL, WSL2 docker compose up)
- spec 정정: signal_v2→ai_trade, Ubuntu-22.04→24.04, web-ai-services→web-ai/services
## NAS holidays endpoint (신설)
- GET /api/stock/holidays — holidays.json 노출. 기존엔 _is_holiday() 내부 함수만 있었음.
## 다음
- frontend video/music/insta UI (backend gateway만 완료, UI 별도)
- FOLLOW-UP B: -lab suffix 제거
```
### Step 2: MEMORY.md 인덱스 추가
`reference_plan_b_video_complete.md` 항목 뒤:
```markdown
- [Plan-B-Infra 완료](reference_plan_b_infra_complete.md) — 2026-05-22 NSSM 자동 시작(SP-9) + task-watcher 시간대 큐 토글(SP-10). idle 감지 생략. spec 12 SP 전부 완료
```
### Step 3: 양쪽 push 확인
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-backend && git status && git log --oneline -3
cd C:/Users/jaeoh/Desktop/workspace/web-ai && git status && git log --oneline -5
```
### Step 4: 박재오 보고
- spec 12 SP 전부 완료
- task-watcher 시간대 토글 동작
- NSSM은 박재오 수동 (NSSM_SETUP.md 참고)
## Report
- 메모리 파일 생성
- push 상태
- 최종 보고
---
## Self-Review
**1. Spec coverage**
| Spec 요구사항 | 구현 위치 | 상태 |
|--------------|-----------|------|
| SP-9 §10: NSSM ai_trade(HIGH) + wsl_docker(NORMAL) 자동 시작 | Task 6 NSSM_SETUP.md | ✓ (박재오 수동 + 안내) |
| SP-10 §10: task-watcher 컨테이너 30초 loop | Task 3 watcher.py | ✓ |
| SP-10 §10: current_mode (시간대 + holidays + KST) | Task 2 mode.py | ✓ |
| SP-10 §10: queue:paused 토글 (free→DEL, trading→SET) | Task 3 | ✓ |
| §3 휴장일 단일 소스 GET /api/stock/holidays | Task 1 | ✓ (신설) |
| 박재오 결정: idle 감지 생략 — 시간대만 | Task 2 (is_user_active 제거) | ✓ |
| §3 트레이딩 모드 = 평일 비휴장 07:00-16:30 | Task 2 TRADING_START/END | ✓ |
**spec 대비 의도적 변경 (박재오 승인):**
- idle/게임 감지 생략 — spec §10 SP-10의 `is_user_active()` 제거. trading 시간대면 무조건 paused.
- spec §3의 🟡 일반(16:30-23:30) 모드 → free로 통합 (트레이딩 시간대만 paused).
**2. Placeholder scan:** 통과. NSSM_SETUP.md의 "(확인)" 표기는 박재오 환경 검증 안내 (placeholder 아님).
**3. Type consistency:**
- `current_mode(now: dt.datetime, holidays: Set[str]) -> str` — Task 2 정의, Task 3 watcher_loop + Task 7 검증 호출 일관
- `fetch_holidays() -> Set[str]` — Task 2 정의, Task 3 호출
- mode 값 `"trading"` | `"free"` 2개 — Task 2/3/7 일관
- `PAUSED_KEY = "queue:paused"` — Task 3, render workers의 PAUSED_KEY와 동일 문자열 (Plan-B-Insta/Music/Video)
**4. 함정 사전 인지:**
- task-watcher는 services/ 컨테이너 (NAS lab 아님) → deploy.sh 6위치 등재 불필요
- holidays endpoint(stock)는 기존 컨테이너 수정 → deploy.sh 등재 이미 됨
- services/.env: TRADING_START/END는 task-watcher 전용 → 다른 서비스와 충돌 없음 (compose default로 분기)
- PAUSED_TTL로 watcher 장애 시 큐 영구정지 방지
플랜 완성. 모든 검토 통과.
---
## 부록 — 알려진 결정 + follow-up
**박재오 결정 (2026-05-22):** idle/게임 감지 생략. 시간대만으로 큐 토글. 박재오 7결정 #1의 "Windows 작업 감지 큐 정지"는 부분 포기 (시간대 기반만). 향후 idle 감지 필요 시 Windows native idle-reporter(GetLastInputInfo) → Redis user:last_input_ts 기록 → task-watcher가 읽는 hybrid로 확장 가능.
**spec 12 SP 완료 후 follow-up:**
- frontend `/video` `/music` UI (backend gateway만 완료)
- FOLLOW-UP B: `-lab` suffix 일괄 제거
- GCS lifecycle (Veo Vertex 미사용으로 무관 — Gemini API는 GCS 안 씀)
- Sora 2 alternative (2026-09-24 deprecated 대비)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,163 @@
# Pet Lab - Desktop Pet Application Design
## Overview
Windows PC 바탕화면에 항상 떠있는 데스크톱 펫 애플리케이션. 캐릭터(박뚱냥)가 화면 하단에 고정되어 마우스 방향으로 시선을 추적하고, 클릭/우클릭으로 상호작용할 수 있다.
**프로젝트 위치**: `C:\Users\jaeoh\Desktop\workspace\pet-lab` (독립 프로젝트, web-backend 모노레포 외부)
**기술 스택**: Python 3.12 + PyQt5
**배포**: 로컬 Windows PC 실행 전용 (NAS 배포 불필요). 추후 PyInstaller로 .exe 패킹.
---
## Architecture
### Project Structure
```
pet-lab/
├── app/
│ ├── main.py # 엔트리포인트 (QApplication 초기화, 시스템 트레이)
│ ├── pet_widget.py # 메인 위젯 (투명 윈도우 + 캐릭터 렌더링)
│ ├── eye_tracker.py # 마우스 위치 기반 시선/기울기 계산
│ ├── interaction.py # 클릭 반응 애니메이션 + 우클릭 컨텍스트 메뉴
│ └── config.py # 설정값 (크기, 위치, 속도 상수)
├── assets/
│ └── characters/
│ └── 박뚱냥.png # 캐릭터 이미지 (투명 배경 PNG)
├── requirements.txt # PyQt5
└── README.md
```
### Component Responsibilities
| 파일 | 역할 |
|------|------|
| `main.py` | QApplication 생성, PetWidget 인스턴스화, 이벤트 루프 시작 |
| `pet_widget.py` | 투명 프레임리스 윈도우, 캐릭터 이미지 표시, QTimer 루프로 시선 업데이트 |
| `eye_tracker.py` | 마우스 좌표 → 기울기 각도/좌우 반전 여부 계산 (순수 계산 모듈) |
| `interaction.py` | 좌클릭(점프), 더블클릭(흔들기) 애니메이션, 우클릭 메뉴 생성/처리 |
| `config.py` | 상수 정의: 캐릭터 크기(소/중/대), 틸트 범위, 타이머 간격 등 |
---
## Core Behavior
### 투명 윈도우
PyQt5 윈도우 플래그 조합:
- `Qt.FramelessWindowHint`: 타이틀바 제거
- `Qt.WindowStaysOnTopHint`: 항상 위 (토글 가능)
- `Qt.Tool`: 태스크바에 표시 안 함
- `WA_TranslucentBackground`: 배경 투명
캐릭터 이미지 영역만 클릭 이벤트 수신. 투명 영역은 `WA_TransparentForMouseEvents`가 아닌, 위젯 크기를 캐릭터 이미지 크기에 맞춰서 처리.
### 바닥 고정 위치
- Y = 화면 높이 - 태스크바 높이(기본 48px) - 캐릭터 높이
- X = 수평 위치 프리셋: 좌(화면 10%), 중앙(50%), 우(90%)
- 기본 위치: 화면 우측(90%)
- 태스크바 높이는 Windows API 없이 기본값 48px 사용 (충분히 실용적)
### 시선 추적
QTimer(30ms 간격, 약 33fps)로 글로벌 마우스 좌표 폴링:
1. `QCursor.pos()`로 마우스 절대 좌표 획득
2. 캐릭터 중심점과 마우스 사이의 각도 계산 (`math.atan2`)
3. 각도를 기울기로 변환:
- 마우스가 캐릭터 왼쪽 → 이미지 좌측 기울기 (음수 각도)
- 마우스가 캐릭터 오른쪽 → 이미지 우측 기울기 (양수 각도)
- 기울기 범위: -15도 ~ +15도
4. 마우스가 캐릭터 왼쪽이면 이미지 좌우 반전 (`QTransform.scale(-1, 1)`)
5. `QTransform.rotate(angle)`로 기울기 적용
6. 마우스 좌표 변화 없으면 렌더링 스킵 (성능 최적화)
### 클릭 반응
**좌클릭 — 점프**:
- `QPropertyAnimation`으로 위젯 Y좌표를 위로 30px 이동 후 복귀
- duration: 300ms, easing: `QEasingCurve.OutBounce`
**더블클릭 — 흔들기**:
- `QPropertyAnimation`으로 X좌표를 좌우 진동
- duration: 400ms, 좌(-10) → 우(+10) → 원위치
### 우클릭 컨텍스트 메뉴
| 메뉴 항목 | 동작 |
|-----------|------|
| 위치: 좌/중앙/우 | 캐릭터 수평 위치 변경 |
| 크기: 소/중/대 | 캐릭터 크기 변경 (100/150/200px) |
| 항상 위 | `WindowStaysOnTopHint` 토글 |
| 종료 | 애플리케이션 종료 |
`QMenu`로 구현. 서브메뉴 사용하여 위치/크기를 그룹화.
---
## Configuration Constants (`config.py`)
```python
# 캐릭터 크기 (높이 기준, 너비는 비율 유지)
SIZES = {"small": 100, "medium": 150, "large": 200}
DEFAULT_SIZE = "medium"
# 수평 위치 프리셋 (화면 너비 비율)
POSITIONS = {"left": 0.1, "center": 0.5, "right": 0.9}
DEFAULT_POSITION = "right"
# 시선 추적
TIMER_INTERVAL_MS = 30 # 약 33fps
MAX_TILT_ANGLE = 15.0 # 최대 기울기 (도)
# 태스크바
TASKBAR_HEIGHT = 48 # Windows 기본 태스크바 높이
# 애니메이션
JUMP_HEIGHT = 30 # 점프 높이 (px)
JUMP_DURATION_MS = 300
SHAKE_OFFSET = 10 # 흔들기 좌우 폭 (px)
SHAKE_DURATION_MS = 400
# 에셋 경로
CHARACTER_DIR = "assets/characters"
DEFAULT_CHARACTER = "박뚱냥.png"
```
---
## Dependencies
```
PyQt5>=5.15,<6.0
```
개발 시 추가:
```
pyinstaller>=6.0 # .exe 패킹용 (나중에)
```
---
## Constraints
- **Windows 전용**: PyQt5 투명 윈도우는 Windows에서 가장 안정적. macOS/Linux는 고려하지 않음.
- **이미지 1장으로 시작**: 현재 박뚱냥.png 정면 포즈 1장. 시선은 이미지 기울기 + 좌우 반전으로 표현.
- **NAS 배포 불필요**: Docker, docker-compose.yml, deploy.sh 수정 없음.
- **독립 프로젝트**: `C:\Users\jaeoh\Desktop\workspace\pet-lab`에 별도 Git 저장소.
---
## Future Extensions
- 스프라이트 시트 추가: idle, walk, sit, sleep 등 포즈별 이미지 → 상태 머신 기반 애니메이션
- 자율 행동: 일정 시간 마우스 비활동 시 졸기/잠자기 상태 전환
- 시스템 트레이 아이콘: 종료/설정 접근
- 설정 파일 저장/로드: JSON으로 크기/위치/캐릭터 선택 영속화
- 다중 캐릭터: `assets/characters/` 디렉토리에 여러 캐릭터 추가, 우클릭 메뉴에서 선택
- PyInstaller .exe 패킹: 단독 배포용 실행파일 생성
- 웹 서비스 연동: pet-lab API 서버 → 캐릭터 다운로드/공유

View File

@@ -0,0 +1,471 @@
# packs-lab 인프라 통합 + admin mint-token 설계
> 대상: `web-backend/packs-lab/`
> 외부 의존: Supabase(`pack_files` 테이블) + Vercel SaaS(HMAC 호출자)
> 후속 별도 스펙: Vercel-side admin UI / 사용자 다운로드 / cleanup cron / multi-admin
---
## 1. 목표
`packs-lab`은 NAS 자료 다운로드 자동화 백엔드. Synology DSM 공유 링크 발급 + 5GB 멀티파트 업로드 수신을 담당하고, Vercel SaaS와 HMAC으로 통신한다. 사용자 인증은 Vercel이 Supabase로 처리하고 본 서비스는 외부 인증을 다루지 않는다.
이미 코드(HMAC 미들웨어 / DSM client / 4 라우트)는 작성되어 있으나 인프라 통합 + Supabase 스키마 + admin upload 토큰 발급 흐름이 빠져 있어 운영 가능 상태가 아니다. 본 스펙은 그 갭을 메운다.
### 핵심 변경
- **신규 라우트**: `POST /api/packs/admin/mint-token` (Vercel HMAC → 일회성 업로드 토큰)
- **Supabase DDL**: `pack_files` 테이블 + 활성·삭제 인덱스
- **인프라**: docker-compose `packs-lab` 서비스 등록(18950) + nginx `/api/packs/` 5GB 통과 + `.env.example` 6+1 환경변수
- **테스트**: routes 통합 + DSM client mock
- **문서**: web-backend / workspace CLAUDE.md 5곳 갱신
- **DELETE 라우트 docstring**: "DSM 공유 정리" 표현을 "DSM 공유 자동 만료"로 수정 (실제 동작과 일치)
### 변경하지 않는 것
- 기존 `auth.py` (`mint_upload_token` 그대로 활용)
- 기존 `dsm_client.py`
- 기존 `routes.py`의 sign-link / upload / list / delete 본문
- DSM 공유 추적 테이블 — 4시간 자동 만료로 충분(브레인스토밍 결정)
---
## 2. 컴포넌트 + 통신 흐름
### 2.1 변경 받는 파일
| 영역 | 파일 | 변경 |
|------|------|------|
| 백엔드 | `packs-lab/app/routes.py` | DELETE docstring 수정 + admin mint-token 라우트 추가 |
| 백엔드 | `packs-lab/app/models.py` | `MintTokenRequest`, `MintTokenResponse` 스키마 추가 |
| 백엔드 | `packs-lab/app/auth.py` | 변경 없음 (기존 `mint_upload_token` 활용) |
| 테스트 | `packs-lab/tests/conftest.py` (신규) | autouse `BACKEND_HMAC_SECRET` 셋팅 |
| 테스트 | `packs-lab/tests/test_routes.py` (신규) | 5 라우트 통합 테스트 |
| 테스트 | `packs-lab/tests/test_dsm_client.py` (신규) | DSM 7.x API mock 테스트 |
| DB | `packs-lab/supabase/pack_files.sql` (신규) | DDL + 인덱스 |
| 인프라 | `docker-compose.yml` | `packs-lab` 서비스 추가 |
| 인프라 | `nginx/default.conf` | `/api/packs/` 라우팅 (`client_max_body_size 5G` + streaming) |
| 인프라 | `.env.example` | 6+1 신규 환경변수 |
| 문서 | `web-backend/CLAUDE.md` | 1·4·5·8·9 섹션 갱신 |
| 문서 | `workspace/CLAUDE.md` | 컨테이너 표 한 줄 추가 |
### 2.2 통신 흐름
**ADMIN 업로드**
```
Vercel admin UI ─────→ Vercel API (HMAC 헤더 추가)
POST /api/packs/admin/mint-token
backend: verify_request_hmac
mint_upload_token({tier, label, filename, size_bytes, jti, expires_at})
Vercel ←─────────────── token ──────┘
admin browser → POST /api/packs/upload
Authorization: Bearer <token>
multipart body (≤5GB)
backend: verify_upload_token + JTI mark
파일 저장 (PACK_BASE_DIR/{filename}, 평면 구조 — tier는 filename 규칙으로 구분)
Supabase INSERT pack_files
```
**사용자 다운로드**
```
사용자 → Vercel SaaS (Supabase auth + tier·결제 검증)
POST /api/packs/sign-link (HMAC + file_path)
backend: verify_request_hmac
DSM Sharing.create (4시간 만료)
사용자 ← Vercel ← 다운로드 URL (4시간 유효)
```
### 2.3 기각된 대안
| 대안 | 기각 사유 |
|------|-----------|
| Vercel-side 토큰 발급 | 토큰 포맷 양쪽 분산, 변경 시 동기화 부담 |
| admin browser → backend 직접 HMAC | admin browser에 secret 노출, 보안 약화 |
| DSM 공유 추적 테이블 | 4시간 자동 만료로 충분, YAGNI |
| Resumable multipart upload | 5GB는 단일 stream으로 충분, 복잡도 증가 |
| `pack_files.min_tier`를 PostgreSQL ENUM | tier 추가 시 ALTER TYPE 번거로움. text+CHECK 채택 |
---
## 3. `POST /api/packs/admin/mint-token`
### 3.1 Pydantic 스키마 (`models.py` 추가)
```python
class MintTokenRequest(BaseModel):
"""Vercel → backend: admin upload 토큰 발급 요청."""
tier: PackTier
label: str = Field(..., max_length=200)
filename: str = Field(..., max_length=255)
size_bytes: int = Field(..., gt=0, le=5 * 1024 * 1024 * 1024)
class MintTokenResponse(BaseModel):
token: str
expires_at: datetime
jti: str
```
### 3.2 라우트 본문 (`routes.py` 추가)
```python
import time, uuid
from datetime import datetime, timezone
from .auth import mint_upload_token, verify_request_hmac
from .models import MintTokenRequest, MintTokenResponse
UPLOAD_TOKEN_TTL_SEC = int(os.getenv("UPLOAD_TOKEN_TTL_SEC", "1800")) # 30분 default
@router.post("/admin/mint-token", response_model=MintTokenResponse)
async def mint_token(
request: Request,
x_timestamp: str = Header(""),
x_signature: str = Header(""),
):
body = await request.body()
verify_request_hmac(body, x_timestamp, x_signature)
payload = MintTokenRequest.model_validate_json(body)
_check_filename(payload.filename) # upload 라우트와 동일 검증
jti = str(uuid.uuid4())
expires_ts = int(time.time()) + UPLOAD_TOKEN_TTL_SEC
token = mint_upload_token({
"tier": payload.tier,
"label": payload.label,
"filename": payload.filename,
"size_bytes": payload.size_bytes,
"jti": jti,
"expires_at": expires_ts,
})
return MintTokenResponse(
token=token,
expires_at=datetime.fromtimestamp(expires_ts, tz=timezone.utc),
jti=jti,
)
```
### 3.3 결정 근거
| 항목 | 값 | 근거 |
|------|-----|------|
| TTL default | 1800s (30분) | 5GB 업로드 시작 + 진행 시간 여유. 1Gbps에서 약 40s, 50Mbps에서 약 14분 |
| TTL env override | `UPLOAD_TOKEN_TTL_SEC` | 운영 중 조정 가능 |
| filename 검증 | upload와 동일 (`_check_filename`) | 토큰 발급 시점에 미리 거부 → admin UI 즉시 피드백 |
| jti 응답 포함 | yes | admin이 업로드 결과 추적용 |
| Vercel ↔ backend | HMAC (`X-Timestamp` + `X-Signature`) | 다른 admin 라우트와 동일 패턴 |
| admin browser ↔ backend | Bearer token (단발성 jti) | 기존 upload 라우트 그대로 |
### 3.4 DELETE 라우트 docstring 수정
`routes.py` 모듈 docstring에서:
```diff
- DELETE /api/packs/{file_id} — Vercel HMAC 인증 → soft delete + DSM 공유 정리
+ DELETE /api/packs/{file_id} — Vercel HMAC 인증 → soft delete (DSM 공유는 자동 만료)
```
`delete_file` 함수에는 변경 없음.
---
## 4. Supabase `pack_files` DDL
**파일**: `packs-lab/supabase/pack_files.sql` (신규, 운영 배포 시 Supabase SQL editor에서 실행)
```sql
-- pack_files: NAS에 저장된 다운로드 가능한 패키지 파일 메타
create table if not exists public.pack_files (
id uuid primary key default gen_random_uuid(),
min_tier text not null check (min_tier in ('starter','pro','master')),
label text not null,
file_path text not null unique, -- NAS 절대경로, 동일 경로 중복 방지
filename text not null,
size_bytes bigint not null check (size_bytes > 0),
sort_order integer not null default 0,
uploaded_at timestamptz not null default now(),
deleted_at timestamptz
);
-- list 라우트의 hot path: deleted_at IS NULL + tier/order 정렬
create index if not exists pack_files_active_idx
on public.pack_files (min_tier, sort_order)
where deleted_at is null;
-- soft-deleted 통계 / cleanup 잡 대비
create index if not exists pack_files_deleted_at_idx
on public.pack_files (deleted_at)
where deleted_at is not null;
```
### 4.1 필드 결정 근거
| 필드 | 타입 / 제약 | 근거 |
|------|------------|------|
| `id` | uuid PK + `gen_random_uuid()` default | routes.py가 client-side `uuid.uuid4()` 생성하지만 default도 둬 fallback |
| `min_tier` | text + CHECK | enum 대신 text+CHECK가 PostgreSQL에서 더 유연 |
| `file_path` | text NOT NULL UNIQUE | 같은 tier/filename 충돌은 파일시스템에서 잡지만 DB 레벨도 보강 |
| `size_bytes` | bigint + CHECK > 0 | 5GB는 int 범위 안이지만 미래 대비 bigint |
| `sort_order` | int NOT NULL default 0 | routes INSERT가 sort_order 미지정 → 0 기본 |
| `uploaded_at` | timestamptz default now() | routes 코드가 `res.data[0]["uploaded_at"]` 그대로 응답에 사용 — DB가 채워줌 |
| `deleted_at` | nullable | soft delete |
### 4.2 RLS
비활성. backend가 `service_role` key 사용하므로 RLS 우회. Vercel/사용자 직접 접근 없음 → unsafe 아님.
---
## 5. 인프라 통합
### 5.1 `docker-compose.yml` — `packs-lab` 서비스
```yaml
packs-lab:
build:
context: ./packs-lab
dockerfile: Dockerfile
container_name: packs-lab
restart: unless-stopped
ports:
- "18950:8000"
environment:
TZ: Asia/Seoul
DSM_HOST: ${DSM_HOST}
DSM_USER: ${DSM_USER}
DSM_PASS: ${DSM_PASS}
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}
```
| 결정 | 값 | 근거 |
|------|-----|------|
| 포트 | 18950 | 18800(realestate) → 18900(agent-office) → 18950(packs) 순차 |
| `PACK_BASE_DIR` (컨테이너 내부) | `/app/data/packs` | routes.py upload target. docker-compose volume 우측. |
| `PACK_HOST_DIR` (NAS 호스트) | 운영 `/volume1/docker/webpage/media/packs` / 로컬 fallback `./data/packs` | DSM·Supabase에 노출되는 절대경로. routes.py가 file_path로 저장. 미설정 시 `PACK_BASE_DIR`로 fallback. |
| `PACK_DATA_PATH` (호스트 마운트) | default `./data/packs` (로컬), NAS `/volume1/docker/webpage/media/packs` | docker-compose volume 좌측만 사용 |
### 5.2 `nginx/default.conf` — `/api/packs/` 라우팅
```nginx
location /api/packs/ {
proxy_pass http://packs-lab:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 5GB 멀티파트 업로드 대응
client_max_body_size 5G;
proxy_request_buffering off; # 스트리밍 통과 (메모리/디스크 buffer 회피)
proxy_read_timeout 1800s;
proxy_send_timeout 1800s;
}
```
| 결정 | 근거 |
|------|------|
| `client_max_body_size 5G` | 라우트 단위 — 다른 location은 default 유지 |
| `proxy_request_buffering off` | 5GB 파일을 nginx가 모두 받고 backend에 forward하면 ~5GB 디스크 buffer 발생 |
| `proxy_read/send_timeout 1800s` | 30분 — 업로드 토큰 TTL과 일치, 느린 업링크에서 5GB 전송 여유 |
### 5.3 `.env.example` — 신규 환경변수 (7 + 3 path)
```bash
# ─── packs-lab — NAS 자료 다운로드 자동화 ────────────────────────────
# Synology DSM 7.x 인증 (공유 링크 발급용)
DSM_HOST=https://gahusb.synology.me:5001
DSM_USER=
DSM_PASS=
# LAN IP + self-signed cert 환경에서 IP mismatch 시 false (LAN 내부 통신이라 허용)
DSM_VERIFY_SSL=false
# Vercel SaaS ↔ backend HMAC 시크릿 (양쪽 동일 값)
BACKEND_HMAC_SECRET=
# Supabase pack_files 테이블 접근 (service_role 키, RLS 우회)
SUPABASE_URL=https://<project>.supabase.co
SUPABASE_SERVICE_KEY=
# admin upload 토큰 TTL (초). default 1800 = 30분
UPLOAD_TOKEN_TTL_SEC=1800
# 호스트 마운트 경로 (로컬 ./data/packs, NAS /volume1/docker/webpage/media/packs)
PACK_DATA_PATH=./data/packs
# 컨테이너 내부 저장 경로 (routes.py upload target. docker-compose volume 우측)
PACK_BASE_DIR=/app/data/packs
# DSM API용 path. Synology DSM API는 일반 사용자 권한일 때 /<shared_folder>/... 형식만 인식하고 /volume1/... 절대경로는 거부(error 408).
# 운영 NAS는 반드시 shared folder 시점 — /docker/webpage/media/packs.
# admin 사용자는 /volume1/... 도 가능하지만 보안상 별도 packs-bot user 권장.
PACK_HOST_DIR=/docker/webpage/media/packs
```
### 5.4 NAS 디렉토리 준비
운영 첫 배포 시 SSH로 1회. 파일은 `PACK_HOST_DIR` 평면에 직접 저장 — tier 디렉토리 분기는 만들지 않음(tier 구분은 filename 규칙으로 admin이 관리):
```bash
mkdir -p /volume1/docker/webpage/media/packs # 호스트 OS path (volume 마운트용)
chown -R PUID:PGID /volume1/docker/webpage/media/packs
```
PUID/PGID는 `.env`의 기존 값 사용.
> ⚠️ **DSM 사용자 권한 — File Station + Sharing 둘 다 필요**: Control Panel → User → packs-bot(또는 admin) → Permissions → File Station에서 `docker` shared folder Read 권한 + Applications → Sharing 권한 ON.
### 5.5 `scripts/deploy-nas.sh` SERVICES 화이트리스트
webhook 자동 배포(deployer)가 호출하는 sync 스크립트는 화이트리스트로 동기화 대상 디렉토리를 명시한다. 신규 서비스 추가 시 반드시 함께 수정해야 NAS 운영 디렉토리에 소스 sync + docker compose 빌드가 동작한다.
```bash
SERVICES="lotto travel-proxy deployer stock-lab music-lab blog-lab realestate-lab agent-office personal packs-lab nginx scripts"
```
(packs-lab 누락 시 `docker compose ps`에 packs-lab 미등장 — 첫 배포 시 가장 흔한 누락 항목)
---
## 6. 테스트 전략
기존 `tests/test_auth.py` 유지. 신규 3 파일.
### 6.1 `tests/conftest.py` (신규)
```python
import pytest
@pytest.fixture(autouse=True)
def _hmac_secret(monkeypatch):
"""모든 테스트에서 동일한 HMAC secret 사용."""
monkeypatch.setenv("BACKEND_HMAC_SECRET", "test-secret-do-not-use-in-prod")
```
### 6.2 `tests/test_routes.py` (신규) — 통합 테스트
DSM·Supabase 모두 mock. `pytest`, `monkeypatch`, `unittest.mock`, `fastapi.testclient.TestClient` 사용.
| 테스트 | 검증 |
|--------|------|
| `test_sign_link_hmac_required` | timestamp/signature 헤더 누락 → 401 |
| `test_sign_link_outside_base_dir` | file_path가 `PACK_BASE_DIR` 외부 → 400 |
| `test_sign_link_calls_dsm` | mock된 `create_share_link` 호출 검증, URL 응답 |
| `test_mint_token_hmac_required` | HMAC 누락 → 401 |
| `test_mint_token_returns_valid_token` | 발급된 token이 `verify_upload_token`으로 통과 |
| `test_mint_token_invalid_filename` | 확장자 미허용 → 400 |
| `test_upload_token_required` | Authorization Bearer 누락 → 401 |
| `test_upload_size_mismatch` | 토큰 size_bytes ≠ 실제 → 400 |
| `test_upload_jti_replay` | 같은 토큰 두 번 → 두 번째 409 |
| `test_list_returns_active_only` | mock supabase 응답에서 deleted_at NULL만 반환 |
| `test_delete_soft_deletes` | mock supabase update에 deleted_at ISO timestamp 들어감 |
### 6.3 `tests/test_dsm_client.py` (신규)
httpx mock(`respx` 또는 `MockTransport`) 또는 `monkeypatch.setattr` 패치.
| 테스트 | 검증 |
|--------|------|
| `test_create_share_link_login_logout` | login → Sharing.create → logout 순서 |
| `test_create_share_link_returns_url_and_expiry` | 응답 파싱 |
| `test_dsm_login_failure_raises` | login API success=false → DSMError |
| `test_dsm_share_failure_logs_out` | Sharing.create 실패해도 logout 호출 (try/finally) |
---
## 7. 문서 갱신
### 7.1 `web-backend/CLAUDE.md` — 5곳
**1. 1.프로젝트 개요**
```diff
- 서비스: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, personal, deployer (9개)
+ 서비스: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, personal, packs-lab, deployer (10개)
```
**2. 4.Docker 서비스 표** — 신규 행
```
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (DSM 공유 링크 + 5GB 업로드, Vercel SaaS와 HMAC 통신) |
```
**3. 5.Nginx 라우팅 표** — 신규 행
```
| `/api/packs/` | `packs-lab:8000` | 5GB 업로드 (`client_max_body_size 5G` + `proxy_request_buffering off`) |
```
**4. 8.로컬 개발 표** — 신규 행
```
| Packs Lab | http://localhost:18950 |
```
**5. 9.서비스별**`### packs-lab (packs-lab/)` 신규 섹션
내용:
- 용도 (NAS DSM 공유링크 + 5GB 업로드 + Vercel HMAC, 사용자 인증은 Vercel이 Supabase로 처리)
- 환경변수 6+1개
- DB는 외부 Supabase `pack_files` (DDL은 `packs-lab/supabase/pack_files.sql`)
- 파일 구조: `main.py`, `auth.py`, `dsm_client.py`, `routes.py`, `models.py`
- API 표 5개:
- `POST /api/packs/sign-link` (Vercel HMAC → DSM Sharing.create)
- `POST /api/packs/admin/mint-token` (Vercel HMAC → upload 토큰)
- `POST /api/packs/upload` (Bearer token → multipart 5GB)
- `GET /api/packs/list` (Vercel HMAC → 활성 파일 목록)
- `DELETE /api/packs/{file_id}` (Vercel HMAC → soft delete)
### 7.2 `workspace/CLAUDE.md`
컨테이너 표에 한 줄 추가:
```
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (Vercel SaaS와 HMAC 통신) |
```
---
## 8. 스코프
### 본 spec 범위
- ✅ admin mint-token 라우트 신설
- ✅ Supabase `pack_files` DDL
- ✅ docker-compose / nginx / .env.example / NAS 디렉토리 마운트
- ✅ tests (auth 유지 + routes 통합 + dsm_client mock)
- ✅ CLAUDE.md 2곳 갱신
- ✅ DELETE 라우트 docstring 수정
### 후속 별도 spec
- ❌ Vercel SaaS-side admin UI / 사용자 다운로드 UI / Supabase pricing & user 테이블
- ❌ DSM 공유 추적 (즉시 차단 필요시)
- ❌ deleted_at + N일 후 실제 파일 삭제 cron
- ❌ multi-admin 토큰 발급 권한 분리
- ❌ resumable multipart 업로드 (5GB tus 등)
- ❌ pack_files sort_order 편집 endpoint (admin UI 단계)
- ❌ monitoring (업로드 실패율, DSM API latency)

View File

@@ -0,0 +1,519 @@
# Music YouTube 파이프라인 — 단계별 승인 자동화 설계
> 작성일: 2026-05-07
> 상태: 설계 승인 대기
> 관련 후속 작업: STATUS.md 2-3, 2-4
---
## 1. 배경
현재 Music YouTube 탭에는 영상 제작 / 수익 추적 / 시장 트렌드 / 컴파일 4개 서브탭이 있고, music-lab 백엔드는 video_producer로 로컬 영상(MP4)까지 만들 수 있다. 그러나 **YouTube 자동 업로드와 AI 커버·메타데이터 자동 생성, AI 검토는 없다.** 트랙 생성부터 발행까지 한 편 완성하려면 매번 수동으로 영상 만들고 직접 YouTube Studio에 업로드해야 한다.
목표: **트랙을 골라 한 번 시작하면 단계별로 텔레그램 승인을 받으며 영상이 발행되는 파이프라인**을 구축한다. 사용자는 각 단계 산출물을 텔레그램에서 승인/반려할 수 있고, 반려 시 자연어 피드백으로 같은 단계가 재생성된다.
---
## 2. 비목표 (Out of scope)
- 가사 자막 영상 (synced lyrics → 영상) — 차후
- YouTube Shorts 전용 워크플로 (1080×1920) — 비주얼 기본값에 옵션만 두고, 실제 Shorts 최적화(60초 클립 추출 등)는 차후
- 멀티 채널 운영 — 단일 채널 OAuth 1행만 지원
- 비디오 편집기 UI — 트림/페이드 등은 컴파일 탭에 있고 본 파이프라인은 단일 트랙 1개 영상 가정
---
## 3. 사용자 흐름
```
[사용자가 진행 시작]
Library 트랙 카드 → "🎬 영상 파이프라인" 또는 진행 탭 → "+ 새 파이프라인"
step 2: AI 커버 아트 생성 → 텔레그램 알림 "커버 승인?"
step 3: 영상 비주얼 생성 (커버 + 음원) → 텔레그램 알림
step 4: 썸네일 생성 → 텔레그램 알림
step 5: 메타데이터 생성 → 텔레그램 알림
AI 최종 검토 (자동, 4축 검사) → 텔레그램에 점수 + 발행 요청
[사용자 발행 승인]
step 6: YouTube 업로드 (private/public 정책에 따라)
step 7: 발행 후 추적 시작 (수익 추적 탭에 표시)
```
각 단계 텔레그램 알림에 사용자가 자연어로 응답한다.
- 승인: "승인" / "시작" / "진행" / "OK" / "Agree" / "네" / "예" / "좋아"
- 반려: "반려" / "거절" / "취소" / "no" + 수정 방향 텍스트 (예: "썸네일 색 더 어둡게")
---
## 4. 아키텍처
```
┌──────────────────────────────────────────────────────────────┐
│ Frontend (web-ui) │
│ /lab/music → MusicStudio → YouTube 탭 │
│ ├─ 영상 제작 (기존) │
│ ├─ 수익 추적 (기존) │
│ ├─ 시장 트렌드 (기존) │
│ ├─ 컴파일 (기존) │
│ ├─ 진행 (NEW) ← 파이프라인 카드 보드 │
│ └─ 구성 (NEW) ← 설정 허브 │
└──────────────────────────────────────────────────────────────┘
↓ /api/music/pipeline/* (REST)
┌──────────────────────────────────────────────────────────────┐
│ music-lab (FastAPI, 18600) │
│ • 파이프라인 CRUD + 상태 머신 │
│ • AI 커버 (DALL·E 3) — 비동기 BackgroundTask │
│ • 영상 비주얼 (FFmpeg, 기존 video_producer 확장) │
│ • 썸네일 (FFmpeg + 텍스트 오버레이) │
│ • 메타데이터 생성 (Claude Haiku) │
│ • AI 최종 검토 (Claude Sonnet, 4축 가중) │
│ • YouTube 업로드 (google-api-python-client) │
└──────────────────────────────────────────────────────────────┘
↑ poll (30s) / push 결과
┌──────────────────────────────────────────────────────────────┐
│ agent-office (FastAPI + Telegram, 18900) │
│ • youtube_publisher 에이전트 (NEW) — 오케스트레이터 │
│ • 단계 *_pending 진입 감지 → 텔레그램 알림 발송 │
│ • 텔레그램 reply 자연어 의도 분류 (Claude or 화이트리스트) │
│ • music-lab /feedback 호출 → 다음 단계 또는 재생성 │
└──────────────────────────────────────────────────────────────┘
```
**책임 경계**:
- **music-lab**: 무엇을 만들지 안다. 산출물 생성·저장·상태 전이.
- **agent-office**: 언제 다음으로 넘길지 결정. 텔레그램 단일 채널 인터페이스.
- **frontend**: 진행 상태 조회 + 사용자 트리거(시작/취소/수동 발행).
---
## 5. 상태 머신
```
created
→ cover_pending (자동 생성 후 진입)
→ cover_approved (승인)
→ video_pending
→ video_approved
→ thumb_pending
→ thumb_approved
→ meta_pending
→ meta_approved
→ ai_review (자동, 사용자 액션 X)
→ publish_pending (검토 결과 + 발행 요청 텔레그램)
→ publishing (업로드 중)
→ published (완료)
어디서나:
→ cancelled (사용자 취소)
→ failed (복구 불가 오류)
→ awaiting_manual (재생성 5회 한도 초과)
```
`*_pending` 진입 시 → 텔레그램 알림.
`*_approved` 진입 시 → 다음 단계 BackgroundTask 시작.
---
## 6. 프론트엔드 상세
### 6-1. 새 탭 — 구성 (`SetupTab.jsx`)
세로 카드 형식, 카드별 저장 버튼:
| 카드 | 필드 |
|------|------|
| YouTube 채널 연동 | OAuth 시작 → Google 인증 → 채널명·아바타 표시. 재인증 / 연결 해제 |
| Telegram 알림 채널 | 현재 chat_id (read-only, ENV 출처). 테스트 메시지 발송 |
| 메타데이터 템플릿 | 제목 패턴 (`[{genre}] {title} \| {bpm}BPM Lo-fi Mix` 등), 설명 multiline, 태그 CSV, 카테고리 |
| AI 커버 아트 prompt | 장르별 prompt 템플릿 (lo-fi/phonk/ambient/pop/...) 추가/편집/삭제 |
| AI 최종 검토 기준 | 4축 가중치 슬라이더 + pass score 임계값 (기본 60) |
| 영상 비주얼 기본값 | 해상도 (1920×1080 / 1080×1920), 스타일 (visualizer/슬라이드쇼), 배경 (AI 커버/그라데이션) |
| 발행 정책 | 즉시 / 예약 시간대 / privacy (private 우선) |
### 6-2. 새 탭 — 진행 (`PipelineTab.jsx`)
**상단**: "+ 새 파이프라인 시작" 버튼 → Library 트랙 선택 모달.
**카드 그리드** — 진행 중 + 완료/실패/취소 (필터 토글):
```
┌─ Track Title (genre · BPM) ───────────── [Cancel] ─┐
│ ●━━━━━●━━━━━●━━━━━○━━━━━○━━━━━○ (6단계 진행 바) │
│ 커버 영상 썸네 메타 검토 발행 │
│ │
│ 현재: [메타데이터 승인 대기] │
│ 텔레그램에 알림 보냄 — 12분 전 │
│ │
│ [최근 산출물 미리보기] │
│ • 메타: "[Lo-fi] Midnight Drive | 85BPM..." │
│ • 썸네일: ▭ │
│ │
│ 📜 피드백 히스토리 │
│ • "썸네일 색이 너무 어두워" → 재생성 (5분 전) │
└──────────────────────────────────────────────────────┘
```
**상태 시각**:
- `running` — 스피너 + "처리 중..."
- `awaiting_approval` — 점멸 도트 + "텔레그램 응답 대기"
- `regenerating` — 회전 화살표 + "피드백 반영 중"
- `completed` — 체크 + YouTube 링크
- `failed` / `awaiting_manual` — 빨간 배지 + 사유
**폴링**: 카드 보일 때 5초 간격 `GET /api/music/pipeline?status=active`.
### 6-3. 영상 제작 탭 (기존)
그대로 유지. footer에 "💡 단계별 자동화는 진행 탭에서" 1줄 안내.
### 6-4. Library 카드 변경
기존 액션 옆에 "🎬 영상 파이프라인" 버튼 추가 → 클릭 시 신규 파이프라인 생성 후 진행 탭 이동.
---
## 7. 백엔드 상세
### 7-1. music-lab 신규 모듈
| 파일 | 역할 |
|------|------|
| `app/pipeline/state_machine.py` | 상태 전이 + 검증 |
| `app/pipeline/orchestrator.py` | `start_step(pipeline_id, step)` — BackgroundTask 등록 |
| `app/pipeline/cover.py` | DALL·E 3 호출 + 폴백 |
| `app/pipeline/metadata.py` | Claude Haiku 호출 + 템플릿 치환 |
| `app/pipeline/review.py` | Claude Sonnet 4축 검토 + 가중평균 |
| `app/pipeline/youtube.py` | OAuth + 업로드 (google-api-python-client) |
| `app/pipeline/storage.py` | `/data/videos/{id}/` 산출물 관리 |
기존 `app/video_producer.py``app/pipeline/video.py`로 이동 + 슬라이드쇼 입력으로 AI 커버 사용 옵션 추가.
### 7-2. agent-office 신규/변경
| 파일 | 변경 |
|------|------|
| `app/agents/youtube_publisher.py` | NEW — 오케스트레이터 |
| `app/scheduler.py` | 30초 간격 `_poll_pipelines` 잡 추가 |
| `app/telegram/conversational.py` | reply 매칭 + youtube_publisher로 라우팅 |
| `app/service_proxy.py` | music-lab pipeline 호출 헬퍼 추가 |
`youtube_publisher`:
- `poll_state_changes()` — music-lab `/api/music/pipeline?status=active` 폴링, `*_pending` 신규 진입 시 텔레그램 발송. 멱등 처리(메시지 ID 저장).
- `on_telegram_reply(message)``reply_to_message_id`로 pipeline 매칭, 자연어 분류 → `/feedback` 호출.
### 7-3. 자연어 의도 분류
```python
APPROVE_WORDS = {"승인", "시작", "진행", "ok", "okay", "agree", "", "", "좋아", "go"}
REJECT_WORDS = {"반려", "거절", "취소", "no", "nope"}
def classify_intent(text: str) -> tuple[str, str | None]:
t = text.strip().lower()
# 1. 명확한 단어만 — LLM 우회
if t in APPROVE_WORDS:
return ("approve", None)
if t in REJECT_WORDS:
return ("reject", None)
# 2. 반려 단어 + 추가 텍스트 — 단순 분리
for w in REJECT_WORDS:
if t.startswith(w):
return ("reject", text[len(w):].strip(" ,.-:"))
# 3. 모호한 경우 — Claude Haiku 호출
return _llm_classify(text)
```
LLM 분류 응답 (JSON):
```json
{"intent": "approve|reject|unclear", "feedback": "..."}
```
`unclear` → 텔레그램에 "다시 입력해주세요. 예: '승인' 또는 '제목을 짧게'" 안내 + 같은 상태 유지.
### 7-4. AI 최종 검토 (4축)
`meta_approved` 직후 자동 진행. Claude Sonnet 1회 호출.
입력:
- 트랙 정보 (title, genre, BPM, key, scale, moods, instruments)
- 영상 정보 (length, resolution, style)
- 메타데이터 (title, description, tags, category)
- 썸네일 URL
- 트렌드 데이터 (`market_trends` top 10)
출력 JSON:
```json
{
"metadata_quality": {"score": 0-100, "notes": "..."},
"policy_compliance": {"score": 0-100, "issues": []},
"viewer_experience": {"score": 0-100, "notes": "..."},
"trend_alignment": {"score": 0-100, "matched_keywords": []},
"weighted_total": 0-100,
"verdict": "pass" | "fail",
"summary": "..."
}
```
**가중치 (기본, 구성 탭에서 조정 가능)**:
- 메타데이터 품질 25
- 콘텐츠 정책 30
- 시청 경험 25
- 트렌드 정렬 20
**임계값 60 미만 → `fail`**. 텔레그램 메시지에 "강제 발행" / "메타로 돌아가 재검토" 안내.
### 7-5. AI 커버 아트
- 모델: OpenAI `gpt-image-1` (DALL·E 3 후속)
- 해상도: 1024×1024
- 환경변수: `OPENAI_API_KEY`
- 비용: 1024×1024 standard ≈ $0.04/장 (단계당 최대 5회 = $0.20)
- 폴백: 그라데이션 (`GENRE_COLORS`) + 트랙 제목 텍스트 오버레이
prompt 빌더 (구성 탭의 장르별 템플릿 사용):
```
{genre_template}, {mood_descriptor}, no text, high quality
```
### 7-6. 메타데이터 자동 생성
- 모델: Claude Haiku
- 호출 시점: `meta_pending` 진입 시 (커버 승인 후 미리 생성하지 않음)
- 입력: 트랙 정보 + 구성 탭 메타 템플릿 + 트렌드 키워드
- 출력: title (60자 이내), description (3-5문단, 1000자 이내), tags (15개 이내), category_id
### 7-7. YouTube 업로드
- 라이브러리: `google-api-python-client` + `google-auth-oauthlib`
- OAuth flow: Authorization Code → refresh_token 저장 (`youtube_oauth_tokens` 테이블)
- 업로드 시 access_token 갱신 → resumable upload
- Privacy: 구성 탭 정책 (private/unlisted/public)
- 카테고리: 메타데이터의 category_id (기본 10 = Music)
---
## 8. 데이터 모델
### 8-1. 신규 테이블 (music-lab `db.py`)
```sql
CREATE TABLE video_pipelines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
track_id INTEGER NOT NULL,
state TEXT NOT NULL,
state_started_at TEXT NOT NULL,
cover_url TEXT,
video_url TEXT,
thumbnail_url TEXT,
metadata_json TEXT,
review_json TEXT,
youtube_video_id TEXT,
feedback_count_per_step TEXT NOT NULL DEFAULT '{}',
last_telegram_msg_ids TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
cancelled_at TEXT,
failed_reason TEXT,
FOREIGN KEY (track_id) REFERENCES tracks(id)
);
CREATE TABLE pipeline_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pipeline_id INTEGER NOT NULL,
step TEXT NOT NULL,
status TEXT NOT NULL,
error TEXT,
started_at TEXT,
finished_at TEXT,
duration_ms INTEGER,
FOREIGN KEY (pipeline_id) REFERENCES video_pipelines(id)
);
CREATE TABLE pipeline_feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pipeline_id INTEGER NOT NULL,
step TEXT NOT NULL,
feedback_text TEXT NOT NULL,
received_at TEXT NOT NULL,
FOREIGN KEY (pipeline_id) REFERENCES video_pipelines(id)
);
CREATE TABLE youtube_oauth_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id TEXT NOT NULL,
channel_title TEXT,
avatar_url TEXT,
refresh_token TEXT NOT NULL,
access_token TEXT,
expires_at TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE youtube_setup (
id INTEGER PRIMARY KEY AUTOINCREMENT,
metadata_template_json TEXT NOT NULL,
cover_prompts_json TEXT NOT NULL,
review_weights_json TEXT NOT NULL,
review_threshold INTEGER NOT NULL DEFAULT 60,
visual_defaults_json TEXT NOT NULL,
publish_policy_json TEXT NOT NULL,
updated_at TEXT NOT NULL
);
```
### 8-2. 산출물 저장 경로
```
/data/videos/{pipeline_id}/
├─ cover.jpg (AI 또는 폴백)
├─ video.mp4 (FFmpeg 결과)
├─ thumbnail.jpg
└─ logs/ (FFmpeg/upload 로그)
```
노출 URL: `/media/videos/{pipeline_id}/<file>` (nginx 정적 서빙).
---
## 9. API 엔드포인트
### 9-1. music-lab 신규
| 메서드 | 경로 | 용도 |
|--------|------|------|
| GET | `/api/music/pipeline` | 파이프라인 목록 (`?status=active|all`) |
| GET | `/api/music/pipeline/{id}` | 단건 + jobs + feedback |
| POST | `/api/music/pipeline` | 신규 (body: `{track_id}`) |
| POST | `/api/music/pipeline/{id}/start` | 첫 단계 시작 → 202 |
| POST | `/api/music/pipeline/{id}/feedback` | 승인/반려 (body: `{step, intent, feedback_text?}`) |
| POST | `/api/music/pipeline/{id}/cancel` | 취소 |
| POST | `/api/music/pipeline/{id}/publish` | 검토 후 업로드 트리거 |
| GET | `/api/music/setup` | 구성 조회 |
| PUT | `/api/music/setup` | 구성 저장 |
| GET | `/api/music/youtube/auth-url` | OAuth 시작 URL |
| GET | `/api/music/youtube/callback` | OAuth callback |
| POST | `/api/music/youtube/disconnect` | 연결 해제 |
| GET | `/api/music/youtube/status` | 연결 상태 |
모든 생성/처리 엔드포인트는 **즉시 202 + job_id 반환**, BackgroundTask로 처리. 프론트는 `GET /api/music/pipeline/{id}`로 폴링.
### 9-2. 멱등성
- `/feedback`은 동일 `(pipeline_id, step, intent)` 중복 호출 시 무시 (이미 다음 상태로 넘어간 경우 텔레그램 reply 지연 방지)
- 텔레그램 메시지 ID 저장으로 동일 메시지 중복 처리 방지
---
## 10. 비동기 처리 + 폴백
**원칙**: 모든 AI/생성 작업은 `BackgroundTasks` + DB job 상태로 처리. 호출 즉시 202, 폴링으로 결과 확인. **사용자 경험: 어떻게든 다음 단계로 보낸다, 단 폴백 사용 시 텔레그램에 명시.**
| 작업 | 타임아웃 | 폴백 |
|------|---------|------|
| DALL·E 3 | 90초 | 그라데이션 + 텍스트 오버레이 |
| Claude Haiku (메타) | 30초 | 템플릿 변수 그대로 치환 |
| Claude Sonnet (검토) | 60초 | 휴리스틱만 (정책 단어 매치 + 길이 체크) |
| FFmpeg | 5분 | `failed` + 텔레그램 알림 |
| YouTube upload | 10분 | 재시도 3회 → `failed` |
각 BackgroundTask는 `pipeline_jobs``running → succeeded/failed` 기록. 진행 탭은 이 정보로 카드 진행도 표시.
---
## 11. 에러 처리 매트릭스
| 시나리오 | 동작 |
|---------|------|
| OAuth refresh 실패 | 발행 단계 `failed` + 텔레그램 "재인증 필요" + 구성 탭 빨간 배지 |
| DALL·E timeout | 폴백(그라데이션) + 텔레그램 "AI 폴백 사용됨" |
| Claude timeout | 폴백(템플릿/휴리스틱) + 동일 표기 |
| FFmpeg 실패 | `failed` + 텔레그램 "수동 점검 필요" + task_id |
| YouTube quota | 24시간 후 자동 재시도 1회 → 그래도 실패 시 `failed` |
| 텔레그램 reply 의도 `unclear` | 안내 메시지 + 같은 상태 유지 |
| 재생성 5회 초과 | `awaiting_manual` + 텔레그램 안내 |
| 동일 트랙 파이프라인 중복 | 409 Conflict |
| 트랙 삭제됨 | 파이프라인 보존, 재생성 불가, 진행 탭 "트랙 누락" 배지 |
---
## 12. 보안 / 비밀
- OAuth refresh_token: SQLite에 평문(현재 패턴) — 향후 Fernet 암호화 또는 OS keystore 검토. 기본은 컨테이너 파일 권한 600 + DB 읽기 deny (이미 settings.json에 `Read(**/*.db)` 차단 추가됨)
- `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `YOUTUBE_OAUTH_CLIENT_ID/SECRET`: docker-compose env로 주입
- 구성 탭은 인증 게이트 없음(개인 사이트 가정) — 향후 admin 게이트 필요시 personal 서비스의 `/api/profile/auth` 패턴 적용
---
## 13. 테스트 전략
### 13-1. 단위 테스트 (music-lab)
| 대상 | 테스트 |
|------|--------|
| `state_machine` | 정상 전이 / 잘못된 전이 거부 |
| `feedback_handler` | approve → 다음 / reject → 동일 + feedback 저장 / 5회 초과 → awaiting_manual |
| `cover.generate` | DALL·E mock 성공/timeout/오류 → 폴백 |
| `metadata.generate` | Claude mock + 템플릿 치환 |
| `review.run_4_axis` | 4축 점수 계산 + 가중평균 + verdict 임계값(60) |
| `youtube_upload.upload` | google-api mock + 재시도 + quota 분기 |
| OAuth | code → refresh_token, refresh 만료 시 재인증 트리거 |
`pytest` + `httpx_mock` + `freezegun`. 기존 music-lab 테스트 컨벤션 준수.
### 13-2. 단위 테스트 (agent-office)
| 대상 | 테스트 |
|------|--------|
| `classify_intent` | 화이트리스트 → LLM 미호출, 반려 단어 + 텍스트 → 분리, 모호 → LLM 호출 검증 |
| `_poll_pipelines` | state 변경 → 텔레그램 1회만(멱등) |
| reply 매칭 | message_id로 정확한 pipeline_id 매칭 |
### 13-3. 통합 테스트
`tests/test_pipeline_flow.py`:
- 전체 흐름 1회: track → pipeline → 모든 단계 mock 승인 → published
- 반려 분기: cover에서 reject + feedback → 같은 단계 재생성 → 승인 → 다음 단계
### 13-4. 프론트엔드 테스트
- `SetupTab` 폼 저장: 단순 단위 테스트 (API 인자 검증)
- `PipelineTab` 카드 렌더링: 상태별 시각 — 빌드 + 수동 브라우저 확인
- 폴링 로직: mock fetch + setInterval
기존 web-ui 패턴 (vitest 등 별도 러너 없음) 유지.
### 13-5. 수동 E2E 체크리스트 (출시 전)
- [ ] OAuth 인증 → 구성 탭 채널명 표시
- [ ] 트랙 → 파이프라인 시작 → 텔레그램 "커버 승인" 알림
- [ ] "승인" 답장 → 다음 단계 진행
- [ ] "썸네일 색 어둡게" 답장 → 재생성 → 알림 재도착
- [ ] AI 최종 검토 4축 점수 표시
- [ ] 발행 승인 → YouTube 업로드 (private) → URL 수신
- [ ] 24시간 후 수익 추적 탭에 신규 영상 표시
---
## 14. 마이그레이션 / 환경
- 신규 환경변수: `OPENAI_API_KEY`, `YOUTUBE_OAUTH_CLIENT_ID`, `YOUTUBE_OAUTH_CLIENT_SECRET`, `YOUTUBE_OAUTH_REDIRECT_URI`
- music-lab Dockerfile: `google-api-python-client`, `google-auth-oauthlib`, `openai` 추가
- 기존 music.db 마이그레이션: `init_db()`에 신규 테이블 5개 `CREATE IF NOT EXISTS` 추가
- nginx 설정: `/api/music/youtube/callback` 외부 노출 필요 (OAuth redirect)
---
## 15. 산출물 / 후속
본 스펙은 다음 산출물을 가진다:
- music-lab: pipeline 모듈, OAuth, 5개 테이블, 12개 엔드포인트
- agent-office: youtube_publisher 에이전트, scheduler 폴링 잡, 자연어 분류기
- web-ui: SetupTab, PipelineTab, Library 카드 트리거 버튼
- 통합/단위 테스트, 수동 E2E 체크리스트
후속(이 스펙 외):
- Shorts 전용 파이프라인 (60초 클립 추출 + 1080×1920)
- 가사 자막 영상 (synced lyrics 영상화)
- 멀티 채널 운영
- 검토 임계값/가중치 학습 (실제 발행 후 성과 데이터 기반 자동 튜닝)

View File

@@ -0,0 +1,706 @@
# Essential Mix 파이프라인 — 1시간 mix + essential 시각 스타일 + UX 강화 설계
> 작성일: 2026-05-09
> 관련 spec:
> - `2026-05-07-music-youtube-pipeline-design.md` (본 파이프라인의 베이스)
> - `2026-05-09-gpu-video-offload-design.md` (Windows GPU 인코딩)
---
## 1. 배경
현재 파이프라인은 **단일 트랙 → 단일 영상**(커버 + 가장자리 파형)만 지원. 사용자는 YouTube essential 채널처럼 **1시간 이상의 음악 mix + 차분한 배경 + 중앙 비주얼라이저** 영상을 원함.
또한 진행 중 산출물(커버·썸네일·영상)을 NAS 파일시스템에서 직접 확인하는 게 번거로워, 진행 탭에서도 미리보기 가능했으면 함.
---
## 2. 비목표
- 사용자 직접 업로드 사진/영상 (P3로 미룸)
- 360° 정확한 방사형 비주얼라이저 (ffmpeg 단독으로 한계 — `showfreqs` + ring overlay로 근사)
- Mix 자동 큐레이션(곡 자동 선택) — 기존 컴파일 탭의 수동 선택 그대로 활용
- AI 검토 가중치 자동 튜닝 (Mix와 단일 트랙의 다른 기준 등 — P3)
- 텔레그램 사진 첨부 — 본 작업의 PipelineDetailModal로 우선 해결, 차후 P3
---
## 3. 사용자 흐름
### 3-1. Mix 영상 만들기
```
[사용자] Compile 탭에서 트랙 N개 선택 → crossfade 설정 → 컴파일 시작
→ 컴파일 완료 (1시간+ mp3 생성, 기존 흐름)
→ 컴파일 카드에 [🎬 영상 만들기] 버튼 클릭
→ 백엔드: POST /api/music/pipeline { compile_job_id, visual_style: 'essential' }
→ 진행 탭으로 자동 이동, 새 카드 생성
→ 단계별 텔레그램 승인 (기존과 동일):
cover (또는 background_video) → video → thumbnail → metadata → AI 검토 → 발행
→ YouTube 비공개 영상 1편
```
### 3-2. 단일 트랙 영상 만들기 (기존)
진행 탭 모달에 라디오 "단일 트랙 / Mix" 추가. 단일 선택 시 기존 흐름 그대로.
### 3-3. 산출물 미리보기
진행 탭 카드의 cover/thumbnail 미니 썸네일 → 카드 클릭 → 상세 모달 → 큰 이미지 + 영상 플레이어 + 메타·검토 JSON.
---
## 4. 데이터 모델 변경
### 4-1. `video_pipelines` 테이블 확장
신규 컬럼:
```sql
ALTER TABLE video_pipelines ADD COLUMN compile_job_id INTEGER NULL REFERENCES compile_jobs(id);
ALTER TABLE video_pipelines ADD COLUMN visual_style TEXT NOT NULL DEFAULT 'essential';
ALTER TABLE video_pipelines ADD COLUMN background_mode TEXT NOT NULL DEFAULT 'static';
ALTER TABLE video_pipelines ADD COLUMN background_keyword TEXT;
```
| 컬럼 | 의미 |
|------|------|
| `track_id` (기존) | 단일 트랙 입력 시 |
| `compile_job_id` (신규) | Mix 입력 시 — `track_id` XOR `compile_job_id` |
| `visual_style` | `single` / `essential` |
| `background_mode` | `static` (사진) / `video_loop` (영상) |
| `background_keyword` | Pexels 검색용 (예: "rainy window cafe"). 비어있으면 장르 기반 자동 |
마이그레이션: `ADD COLUMN`은 SQLite에서 안전. 기존 행은 NULL 또는 default 값 부여.
### 4-2. `youtube_setup.visual_defaults` JSON 확장
기존:
```json
{"resolution": "1920x1080", "style": "visualizer", "background": "ai_cover"}
```
신규:
```json
{
"resolution": "1920x1080",
"default_visual_style": "essential",
"default_background_mode": "static",
"default_background_keyword": "",
"background_image_source": "ai", // ai | pexels (Mix는 default ai)
"subtitle_track_titles": true // Mix에서 곡명 자막 표시
}
```
기존 클라이언트 호환을 위해 미설정 키는 default로 fallback.
---
## 5. API 변경
### 5-1. `POST /api/music/pipeline` 요청 body 확장
```json
{
"track_id": 13,
// 또는
"compile_job_id": 5,
// 옵션 (default는 setup에서)
"visual_style": "essential", // single | essential
"background_mode": "static", // static | video_loop
"background_keyword": "rainy cafe"
}
```
검증:
- `track_id` XOR `compile_job_id` 정확히 하나만 — 둘 다거나 둘 다 없으면 400
- `compile_job_id`인 경우 `compile_jobs` 테이블에서 status='succeeded' 확인 — 아니면 400
- `visual_style` 미지정 시 `youtube_setup.visual_defaults.default_visual_style`
- `background_mode` 미지정 시 `youtube_setup.visual_defaults.default_background_mode`
응답:
```json
{
"id": 7,
"track_id": null,
"compile_job_id": 5,
"visual_style": "essential",
"background_mode": "static",
"state": "created",
...
}
```
### 5-2. `GET /api/music/pipeline/{id}` 응답 확장
신규 필드: `compile_job_id`, `visual_style`, `background_mode`, `background_keyword`, `tracks` (Mix면 트랙 리스트, 단일이면 단일 트랙 1개)
`tracks` 형식:
```json
[
{"id": 13, "title": "Lo-Fi Drive", "start_offset_sec": 0, "duration_sec": 176},
{"id": 14, "title": "Midnight Cafe", "start_offset_sec": 173, "duration_sec": 200},
...
]
```
`start_offset_sec`은 컴파일 시 acrossfade 적용을 고려한 누적 시작 시각 (=영상 자막 트리거 타이밍).
### 5-3. 변경 없음
`/feedback`, `/cancel`, `/publish`, `/setup`, `/youtube/*` 모두 그대로.
---
## 6. 백엔드 — NAS music-lab
### 6-1. `pipeline/orchestrator.py` 변경
`run_step`에 입력 audio 결정 로직 추가:
```python
def _resolve_input(p: dict) -> dict:
"""파이프라인 입력 = 단일 트랙 또는 컴파일 결과.
반환: {"audio_path": str, "duration_sec": int, "tracks": list[dict],
"title": str, "genre": str, "moods": list, ...}
"""
if p.get("compile_job_id"):
job = db.get_compile_job(p["compile_job_id"])
if not job or job["status"] != "succeeded":
raise ValueError(f"compile job {p['compile_job_id']} not ready")
# 누적 offset 계산 (acrossfade 고려)
tracks = []
offset = 0.0
crossfade = job["crossfade_sec"]
for tid in job["track_ids"]:
t = db.get_track_by_id(tid)
tracks.append({
"id": tid, "title": t["title"],
"start_offset_sec": offset,
"duration_sec": t["duration_sec"],
})
offset += t["duration_sec"] - crossfade # acrossfade overlap만큼 차감
return {
"audio_path": job["audio_path"], # /app/data/compiles/{id}.mp3
"duration_sec": int(offset + crossfade), # 마지막 트랙은 풀 길이
"tracks": tracks,
"title": job["title"] or "Mix",
"genre": "mix",
"moods": [],
}
else:
t = db.get_track_by_id(p["track_id"])
return {
"audio_path": t["file_path"],
"duration_sec": t["duration_sec"],
"tracks": [{"id": t["id"], "title": t["title"],
"start_offset_sec": 0, "duration_sec": t["duration_sec"]}],
"title": t["title"], "genre": t["genre"], "moods": t.get("moods", []),
}
```
각 step runner는 `_resolve_input(p)` 결과를 사용:
- `_run_cover`: `genre`, `moods`, `title` 활용 (Mix면 `genre="mix"` → "mix" 키 prompt 또는 default)
- `_run_video`: `audio_path`, `duration_sec`, `tracks` 모두 Windows로 전달
- `_run_meta`: `tracks` 리스트를 메타 prompt에 포함
- `_run_review`: `tracks` 리스트를 검토 prompt에 포함 (트랙 수, 다양한 장르 등)
### 6-2. `pipeline/cover.py` Pexels 폴백/대안
```python
async def generate(*, pipeline_id: int, genre: str, prompt_template: str,
mood: str = "", track_title: str = "", feedback: str = "",
image_source: str = "ai") -> dict:
"""image_source: 'ai' (DALL·E) | 'pexels' (스톡 검색)."""
if image_source == "pexels":
return await _generate_with_pexels(pipeline_id, genre, mood, track_title)
# 기존 AI 흐름 그대로
...
# AI 실패 시 — 그라데이션 폴백 대신 Pexels 시도 (config 옵션)
...
```
신규 `_generate_with_pexels`:
- Pexels API: `GET https://api.pexels.com/v1/search?query={keyword}&per_page=10`
- 결과 1번째 큰 사진 다운로드 → `/app/data/videos/{id}/cover.jpg`
- API key 미설정/실패 시 그라데이션 폴백
### 6-3. 신규 `pipeline/background.py` (video_loop 모드)
```python
async def fetch_video_loop(pipeline_id: int, keyword: str) -> dict:
"""Pexels Video API로 515초 루프 영상 받아옴.
/app/data/videos/{id}/loop.mp4 저장.
"""
# GET https://api.pexels.com/videos/search?query=...&per_page=5
# SD/HD 720p 중에서 골라 다운로드
...
return {"path": "/app/data/videos/{id}/loop.mp4", "duration_sec": ...}
```
오케스트레이터에서 `background_mode == "video_loop"` 분기 시 cover step 대신 또는 보조로 호출 (디자인 결정: cover step을 두 모드의 공통 입력 준비 단계로 통합 — 정적이면 cover.jpg, 영상이면 loop.mp4).
### 6-4. `pipeline/metadata.py` Mix 지원
`generate(*, track, template, trend_keywords, feedback="", tracks=None)` 시그니처 확장. `tracks` 있으면 Claude prompt에 다음 추가:
```
이 영상은 {len(tracks)}개 트랙의 mix입니다. 트랙 리스트:
1. [00:00] Lo-Fi Drive — lo-fi
2. [03:00] Midnight Cafe — lo-fi
...
설명에는 트랙 리스트를 타임스탬프와 함께 포함하세요.
```
응답 description은 자동으로 트랙리스트 포함됨. 이는 YouTube에서 챕터로 자동 인식.
### 6-5. `pipeline/video.py` (NAS측, 변경 작음)
기존 함수에 추가 파라미터 전달:
```python
def generate(*, pipeline_id, audio_path, cover_path, genre, duration_sec,
resolution="1920x1080", style="essential",
background_mode="static", background_path=None,
tracks=None) -> dict:
payload = {
"audio_path_nas": ..., "cover_path_nas": ...,
"output_path_nas": ...,
"resolution": resolution,
"duration_sec": duration_sec,
"style": style, # NEW: single | essential
"background_mode": background_mode, # NEW: static | video_loop
"background_path_nas": ..., # NEW: video_loop일 때 loop.mp4 경로
"tracks": tracks, # NEW: Mix면 트랙 리스트 (자막용)
}
...
```
### 6-6. `db.py` 변경
신규 컬럼 추가 마이그레이션 + `get_compile_job(id)` (없으면 추가) + `get_track_by_id(id)` 활용.
---
## 7. 백엔드 — Windows music_ai
### 7-1. `/encode_video` 요청 확장
```json
{
"audio_path_nas": "...",
"cover_path_nas": "...",
"output_path_nas": "...",
"resolution": "1920x1080",
"duration_sec": 3600,
"style": "essential", // NEW
"background_mode": "static", // NEW
"background_path_nas": "...", // NEW: video_loop면 loop.mp4
"tracks": [ // NEW: 자막용
{"start_offset_sec": 0, "title": "Lo-Fi Drive"},
{"start_offset_sec": 173, "title": "Midnight Cafe"}
]
}
```
### 7-2. `video_encoder.py` 분기 로직
```python
def encode_video(*, ..., style="essential", background_mode="static",
background_path_nas=None, tracks=None):
if style == "single":
cmd = build_single_track_cmd(...)
else: # essential
if background_mode == "static":
cmd = build_essential_static_cmd(cover, audio, out, w, h, tracks)
else:
bg = translate_path(background_path_nas)
cmd = build_essential_video_loop_cmd(bg, audio, out, w, h, tracks)
...
```
### 7-3. Essential 정적 ffmpeg 명령
핵심 filter_complex 구조:
```
[0:v]scale=1920:1080,format=yuv420p[bg]; # 정적 배경 사진
[1:a]showfreqs=s=400x200:mode=bar:cmode=combined:colors=0xFFFFFF@0.9[bars]; # 중앙 막대
[2:v]format=rgba[ring]; # 데코 ring PNG (사전 제작 1장)
[bg][bars]overlay=(W-w)/2:(H-h)/2[mid]; # 막대 정중앙 배치
[mid][ring]overlay=(W-w)/2:(H-h)/2[viz]; # ring 데코 같은 위치
[viz]drawtext=...:enable='between(t,0,5)+between(t,173,178)+...'[final]
```
- `showfreqs s=400x200 mode=bar` — 가로 막대 (방사형 근사 1차 버전)
- `ring.png` — 사전 제작된 투명 PNG (`music_ai/assets/visualizer_ring.png`, 단순 흰색 원 + 외곽 점선)
- `drawtext` — 트랙 리스트 순회하며 enable expression 동적 생성
향후(V2): `showcqt``showspectrum` 시도 + 진짜 360° 방사형은 외부 도구(예: SuperCollider, butterchurn) 검토.
### 7-4. Essential 영상 루프 ffmpeg 명령
```
[0:v]scale=1920:1080,setpts=PTS-STARTPTS[bg_loop];
loop=loop=-1:size=N # 루프 영상 무한 반복
[1:a]showfreqs=...[bars];
[bg_loop][bars]overlay=center[mid];
[mid][ring]overlay=center[viz];
... drawtext 동일
```
루프는 `-stream_loop -1 -i loop.mp4` 입력 옵션 + `-shortest` 출력으로 audio 길이만큼 반복.
### 7-5. 자막(곡명) drawtext
```python
def build_drawtext_filter(tracks, total_duration):
expressions = []
for tr in tracks:
start = tr["start_offset_sec"]
end = start + 5 # 5초 표시
# alpha fade in/out
text = tr["title"].replace(":", r"\:").replace("'", r"\'")
expressions.append(
f"drawtext=fontfile='Arial Bold':text='{text}'"
f":fontcolor=white:fontsize=36:x=(w-text_w)/2:y=h-100"
f":alpha='if(between(t,{start},{end}),"
f" if(lt(t-{start},1), t-{start}," # 0~1s fade in
f" if(gt(t-{start},4), {end}-t, 1)), 0)'" # 4~5s fade out
)
return ",".join(expressions) # 체인으로 연결
```
폰트는 Windows에 기본 설치된 Arial 또는 NanumGothic 사용. 한글 트랙명 지원 위해 NanumGothic 권장.
### 7-6. 신규 자산 파일
`music_ai/assets/visualizer_ring.png` — 1920×1080 캔버스 정중앙 400×400 영역에 그려진 흰색 원형 (외곽선 + 옅은 inner glow). 사전 제작 1장 — Pillow로 자동 생성도 가능 (서버 시작 시 없으면 생성).
---
## 8. 프론트엔드 변경
### 8-1. `CompileTab.jsx` — 영상 만들기 버튼
완료된 compile job 카드에 버튼 추가:
```jsx
{job.status === 'succeeded' && (
<button onClick={() => handleVideoFromCompile(job.id)}>
🎬 영상 만들기
</button>
)}
```
`handleVideoFromCompile`:
```js
async (compileJobId) => {
const p = await createPipeline({ compile_job_id: compileJobId });
await startPipeline(p.id);
// 진행 탭으로 이동 (router push 또는 setTab + setOpenPipelineFor 패턴)
};
```
### 8-2. `PipelineStartModal.jsx` 확장
```jsx
const [inputType, setInputType] = useState('track'); // 'track' | 'compile'
const [compileJobs, setCompileJobs] = useState([]);
useEffect(() => {
if (inputType === 'compile') getCompileJobs().then(setCompileJobs);
}, [inputType]);
return (
<div className="modal-body">
<h3> 파이프라인 시작</h3>
<fieldset>
<legend>입력</legend>
<label><input type="radio" checked={inputType==='track'}
onChange={() => setInputType('track')}/> 단일 트랙</label>
<label><input type="radio" checked={inputType==='compile'}
onChange={() => setInputType('compile')}/> Mix (컴파일 결과)</label>
</fieldset>
{inputType === 'track' && (
<select>{library.map(...)}</select>
)}
{inputType === 'compile' && (
<select>{compileJobs.filter(j=>j.status==='succeeded').map(j =>
<option key={j.id} value={j.id}>{j.title} ({j.tracks_count}, {fmtDuration(j.duration_sec)})</option>
)}</select>
)}
{/* 시각 모드 override */}
<details>
<summary>고급 옵션</summary>
<select>visual_style: single | essential</select>
<select>background_mode: static | video_loop</select>
<input>background_keyword</input>
</details>
{/* ... 기존 시작/취소 버튼 */}
</div>
);
```
### 8-3. `PipelineCard.jsx` — 미리보기 inline
```jsx
return (
<div className="pipeline-card" onClick={() => setShowDetail(true)}>
<div className="pipeline-card__head">
<h4>{pipeline.track_title || pipeline.compile_title || `Pipeline #${pipeline.id}`}</h4>
<span className="pipeline-style-badge">{pipeline.visual_style}</span>
...
</div>
{/* 미니 미리보기 */}
<div className="pipeline-previews">
{pipeline.cover_url && <img src={pipeline.cover_url} alt="" className="pipeline-preview-mini" />}
{pipeline.thumbnail_url && <img src={pipeline.thumbnail_url} alt="" className="pipeline-preview-mini" />}
{pipeline.video_url && <span className="pipeline-video-icon"></span>}
</div>
{/* 진행도 바 + 현재 상태 (기존) */}
...
</div>
);
```
### 8-4. `PipelineDetailModal.jsx` (신규)
```jsx
export default function PipelineDetailModal({ pipeline, onClose }) {
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-body modal-body--lg" onClick={e=>e.stopPropagation()}>
<header>
<h3>{pipeline.compile_title || pipeline.track_title}</h3>
<span className="badge">{pipeline.visual_style}</span>
<button onClick={onClose}>×</button>
</header>
{/* 큰 미리보기 그리드 */}
<div className="pdm-grid">
{pipeline.cover_url && (
<figure>
<img src={pipeline.cover_url} alt="cover" />
<figcaption>커버 (배경)</figcaption>
</figure>
)}
{pipeline.thumbnail_url && (
<figure>
<img src={pipeline.thumbnail_url} alt="thumbnail" />
<figcaption>썸네일</figcaption>
</figure>
)}
</div>
{/* 영상 플레이어 */}
{pipeline.video_url && (
<div className="pdm-video">
<video src={pipeline.video_url} controls width="100%" />
</div>
)}
{/* 메타데이터 */}
{pipeline.metadata && (
<section className="pdm-meta">
<h4>메타데이터</h4>
<p><strong>제목:</strong> {pipeline.metadata.title}</p>
<details>
<summary>설명</summary>
<pre>{pipeline.metadata.description}</pre>
</details>
<p><strong>태그:</strong> {pipeline.metadata.tags?.join(', ')}</p>
</section>
)}
{/* AI 검토 */}
{pipeline.review && (
<section className="pdm-review">
<h4>AI 검토 <span className="badge">{pipeline.review.verdict}</span> ({pipeline.review.weighted_total}/100)</h4>
<table>
<tbody>
<tr><td>메타데이터 품질</td><td>{pipeline.review.metadata_quality.score}</td></tr>
<tr><td>콘텐츠 정책</td><td>{pipeline.review.policy_compliance.score}</td></tr>
<tr><td>시청 경험</td><td>{pipeline.review.viewer_experience.score}</td></tr>
<tr><td>트렌드 정렬</td><td>{pipeline.review.trend_alignment.score}</td></tr>
</tbody>
</table>
<p><em>{pipeline.review.summary}</em></p>
</section>
)}
{/* 트랙 리스트 (Mix일 때) */}
{pipeline.tracks && pipeline.tracks.length > 1 && (
<section className="pdm-tracks">
<h4>트랙 리스트 ({pipeline.tracks.length})</h4>
<ol>
{pipeline.tracks.map(t => (
<li key={t.id}>
[{fmtTimestamp(t.start_offset_sec)}] {t.title} ({fmtDuration(t.duration_sec)})
</li>
))}
</ol>
</section>
)}
{/* 피드백 히스토리 */}
{pipeline.feedback && pipeline.feedback.length > 0 && (
<section className="pdm-feedback">
<h4>피드백 ({pipeline.feedback.length})</h4>
<ul>
{pipeline.feedback.map(f => (
<li key={f.id}>
<code>[{f.step}]</code> {f.feedback_text}
<small>{f.received_at}</small>
</li>
))}
</ul>
</section>
)}
{/* YouTube 링크 */}
{pipeline.youtube_video_id && (
<a href={`https://youtu.be/${pipeline.youtube_video_id}`}
target="_blank" rel="noreferrer" className="pdm-youtube">
🎬 YouTube에서 보기
</a>
)}
</div>
</div>
);
}
```
### 8-5. `SetupTab.jsx` 확장
영상 비주얼 기본값 카드 확장:
- **default_visual_style** 드롭다운: `single` / `essential`
- **default_background_mode** 드롭다운: `static` / `video_loop`
- **default_background_keyword** 텍스트 입력 (예: "lofi cafe")
- **background_image_source** 드롭다운: `ai` / `pexels`
- **subtitle_track_titles** 체크박스: Mix에서 곡명 자막 표시
---
## 9. 환경변수 (NAS측)
신규 — 이미 `.env`에 있을 가능성 높음:
```env
PEXELS_API_KEY=xxx # 이미 있음 (현재 미사용)
```
신규 (Windows측 — music_ai/.env):
```env
# 한글 자막용 폰트 경로 (선택)
SUBTITLE_FONT=C:\Windows\Fonts\malgun.ttf
```
---
## 10. 에러 처리
| 시나리오 | 결과 |
|---------|------|
| compile_job 미완료 (status != succeeded) | POST /pipeline 시 400 |
| compile_job 삭제됨 | get_pipeline에서 `compile_title=null`, 진행 탭에 "삭제됨" 배지 |
| Pexels API 실패 (image) | AI 폴백 |
| Pexels API 실패 (video) | 단색 폴백 + 텔레그램에 "Pexels 실패" 명시 |
| drawtext 자막 한글 폰트 누락 | 자막 없이 인코딩 + 경고 로그 |
| 1시간 NVENC timeout | 영상 단계 timeout 600s → 그래도 부족하면 failed (보통 NVENC면 5분 내) |
---
## 11. 테스트 전략
### 11-1. 단위 테스트 (NAS music-lab)
| 대상 | 테스트 |
|------|--------|
| `orchestrator._resolve_input` | track_id 분기 / compile_job_id 분기 / 둘 다 / 둘 다 없음 / compile not ready |
| `cover.generate` `image_source='pexels'` | Pexels API mock + 다운로드 + 파일 저장 |
| `background.fetch_video_loop` | Pexels Video API mock + mp4 다운로드 |
| `metadata.generate` `tracks=[...]` | 트랙 리스트가 prompt에 포함되는지, 응답 description에 chapter 포맷 |
| API `POST /pipeline { compile_job_id }` | 정상 / not ready 400 / 둘 다 400 / 단일은 기존 작동 |
| DB 마이그레이션 | 새 컬럼 default 값 |
### 11-2. 단위 테스트 (Windows music_ai)
| 대상 | 테스트 |
|------|--------|
| `build_essential_static_cmd` | filter_complex 문자열 검증 (showfreqs, overlay 위치 등) |
| `build_drawtext_filter` | 트랙 N개 → enable expression N개 생성, alpha fade 검증 |
| `encode_video` `style='essential'` | 새 분기 호출됨 |
| `encode_video` `style='single'` | 기존 단일 트랙 명령 그대로 |
| 자산 ring.png 자동 생성 | 서버 시작 시 없으면 PIL로 생성 |
### 11-3. 통합 테스트
`test_essential_pipeline_flow.py`:
- compile job 생성 → 파이프라인 시작 (compile_job_id) → 모든 단계 mock → published → tracks 리스트가 metadata description에 포함됐는지
### 11-4. 수동 E2E
- [ ] 컴파일 탭에서 3-5분 mix 컴파일
- [ ] "🎬 영상 만들기" 클릭 → 진행 탭 카드 생성, visual_style=essential
- [ ] cover 단계 → 텔레그램 알림 + 카드에 cover 미니 썸네일 표시
- [ ] 카드 클릭 → 상세 모달 → cover 큰 이미지, 메타·검토 영역 표시 (해당 단계 진행 시)
- [ ] 모든 단계 승인 → 발행 → YouTube 비공개 영상에 essential 시각 + 챕터 자동 인식 확인
- [ ] 1시간 mix로 동일 흐름 — Windows NVENC 인코딩 시간 5분 미만 확인
- [ ] background_mode=video_loop로 시도 — Pexels 영상 다운로드 + 루프 인코딩
---
## 12. 마이그레이션 + 배포
### 12-1. DB 마이그레이션
`init_db()` 신규 컬럼 `ALTER TABLE` (SQLite는 idempotent: 컬럼 존재 확인 후 추가):
```python
def _add_column_if_missing(cursor, table, column, ddl):
cursor.execute(f"PRAGMA table_info({table})")
cols = [r[1] for r in cursor.fetchall()]
if column not in cols:
cursor.execute(f"ALTER TABLE {table} ADD COLUMN {column} {ddl}")
```
### 12-2. 자산 파일
`music_ai/assets/visualizer_ring.png`은 git에 커밋 (small, ~30KB). Windows 측이므로 사용자가 수동 배포 (이미 music_ai는 로컬 전용).
또는 **서버 시작 시 자동 생성** (PIL로 단순 ring 그리기) — 권장. assets 디렉토리도 자동 생성.
### 12-3. 환경변수
NAS `.env` 변경 없음 (PEXELS_API_KEY 이미 있음).
Windows `.env``SUBTITLE_FONT` 추가 (선택).
---
## 13. 산출물
| 영역 | 파일 |
|------|------|
| Spec/Plan | 본 문서 + plan |
| NAS music-lab | `db.py` (마이그레이션), `pipeline/orchestrator.py` (resolve_input), `pipeline/cover.py` (Pexels 분기), `pipeline/background.py` (신규), `pipeline/metadata.py` (tracks 옵션), `pipeline/video.py` (style/background 파라미터), `app/main.py` (POST /pipeline body 확장) |
| Windows music_ai | `video_encoder.py` (style 분기, drawtext, ring), `server.py` (요청 schema 확장), `assets/visualizer_ring.png` (자동 생성), Pillow 이미 있음 |
| Frontend | `CompileTab.jsx` (영상 만들기 버튼), `PipelineStartModal.jsx` (라디오), `PipelineCard.jsx` (미리보기 inline), `PipelineDetailModal.jsx` (신규), `SetupTab.jsx` (visual_defaults 확장), `api.js` 헬퍼 추가, `MusicStudio.css` 스타일 |
| 테스트 | NAS 단위 6+ / Windows 단위 5+ / 통합 1 / 수동 E2E |
---
## 14. 후속 (P3)
- 사용자 직접 사진/영상 업로드
- 텔레그램에 cover/thumbnail 사진 첨부
- 360° 진짜 방사형 visualizer (외부 도구 또는 GPU shader)
- AI 검토 가중치 mix vs 단일 자동 분리
- Pexels 검색 미리보기 UI (구성 탭에서 "이 키워드로 검색해보기" 버튼)
---

View File

@@ -0,0 +1,486 @@
# GPU 영상 인코딩 오프로드 — 설계
> 작성일: 2026-05-09
> 관련: `2026-05-07-music-youtube-pipeline-design.md` (Task 4 대체)
---
## 1. 배경
NAS Synology Celeron J4025(2 cores @ 2.0GHz, GPU 없음)에서 1920×1080 visualizer 영상 인코딩이 너무 느림. 176초 트랙 인코딩에 5분 초과 → ffmpeg `subprocess.TimeoutExpired`. `-preset ultrafast`로 가속해도 한계 있고 화질 저하.
대안: 사용자 Windows PC(RTX 5070 Ti, 16GB VRAM)에서 NVIDIA NVENC 하드웨어 인코딩으로 처리. 같은 영상이 **1020초**에 완료(20×+ 빠름).
이미 `music_ai` 서버(Windows, port 8765)가 MusicGen용으로 동작 중이므로 **같은 서버에 영상 인코딩 endpoint를 추가**하는 것이 가장 자연스럽다.
---
## 2. 비목표
- 다중 GPU/멀티 머신 — 단일 Windows PC만 지원
- NAS 로컬 ffmpeg 폴백 — 사용자 결정으로 제외 (Windows 서버 다운 시 명확한 실패 선호)
- 영상 길이 제한 — 일반 트랙 길이(110분) 가정
- 인증 — LAN 전용, 무인증
---
## 3. 아키텍처
```
┌────────────────────────────────────────────────────────────┐
│ NAS (Synology) │
│ │
│ music-lab container │
│ pipeline/video.py │
│ ↓ HTTP POST {paths, resolution} │
│ ↓ 192.168.45.59:8765/encode_video │
│ │
│ /volume1/docker/webpage/data/ │
│ videos/{id}/cover.jpg ← input │
│ videos/{id}/video.mp4 ← output (Windows가 직접 씀) │
│ {audio}.mp3 ← input │
└────────────────────────────────────────────────────────────┘
↓ HTTP ↑ SMB read/write
↓ ↑ (Z:\ 마운트)
┌────────────────────────────────────────────────────────────┐
│ Windows PC (192.168.45.59) │
│ │
│ music_ai server.py (port 8765) │
│ • POST /generate (기존, MusicGen) │
│ • POST /encode_video (신규) │
│ ↓ 경로 변환: /volume1/... → Z:\... │
│ ↓ ffmpeg.exe -hwaccel cuda -c:v h264_nvenc ... │
│ ↓ 입력/출력 모두 Z:\ 직접 (SMB) │
│ ↓ 응답: {ok, duration_ms, output_path} │
│ │
│ Z:\docker\webpage\data\ (NAS SMB mount, 기존) │
│ videos\{id}\cover.jpg │
│ videos\{id}\video.mp4 │
│ {audio}.mp3 │
└────────────────────────────────────────────────────────────┘
```
**핵심 원칙:** 파일은 SMB로 직접 읽고 쓰기 — HTTP는 메타데이터(경로 + 옵션)만 전달.
---
## 4. Windows `music_ai` 서버 — `/encode_video` endpoint
### 4-1. Request
```http
POST /encode_video HTTP/1.1
Host: 192.168.45.59:8765
Content-Type: application/json
```
| 필드 | 타입 | 필수 | 설명 |
|------|------|------|------|
| `cover_path_nas` | string | ✓ | 배경 이미지 NAS 절대경로 |
| `audio_path_nas` | string | ✓ | 오디오 파일 NAS 절대경로 |
| `output_path_nas` | string | ✓ | 출력 mp4 NAS 절대경로 |
| `resolution` | string | ✓ | `WIDTHxHEIGHT` (예: `1920x1080`) |
| `duration_sec` | int | | 트랙 길이 — 진행 추적용 (옵션) |
| `style` | string | | 현재 `visualizer`만 (확장용) |
### 4-2. Response
**성공 (200):**
```json
{
"ok": true,
"duration_ms": 12340,
"output_path_nas": "/volume1/docker/webpage/data/videos/3/video.mp4",
"output_bytes": 28470000,
"encoder": "h264_nvenc",
"preset": "p4"
}
```
**실패 (4xx/5xx):**
```json
{
"ok": false,
"error": "ffmpeg returncode=1: ...",
"stage": "ffmpeg" // path_translate | input_validation | ffmpeg | output_check
}
```
### 4-3. 경로 변환
Windows 서버는 `nas_path → windows_path` 변환을 환경변수 기반으로 수행:
```python
# .env (Windows music_ai)
NAS_VOLUME_PREFIX=/volume1/
WINDOWS_DRIVE_ROOT=Z:\
```
변환 로직:
```python
def translate_path(nas_path: str) -> str:
# /volume1/docker/webpage/data/videos/3/cover.jpg
# → Z:\docker\webpage\data\videos\3\cover.jpg
if not nas_path.startswith(NAS_VOLUME_PREFIX):
raise ValueError(f"NAS prefix 불일치: {nas_path}")
rel = nas_path[len(NAS_VOLUME_PREFIX):] # "docker/webpage/..."
return WINDOWS_DRIVE_ROOT + rel.replace("/", "\\")
```
### 4-4. 입력 검증
ffmpeg 호출 전:
- `cover_path` 변환된 Windows 경로의 파일 존재 확인 → 없으면 400 stage=input_validation
- `audio_path` 동일
- `output_path`의 부모 디렉토리 존재 확인 — 없으면 자동 생성
- `resolution` 정규식 `^\d{3,4}x\d{3,4}$` 검증 → 실패 시 400
### 4-5. ffmpeg 명령 (NVENC)
```python
def build_visualizer_cmd(cover_win, audio_win, out_win, w, h):
return [
"ffmpeg", "-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", # quality preset (p1=fastest, p7=slowest/best)
"-rc", "vbr",
"-cq", "23", # quality (lower=better, 18-25 sane range)
"-b:v", "0", # let CQ control bitrate
"-pix_fmt", "yuv420p", # YouTube 호환
"-c:a", "aac", "-b:a", "192k",
"-shortest", out_win,
]
```
**주요 플래그 설명:**
- `-hwaccel cuda` — CUDA 사용
- `-c:v h264_nvenc` — NVIDIA NVENC H.264 인코더
- `-preset p4` — 품질·속도 균형 (5070 Ti 기준 1080p 영상 ~1020s)
- `-rc vbr -cq 23 -b:v 0` — VBR + 일정 품질 (CQ 23 = ~CRF 23)
- `format=yuv420p` 명시 — NVENC가 가끔 yuv444 출력하는데 YouTube 호환 X
### 4-6. 타임아웃 + 출력 검증
- ffmpeg subprocess timeout: **180초** (NAS 측 HTTP timeout 200s 미만)
- 종료 후 출력 파일 존재 + 크기 > 1MB 검증 → 미달 시 stage=output_check 실패
- 종료 코드 0이지만 파일 비어있는 케이스 catch
### 4-7. 동시 처리
별도 큐 없음. 동시 호출 시 ffmpeg 프로세스 병렬 실행 — RTX 5070 Ti는 NVENC 세션 5개까지 지원.
단일 사용자 시나리오에서 동시 인코딩은 거의 발생 안 함. 발생해도 GPU 리소스 충분.
### 4-8. 헬스 체크 확장
기존 `GET /health`에 인코더 가용성 정보 추가:
```json
{
"ok": true,
"gpu": "NVIDIA GeForce RTX 5070 Ti",
"musicgen_loaded": true,
"ffmpeg_path": "C:/ffmpeg/bin/ffmpeg.exe",
"ffmpeg_nvenc": true
}
```
`ffmpeg_nvenc` 검증: 서버 시작 시 `ffmpeg -encoders | grep h264_nvenc` 한 번 실행 + 캐시.
---
## 5. NAS music-lab — `pipeline/video.py` 리팩토링
### 5-1. 환경변수 (필수)
```env
WINDOWS_VIDEO_ENCODER_URL=http://192.168.45.59:8765
```
미설정 시: `pipeline/video.py`가 기동 시 명확한 에러로 실패 (ImportError 또는 RuntimeError).
### 5-2. `video.generate(...)` — 새 구현
```python
"""영상 비주얼 생성 — Windows GPU 서버 (NVENC) 호출."""
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 + 마진
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 서버 호출. 다운/실패 시 즉시 예외."""
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 인코더 호출: %s%s", audio_path, out_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) as e:
raise VideoGenerationError(f"Windows 인코더 연결 실패: {e}")
if resp.status_code != 200:
try:
detail = resp.json()
except Exception:
detail = {"error": resp.text[:300]}
raise VideoGenerationError(
f"Windows 인코더 오류 ({resp.status_code}): "
f"{detail.get('stage','?')}{detail.get('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
"""
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")
if container_path.startswith("/app/data/videos/"):
return container_path.replace("/app/data/videos/", nas_videos_root + "/", 1)
if container_path.startswith("/app/data/"):
# 음악 파일 마운트가 /app/data 직접이라 서브디렉토리 없음 → music root에 직접
rel = container_path[len("/app/data/"):]
return nas_music_root + "/" + rel
return container_path # fallback (shouldn't happen)
```
### 5-3. 제거 항목
- `subprocess.run(...)` ffmpeg 호출 — 완전 제거
- `VIDEO_TIMEOUT_S = 600` — 사용 안 함 (`ENCODER_TIMEOUT_S`로 대체)
- `_build_visualizer_cmd` — 제거 (Windows 서버로 이전)
- `subprocess.TimeoutExpired` 예외 처리 — 제거
### 5-4. 환경변수 (NAS music-lab)
```yaml
# docker-compose.yml music-lab service environment
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}
```
NAS `.env` 추가:
```env
WINDOWS_VIDEO_ENCODER_URL=http://192.168.45.59:8765
```
---
## 6. 에러 응답 매트릭스
| 상황 | NAS 측 결과 | 사용자 경험 |
|------|------------|-------------|
| Windows PC 꺼짐 | `VideoGenerationError("연결 실패")` | 진행 카드 `failed`, 텔레그램에 명확한 에러 |
| Windows ffmpeg 실패 | `VideoGenerationError("Windows 인코더 오류 500: ffmpeg — ...")` | 동일 |
| 입력 파일 NAS에 없음 | Windows가 400 응답 | "input_validation: cover not found" 메시지 |
| 출력 파일이 비어있음 | Windows가 500 응답 | "output_check: file empty" |
| 타임아웃 (180s+) | Windows가 504 응답 또는 connection close | "타임아웃 — GPU 부하 또는 입력 손상" |
| WINDOWS_VIDEO_ENCODER_URL 미설정 | 즉시 `VideoGenerationError` | 환경 미설정 안내 |
모두 pipeline state `failed`로 전이. 재생성 5회 한도 적용.
---
## 7. 헬스 모니터링
NAS music-lab 시작 시 1회 `GET {ENCODER_URL}/health` 호출 → 결과를 로그에 출력:
- 성공 + `ffmpeg_nvenc=true` → 인코더 사용 가능
- 실패 → 경고 로그 (구동은 계속, 호출 시점에 명확한 에러)
---
## 8. 테스트 전략
### 8-1. NAS music-lab 단위 테스트
`music-lab/tests/test_video_thumb.py` — 기존 ffmpeg 테스트를 HTTP mock 기반으로 교체:
```python
@respx.mock
def test_generate_video_calls_remote_encoder(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.post("http://192.168.45.59:8765/encode_video").mock(
return_value=Response(200, json={
"ok": True, "duration_ms": 12000,
"output_path_nas": "/volume1/...",
"encoder": "h264_nvenc", "preset": "p4"
})
)
out = video.generate(...)
assert out["url"].endswith("/video.mp4")
assert out["encode_duration_ms"] == 12000
@respx.mock
def test_generate_video_raises_on_connection_error(monkeypatch):
monkeypatch.setattr(video, "ENCODER_URL", "http://192.168.45.59:8765")
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(...)
assert "연결 실패" in str(exc.value)
def test_generate_video_no_url_configured(monkeypatch):
monkeypatch.setattr(video, "ENCODER_URL", "")
with pytest.raises(video.VideoGenerationError) as exc:
video.generate(...)
assert "WINDOWS_VIDEO_ENCODER_URL" in str(exc.value)
```
기존 `test_generate_video_calls_ffmpeg` / `test_generate_video_failure_marks_failed` 제거.
### 8-2. Windows `music_ai` 단위 테스트
`music_ai/tests/test_video_encoder.py` (신규):
```python
@patch("subprocess.run")
def test_translate_path():
assert video_encoder.translate_path("/volume1/docker/webpage/data/x.jpg") == r"Z:\docker\webpage\data\x.jpg"
def test_translate_path_rejects_bad_prefix():
with pytest.raises(ValueError):
video_encoder.translate_path("/something/else/x.jpg")
@patch("subprocess.run")
def test_encode_endpoint_success(mock_run, client, tmp_path):
# mock paths exist + ffmpeg succeeds
...
@patch("subprocess.run")
def test_encode_endpoint_input_missing(mock_run, client):
# 입력 파일 안 보이면 400
...
@patch("subprocess.run")
def test_encode_endpoint_ffmpeg_fails(mock_run, client, tmp_path):
# ffmpeg returncode=1 → 500 stage=ffmpeg
...
```
### 8-3. 통합 테스트
기존 `test_pipeline_flow.py``cover.generate`를 mock하므로 영향 없음. video도 같이 mock — 변경 없음.
### 8-4. 수동 E2E
- [ ] Windows PC에서 `music_ai` 서버 시작 → `curl http://192.168.45.59:8765/health``ffmpeg_nvenc: true` 확인
- [ ] NAS에서 `curl -X POST http://192.168.45.59:8765/encode_video -d '{...}'` 직접 호출 → 200 응답 + Z:\에 video.mp4 생성 확인
- [ ] 진행 탭에서 새 파이프라인 시작 → 영상 단계가 1020초 안에 완료 → 텔레그램 알림 도착
- [ ] Windows PC 꺼두고 새 파이프라인 시작 → 영상 단계 즉시 실패 → 진행 카드 failed + 명확한 에러 메시지
---
## 9. Windows PC 사전 준비
사용자가 Windows PC에서 1회 수행할 작업:
1. **ffmpeg + NVENC 빌드 설치**
- https://www.gyan.dev/ffmpeg/builds/ → "release full" 다운로드
- 압축 해제 → `C:\ffmpeg\bin\ffmpeg.exe`
- PATH 환경변수에 `C:\ffmpeg\bin` 추가
- 검증: `ffmpeg -version` 동작, `ffmpeg -encoders | findstr h264_nvenc` 결과 출력
2. **NVIDIA 드라이버** — 이미 MusicGen용으로 설치돼 있음
3. **SMB 마운트 확인**`Z:\docker\webpage\` 접근 가능해야 함
4. **방화벽** — 포트 8765 LAN 인바운드 허용 (이미 MusicGen용으로 설정돼 있음)
5. **`music_ai/.env`에 추가**:
```env
NAS_VOLUME_PREFIX=/volume1/
WINDOWS_DRIVE_ROOT=Z:\
FFMPEG_PATH=C:\ffmpeg\bin\ffmpeg.exe
```
6. **`music_ai/start.bat` 재시작** — 새 endpoint 활성화
---
## 10. 산출물
| 영역 | 파일 |
|------|------|
| Windows | `music_ai/video_encoder.py` (신규) |
| Windows | `music_ai/server.py` (수정 — `/encode_video` endpoint 등록, `/health` 확장) |
| Windows | `music_ai/.env.example` (수정 — 새 변수 문서화) |
| Windows | `music_ai/tests/test_video_encoder.py` (신규) |
| NAS | `music-lab/app/pipeline/video.py` (재작성) |
| NAS | `music-lab/tests/test_video_thumb.py` (수정 — HTTP mock 기반) |
| Infra | `web-backend/docker-compose.yml` (env 3개 추가) |
| Infra | NAS `.env` (사용자 수동, 1개 추가) |
---
## 11. 후속
- (P3) 영상 인코딩 진행률 실시간 보고 — Windows에서 ffmpeg progress 파싱 후 진행 탭 카드에 표시 (현재는 단순 "running")
- (P3) Windows 서버 다중 큐 — 동시 요청 시 GPU 부하 추적 + 큐잉
- (P4) 인코딩 옵션을 youtube_setup `visual_defaults`로 추가 — preset(p1~p7), CQ, 해상도 옵션 노출
- (P4) Shorts 전용 1080×1920 인코딩 프로파일
---
## 11. 후속
- (P3) 영상 인코딩 진행률 실시간 보고 — Windows에서 ffmpeg progress 파싱 후 진행 탭 카드에 표시 (현재는 단순 "running")
- (P3) Windows 서버 다중 큐 — 동시 요청 시 GPU 부하 추적 + 큐잉
- (P4) 인코딩 옵션을 youtube_setup `visual_defaults`로 추가 — preset(p1~p7), CQ, 해상도 옵션 노출
- (P4) Shorts 전용 1080×1920 인코딩 프로파일
---

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