231 Commits

Author SHA1 Message Date
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: #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
137 changed files with 36309 additions and 273 deletions

View File

@@ -1,17 +1,84 @@
# 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 (trade/order 등 민감 엔드포인트 보호, 미설정 시 인증 비활성화)
ADMIN_API_KEY=
# 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

514
CLAUDE.md Normal file
View File

@@ -0,0 +1,514 @@
# CLAUDE.md — web-backend 프로젝트 가이드
> Claude Code가 이 프로젝트를 작업할 때 참조하는 설정 및 구조 문서.
---
## 1. 프로젝트 개요
Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
- **서비스**: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, deployer (8개)
- **프론트엔드**: 별도 레포 (React + Vite SPA), 빌드 산출물만 NAS에 배포
- **인프라**: Docker Compose (9컨테이너) + 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 실행 위치)
│ ├── backend/ # lotto-backend 소스 (rsync 동기화)
│ ├── stock-lab/ # stock-lab 소스 (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-backend` | 18000 | 로또 데이터 수집·분석·추천 API |
| `stock-lab` | 18500 | 주식 뉴스·AI 분석·KIS API 연동 |
| `music-lab` | 18600 | AI 음악 생성·라이브러리 관리 API |
| `blog-lab` | 18700 | 블로그 마케팅 수익화 API |
| `realestate-lab` | 18800 | 부동산 청약 자동 수집·매칭 API |
| `agent-office` | 18900 | AI 에이전트 오피스 (실시간 WebSocket + 텔레그램 연동) |
| `travel-proxy` | 19000 | 여행 사진 API + 썸네일 생성 |
| `lotto-frontend` (nginx) | 8080 | 정적 SPA 서빙 + API 리버스 프록시 |
| `webpage-deployer` | 19010 | Gitea Webhook 수신 → 자동 배포 |
---
## 5. Nginx 라우팅 규칙
| 경로 | 프록시 대상 | 비고 |
|------|------------|------|
| `/api/` | `lotto-backend:8000` | lotto API (기본) |
| `/api/travel/` | `travel-proxy:8000` | travel API |
| `/api/stock/` | `stock-lab:8000` | stock API |
| `/api/trade/` | `stock-lab:8000` | KIS 실계좌 API |
| `/api/portfolio` | `stock-lab:8000` | trailing slash 유무 모두 매칭 |
| `/api/music/` | `music-lab:8000` | AI 음악 생성·라이브러리 API |
| `/api/blog-marketing/` | `blog-lab:8000` | 블로그 마케팅 수익화 API |
| `/api/realestate/` | `realestate-lab:8000` | 부동산 청약 API |
| `/api/agent-office/` | `agent-office:8000` | AI 에이전트 오피스 API + WebSocket |
| `/webhook`, `/webhook/` | `deployer:9000` | Gitea Webhook |
| `/media/music/` | `/data/music/` (파일 직접 서빙) | 생성된 오디오 파일 |
| `/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 |
| Blog Lab | http://localhost:18700 |
| Realestate Lab | http://localhost:18800 |
---
## 9. 서비스별 핵심 정보
### lotto-lab (backend/)
- 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) |
| `blog_posts` | 블로그 글 (tags: JSON 배열) |
**스케줄러 job**
- 09:10 / 21:10 매일 — 당첨번호 동기화 + 채점 (`sync_latest``check_results_for_draw`)
- 00:05, 04:05, 08:05, 12:05, 16:05, 20:05 — 몬테카를로 시뮬레이션 (20,000후보 → 상위100 → best_picks 20개 교체)
**lotto-lab API 목록**
| 메서드 | 경로 | 설명 |
|--------|------|------|
| 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/todos` | 투두 전체 목록 |
| POST | `/api/todos` | 투두 생성 (status: todo\|in_progress\|done) |
| PUT | `/api/todos/{id}` | 투두 수정 |
| DELETE | `/api/todos/done` | 완료 항목 일괄 삭제 |
| DELETE | `/api/todos/{id}` | 투두 개별 삭제 |
| GET | `/api/blog/posts` | 블로그 글 목록 (`{"posts": [...]}`, date DESC) |
| POST | `/api/blog/posts` | 블로그 글 생성 (date 미입력 시 오늘) |
| PUT | `/api/blog/posts/{id}` | 블로그 글 수정 |
| DELETE | `/api/blog/posts/{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` | 브리핑 이력 |
### stock-lab (stock-lab/)
- Windows AI 서버 연동: `WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000`
- KIS API 연동으로 실계좌 잔고·거래 조회
- 뉴스 스크래핑: 네이버 증권 + 해외 사이트
- DB: `/app/data/stock.db` (articles, portfolio, broker_cash, asset_snapshots, sell_history 테이블)
- 파일 구조: `main.py`, `db.py`, `scraper.py`, `price_fetcher.py`, `holidays.json`
**stock-lab API 목록**
| 메서드 | 경로 | 설명 |
|--------|------|------|
| 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)
- 생성된 오디오 파일: `/app/data/music/` (Nginx가 `/media/music/`로 직접 서빙)
- DB: `/app/data/music.db` (music_tasks, music_library 테이블)
- 파일 구조: `main.py`, `db.py`, `suno_provider.py`, `local_provider.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}` | 가사 삭제 |
**환경변수**
- `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`)
**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 연동: 한국부동산원 청약홈 분양정보 조회 서비스
- DB: `/app/data/realestate.db` (announcements, announcement_models, user_profile, match_results, collect_log 테이블)
- 파일 구조: `main.py`, `db.py`, `collector.py`, `matcher.py`, `models.py`
**환경변수**
- `DATA_GO_KR_API_KEY`: 공공데이터포털 API 키 (미설정 시 수동 등록만 가능)
**스케줄러 job**
- 09:00 매일 — 청약 공고 수집 + 매칭 (`scheduled_collect`)
- 00:00 매일 — 상태 갱신 + 재매칭 (`scheduled_status_update`)
**realestate-lab API 목록**
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/realestate/announcements` | 공고 목록 (region, status, house_type, matched_only, sort, page, size) |
| GET | `/api/realestate/announcements/{id}` | 공고 상세 (주택형별 포함) |
| POST | `/api/realestate/announcements` | 수동 공고 등록 |
| PUT | `/api/realestate/announcements/{id}` | 공고 수정 |
| DELETE | `/api/realestate/announcements/{id}` | 공고 삭제 |
| POST | `/api/realestate/collect` | 수동 수집 트리거 |
| GET | `/api/realestate/collect/status` | 마지막 수집 결과 |
| GET | `/api/realestate/profile` | 내 프로필 조회 |
| PUT | `/api/realestate/profile` | 프로필 수정 (upsert) |
| GET | `/api/realestate/matches` | 매칭 결과 목록 |
| POST | `/api/realestate/matches/refresh` | 매칭 재계산 |
| PATCH | `/api/realestate/matches/{id}/read` | 신규 알림 읽음 처리 |
| GET | `/api/realestate/dashboard` | 요약 (진행중 공고수, 신규 매칭수, 다가오는 일정) |
### travel-proxy (travel-proxy/)
- 원본 사진: `/data/travel/` (RO)
- 썸네일 캐시: `/data/thumbs/` (RW)
- DB: `/data/thumbs/travel.db` (photos, album_covers 테이블)
- 메타: `/data/travel/_meta/region_map.json`, `regions.geojson`
- 지역 오버라이드: `/data/thumbs/region_map_extra.json` (RW, `_regions_meta` 포함)
- 파일 구조: `main.py`, `db.py`, `indexer.py`
- 썸네일: 480×480 리사이징 (Pillow), 동기화 시 사전 생성 + 온디맨드 폴백
- 데이터 흐름: 수동 sync → 폴더 스캔 → SQLite 인덱싱 + 썸네일 일괄 생성
**travel.db 테이블**
| 테이블 | 설명 |
|--------|------|
| `photos` | 사진 인덱스 (album, filename, mtime, has_thumb) |
| `album_covers` | 앨범별 커버 사진 지정 |
**지역 관리 아키텍처**
- `region_map.json` (RO): 원본 지역→앨범 매핑 (`_meta/` 안에 위치)
- `region_map_extra.json` (RW): 사용자 수정분 오버라이드 (앨범 이동, 신규 지역)
- `_regions_meta`: 커스텀 지역의 이름·좌표 저장 (`{ "region_id": { "name": "...", "coordinates": [lng, lat] } }`)
- `regions.geojson` (RO): GeoJSON Polygon 지역 경계
- 커스텀 지역: `GET /api/travel/regions`에서 `region_map`에 있지만 GeoJSON에 없는 지역을 자동 추가 (Point geometry 또는 null)
**travel-proxy API 목록**
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/travel/regions` | 지역 GeoJSON (커스텀 지역 동적 추가 포함) |
| GET | `/api/travel/photos` | 사진 목록 (region, page=1, size=20) |
| POST | `/api/travel/sync` | 폴더 스캔 → DB 동기화 + 썸네일 생성 |
| GET | `/api/travel/albums` | 앨범 목록 + 사진 수 + 커버 + region/regionName |
| PUT | `/api/travel/albums/{album}/cover` | 앨범 커버 지정 |
| PUT | `/api/travel/albums/{album}/region` | 앨범 지역 변경 (region_map_extra 수정) |
| PUT | `/api/travel/regions/{region_id}` | 커스텀 지역 이름/좌표 수정 (지도 핀 표시용) |
### blog-lab (blog-lab/)
- 블로그 마케팅 수익화 서비스 (키워드 분석 → AI 글 생성 → 마케팅 강화 → 품질 리뷰 → 포스팅 → 수익 추적)
- AI 엔진: Claude API (Anthropic, `claude-sonnet-4-20250514`)
- 웹 검색: Naver Search API (블로그 + 쇼핑) + 상위 블로그 본문 크롤링
- DB: `/app/data/blog_marketing.db`
- 파일 구조: `main.py`, `db.py`, `config.py`, `naver_search.py`, `content_generator.py`, `marketer.py`, `quality_reviewer.py`, `web_crawler.py`
**파이프라인**: 리서치(+크롤링) → 작가(초안) → 마케터(링크 삽입) → 평가자(6기준 60점)
**상태 흐름**: `draft``marketed``reviewed``published`
**blog_marketing.db 테이블**
| 테이블 | 설명 |
|--------|------|
| `keyword_analyses` | 키워드 분석 결과 (네이버 검색 데이터 + 경쟁도/기회 점수 + 크롤링 본문) |
| `blog_posts` | 블로그 글 (draft → marketed → reviewed → published) |
| `brand_links` | 브랜드커넥트 제휴 링크 (post_id/keyword_id FK) |
| `commissions` | 포스트별 월간 클릭/구매/수익 |
| `generation_tasks` | 비동기 작업 상태 (research/generate/market/review) |
| `prompt_templates` | AI 프롬프트 템플릿 (DB 저장, 코드 배포 없이 수정 가능) |
**blog-lab API 목록**
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/blog-marketing/status` | 서비스 상태 (API 키 설정 현황) |
| POST | `/api/blog-marketing/research` | 키워드 분석 시작 (+ 상위 블로그 크롤링) |
| GET | `/api/blog-marketing/research/history` | 분석 이력 조회 |
| GET | `/api/blog-marketing/research/{id}` | 분석 상세 조회 |
| DELETE | `/api/blog-marketing/research/{id}` | 분석 삭제 |
| GET | `/api/blog-marketing/task/{task_id}` | 작업 상태 폴링 |
| POST | `/api/blog-marketing/generate` | 작가 단계: AI 글 생성 (크롤링 참고 + 링크 반영) |
| POST | `/api/blog-marketing/market/{post_id}` | 마케터 단계: 전환율 강화 + 링크 삽입 |
| POST | `/api/blog-marketing/review/{post_id}` | 평가자 단계: 품질 리뷰 (6기준 × 10점, 42/60 통과) |
| POST | `/api/blog-marketing/regenerate/{post_id}` | 피드백 기반 재생성 |
| POST | `/api/blog-marketing/links` | 브랜드커넥트 링크 등록 |
| GET | `/api/blog-marketing/links` | 링크 조회 (post_id, keyword_id 필터) |
| PUT | `/api/blog-marketing/links/{id}` | 링크 수정 |
| DELETE | `/api/blog-marketing/links/{id}` | 링크 삭제 |
| GET | `/api/blog-marketing/posts` | 포스트 목록 (status 필터) |
| GET | `/api/blog-marketing/posts/{id}` | 포스트 상세 |
| PUT | `/api/blog-marketing/posts/{id}` | 포스트 수정 |
| DELETE | `/api/blog-marketing/posts/{id}` | 포스트 삭제 |
| POST | `/api/blog-marketing/posts/{id}/publish` | 발행 (네이버 URL 등록) |
| GET | `/api/blog-marketing/commissions` | 수익 내역 조회 |
| POST | `/api/blog-marketing/commissions` | 수익 기록 추가 |
| PUT | `/api/blog-marketing/commissions/{id}` | 수익 기록 수정 |
| DELETE | `/api/blog-marketing/commissions/{id}` | 수익 기록 삭제 |
| GET | `/api/blog-marketing/dashboard` | 대시보드 집계 |
**환경변수**
- `ANTHROPIC_API_KEY`: Claude API 키 (미설정 시 AI 생성 비활성화)
- `NAVER_CLIENT_ID`: 네이버 검색 API 클라이언트 ID
- `NAVER_CLIENT_SECRET`: 네이버 검색 API 시크릿
- `BLOG_DATA_PATH`: SQLite DB 저장 경로 (기본 `./data/blog`)
### agent-office (agent-office/)
- AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 에이전트가 실제 작업 수행
- stock-lab/music-lab 기존 API를 서비스 프록시로 호출 (직접 DB 접근 없음)
- 실시간 상태 동기화: WebSocket (`/api/agent-office/ws`)
- 텔레그램 봇: 양방향 알림 + 승인 (인라인 키보드)
- 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`
**에이전트 FSM 상태**: idle → working → waiting (승인 대기) → reporting → break (휴식)
**환경변수**
- `STOCK_LAB_URL`: stock-lab 내부 URL (기본 `http://stock-lab:8000`)
- `MUSIC_LAB_URL`: music-lab 내부 URL (기본 `http://music-lab:8000`)
- `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-backend:8000`
- `LOTTO_CURATOR_MODEL`: 기본 `claude-sonnet-4-5`
**텔레그램 자연어 대화 (옵션 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`)
**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 수신 |
| GET | `/api/agent-office/states` | 전체 에이전트 상태 조회 |
| GET | `/api/agent-office/conversation/stats` | 텔레그램 자연어 대화 토큰·캐시 통계 (`days` 필터) |
### 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}` 보다 **반드시 먼저** 등록 (FastAPI prefix 매칭 순서)
- **PUID/PGID**: travel-proxy는 NAS 파일 권한을 위해 PUID/PGID를 환경변수로 주입
- **캐시 전략**: `index.html``no-store`, `assets/`는 1년 장기 캐시(immutable)
- **Frontend 배포**: git push로 자동 배포되지 않음. 로컬 빌드 후 NAS에 수동 업로드
- **.env 파일**: 절대 커밋 금지. `.env.example`만 레포에 포함
- **공휴일 목록**: `stock-lab/app/holidays.json` 매년 수동 갱신 필요 (KRX 기준)
- **Windows AI 서버 IP**: `192.168.45.59` — 공유기 DHCP 고정 예약으로 고정. Tailscale은 Synology에서 TCP 불가(userspace 모드)라 로컬 IP 사용
- **현재가 조회**: 네이버 모바일 API → HTML 파싱 폴백, 3분 TTL 캐시 (`price_fetcher.py`)
- **시뮬레이션 교체 방식**: `best_picks`는 교체형 — 새 시뮬레이션 실행 시 `is_active=0`으로 비활성화 후 신규 입력

357
README.md
View File

@@ -0,0 +1,357 @@
# web-backend
Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
로또 분석, 주식 포트폴리오, AI 음악 생성, 블로그 마케팅, 부동산 청약, AI 에이전트 오피스, 여행 앨범을 하나의 Docker Compose 스택으로 운영한다.
---
## 서비스 구성
```
┌──────────────────────────────────────────────────────────────────────┐
│ lotto-frontend (Nginx:8080) │
│ ├── 정적 SPA 서빙 (React + Vite) │
│ └── API 리버스 프록시 │
│ ├── /api/ → lotto-backend:8000 (로또·블로그·투두)│
│ ├── /api/stock/, /trade/ → stock-lab:8000 │
│ ├── /api/portfolio → stock-lab:8000 │
│ ├── /api/music/ → music-lab:8000 │
│ ├── /api/blog-marketing/ → blog-lab:8000 │
│ ├── /api/realestate/ → realestate-lab:8000 │
│ ├── /api/agent-office/ → agent-office:8000 (+ WebSocket) │
│ ├── /api/travel/ → travel-proxy:8000 │
│ ├── /media/music/… (nginx 직접 서빙, 생성 오디오) │
│ ├── /media/travel/… (nginx 직접 서빙, 사진/썸네일) │
│ └── /webhook → deployer:9000 │
└──────────────────────────────────────────────────────────────────────┘
```
| 컨테이너 | 포트 | 역할 |
|---------|------|------|
| `lotto-backend` | 18000 | 로또 데이터 수집·분석·추천 + 블로그·투두 API |
| `stock-lab` | 18500 | 주식 뉴스·AI 요약·KIS 실계좌·포트폴리오·자산 추적 |
| `music-lab` | 18600 | AI 음악 생성 (Suno + 로컬 MusicGen 듀얼 프로바이더) |
| `blog-lab` | 18700 | 블로그 마케팅 수익화 (키워드→글 생성→리뷰→발행) |
| `realestate-lab` | 18800 | 청약 공고 자동 수집·프로필 매칭 |
| `agent-office` | 18900 | AI 에이전트 가상 오피스 (WebSocket + 텔레그램 봇) |
| `travel-proxy` | 19000 | 여행 사진 API + 온디맨드 썸네일 |
| `lotto-frontend` | 8080 | SPA 서빙 + 리버스 프록시 |
| `webpage-deployer` | 19010 | Gitea Webhook → 자동 배포 |
---
## 디렉토리 구조
```
web-backend/
├── backend/ # lotto-backend (로또·블로그·투두)
├── stock-lab/ # 주식·포트폴리오
├── music-lab/ # AI 음악 생성
├── blog-lab/ # 블로그 마케팅 파이프라인
├── realestate-lab/ # 청약 자동 수집·매칭
├── agent-office/ # AI 에이전트 오피스 (WS + 텔레그램)
├── 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-backend | http://localhost:18000 |
| stock-lab | http://localhost:18500 |
| music-lab | http://localhost:18600 |
| blog-lab | http://localhost:18700 |
| realestate-lab | http://localhost:18800 |
| agent-office | http://localhost:18900 |
| 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-lab (`/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. blog-lab (`/api/blog-marketing/`)
블로그 마케팅 수익화 4단계 파이프라인 (`draft → marketed → reviewed → published`).
```
리서치(Naver Search + 상위 블로그 본문 크롤링)
→ 작가(AI 초안 생성)
→ 마케터(전환율 강화 + 브랜드 링크 삽입)
→ 평가자(6기준×10점, 42/60 통과 시 published)
```
- **AI 엔진**: Claude API (`claude-sonnet-4-20250514`)
- **키워드 분석**: 네이버 검색(블로그+쇼핑) API + 경쟁도/기회 점수
- **수익 추적**: 포스트별 월간 클릭/구매/수익 기록
- **프롬프트 템플릿**: 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-lab / music-lab / blog-lab / realestate-lab 기존 API를 서비스 프록시로 호출 (직접 DB 접근 없음)
- **FSM 상태**: `idle → working → waiting(승인 대기) → reporting → break`
- **실시간 동기화**: WebSocket `/api/agent-office/ws` (init, agent_state, task_complete, command_result)
- **텔레그램 연동**: 양방향 알림 + 인라인 키보드 승인
- 봇이 작업 결과를 텔레그램으로 푸시, 명령은 텔레그램에서 바로 에이전트에 전달
- Webhook 검증 후 `chat.id` 기준 라우팅
#### 에이전트 구성
| 에이전트 | 스케줄 | 승인 | 주요 기능 |
|---------|--------|-----|----------|
| 📈 **주식 트레이더** (`stock`) | 08:00 매일 | — | 뉴스 요약 (LLM) → 텔레그램 아침 브리핑, 종목 알람 등록 |
| 🎵 **음악 프로듀서** (`music`) | 수동 트리거 | ✅ 작곡 | 프롬프트 수신 → 승인 → Suno API 작곡 → 트랙 푸시 |
| ✍️ **블로그 마케터** (`blog`) | 10:00 매일 | ✅ 발행 | 트렌드 키워드 1개 선택 → 리서치→작가→마케터→평가 자동 실행 → 점수·본문을 텔레그램 승인 요청 → 승인 시 `published` 전환, 거절 시 재생성 |
| 🏢 **청약 애널리스트** (`realestate`) | 09:15 매일 | — | realestate-lab 수집 트리거 → 신규 매칭 상위 5건 + 대시보드 요약을 텔레그램 리포트 (읽음 처리 자동) |
#### 에이전트별 명령
**Stock**`fetch_news`, `list_alerts`, `add_alert`, `test_telegram`
**Music**`compose` (승인 필요), `credits`
**Blog**`research {keyword}`, `add_trend_keyword`, `list_trend_keywords`
**Realestate**`fetch_matches`, `dashboard`
#### 스케줄러 잡
- 07:00 월요일 — Lotto: AI 큐레이터 브리핑 (5세트 + 내러티브)
- 07:30 — Stock: 뉴스 요약
- 09:15 — Realestate: 매칭 리포트
- 10:00 — Blog: 자동 파이프라인 (리서치→생성→리뷰→승인 대기)
- 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-lab)
`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-lab)
평일 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-backend | draws, recommendations, simulation_runs/candidates, best_picks, purchase_history, strategy_performance/weights, weekly_reports, lotto_briefings, todos, blog_posts |
| `stock.db` | stock-lab | articles, portfolio, broker_cash, asset_snapshots, sell_history |
| `music.db` | music-lab | music_tasks, music_library (provider, lyrics, image_url, suno_id, file_hash, cover_images, wav_url, video_url, stem_urls) |
| `blog_marketing.db` | blog-lab | keyword_analyses, blog_posts, brand_links, commissions, generation_tasks, prompt_templates |
| `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 |
| `travel.db` | travel-proxy | photos (album, filename, mtime, has_thumb), album_covers |
---
## 환경변수
```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-lab, blog-lab, agent-office 공통)
ANTHROPIC_API_KEY=sk-ant-...
ANTHROPIC_MODEL=claude-haiku-4-5-20251001
LLM_PROVIDER=claude # claude | ollama
OLLAMA_URL=http://192.168.45.59:11435
OLLAMA_MODEL=qwen3:14b
# music-lab
SUNO_API_KEY=
MUSIC_AI_SERVER_URL=
MUSIC_MEDIA_BASE=/media/music
# blog-lab
NAVER_CLIENT_ID=
NAVER_CLIENT_SECRET=
# realestate-lab
DATA_GO_KR_API_KEY=
# agent-office
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
TELEGRAM_WEBHOOK_URL=
STOCK_LAB_URL=http://stock-lab:8000
MUSIC_LAB_URL=http://music-lab:8000
BLOG_LAB_URL=http://blog-lab:8000
REALESTATE_LAB_URL=http://realestate-lab:8000
```
---
## 인프라
| 항목 | 값 |
|------|----|
| 장비 | 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-lab/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/` — 서비스별 기획·설계 문서

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"]

View File

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

View File

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

View File

@@ -0,0 +1,44 @@
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 not in ("idle", "break"):
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}"}
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(self, source: str) -> dict:
task_id = create_task(self.agent_id, "curate_weekly", {"source": source})
await self.transition("working", "후보 수집 및 AI 큐레이션 중...", task_id)
try:
result = await curate_weekly(source=source)
update_task_status(task_id, "succeeded", result_data=result)
await self.transition("reporting", f"#{result['draw_no']} 브리핑 저장 완료")
add_log(self.agent_id, f"큐레이션 완료: #{result['draw_no']} conf={result['confidence']}", task_id=task_id)
await self.transition("idle", "대기 중")
return {"ok": True, **result}
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,98 @@
import asyncio
from .base import BaseAgent
from ..db import create_task, update_task_status, add_log
from .. import service_proxy
from .. import telegram_bot
class RealestateAgent(BaseAgent):
"""부동산 청약 에이전트.
매일 09:15 자동 실행: realestate-lab의 수집을 트리거하고
신규 매칭 결과를 텔레그램으로 푸시 (승인 없는 리포트형).
"""
agent_id = "realestate"
display_name = "청약 애널리스트"
async def on_schedule(self) -> None:
if self.state not in ("idle", "break"):
return
task_id = create_task(self.agent_id, "daily_match_report", {})
await self.transition("working", "청약 공고 수집 중", task_id)
try:
collect = await service_proxy.realestate_collect()
new_count = collect.get("new_count", 0) or 0
await self.transition("working", "신규 매칭 조회 중", task_id)
matches = await service_proxy.realestate_matches(limit=20)
dashboard = await service_proxy.realestate_dashboard()
await self.transition("reporting", "리포트 전송 중", task_id)
if not matches:
body = (
f"수집된 신규 공고: {new_count}\n"
f"진행 중 공고: {dashboard.get('active_count', 0)}\n"
f"신규 매칭: 없음"
)
else:
lines = [
f"📌 수집 {new_count}건 / 매칭 {len(matches)}",
"",
]
for m in matches[:5]:
title = m.get("title") or m.get("announcement_title") or "(제목 없음)"
region = m.get("region") or ""
score = m.get("match_score") or m.get("score") or ""
lines.append(f"• [{region}] {title} (매칭 {score})")
if len(matches) > 5:
lines.append(f"… 외 {len(matches) - 5}")
body = "\n".join(lines)
tg = await telegram_bot.send_task_result(
self.agent_id,
"🏢 [청약 에이전트] 오늘의 매칭 리포트",
body,
)
# 확인한 매칭 read 처리
for m in matches[:5]:
mid = m.get("id")
if mid:
try:
await service_proxy.realestate_mark_read(int(mid))
except Exception:
pass
update_task_status(task_id, "succeeded", {
"new_count": new_count,
"match_count": len(matches),
"telegram_sent": tg.get("ok", False),
"telegram_message_id": tg.get("message_id"),
})
await self.transition("idle", f"매칭 {len(matches)}")
except Exception as e:
add_log(self.agent_id, f"Realestate report failed: {e}", "error", task_id)
update_task_status(task_id, "failed", {"error": str(e)})
await self.transition("idle", f"오류: {e}")
async def on_command(self, command: str, params: dict) -> dict:
if command == "fetch_matches":
await self.on_schedule()
return {"ok": True, "message": "매칭 리포트 시작"}
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,157 @@
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 not in ("idle", "break"):
return
task_id = create_task(self.agent_id, "news_summary", {"limit": 15})
await self.transition("working", "AI 뉴스 요약 생성 중...", task_id)
try:
# AI 요약 호출 (뉴스 수집 + LLM 처리는 stock-lab이 담당)
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_command(self, command: str, params: dict) -> dict:
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,36 @@
import os
# Service URLs (Docker internal network)
STOCK_LAB_URL = os.getenv("STOCK_LAB_URL", "http://localhost:18500")
MUSIC_LAB_URL = os.getenv("MUSIC_LAB_URL", "http://localhost:18600")
BLOG_LAB_URL = os.getenv("BLOG_LAB_URL", "http://localhost:18700")
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"
)
# Idle break threshold (seconds)
IDLE_BREAK_THRESHOLD = int(os.getenv("IDLE_BREAK_THRESHOLD", "300")) # 5 min
BREAK_DURATION_MIN = int(os.getenv("BREAK_DURATION_MIN", "60")) # 1 min
BREAK_DURATION_MAX = int(os.getenv("BREAK_DURATION_MAX", "180")) # 3 min
# Lotto Curator
LOTTO_BACKEND_URL = os.getenv("LOTTO_BACKEND_URL", "http://lotto-backend:8000")
LOTTO_CURATOR_MODEL = os.getenv("LOTTO_CURATOR_MODEL", "claude-sonnet-4-5")

View File

View File

@@ -0,0 +1,121 @@
"""큐레이터 파이프라인 — 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
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": 4096,
"system": system_blocks,
"messages": [{"role": "user", "content": [{"type": "text", "text": user_text}]}],
}
started = time.monotonic()
async with httpx.AsyncClient(timeout=120) 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=20)
draw_no = cand_resp["draw_no"]
candidates = cand_resp["candidates"]
context = await service_proxy.lotto_context()
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", []),
})
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": [p.model_dump() for p in validated.picks],
"narrative": validated.narrative.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"]},
}

View File

@@ -0,0 +1,46 @@
"""큐레이터 system/user 프롬프트. system은 정적이므로 캐시 대상."""
import json
SYSTEM_PROMPT = """당신은 로또 번호 큐레이터입니다. 주어진 후보 20세트 중 5세트를 다음 규칙으로 선별합니다.
선별 규칙:
- 5세트의 리스크 분포는 안정 2 · 균형 2 · 공격 1 을 권장(유연 ±1).
- 홀짝 비율, 저/고 구간, 연속번호 포함 여부가 세트끼리 겹치지 않도록 다양성을 확보.
- hot_number_count=0 이고 cold_number_count=0 인 '중립형' 세트를 최소 1개 포함.
- 후보에 없는 번호 조합은 절대 사용 금지. numbers 필드는 반드시 candidates 중 하나와 정확히 일치해야 함.
- 각 세트 reason은 한국어 40자 이내 한 줄. 해당 세트의 features 값과 context 값만 근거로.
narrative 규칙:
- headline: 한 줄, 이번 주 추첨 전망 요약.
- summary_3lines: 정확히 3개 항목의 배열.
- hot_cold_comment: hot/cold 번호에 대한 한 줄 논평.
- warnings: 특별한 주의사항 없으면 빈 문자열.
출력은 반드시 JSON 하나, 그 외 어떤 텍스트도 금지. 스키마:
{
"picks": [
{"numbers":[int,int,int,int,int,int], "risk_tag":"안정"|"균형"|"공격", "reason": str}
],
"narrative": {
"headline": str,
"summary_3lines": [str, str, str],
"hot_cold_comment": str,
"warnings": str
},
"confidence": int (0~100)
}
"""
def build_user_message(draw_no: int, candidates: list, context: dict) -> str:
payload = {
"draw_no": draw_no,
"context": context,
"candidates": candidates,
}
return (
f"이번 회차: {draw_no}\n"
f"아래 데이터로 5세트를 큐레이션하고 위 스키마로만 응답하세요.\n\n"
f"```json\n{json.dumps(payload, ensure_ascii=False)}\n```"
)

View File

@@ -0,0 +1,41 @@
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 Narrative(BaseModel):
headline: str
summary_3lines: List[str] = Field(min_length=3, max_length=3)
hot_cold_comment: str = ""
warnings: str = ""
class CuratorOutput(BaseModel):
picks: List[Pick]
narrative: Narrative
confidence: int = Field(ge=0, le=100)
def validate_response(data: dict, candidate_numbers: List[List[int]]) -> CuratorOutput:
out = CuratorOutput.model_validate(data)
if len(out.picks) != 5:
raise ValueError("picks must have exactly 5 sets")
candidate_set = {tuple(sorted(c)) for c in candidate_numbers}
for p in out.picks:
if tuple(p.numbers) not in candidate_set:
raise ValueError(f"pick {p.numbers} not in candidates")
return out

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

@@ -0,0 +1,503 @@
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=10)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
return conn
def init_db() -> None:
with _conn() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS 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)
""")
# Seed default agent configs
for agent_id, name in [
("stock", "주식 트레이더"),
("music", "음악 프로듀서"),
("blog", "블로그 마케터"),
("realestate", "청약 애널리스트"),
("lotto", "로또 큐레이터"),
]:
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) -> List[Dict[str, Any]]:
with _conn() as conn:
rows = conn.execute(
"SELECT * FROM agent_tasks WHERE agent_id=? ORDER BY created_at DESC LIMIT ?",
(agent_id, limit),
).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}

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

@@ -0,0 +1,182 @@
import os
import json
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
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
app = FastAPI()
_cors_origins = CORS_ALLOW_ORIGINS.split(",")
app.add_middleware(
CORSMiddleware,
allow_origins=[o.strip() for o in _cors_origins],
allow_credentials=False,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Content-Type"],
)
@app.on_event("startup")
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):
return {"tasks": get_agent_tasks(agent_id, limit)}
@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)

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

@@ -0,0 +1,38 @@
import asyncio
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from .agents import AGENT_REGISTRY
scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
async def _check_idle_breaks():
for agent in AGENT_REGISTRY.values():
await agent.check_idle_break()
async def _run_stock_schedule():
agent = AGENT_REGISTRY.get("stock")
if agent:
await agent.on_schedule()
async def _run_realestate_schedule():
agent = AGENT_REGISTRY.get("realestate")
if agent:
await agent.on_schedule()
async def _run_blog_schedule():
agent = AGENT_REGISTRY.get("blog")
if agent:
await agent.on_schedule()
async def _run_lotto_schedule():
agent = AGENT_REGISTRY.get("lotto")
if agent:
await agent.on_schedule()
def init_scheduler():
scheduler.add_job(_run_stock_schedule, "cron", hour=7, minute=30, id="stock_news")
scheduler.add_job(_run_realestate_schedule, "cron", hour=9, minute=15, id="realestate_report")
scheduler.add_job(_run_blog_schedule, "cron", hour=10, minute=0, id="blog_pipeline")
scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=7, minute=0, id="lotto_curate")
scheduler.add_job(_check_idle_breaks, "interval", seconds=60, id="idle_check")
scheduler.start()

View File

@@ -0,0 +1,156 @@
import httpx
from typing import Any, Dict, List, Optional
from .config import STOCK_LAB_URL, MUSIC_LAB_URL, BLOG_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_LAB_URL}/api/stock/news", params=params)
resp.raise_for_status()
return resp.json()
async def fetch_stock_indices() -> Dict[str, Any]:
resp = await _client.get(f"{STOCK_LAB_URL}/api/stock/indices")
resp.raise_for_status()
return resp.json()
async def summarize_stock_news(limit: int = 15) -> Dict[str, Any]:
"""stock-lab의 AI 요약 엔드포인트 호출.
반환: {"summary": str, "tokens": {...}, "model": str, "duration_ms": int, "article_count": int}
"""
# stock-lab 내부 Ollama 호출이 180s까지 가능하므로 여유있게 200s
async with httpx.AsyncClient(timeout=200.0) as client:
resp = await client.post(
f"{STOCK_LAB_URL}/api/stock/news/summarize",
json={"limit": limit},
)
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()
# --- blog-lab ---
async def blog_research(keyword: str) -> Dict[str, Any]:
"""키워드 리서치 시작 → task_id 반환"""
resp = await _client.post(
f"{BLOG_LAB_URL}/api/blog-marketing/research",
json={"keyword": keyword},
)
resp.raise_for_status()
return resp.json()
async def blog_task_status(task_id: str) -> Dict[str, Any]:
resp = await _client.get(f"{BLOG_LAB_URL}/api/blog-marketing/task/{task_id}")
resp.raise_for_status()
return resp.json()
async def blog_generate(keyword_id: int) -> Dict[str, Any]:
resp = await _client.post(
f"{BLOG_LAB_URL}/api/blog-marketing/generate",
json={"keyword_id": keyword_id},
)
resp.raise_for_status()
return resp.json()
async def blog_market(post_id: int) -> Dict[str, Any]:
resp = await _client.post(f"{BLOG_LAB_URL}/api/blog-marketing/market/{post_id}")
resp.raise_for_status()
return resp.json()
async def blog_review(post_id: int) -> Dict[str, Any]:
resp = await _client.post(f"{BLOG_LAB_URL}/api/blog-marketing/review/{post_id}")
resp.raise_for_status()
return resp.json()
async def blog_publish(post_id: int, url: str = "") -> Dict[str, Any]:
resp = await _client.post(
f"{BLOG_LAB_URL}/api/blog-marketing/posts/{post_id}/publish",
json={"url": url},
)
resp.raise_for_status()
return resp.json()
async def blog_get_post(post_id: int) -> Dict[str, Any]:
resp = await _client.get(f"{BLOG_LAB_URL}/api/blog-marketing/posts/{post_id}")
resp.raise_for_status()
return resp.json()
# --- realestate-lab ---
async def realestate_collect() -> Dict[str, Any]:
"""청약 공고 수동 수집 트리거"""
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]]:
resp = await _client.get(
f"{REALESTATE_LAB_URL}/api/realestate/matches",
params={"limit": limit, "unread_only": True},
)
resp.raise_for_status()
data = resp.json()
return data if isinstance(data, list) else data.get("matches", [])
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()
# --- 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()

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,34 @@
"""에이전트 메타 등록소."""
AGENT_META = {
"stock": {
"display_name": "주식 트레이더",
"emoji": "📈",
"color": "#4488cc",
},
"music": {
"display_name": "음악 프로듀서",
"emoji": "🎵",
"color": "#44aa88",
},
"lotto": {
"emoji": "🎱",
"display_name": "로또 큐레이터",
},
}
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,154 @@
"""텔레그램 자연어 대화 핸들러 — 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 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,75 @@
"""고수준 메시지 전송 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) -> dict:
"""가장 저수준. 원문 텍스트 그대로 전송. chat_id 생략 시 기본 TELEGRAM_CHAT_ID로."""
if not _enabled():
return {"ok": False, "message_id": None}
payload = {
"chat_id": chat_id or TELEGRAM_CHAT_ID,
"text": text,
"parse_mode": "HTML",
}
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,87 @@
"""텔레그램 메시지 명령 → 에이전트 라우팅.
새 명령을 추가하려면 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는 인자 필요 — 아래 특수 케이스에서 처리
},
}
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; — 작곡 시작
"""

View File

@@ -0,0 +1,162 @@
"""텔레그램 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]:
"""기존 승인/거절 콜백 처리 로직."""
callback_id = callback_query.get("data", "")
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_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
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,5 @@
fastapi==0.115.6
uvicorn[standard]==0.30.6
apscheduler==3.10.4
websockets>=12.0
httpx>=0.27

View File

@@ -0,0 +1,60 @@
import pytest
from app.curator.schema import validate_response, CuratorOutput
CANDIDATE_NUMBERS = [
[1, 2, 3, 4, 5, 6],
[7, 8, 9, 10, 11, 12],
[13, 14, 15, 16, 17, 18],
[19, 20, 21, 22, 23, 24],
[25, 26, 27, 28, 29, 30],
[31, 32, 33, 34, 35, 36],
]
def _valid_payload():
return {
"picks": [
{"numbers": s, "risk_tag": "안정", "reason": "test"}
for s in CANDIDATE_NUMBERS[:5]
],
"narrative": {
"headline": "h", "summary_3lines": ["a", "b", "c"],
"hot_cold_comment": "hc", "warnings": "",
},
"confidence": 80,
}
def test_valid_payload_passes():
result = validate_response(_valid_payload(), CANDIDATE_NUMBERS)
assert isinstance(result, CuratorOutput)
assert len(result.picks) == 5
def test_rejects_number_out_of_candidates():
bad = _valid_payload()
bad["picks"][0]["numbers"] = [40, 41, 42, 43, 44, 45] # valid numbers but not in candidates
with pytest.raises(ValueError, match="not in candidates"):
validate_response(bad, CANDIDATE_NUMBERS)
def test_rejects_wrong_pick_count():
bad = _valid_payload()
bad["picks"] = bad["picks"][:3]
with pytest.raises(ValueError, match="exactly 5"):
validate_response(bad, CANDIDATE_NUMBERS)
def test_rejects_duplicate_numbers_within_set():
bad = _valid_payload()
bad["picks"][0]["numbers"] = [1, 1, 2, 3, 4, 5]
with pytest.raises(ValueError):
validate_response(bad, CANDIDATE_NUMBERS)
def test_rejects_invalid_risk_tag():
bad = _valid_payload()
bad["picks"][0]["risk_tag"] = "미친"
with pytest.raises(ValueError):
validate_response(bad, CANDIDATE_NUMBERS)

6
backend/.dockerignore Normal file
View File

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

View File

@@ -16,3 +16,7 @@ ENV PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
ARG APP_VERSION=dev
ENV APP_VERSION=$APP_VERSION
LABEL org.opencontainers.image.version=$APP_VERSION

655
backend/app/analyzer.py Normal file
View File

@@ -0,0 +1,655 @@
"""
통계 분석 엔진 - lotto-lab 고도화
[팀 회의 합의 기반 5가지 통계 기법]
1. 빈도 Z-score 분석: 각 번호의 출현 빈도가 기댓값에서 얼마나 벗어났는지
2. 조합 지문(Fingerprint): 조합의 합계, 홀짝 비율, 구간 분포가 역대 당첨번호와 유사한지
3. 갭 분석(Gap): 각 번호의 마지막 출현으로부터 경과 회차 수 기반 점수
4. 공동 출현 행렬(Co-occurrence): 번호 쌍이 역대에 함께 나온 빈도 기반 점수
5. 다양성(Diversity): 연속 번호, 범위, 구간 분포 다양성
[통계 근거]
- 1~45번 각각의 이론적 출현 확률: 6/45 ≈ 13.33% per draw
- 기댓값 합계: E[sum] = 6 × E[1..45] = 6 × 23 = 138
- 표준편차 합계: std ≈ sqrt(6 × Var[uniform 1..45]) ≈ 31
- 홀수 23개 (1,3,...,45), 짝수 22개 (2,4,...,44)
- 번호 쌍 공동 출현 확률: C(43,4)/C(45,6) ≈ 1.516% per draw
"""
import math
from collections import Counter, defaultdict
from datetime import datetime, timezone
from typing import List, Tuple, Dict, Any, Optional
# 구간 정의: (시작, 끝) 포함
ZONE_RANGES: List[Tuple[int, int]] = [
(1, 9),
(10, 19),
(20, 29),
(30, 39),
(40, 45),
]
def _get_zone(n: int) -> int:
"""번호가 속하는 구간 인덱스 (0-4)"""
for z, (lo, hi) in enumerate(ZONE_RANGES):
if lo <= n <= hi:
return z
return 4
def build_analysis_cache(draws: List[Tuple[int, List[int]]]) -> Dict[str, Any]:
"""
역대 당첨번호 데이터 기반 통계 분석 캐시 구성.
시뮬레이션 실행 시 한 번만 호출하여 재사용 (성능 최적화).
Args:
draws: [(drw_no, [n1,n2,n3,n4,n5,n6]), ...] 오름차순
Returns:
통계 캐시 딕셔너리
"""
if not draws:
return {}
total_draws = len(draws)
all_nums_list = [n for _, nums in draws for n in nums]
freq_all = Counter(all_nums_list)
# ── 1. 빈도 Z-score ──────────────────────────────────────────────────────
freq_values = [freq_all.get(n, 0) for n in range(1, 46)]
mean_freq = sum(freq_values) / 45.0
variance_freq = sum((f - mean_freq) ** 2 for f in freq_values) / 45.0
std_freq = math.sqrt(variance_freq)
z_scores: Dict[int, float] = {}
for n in range(1, 46):
z_scores[n] = (freq_all.get(n, 0) - mean_freq) / max(std_freq, 0.001)
# ── 2. 갭 분석: 마지막 출현 이후 경과 회차 ──────────────────────────────
# gap = 0: 가장 최근 회차에 출현, gap = k: k회 전에 마지막 출현
last_seen_gap: Dict[int, int] = {}
for gap_idx, (_, nums) in enumerate(reversed(draws)):
for n in nums:
if n not in last_seen_gap:
last_seen_gap[n] = gap_idx
for n in range(1, 46):
if n not in last_seen_gap:
last_seen_gap[n] = total_draws # 한 번도 안 나옴 (이론상 거의 불가)
# ── 3. 공동 출현 행렬 ────────────────────────────────────────────────────
# cooccur[(i,j)] = 번호 i와 j가 같은 회차에 함께 출현한 횟수 (i < j)
cooccur: Dict[Tuple[int, int], int] = defaultdict(int)
for _, nums in draws:
s = sorted(nums)
for i in range(len(s)):
for j in range(i + 1, len(s)):
cooccur[(s[i], s[j])] += 1
# 번호 쌍 공동 출현 기댓값: C(43,4)/C(45,6) × total_draws
# C(43,4) = 123,410 / C(45,6) = 8,145,060
expected_cooccur = total_draws * 123410.0 / 8145060.0
# ── 4. 역대 조합 통계 (합계, 홀수 개수) ──────────────────────────────────
historical_sums = [sum(nums) for _, nums in draws]
mean_sum = sum(historical_sums) / total_draws
std_sum = math.sqrt(
sum((s - mean_sum) ** 2 for s in historical_sums) / total_draws
)
std_sum = max(std_sum, 1.0) # 0 나누기 방지
historical_odds = [sum(1 for n in nums if n % 2 == 1) for _, nums in draws]
odd_dist = Counter(historical_odds)
odd_prob: Dict[int, float] = {k: v / total_draws for k, v in odd_dist.items()}
max_odd_prob = max(odd_prob.values()) if odd_prob else 1.0
# ── 5. 구간별 분포 통계 ───────────────────────────────────────────────────
# 각 구간에 몇 개 포함되는지의 역대 분포
zone_counts = [Counter() for _ in ZONE_RANGES]
for _, nums in draws:
for z_idx, (lo, hi) in enumerate(ZONE_RANGES):
cnt = sum(1 for n in nums if lo <= n <= hi)
zone_counts[z_idx][cnt] += 1
zone_probs: List[Dict[int, float]] = []
for zc in zone_counts:
total_z = sum(zc.values())
zone_probs.append({k: v / total_z for k, v in zc.items()})
max_zone_probs = [max(zp.values()) if zp else 1.0 for zp in zone_probs]
# ── 6. 최근 빈도 (후보 생성 가중치용) ────────────────────────────────────
recent_100 = draws[-100:] if len(draws) >= 100 else draws
freq_recent = Counter(n for _, nums in recent_100 for n in nums)
return {
"total_draws": total_draws,
"freq_all": freq_all,
"z_scores": z_scores,
"last_seen_gap": last_seen_gap,
"cooccur": dict(cooccur),
"expected_cooccur": expected_cooccur,
"mean_sum": mean_sum,
"std_sum": std_sum,
"odd_prob": odd_prob,
"max_odd_prob": max_odd_prob,
"zone_probs": zone_probs,
"max_zone_probs": max_zone_probs,
"freq_recent": freq_recent,
}
def build_number_weights(cache: Dict[str, Any]) -> Dict[int, float]:
"""
몬테카를로 시뮬레이션의 후보 생성에 사용할 번호별 샘플링 가중치.
빈도 + 최근 빈도 + 갭 분석을 반영하여 '좋은' 번호가 더 자주 선택되도록 유도.
"""
freq_all = cache["freq_all"]
last_seen_gap = cache["last_seen_gap"]
freq_recent = cache["freq_recent"]
weights: Dict[int, float] = {}
for n in range(1, 46):
w = freq_all.get(n, 0) + 1.5 * freq_recent.get(n, 0)
gap = last_seen_gap.get(n, 0)
if gap <= 1:
gap_factor = 0.50 # 바로 직전 등장 → 패널티
elif gap <= 3:
gap_factor = 0.75
elif gap <= 12:
gap_factor = 1.00 # 적정 범위
elif gap <= 25:
gap_factor = 1.10 # 약간 오래된 번호 → 소폭 보너스
else:
gap_factor = 1.20 # 오래된 번호 → 보너스
weights[n] = max(w * gap_factor, 0.5)
return weights
def score_combination(numbers: List[int], cache: Dict[str, Any]) -> Dict[str, float]:
"""
6개 번호 조합의 통계적 품질 점수 계산 (0~1 범위 정규화).
5가지 기법별 점수:
- score_frequency (25%): 빈도 Z-score
- score_fingerprint(30%): 조합의 통계적 지문 (합계, 홀짝, 구간)
- score_gap (20%): 갭 분석
- score_cooccur (15%): 공동 출현 기댓값 대비
- score_diversity (10%): 연속번호, 범위, 구간 다양성
Returns:
{"score_total": ..., "score_frequency": ..., ...}
"""
nums = sorted(numbers)
# ── 1. 빈도 점수 (Frequency Score) ────────────────────────────────────────
z_scores = cache["z_scores"]
avg_z = sum(z_scores.get(n, 0.0) for n in nums) / 6.0
# Sigmoid 정규화: avg_z > 0이면 0.5 이상
score_frequency = 1.0 / (1.0 + math.exp(-avg_z / 1.5))
# ── 2. 조합 지문 점수 (Fingerprint Score) ─────────────────────────────────
# 2a. 합계 정규분포 점수
total = sum(nums)
mean_sum = cache["mean_sum"]
std_sum = cache["std_sum"]
z_sum = (total - mean_sum) / std_sum
sum_score = math.exp(-0.5 * z_sum ** 2) # 정규분포 밀도 (peak=1 at mean)
# 2b. 홀짝 비율 점수
odd_count = sum(1 for n in nums if n % 2 == 1)
odd_prob = cache["odd_prob"]
max_odd_prob = cache["max_odd_prob"]
odd_score = odd_prob.get(odd_count, 0.01) / max_odd_prob
# 2c. 구간 분포 점수
zone_probs = cache["zone_probs"]
max_zone_probs = cache["max_zone_probs"]
zone_score = 0.0
for z_idx, (lo, hi) in enumerate(ZONE_RANGES):
cnt = sum(1 for n in nums if lo <= n <= hi)
zp = zone_probs[z_idx]
mzp = max_zone_probs[z_idx]
zone_score += zp.get(cnt, 0.01) / mzp
zone_score /= len(ZONE_RANGES)
score_fingerprint = sum_score * 0.50 + odd_score * 0.30 + zone_score * 0.20
# ── 3. 갭 점수 (Gap Score) ────────────────────────────────────────────────
last_seen_gap = cache["last_seen_gap"]
gap_scores: List[float] = []
for n in nums:
gap = last_seen_gap.get(n, 0)
if gap <= 1:
gs = 0.20 # 직전 등장 번호 - 강한 패널티
elif gap <= 3:
gs = 0.55
elif gap <= 7:
gs = 0.85
elif gap <= 15:
gs = 1.00 # 최적 범위
elif gap <= 25:
gs = 0.90
else:
gs = 0.75 # 오래된 번호 - 여전히 양호
gap_scores.append(gs)
score_gap = sum(gap_scores) / 6.0
# ── 4. 공동 출현 점수 (Co-occurrence Score) ───────────────────────────────
cooccur = cache["cooccur"]
expected_cooccur = cache["expected_cooccur"]
pair_scores: List[float] = []
for i in range(len(nums)):
for j in range(i + 1, len(nums)):
actual = cooccur.get((nums[i], nums[j]), 0)
ratio = actual / max(expected_cooccur, 0.001)
# Sigmoid: ratio = 1에서 0.5, ratio > 1이면 > 0.5
ps = 1.0 / (1.0 + math.exp(-2.0 * (ratio - 1.0)))
pair_scores.append(ps)
score_cooccur = sum(pair_scores) / max(len(pair_scores), 1)
# ── 5. 다양성 점수 (Diversity Score) ─────────────────────────────────────
# 5a. 연속 번호 포함 여부 (역대 당첨번호 약 52%에 최소 1쌍 포함)
has_consecutive = any(nums[i + 1] - nums[i] == 1 for i in range(len(nums) - 1))
consecutive_score = 0.65 if has_consecutive else 0.40
# 5b. 범위 점수 (최소~최대 차이)
num_range = nums[-1] - nums[0]
if 28 <= num_range <= 43:
spread_score = 1.00
elif 20 <= num_range < 28:
spread_score = 0.85
elif 13 <= num_range < 20:
spread_score = 0.65
elif num_range < 13:
spread_score = 0.25
else: # > 43 (최대 44: 1~45)
spread_score = 0.95
# 5c. 구간 커버리지 (몇 개 구간에 걸쳐 있는가)
zones_used = set(_get_zone(n) for n in nums)
zone_coverage = (len(zones_used) - 1) / 4.0 # 0~1
score_diversity = (
consecutive_score * 0.35
+ spread_score * 0.35
+ zone_coverage * 0.30
)
# ── 최종 가중 합산 ────────────────────────────────────────────────────────
score_total = (
score_frequency * 0.25
+ score_fingerprint * 0.30
+ score_gap * 0.20
+ score_cooccur * 0.15
+ score_diversity * 0.10
)
return {
"score_total": round(score_total, 6),
"score_frequency": round(score_frequency, 6),
"score_fingerprint": round(score_fingerprint, 6),
"score_gap": round(score_gap, 6),
"score_cooccur": round(score_cooccur, 6),
"score_diversity": round(score_diversity, 6),
}
def get_statistical_report(draws: List[Tuple[int, List[int]]]) -> Dict[str, Any]:
"""
통계 분석 리포트 생성 (GET /api/lotto/analysis 응답용).
각 번호의 빈도, Z-score, 갭, 히트/콜드/오버듀 분류를 반환.
"""
if not draws:
return {"error": "데이터 없음"}
cache = build_analysis_cache(draws)
total_draws = cache["total_draws"]
freq_all = cache["freq_all"]
z_scores = cache["z_scores"]
last_seen_gap = cache["last_seen_gap"]
number_stats = []
for n in range(1, 46):
freq = freq_all.get(n, 0)
expected = total_draws * 6.0 / 45.0
number_stats.append({
"number": n,
"frequency": freq,
"expected": round(expected, 1),
"frequency_pct": round(freq / (total_draws * 6) * 100, 2),
"z_score": round(z_scores.get(n, 0.0), 3),
"gap": last_seen_gap.get(n, total_draws),
"zone": _get_zone(n),
})
sorted_by_freq = sorted(number_stats, key=lambda x: -x["frequency"])
sorted_by_gap = sorted(number_stats, key=lambda x: -x["gap"])
# 역대 합계 분포 요약
hist_sums = [sum(nums) for _, nums in draws]
sum_buckets: Dict[str, int] = {}
for lo in range(21, 256, 20):
hi = lo + 19
key = f"{lo}-{hi}"
sum_buckets[key] = sum(1 for s in hist_sums if lo <= s <= hi)
return {
"total_draws": total_draws,
"mean_sum": round(cache["mean_sum"], 2),
"std_sum": round(cache["std_sum"], 2),
"odd_distribution": {
str(k): round(v * 100, 1)
for k, v in sorted(cache["odd_prob"].items())
},
"number_stats": number_stats,
"hot_numbers": [x["number"] for x in sorted_by_freq[:10]],
"cold_numbers": [x["number"] for x in sorted_by_freq[-10:]],
"overdue_numbers": [x["number"] for x in sorted_by_gap[:10]],
"sum_distribution": sum_buckets,
}
def analyze_personal_patterns(
all_numbers: List[List[int]],
draws: List[Tuple[int, List[int]]],
) -> Dict[str, Any]:
"""
사용자 추천 이력 기반 개인 패턴 분석.
all_numbers: 저장된 모든 추천 번호 리스트 (각 원소는 6개짜리 리스트)
draws: 역대 당첨번호 (홀짝/합계 평균 비교용)
"""
if not all_numbers:
return {"total_analyzed": 0, "message": "추천 이력이 없습니다"}
total = len(all_numbers)
flat = [n for nums in all_numbers for n in nums]
freq = Counter(flat)
# 번호별 선택 빈도
number_frequency = {n: freq.get(n, 0) for n in range(1, 46)}
top_picks = sorted(range(1, 46), key=lambda n: -freq.get(n, 0))[:10]
least_picks = [n for n in sorted(range(1, 46), key=lambda n: freq.get(n, 0)) if freq.get(n, 0) == 0][:10]
# 패턴 지표
odd_counts = [sum(1 for n in nums if n % 2 == 1) for nums in all_numbers]
sums = [sum(nums) for nums in all_numbers]
ranges = [max(nums) - min(nums) for nums in all_numbers]
consecutive_count = sum(
1 for nums in all_numbers
if any(sorted(nums)[i + 1] - sorted(nums)[i] == 1 for i in range(5))
)
zone_totals = {k: 0 for k in ["1-9", "10-19", "20-29", "30-39", "40-45"]}
zone_ranges = [("1-9", 1, 9), ("10-19", 10, 19), ("20-29", 20, 29), ("30-39", 30, 39), ("40-45", 40, 45)]
for nums in all_numbers:
for label, lo, hi in zone_ranges:
zone_totals[label] += sum(1 for n in nums if lo <= n <= hi)
zone_avg = {k: round(v / total, 2) for k, v in zone_totals.items()}
avg_odd = sum(odd_counts) / total
avg_sum = sum(sums) / total
avg_range = sum(ranges) / total
# 역대 당첨번호 평균과 비교
if draws:
draw_odd_avg = sum(sum(1 for n in nums if n % 2 == 1) for _, nums in draws) / len(draws)
draw_sum_avg = sum(sum(nums) for _, nums in draws) / len(draws)
else:
draw_odd_avg = 3.0
draw_sum_avg = 138.0
return {
"total_analyzed": total,
"number_frequency": number_frequency,
"top_picks": top_picks,
"least_picks": least_picks,
"pattern": {
"avg_odd_count": round(avg_odd, 2),
"avg_sum": round(avg_sum, 1),
"avg_range": round(avg_range, 1),
"consecutive_rate": round(consecutive_count / total, 3),
"zone_avg": zone_avg,
},
"vs_draw_avg": {
"odd_diff": round(avg_odd - draw_odd_avg, 2),
"sum_diff": round(avg_sum - draw_sum_avg, 1),
"odd_tendency": "홀수 선호" if avg_odd > draw_odd_avg + 0.2 else ("짝수 선호" if avg_odd < draw_odd_avg - 0.2 else "균형"),
"sum_tendency": "고합계 선호" if avg_sum > draw_sum_avg + 5 else ("저합계 선호" if avg_sum < draw_sum_avg - 5 else "균형"),
},
}
def generate_combined_recommendation(draws: List[Tuple[int, List[int]]]) -> Dict[str, Any]:
"""
5가지 통계 기법을 종합한 추론 번호 추천.
각 기법이 상위 6개 번호를 추천하고, 기법별 가중치(score_combination 가중치와 동일)로
투표를 집계한 뒤 최종 6개 번호를 선정한다.
가중치: 빈도Z(25%) · 지문(30%) · 갭(20%) · 공동출현(15%) · 다양성(10%)
"""
if not draws:
return {"error": "데이터 없음"}
cache = build_analysis_cache(draws)
z = cache["z_scores"]
gap = cache["last_seen_gap"]
freq = cache["freq_all"]
cooccur = cache["cooccur"]
zone_probs = cache["zone_probs"]
# ── Method 1: 빈도 Z-score ────────────────────────────────────────────────
# Z-score 내림차순 상위 6 (출현 빈도가 기댓값보다 높은 번호)
m_frequency = sorted(range(1, 46), key=lambda n: -z.get(n, 0))[:6]
# ── Method 2: 갭 분석 ─────────────────────────────────────────────────────
# 가장 오래 미출현한 번호 6개 (오버듀)
m_gap = sorted(range(1, 46), key=lambda n: -gap.get(n, 0))[:6]
# ── Method 3: 공동출현 ────────────────────────────────────────────────────
# 각 번호의 총 공동출현 합산 점수 내림차순 6개
cooccur_total: Dict[int, float] = defaultdict(float)
for (a, b), cnt in cooccur.items():
cooccur_total[a] += cnt
cooccur_total[b] += cnt
m_cooccur = sorted(range(1, 46), key=lambda n: -cooccur_total.get(n, 0))[:6]
# ── Method 4: 조합 지문 ───────────────────────────────────────────────────
# 역대 당첨 조합의 구간별 최빈 분포에 맞게 각 구간에서 빈도 상위 번호 선택
zone_targets: List[int] = []
for zp in zone_probs:
zone_targets.append(max(zp, key=zp.get) if zp else 1)
# 합이 정확히 6이 되도록 조정
diff = sum(zone_targets) - 6
if diff > 0:
idxs = sorted(range(5), key=lambda i: -zone_targets[i])
for i in idxs:
if diff <= 0:
break
zone_targets[i] = max(0, zone_targets[i] - 1)
diff -= 1
elif diff < 0:
idxs = sorted(range(5), key=lambda i: zone_targets[i])
for i in idxs:
if diff >= 0:
break
zone_targets[i] += 1
diff += 1
m_fingerprint: List[int] = []
for (lo, hi), tgt in zip(ZONE_RANGES, zone_targets):
zone_nums = sorted(range(lo, hi + 1), key=lambda x: -freq.get(x, 0))
m_fingerprint.extend(zone_nums[:tgt])
m_fingerprint = sorted(m_fingerprint[:6])
# ── Method 5: 다양성 ──────────────────────────────────────────────────────
# 각 구간에서 갭 가장 큰 번호 1개씩 (5개) + 전체 갭 상위 1개 보충
m_diversity: List[int] = []
for lo, hi in ZONE_RANGES:
zone_nums = sorted(range(lo, hi + 1), key=lambda n: -gap.get(n, 0))
if zone_nums:
m_diversity.append(zone_nums[0])
if len(m_diversity) < 6:
rest = sorted(
[x for x in range(1, 46) if x not in m_diversity],
key=lambda n: -gap.get(n, 0),
)
m_diversity.extend(rest[: 6 - len(m_diversity)])
m_diversity = sorted(m_diversity[:6])
# ── 가중 투표 집계 ────────────────────────────────────────────────────────
# score_combination 가중치와 동일: 빈도25, 지문30, 갭20, 공동출현15, 다양성10
method_entries = [
(m_frequency, 25, "frequency", "빈도 Z-score"),
(m_fingerprint, 30, "fingerprint", "조합 지문"),
(m_gap, 20, "gap", "갭 분석"),
(m_cooccur, 15, "cooccur", "공동 출현"),
(m_diversity, 10, "diversity", "다양성"),
]
vote_scores: Dict[int, float] = {n: 0.0 for n in range(1, 46)}
for method_nums, weight, _, _ in method_entries:
for rank, n in enumerate(method_nums):
# rank 0 = 1위: (6-0)×weight = 6w, rank 5 = 6위: (6-5)×weight = w
vote_scores[n] += (6 - rank) * weight
# 상위 6개 — 동점 시 Z-score 타이브레이크
final_numbers = sorted(
sorted(range(1, 46), key=lambda n: (-vote_scores[n], -z.get(n, 0)))[:6]
)
scores = score_combination(final_numbers, cache)
# 각 번호가 몇 개 방법에서 채택됐는지
vote_counts: Dict[str, int] = {
str(n): sum(1 for nums, _, _, _ in method_entries if n in nums)
for n in range(1, 46)
}
methods_result: Dict[str, Any] = {}
for nums, weight, key, label in method_entries:
methods_result[key] = {
"label": label,
"weight_pct": weight,
"numbers": sorted(nums),
}
return {
"methods": methods_result,
"final_numbers": final_numbers,
"scores": scores,
"vote_scores": {str(n): round(vote_scores[n], 1) for n in range(1, 46)},
"vote_counts": vote_counts,
"total_draws": cache["total_draws"],
}
def generate_weekly_report(draws: List[Tuple[int, List[int]]], target_drw_no: int) -> Dict[str, Any]:
"""
특정 회차 공략 리포트 생성.
target_drw_no: 공략 대상 회차 (아직 추첨 안 된 회차)
draws: target_drw_no 이전까지의 당첨번호 (오름차순)
"""
if not draws:
return {"error": "데이터 없음"}
cache = build_analysis_cache(draws)
total_draws = cache["total_draws"]
freq_all = cache["freq_all"]
last_seen_gap = cache["last_seen_gap"]
recent_10 = draws[-10:] if len(draws) >= 10 else draws
recent_3 = draws[-3:] if len(draws) >= 3 else draws
# 과출현: 최근 10회에 2회 이상 출현 번호 (출현 많은 순)
r10_nums = [n for _, nums in recent_10 for n in nums]
r10_freq = Counter(r10_nums)
hot_numbers = [n for n, _ in sorted(r10_freq.items(), key=lambda x: -x[1]) if r10_freq[n] >= 2]
# 냉각: 역대 출현 빈도 낮은 번호
cold_numbers = sorted(range(1, 46), key=lambda n: freq_all.get(n, 0))[:10]
# 오버듀: 가장 오래 미출현 번호
overdue_numbers = sorted(range(1, 46), key=lambda n: -last_seen_gap.get(n, 0))[:10]
# 최근 3회 연속 출현 (2회 이상)
r3_nums = [n for _, nums in recent_3 for n in nums]
r3_freq = Counter(r3_nums)
triple_appear = sorted(n for n, cnt in r3_freq.items() if cnt >= 2)
recent_sums = [sum(nums) for _, nums in recent_10]
recent_odd = [sum(1 for n in nums if n % 2 == 1) for _, nums in recent_10]
# 갭 기반 가중치 (오래된 번호일수록 높음)
gap_w = {n: last_seen_gap.get(n, 0) for n in range(1, 46)}
def _pick(exclude=None, prefer=None, n=6):
ex = set(exclude or [])
chosen = []
# prefer에서 최대 3개 우선 선택
for p in (prefer or []):
if p not in ex and len(chosen) < 3:
chosen.append(p)
# 구간별 1개씩 (갭 우선)
for lo, hi in [(1, 9), (10, 19), (20, 29), (30, 39), (40, 45)]:
if len(chosen) >= n:
break
cands = [x for x in range(lo, hi + 1) if x not in ex and x not in chosen]
if cands:
chosen.append(max(cands, key=lambda x: gap_w.get(x, 0)))
# 부족하면 나머지에서 갭 순
rest = sorted(
[x for x in range(1, 46) if x not in ex and x not in chosen],
key=lambda x: -gap_w.get(x, 0),
)
while len(chosen) < n and rest:
chosen.append(rest.pop(0))
return sorted(chosen[:n])
set1 = _pick(exclude=hot_numbers[:5], prefer=overdue_numbers[:5])
set2 = _pick()
set3 = _pick(exclude=hot_numbers)
# 신뢰도 점수
data_vol = min(total_draws / 500, 1.0)
if len(recent_sums) > 1:
avg_s = sum(recent_sums) / len(recent_sums)
std_s = (sum((s - avg_s) ** 2 for s in recent_sums) / len(recent_sums)) ** 0.5
pattern = max(0.0, 1.0 - std_s / 60.0)
else:
pattern = 0.5
trend = max(0.0, 1.0 - len(hot_numbers) / max(len(r10_nums), 1))
confidence = round((data_vol * 0.4 + pattern * 0.35 + trend * 0.25) * 100)
return {
"target_drw_no": target_drw_no,
"based_on_draw": draws[-1][0],
"generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"hot_numbers": hot_numbers[:8],
"cold_numbers": cold_numbers,
"overdue_numbers": overdue_numbers,
"recent_pattern": {
"last3_numbers": sorted(set(r3_nums)),
"triple_appear": triple_appear,
"recent_sum_avg": round(sum(recent_sums) / len(recent_sums), 1) if recent_sums else 0,
"recent_odd_avg": round(sum(recent_odd) / len(recent_odd), 1) if recent_odd else 0,
},
"recommended_sets": [
{"strategy": "냉각번호 중심", "numbers": set1, "description": "오랫동안 미출현 번호 위주 + 과출현 제외"},
{"strategy": "균형형", "numbers": set2, "description": "구간 균형 + 갭 최적화"},
{"strategy": "과출현 피하기", "numbers": set3, "description": "최근 자주 나온 번호 완전 제외"},
],
"confidence_score": confidence,
"confidence_factors": {
"data_volume": round(data_vol * 100),
"pattern_consistency": round(pattern * 100),
"recent_trend": round(trend * 100),
},
}

73
backend/app/checker.py Normal file
View File

@@ -0,0 +1,73 @@
import json
from .db import (
_conn, get_draw, update_recommendation_result
)
def _calc_rank(my_nums: list[int], win_nums: list[int], bonus: int) -> tuple[int, int, bool]:
"""
(rank, correct_cnt, has_bonus) 반환
rank: 1~5 (1등~5등), 0 (낙첨)
"""
matched = set(my_nums) & set(win_nums)
cnt = len(matched)
has_bonus = bonus in my_nums
if cnt == 6:
return 1, cnt, has_bonus
if cnt == 5 and has_bonus:
return 2, cnt, has_bonus
if cnt == 5:
return 3, cnt, has_bonus
if cnt == 4:
return 4, cnt, has_bonus
if cnt == 3:
return 5, cnt, has_bonus
return 0, cnt, has_bonus
def check_results_for_draw(drw_no: int) -> int:
"""
특정 회차(drw_no) 결과가 나왔을 때,
해당 회차를 타겟으로 했던(based_on_draw == drw_no - 1) 추천들을 채점한다.
반환값: 채점한 개수
"""
win_row = get_draw(drw_no)
if not win_row:
return 0
win_nums = [
win_row["n1"], win_row["n2"], win_row["n3"],
win_row["n4"], win_row["n5"], win_row["n6"]
]
bonus = win_row["bonus"]
# based_on_draw가 (이번회차 - 1)인 것들 조회
# 즉, 1000회차 추첨 결과로는, 999회차 데이터를 바탕으로 1000회차를 예측한 것들을 채점
target_based_on = drw_no - 1
with _conn() as conn:
rows = conn.execute(
"""
SELECT id, numbers
FROM recommendations
WHERE based_on_draw = ? AND checked = 0
""",
(target_based_on,)
).fetchall()
count = 0
for r in rows:
my_nums = json.loads(r["numbers"])
rank, correct, has_bonus = _calc_rank(my_nums, win_nums, bonus)
update_recommendation_result(r["id"], rank, correct, has_bonus)
count += 1
# ── 구매 이력 체크 연동 ──────────────────────────────────────
try:
from .purchase_manager import check_purchases_for_draw as _check_purchases
_check_purchases(drw_no) # 내부에서 evolve_after_check → recalculate_weights 호출
except ImportError:
pass # purchase_manager 미설치 시 무시 (하위호환)
return count

View File

@@ -1,7 +1,7 @@
import requests
from typing import Dict, Any
from .db import get_draw, upsert_draw
from .db import get_draw, upsert_draw, upsert_many_draws, get_latest_draw, count_draws
def _normalize_item(item: dict) -> dict:
# smok95 all.json / latest.json 구조
@@ -27,20 +27,13 @@ def sync_all_from_json(all_url: str) -> Dict[str, Any]:
r.raise_for_status()
data = r.json() # list[dict]
inserted = 0
skipped = 0
# 정규화
rows = [_normalize_item(item) for item in data]
# Bulk Insert (성능 향상)
upsert_many_draws(rows)
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)}
return {"mode": "all_json", "url": all_url, "total": len(rows)}
def sync_latest(latest_url: str) -> Dict[str, Any]:
r = requests.get(latest_url, timeout=30)
@@ -53,3 +46,40 @@ def sync_latest(latest_url: str) -> Dict[str, Any]:
return {"mode": "latest_json", "url": latest_url, "was_new": (before is None), "drawNo": row["drw_no"]}
def sync_ensure_all(latest_url: str, all_url: str) -> Dict[str, Any]:
"""
1회부터 최신 회차까지 빠짐없이 있는지 확인하고, 없으면 전체 동기화 수행.
반환값: {"synced": bool, "reason": str, ...}
"""
# 1. 원격 최신 회차 확인
try:
r = requests.get(latest_url, timeout=10)
r.raise_for_status()
remote_item = r.json()
remote_no = int(remote_item["draw_no"])
except Exception as e:
# 외부 통신 실패 시, 그냥 로컬 데이터로 진행하도록 에러 억제 (혹은 에러 발생)
# 여기서는 통계 기능 작동이 우선이므로 로그만 남기고 pass하고 싶지만,
# 확실한 동기화를 위해 에러를 던지거나 False 리턴
return {"synced": False, "error": str(e)}
# 2. 로컬 상태 확인
local_latest_row = get_latest_draw()
local_no = local_latest_row["drw_no"] if local_latest_row else 0
local_cnt = count_draws()
# 3. 동기화 필요 여부 판단
# - 전체 개수가 최신 회차 번호보다 적으면 중간에 빈 것 (1회부터 시작한다고 가정)
# - 혹은 DB 최신 번호가 원격보다 낮으면 업데이트 필요
need_sync = (local_no < remote_no) or (local_cnt < local_no)
if not need_sync:
return {"synced": True, "updated": False, "local_no": local_no}
# 4. 전체 동기화 실행
# (단순 latest sync로는 중간 구멍을 못 채우므로, 구멍이 있거나 차이가 크면 all_sync 수행)
# 만약 차이가 1회차 뿐이고 구멍이 없다면 sync_latest만 해도 되지만,
# 로직 단순화를 위해 missing 감지 시 그냥 all_sync (Bulk Insert라 빠름)
res = sync_all_from_json(all_url)
return {"synced": True, "updated": True, "detail": res}

View File

@@ -0,0 +1,151 @@
"""큐레이터용 후보 가공 — 여러 엔진 결과를 하나로 병합, 중복 제거, 피처 계산."""
from typing import Dict, List, Any, Set
from . import db
from .recommender import recommend_numbers, recommend_with_heatmap
from .analyzer import get_statistical_report
from .strategy_evolver import generate_smart_recommendation
LOW_HIGH_CUT = 22
def compute_features(numbers: List[int], hot: Set[int], cold: Set[int]) -> Dict[str, Any]:
nums = sorted(numbers)
odd = sum(1 for n in nums if n % 2 == 1)
low = sum(1 for n in nums if n <= LOW_HIGH_CUT)
buckets = [0, 0, 0, 0, 0]
for n in nums:
if n <= 10: buckets[0] += 1
elif n <= 20: buckets[1] += 1
elif n <= 30: buckets[2] += 1
elif n <= 40: buckets[3] += 1
else: buckets[4] += 1
consecutive = any(nums[i+1] - nums[i] == 1 for i in range(len(nums) - 1))
return {
"odd_count": odd,
"even_count": 6 - odd,
"low_count": low,
"high_count": 6 - low,
"range_distribution": buckets,
"has_consecutive": consecutive,
"hot_number_count": len(set(nums) & hot),
"cold_number_count": len(set(nums) & cold),
"sum": sum(nums),
}
def _key(numbers: List[int]) -> str:
return ",".join(str(n) for n in sorted(numbers))
def collect_candidates(n: int, hot: Set[int], cold: Set[int]) -> List[Dict[str, Any]]:
"""우선순위: simulation best_picks → meta → heatmap → statistics. 중복 제거 후 최대 n세트."""
seen: Dict[str, Dict[str, Any]] = {}
order: List[str] = []
def _add(numbers: List[int], source: str) -> None:
if not numbers:
return
k = _key(numbers)
if k in seen:
return
seen[k] = {"numbers": sorted(numbers), "source": source}
order.append(k)
# 1. simulation best_picks
try:
for row in db.get_best_picks(limit=n):
_add(row.get("numbers") or [], "simulation")
except Exception:
pass
# draws는 한 번만 로드
draws = []
try:
draws = db.get_all_draw_numbers()
except Exception:
pass
# 2. meta-strategy (smart)
try:
meta = generate_smart_recommendation(sets=n)
for s in meta.get("sets", []):
_add(s.get("numbers") or [], "meta")
except Exception:
pass
# 3. heatmap (n번 호출, 중복 회피)
if draws:
try:
for _ in range(n * 2):
if len(order) >= n * 2:
break
r = recommend_with_heatmap(draws, [])
_add(r.get("numbers") or [], "heatmap")
except Exception:
pass
# 4. statistics
if draws:
try:
for _ in range(n * 2):
if len(order) >= n * 2:
break
r = recommend_numbers(draws)
_add(r.get("numbers") or [], "statistics")
except Exception:
pass
out = []
for k in order[:n]:
item = seen[k]
item["features"] = compute_features(item["numbers"], hot, cold)
out.append(item)
return out
def build_context(hot_limit: int = 10, cold_limit: int = 10) -> Dict[str, Any]:
"""주간 맥락 패키지 — get_statistical_report가 이미 hot/cold를 제공."""
hot: List[int] = []
cold: List[int] = []
last_summary = ""
try:
draws = db.get_all_draw_numbers()
except Exception:
draws = []
if draws:
try:
report = get_statistical_report(draws)
hot = list(report.get("hot_numbers", []))[:hot_limit]
cold = list(report.get("cold_numbers", []))[:cold_limit]
except Exception:
pass
try:
latest = db.get_latest_draw()
except Exception:
latest = None
if latest:
nums = [latest.get(f"n{i}") for i in range(1, 7)]
nums = [n for n in nums if n is not None]
if nums:
odd = sum(1 for n in nums if n % 2 == 1)
low = sum(1 for n in nums if n <= LOW_HIGH_CUT)
last_summary = f"{latest.get('drw_no')}회: {', '.join(str(n) for n in nums)} (홀{odd}{6-odd}, 저{low}{6-low})"
my_perf: List[Dict[str, Any]] = []
try:
from .purchase_manager import get_recent_performance
my_perf = get_recent_performance(limit=3)
except Exception:
my_perf = []
return {
"hot_numbers": hot,
"cold_numbers": cold,
"last_draw_summary": last_summary,
"my_recent_performance": my_perf,
}

File diff suppressed because it is too large Load Diff

135
backend/app/generator.py Normal file
View File

@@ -0,0 +1,135 @@
"""
시뮬레이션 엔진 - lotto-lab 고도화
[몬테카를로 시뮬레이션 흐름]
1. 역대 당첨번호 기반 통계 캐시 구성 (build_analysis_cache)
2. 통계 가중치로 N개 후보 조합 생성 (weighted sampling)
3. 5가지 기법으로 각 후보 스코어링 (score_combination)
4. 상위 top_k개 선별하여 DB 저장 (simulation_candidates, best_picks 교체)
[시뮬레이션 파라미터]
- n_candidates: 1회 시뮬레이션당 생성 후보 수 (기본 20,000)
- top_k: 선별 및 저장할 상위 개수 (기본 100)
- best_n: best_picks에 올릴 최상위 개수 (기본 20)
"""
import random
from typing import Dict, Any, List, Optional
from .db import (
get_latest_draw,
get_all_draw_numbers,
save_simulation_run,
save_simulation_candidates_bulk,
replace_best_picks,
)
from .analyzer import build_analysis_cache, build_number_weights, score_combination
from .utils import weighted_sample_6
def run_simulation(
n_candidates: int = 20000,
top_k: int = 100,
best_n: int = 20,
) -> Dict[str, Any]:
"""
몬테카를로 시뮬레이션 실행 메인 함수.
Args:
n_candidates: 생성할 후보 조합 수 (기본 20,000)
top_k: DB에 저장할 상위 후보 수 (기본 100)
best_n: best_picks에 올릴 최상위 수 (기본 20)
Returns:
{run_id, total_generated, top_k_selected, avg_score, best_score, based_on_draw}
또는 {"error": ...}
"""
draws = get_all_draw_numbers()
if not draws:
return {"error": "당첨번호 데이터가 없습니다. 먼저 동기화를 실행하세요."}
latest = get_latest_draw()
based_on_draw = latest["drw_no"] if latest else None
# ── 1. 통계 캐시 및 가중치 구성 (시뮬레이션 전체에서 재사용) ────────────
cache = build_analysis_cache(draws)
weights = build_number_weights(cache)
# ── 2. 후보 생성 및 스코어링 ──────────────────────────────────────────────
candidates: List[Dict[str, Any]] = []
seen_keys: set = set()
max_attempts = n_candidates * 3 # 중복 제거 여유분
attempts = 0
while len(candidates) < n_candidates and attempts < max_attempts:
attempts += 1
nums = weighted_sample_6(weights)
key = tuple(sorted(nums))
if key in seen_keys:
continue
seen_keys.add(key)
scores = score_combination(nums, cache)
candidates.append({
"numbers": sorted(nums),
**scores,
})
# ── 3. 점수 내림차순 정렬 및 상위 선별 ──────────────────────────────────
candidates.sort(key=lambda x: -x["score_total"])
top_candidates = candidates[:top_k]
# is_best 플래그 표시
best_keys = {tuple(c["numbers"]) for c in top_candidates[:best_n]}
for c in top_candidates:
c["is_best"] = tuple(c["numbers"]) in best_keys
avg_score = (
sum(c["score_total"] for c in top_candidates) / len(top_candidates)
if top_candidates else 0.0
)
best_score = top_candidates[0]["score_total"] if top_candidates else 0.0
# ── 4. DB 저장 ────────────────────────────────────────────────────────────
run_id = save_simulation_run(
strategy="monte_carlo",
total_generated=len(candidates),
top_k_selected=len(top_candidates),
avg_score=avg_score,
notes=f"based_on_draw={based_on_draw}, history={len(draws)}",
)
# 상위 top_k개만 DB에 저장 (전체 20,000개는 메모리에서만 처리)
save_simulation_candidates_bulk(run_id, top_candidates, based_on_draw)
# best_picks 교체 (상위 best_n개)
best_picks_data = [
{
"numbers": c["numbers"],
"score_total": c["score_total"],
"rank_in_run": i + 1,
}
for i, c in enumerate(top_candidates[:best_n])
]
replace_best_picks(best_picks_data, run_id, based_on_draw)
return {
"run_id": run_id,
"total_generated": len(candidates),
"top_k_selected": len(top_candidates),
"best_n_saved": len(best_picks_data),
"avg_score": round(avg_score, 6),
"best_score": round(best_score, 6),
"based_on_draw": based_on_draw,
}
def generate_smart_recommendations(count: int = 10) -> int:
"""
하위 호환성 유지용 래퍼.
내부적으로 run_simulation을 호출하며, 기존 /api/admin/auto_gen 등에서 계속 사용 가능.
"""
result = run_simulation(n_candidates=5000, top_k=count, best_n=count)
if "error" in result:
return 0
return result.get("best_n_saved", 0)

View File

@@ -1,91 +1,112 @@
import os
import time
import logging
from typing import Optional, List, Dict, Any, Tuple
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from apscheduler.schedulers.background import BackgroundScheduler
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
logger = logging.getLogger("lotto-backend")
from .db import (
init_db, get_draw, get_latest_draw, get_all_draw_numbers,
save_recommendation_dedup, list_recommendations_ex, delete_recommendation,
update_recommendation,
# 시뮬레이션 관련
get_best_picks, get_simulation_runs, get_simulation_candidates,
# todos
get_all_todos, create_todo, update_todo, delete_todo, delete_done_todos,
# blog
get_all_posts, create_post, update_post, delete_post,
# 성과 통계
get_recommendation_performance,
# Phase 2: 구매 이력
add_purchase, get_purchases, update_purchase, delete_purchase, get_purchase_stats,
# Phase 2: 주간 리포트 캐시
save_weekly_report, get_weekly_report_list, get_weekly_report,
# Phase 2: 개인 패턴 분석
get_all_recommendation_numbers,
# Phase 3: 전략 관련
get_strategy_performance as db_get_strategy_performance,
)
from .recommender import recommend_numbers
from .collector import sync_latest
from .recommender import recommend_numbers, recommend_with_heatmap
from .collector import sync_latest, sync_ensure_all
from .generator import run_simulation, generate_smart_recommendations
from .checker import check_results_for_draw
from .utils import calc_metrics, calc_recent_overlap
from .analyzer import get_statistical_report, generate_weekly_report, analyze_personal_patterns, generate_combined_recommendation
from .purchase_manager import check_purchases_for_draw
from .strategy_evolver import (
get_weights_with_trend, recalculate_weights,
generate_smart_recommendation,
)
from .routers import curator as curator_router
from .routers import briefing as briefing_router
app = FastAPI()
app.include_router(curator_router.router)
app.include_router(briefing_router.router)
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
# ── 성과 통계 인메모리 캐시 ───────────────────────────────────────────────────
# 채점 데이터는 하루 2번 스케줄러 실행 시에만 갱신되므로 인메모리 캐시로 충분
_PERF_CACHE: Dict[str, Any] = {"data": None, "at": 0.0}
_PERF_CACHE_TTL = 3600 # 1시간 (스케줄러 미실행 상황 대비 폴백)
# 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 _refresh_perf_cache() -> None:
_PERF_CACHE["data"] = get_recommendation_performance()
_PERF_CACHE["at"] = time.time()
logger.info("성과 통계 캐시 갱신")
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)
# 1. 로또 당첨번호 동기화 (매일 9시, 21시 10분)
# 동기화 후 새로운 회차가 있으면 채점(check)까지 수행
def _sync_and_check():
res = sync_latest(LATEST_URL)
if res["was_new"]:
check_results_for_draw(res["drawNo"])
_refresh_perf_cache() # 새 채점 결과 반영 → 즉시 갱신
scheduler.add_job(_sync_and_check, "cron", hour="9,21", minute=10)
# 2. 몬테카를로 시뮬레이션 (하루 6회: 0, 4, 8, 12, 16, 20시)
# 20,000개 후보 생성 → 스코어링 → 상위 100개 저장 → best_picks 교체
def _run_simulation_job():
run_simulation(n_candidates=20000, top_k=100, best_n=20)
scheduler.add_job(_run_simulation_job, "cron", hour="0,4,8,12,16,20", minute=5)
# 3. 토요일 오전 9시 — 다음 회차 공략 리포트 자동 캐싱
def _save_weekly_report_job():
import json as _json
draws = get_all_draw_numbers()
latest = get_latest_draw()
if not draws or not latest:
return
target = latest["drw_no"] + 1
report = generate_weekly_report(draws, target)
save_weekly_report(target, _json.dumps(report, ensure_ascii=False))
logger.info(f"{target}회차 리포트 저장 완료")
scheduler.add_job(_save_weekly_report_job, "cron", day_of_week="sat", hour=9, minute=0)
scheduler.start()
@app.get("/health")
def health():
return {"ok": True}
@app.get("/api/lotto/latest")
def api_latest():
row = get_latest_draw()
@@ -96,8 +117,10 @@ def api_latest():
"date": row["drw_date"],
"numbers": [row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]],
"bonus": row["bonus"],
"metrics": calc_metrics([row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]]),
}
@app.get("/api/lotto/{drw_no:int}")
def api_draw(drw_no: int):
row = get_draw(drw_no)
@@ -108,28 +131,381 @@ def api_draw(drw_no: int):
"date": row["drw_date"],
"numbers": [row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]],
"bonus": row["bonus"],
"metrics": calc_metrics([row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]]),
}
@app.post("/api/admin/sync_latest")
def admin_sync_latest():
return sync_latest(LATEST_URL)
res = sync_latest(LATEST_URL)
if res["was_new"]:
check_results_for_draw(res["drawNo"])
return res
# ---------- ✅ recommend (dedup save) ----------
@app.post("/api/admin/auto_gen")
def admin_auto_gen(count: int = 10):
"""기존 호환 유지: 소규모 시뮬레이션 수동 트리거"""
n = generate_smart_recommendations(count)
return {"generated": n}
@app.post("/api/admin/simulate")
def admin_simulate(n_candidates: int = 20000, top_k: int = 100, best_n: int = 20):
"""
몬테카를로 시뮬레이션 수동 트리거.
백그라운드 스케줄과 동일한 동작을 즉시 실행.
"""
result = run_simulation(
n_candidates=max(1000, min(n_candidates, 50000)),
top_k=max(10, min(top_k, 500)),
best_n=max(10, min(best_n, 50)),
)
if "error" in result:
raise HTTPException(status_code=500, detail=result["error"])
return result
@app.get("/api/lotto/stats")
def api_stats():
sync_ensure_all(LATEST_URL, ALL_URL)
draws = get_all_draw_numbers()
if not draws:
raise HTTPException(status_code=404, detail="No data yet")
frequency = {n: 0 for n in range(1, 46)}
total_draws = len(draws)
for _, nums in draws:
for n in nums:
frequency[n] += 1
stats = [
{"number": n, "count": frequency[n]}
for n in range(1, 46)
]
return {
"total_draws": total_draws,
"frequency": stats,
}
# ── 추천 성과 통계 (Phase 1, 인메모리 캐시) ──────────────────────────────────
@app.get("/api/lotto/stats/performance")
def api_performance_stats():
"""
채점된 추천 이력 기반 성과 통계 (캐시 반환).
캐시 갱신 시점: 새 회차 채점 직후 | TTL 1시간 만료 시
"""
if _PERF_CACHE["data"] is None or time.time() - _PERF_CACHE["at"] > _PERF_CACHE_TTL:
_refresh_perf_cache()
return _PERF_CACHE["data"]
# ── 회차 공략 리포트 (Phase 1) ────────────────────────────────────────────────
@app.get("/api/lotto/report/latest")
def api_report_latest():
"""
다음 회차 공략 리포트 (최신 회차 기준으로 자동 계산).
- 과출현/냉각/오버듀 번호 분석
- 최근 3회 패턴
- 3가지 전략별 추천 번호
- AI 신뢰도 점수
"""
draws = get_all_draw_numbers()
if not draws:
raise HTTPException(status_code=404, detail="No data yet")
latest = get_latest_draw()
target = latest["drw_no"] + 1
return generate_weekly_report(draws, target)
@app.get("/api/lotto/report/history")
def api_report_history(limit: int = 10):
"""저장된 주간 리포트 목록 (자동 저장된 것만)"""
return {"reports": get_weekly_report_list(limit=min(limit, 52))}
@app.get("/api/lotto/report/{drw_no}")
def api_report_by_draw(drw_no: int):
"""
특정 회차 공략 리포트 (캐시 우선, 없으면 실시간 생성).
"""
cached = get_weekly_report(drw_no)
if cached:
return {**cached, "cached": True}
draws = get_all_draw_numbers()
if not draws:
raise HTTPException(status_code=404, detail="No data yet")
base_draws = [(no, nums) for no, nums in draws if no < drw_no]
if not base_draws:
raise HTTPException(status_code=400, detail=f"{drw_no}회차 이전 데이터가 없습니다")
return {**generate_weekly_report(base_draws, drw_no), "cached": False}
# ── 개인 패턴 분석 (Phase 2) ─────────────────────────────────────────────────
@app.get("/api/lotto/analysis/personal")
def api_personal_analysis():
"""
저장된 추천 이력 기반 개인 패턴 분석.
- 자주 선택한 번호 TOP 10 / 한 번도 선택 안 한 번호
- 홀짝 비율, 합계, 범위, 연속번호 포함률
- 구간별 분포, 역대 당첨번호 평균과 비교
"""
all_numbers = get_all_recommendation_numbers()
draws = get_all_draw_numbers()
return analyze_personal_patterns(all_numbers, draws)
# ── 구매 이력 API (Phase 2) ───────────────────────────────────────────────────
class PurchaseCreate(BaseModel):
draw_no: int
amount: int
sets: int = 1
prize: int = 0
note: str = ""
numbers: List[List[int]] = []
is_real: bool = True
source_strategy: str = "manual"
source_detail: dict = {}
class PurchaseUpdate(BaseModel):
draw_no: Optional[int] = None
amount: Optional[int] = None
sets: Optional[int] = None
prize: Optional[int] = None
note: Optional[str] = None
numbers: Optional[List[List[int]]] = None
is_real: Optional[bool] = None
source_strategy: Optional[str] = None
@app.get("/api/lotto/purchase/stats")
def api_purchase_stats():
"""투자 수익률 통계 (총 투자금, 총 당첨금, 수익률 등)"""
return get_purchase_stats()
@app.get("/api/lotto/purchase")
def api_purchase_list(draw_no: Optional[int] = None, days: Optional[int] = None,
is_real: Optional[bool] = None, strategy: Optional[str] = None):
"""구매 이력 조회 (필터: draw_no, days, is_real, strategy)"""
return {"records": get_purchases(draw_no=draw_no, days=days, is_real=is_real, strategy=strategy)}
@app.post("/api/lotto/purchase", status_code=201)
def api_purchase_create(body: PurchaseCreate):
"""구매 이력 추가 (실제/가상)"""
sets = body.sets if body.sets > 0 else max(len(body.numbers), 1)
amount = body.amount if body.amount > 0 else sets * 1000
return add_purchase(
draw_no=body.draw_no,
amount=amount,
sets=sets,
prize=body.prize,
note=body.note,
numbers=body.numbers,
is_real=body.is_real,
source_strategy=body.source_strategy,
source_detail=body.source_detail,
)
@app.put("/api/lotto/purchase/{purchase_id}")
def api_purchase_update(purchase_id: int, body: PurchaseUpdate):
"""구매 이력 수정 (당첨금 업데이트 등)"""
updated = update_purchase(purchase_id, body.model_dump(exclude_none=True))
if updated is None:
raise HTTPException(status_code=404, detail="Purchase not found")
return updated
@app.delete("/api/lotto/purchase/{purchase_id}")
def api_purchase_delete(purchase_id: int):
"""구매 이력 삭제"""
if not delete_purchase(purchase_id):
raise HTTPException(status_code=404, detail="Purchase not found")
return {"ok": True}
# ── 전략 진화 API ──────────────────────────────────────────────────────────
@app.get("/api/lotto/strategy/weights")
def api_strategy_weights():
"""현재 전략별 가중치 + 성과 요약 + trend"""
return get_weights_with_trend()
@app.get("/api/lotto/strategy/performance")
def api_strategy_performance(strategy: Optional[str] = None, days: Optional[int] = None):
"""전략별 회차 성과 이력 (차트용)"""
rows = db_get_strategy_performance(strategy=strategy, days=days)
return {"records": rows}
@app.post("/api/lotto/strategy/evolve")
def api_strategy_evolve():
"""수동 가중치 재계산 트리거"""
new_weights = recalculate_weights()
return {"ok": True, "weights": new_weights}
# ── 스마트 추천 API ────────────────────────────────────────────────────────
@app.get("/api/lotto/recommend/smart")
def api_recommend_smart(sets: int = 5):
"""전략 가중치 기반 메타 전략 추천"""
sets = max(1, min(sets, 10))
result = generate_smart_recommendation(sets=sets)
if "error" in result:
raise HTTPException(status_code=500, detail=result["error"])
return result
# ── 통계 분석 리포트 ────────────────────────────────────────────────────────
@app.get("/api/lotto/analysis")
def api_analysis():
"""
5가지 통계 기법 기반 분석 리포트.
- 번호별 빈도, Z-score, 갭
- 핫/콜드/오버듀 번호
- 역대 합계 분포, 홀짝 분포
"""
draws = get_all_draw_numbers()
if not draws:
raise HTTPException(status_code=404, detail="No data yet")
return get_statistical_report(draws)
# ── 시뮬레이션 best_picks (메인 추천 엔드포인트) ────────────────────────────
@app.get("/api/lotto/best")
def api_best_picks(limit: int = 20):
"""
시뮬레이션을 통해 선별된 최적 번호 조합 반환 (기본 20쌍).
하루 6회 시뮬레이션 후 자동 갱신됨.
각 조합에 점수 및 메트릭 포함.
"""
limit = max(1, min(limit, 50))
picks = get_best_picks(limit=limit)
if not picks:
raise HTTPException(
status_code=404,
detail="시뮬레이션 결과가 없습니다. /api/admin/simulate로 먼저 실행하세요.",
)
draws = get_all_draw_numbers()
result = []
for p in picks:
nums = p["numbers"]
result.append({
"rank": p["rank_in_run"],
"numbers": nums,
"score_total": p["score_total"],
"based_on_draw": p["based_on_draw"],
"simulation_run_id": p["source_run_id"],
"created_at": p["created_at"],
"metrics": calc_metrics(nums),
})
latest = get_latest_draw()
return {
"based_on_draw": latest["drw_no"] if latest else None,
"count": len(result),
"items": result,
}
# ── 시뮬레이션 전체 결과 조회 (상세 API) ────────────────────────────────────
@app.get("/api/lotto/simulation")
def api_simulation(run_id: Optional[int] = None, runs_limit: int = 5):
"""
시뮬레이션 실행 기록 및 상위 후보 상세 조회.
run_id 미지정 시: 최근 runs_limit개 실행 기록 + 가장 최근 run의 후보 반환.
run_id 지정 시: 해당 run의 후보만 반환.
"""
runs = get_simulation_runs(limit=runs_limit)
if not runs:
raise HTTPException(status_code=404, detail="시뮬레이션 기록이 없습니다.")
target_run_id = run_id if run_id is not None else runs[0]["id"]
candidates = get_simulation_candidates(target_run_id, limit=100)
# 후보에 메트릭 추가
enriched = []
for c in candidates:
enriched.append({
**c,
"metrics": calc_metrics(c["numbers"]),
})
return {
"runs": runs,
"selected_run_id": target_run_id,
"candidates_count": len(enriched),
"candidates": enriched,
}
# ── 종합 추론 추천 ───────────────────────────────────────────────────────────
@app.get("/api/lotto/recommend/combined")
def api_recommend_combined():
"""5가지 통계 기법 종합 추론 추천 — 결과를 이력에 저장한다."""
draws = get_all_draw_numbers()
if not draws:
raise HTTPException(status_code=404, detail="No data")
latest = get_latest_draw()
result = generate_combined_recommendation(draws)
if "error" in result:
raise HTTPException(status_code=500, detail=result["error"])
# 추천 이력 저장 (태그: 종합추론)
params = {"method": "combined"}
saved = save_recommendation_dedup(
latest["drw_no"] if latest else None,
result["final_numbers"],
params,
)
if saved["saved"]:
update_recommendation(saved["id"], tags=["종합추론"])
return {
**result,
"id": saved["id"],
"saved": saved["saved"],
"deduped": saved["deduped"],
"based_on_latest_draw": latest["drw_no"] if latest else None,
}
@app.get("/api/lotto/recommend/combined/history")
def api_combined_history(limit: int = 30):
"""종합추론 추천 이력 조회."""
items = list_recommendations_ex(limit=limit, tag="종합추론", sort="id_desc")
return {"items": items, "total": len(items)}
# ── 기존 수동 추천 API (하위 호환 유지) ─────────────────────────────────────
@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, # 조건 맞는 조합 찾기 재시도
max_overlap_latest: Optional[int] = None,
max_try: int = 200,
):
draws = get_all_draw_numbers()
if not draws:
@@ -141,7 +517,6 @@ def api_recommend(
"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,
@@ -166,7 +541,6 @@ def api_recommend(
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:
@@ -194,11 +568,9 @@ def api_recommend(
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.",
detail=f"Constraints too strict. No valid set found in max_try={max_try}.",
)
# ✅ dedup save
saved = save_recommendation_dedup(
latest["drw_no"] if latest else None,
chosen,
@@ -218,10 +590,121 @@ def api_recommend(
"params": params,
"metrics": metrics,
"recent_overlap": overlap,
"tries": tries,
"tries": tries,
}
# ---------- ✅ history list (filter/paging) ----------
# ── 히트맵 기반 추천 (하위 호환 유지) ────────────────────────────────────────
@app.get("/api/lotto/recommend/heatmap")
def api_recommend_heatmap(
heatmap_window: int = 20,
heatmap_weight: float = 1.5,
recent_window: int = 200,
recent_weight: float = 2.0,
avoid_recent_k: int = 5,
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,
max_try: int = 200,
):
draws = get_all_draw_numbers()
if not draws:
raise HTTPException(status_code=404, detail="No data yet")
past_recs = list_recommendations_ex(limit=100, sort="id_desc")
latest = get_latest_draw()
params = {
"heatmap_window": heatmap_window,
"heatmap_weight": float(heatmap_weight),
"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_with_heatmap(
draws,
past_recs,
heatmap_window=heatmap_window,
heatmap_weight=heatmap_weight,
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}.",
)
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,
}
# ── 추천 이력 ────────────────────────────────────────────────────────────────
@app.get("/api/history")
def api_history(
limit: int = 30,
@@ -260,6 +743,7 @@ def api_history(
"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)
@@ -267,12 +751,13 @@ def api_history_delete(rec_id: int):
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)
@@ -280,11 +765,11 @@ def api_history_patch(rec_id: int, body: HistoryUpdate):
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
@@ -294,9 +779,9 @@ def _batch_unique(draws, count: int, recent_window: int, recent_weight: float, a
continue
seen.add(key)
items.append(r)
return items
@app.get("/api/lotto/recommend/batch")
def api_recommend_batch(
count: int = 5,
@@ -322,14 +807,20 @@ def api_recommend_batch(
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],
"items": [{
"numbers": it["numbers"],
"explain": it["explain"],
"metrics": calc_metrics(it["numbers"]),
} 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()
@@ -342,3 +833,105 @@ def api_recommend_batch_save(body: BatchSave):
return {"saved": True, "created_ids": created, "deduped_ids": deduped}
@app.get("/api/version")
def version():
return {"version": os.getenv("APP_VERSION", "dev")}
# ── Todos API ─────────────────────────────────────────────────────────────────
class TodoCreate(BaseModel):
title: str
description: Optional[str] = None
status: str = "todo"
class TodoUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
status: Optional[str] = None
@app.get("/api/todos")
def api_todos_list():
return get_all_todos()
@app.post("/api/todos", status_code=201)
def api_todos_create(body: TodoCreate):
if body.status not in ("todo", "in_progress", "done"):
raise HTTPException(status_code=422, detail="status must be todo | in_progress | done")
return create_todo(body.title, body.description, body.status)
# ⚠️ /done 라우트를 /{todo_id} 보다 먼저 등록해야 done이 id로 매칭되지 않음
@app.delete("/api/todos/done")
def api_todos_delete_done():
deleted = delete_done_todos()
return {"deleted": deleted}
@app.put("/api/todos/{todo_id}")
def api_todos_update(todo_id: str, body: TodoUpdate):
if body.status is not None and body.status not in ("todo", "in_progress", "done"):
raise HTTPException(status_code=422, detail="status must be todo | in_progress | done")
updated = update_todo(todo_id, body.model_dump(exclude_none=True))
if updated is None:
raise HTTPException(status_code=404, detail="Todo not found")
return updated
@app.delete("/api/todos/{todo_id}")
def api_todos_delete(todo_id: str):
ok = delete_todo(todo_id)
if not ok:
raise HTTPException(status_code=404, detail="Todo not found")
return {"ok": True}
# ── Blog API ──────────────────────────────────────────────────────────────────
class BlogPostCreate(BaseModel):
title: str
body: str = ""
excerpt: str = ""
tags: List[str] = []
date: str = "" # 빈 문자열이면 오늘 날짜 사용
class BlogPostUpdate(BaseModel):
title: Optional[str] = None
body: Optional[str] = None
excerpt: Optional[str] = None
tags: Optional[List[str]] = None
date: Optional[str] = None
@app.get("/api/blog/posts")
def api_blog_list():
return {"posts": get_all_posts()}
@app.post("/api/blog/posts", status_code=201)
def api_blog_create(body: BlogPostCreate):
from datetime import date as _date
post_date = body.date if body.date else _date.today().isoformat()
post = create_post(body.title, body.body, body.excerpt, body.tags, post_date)
return post
@app.put("/api/blog/posts/{post_id}")
def api_blog_update(post_id: int, body: BlogPostUpdate):
updated = update_post(post_id, body.model_dump(exclude_none=True))
if updated is None:
raise HTTPException(status_code=404, detail="Post not found")
return updated
@app.delete("/api/blog/posts/{post_id}")
def api_blog_delete(post_id: int):
ok = delete_post(post_id)
if not ok:
raise HTTPException(status_code=404, detail="Post not found")
return {"ok": True}

View File

@@ -0,0 +1,116 @@
"""
구매 이력 관리 + 결과 체크 모듈.
- check_purchases_for_draw(): 특정 회차 구매 건들의 결과를 자동 체크
- 체커의 _calc_rank 재사용
- 결과 체크 후 strategy_performance 자동 갱신
"""
import logging
from .db import (
get_draw, get_purchases, update_purchase_results,
upsert_strategy_performance,
)
from .checker import _calc_rank
logger = logging.getLogger("lotto-backend")
RANK_PRIZE = {1: 0, 2: 0, 3: 1_500_000, 4: 50_000, 5: 5_000}
def check_purchases_for_draw(drw_no: int) -> int:
"""
특정 회차 결과로 해당 회차 구매 건들을 채점한다.
Returns: 채점한 구매 건 수
"""
win_row = get_draw(drw_no)
if not win_row:
return 0
win_nums = [win_row["n1"], win_row["n2"], win_row["n3"],
win_row["n4"], win_row["n5"], win_row["n6"]]
bonus = win_row["bonus"]
unchecked = get_purchases(draw_no=drw_no, checked=False)
strategy_agg = {}
count = 0
for purchase in unchecked:
numbers_list = purchase["numbers"]
if not numbers_list:
continue
results = []
for nums in numbers_list:
rank, correct, has_bonus = _calc_rank(nums, win_nums, bonus)
prize = RANK_PRIZE.get(rank, 0)
results.append({
"numbers": nums,
"rank": rank,
"correct": correct,
"has_bonus": has_bonus,
"prize": prize,
})
total_prize = sum(r["prize"] for r in results)
update_purchase_results(purchase["id"], results, total_prize)
strat = purchase["source_strategy"]
if strat not in strategy_agg:
strategy_agg[strat] = {
"sets_count": 0,
"total_correct": 0,
"max_correct": 0,
"prize_total": 0,
"scores": [],
"_results": [],
}
agg = strategy_agg[strat]
agg["_results"].extend(results)
for r in results:
agg["sets_count"] += 1
agg["total_correct"] += r["correct"]
agg["max_correct"] = max(agg["max_correct"], r["correct"])
agg["prize_total"] += r["prize"]
agg["scores"].append(r["correct"] / 6.0)
count += 1
for strat, agg in strategy_agg.items():
avg_score = sum(agg["scores"]) / len(agg["scores"]) if agg["scores"] else 0.0
upsert_strategy_performance(
strategy=strat,
draw_no=drw_no,
sets_count=agg["sets_count"],
total_correct=agg["total_correct"],
max_correct=agg["max_correct"],
prize_total=agg["prize_total"],
avg_score=round(avg_score, 4),
)
# EMA 피드백 루프: 전략 가중치 진화
try:
from .strategy_evolver import evolve_after_check
evolve_after_check(strat, drw_no, agg["_results"])
except Exception:
logger.debug(f"[purchase_manager] evolve_after_check 건너뜀: {strat}")
logger.info(f"[purchase_manager] {drw_no}회차 구매 {count}건 체크 완료")
return count
def get_recent_performance(limit: int = 3) -> list:
"""최근 N회차 내 구매 성과 요약. 없으면 빈 리스트."""
from . import db
purchases = db.get_purchases() or []
by_draw: dict = {}
for p in purchases:
d = p.get("draw_no")
if not d:
continue
results = p.get("results") or []
max_correct = max((int(r.get("correct") or 0) for r in results), default=0)
slot = by_draw.setdefault(d, {"draw_no": d, "purchased_sets": 0, "best_match": 0})
slot["purchased_sets"] += int(p.get("sets") or 1)
slot["best_match"] = max(slot["best_match"], max_correct)
return sorted(by_draw.values(), key=lambda x: -x["draw_no"])[:limit]

View File

@@ -2,6 +2,8 @@ import random
from collections import Counter
from typing import Dict, Any, List, Tuple
from .utils import weighted_sample_6
def recommend_numbers(
draws: List[Tuple[int, List[int]]],
*,
@@ -40,20 +42,7 @@ def recommend_numbers(
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)
chosen_sorted = sorted(weighted_sample_6(weights))
explain = {
"recent_window": recent_window,
@@ -66,3 +55,85 @@ def recommend_numbers(
return {"numbers": chosen_sorted, "explain": explain}
def recommend_with_heatmap(
draws: List[Tuple[int, List[int]]],
past_recommendations: List[Dict[str, Any]],
*,
heatmap_window: int = 10,
heatmap_weight: float = 1.5,
recent_window: int = 200,
recent_weight: float = 2.0,
avoid_recent_k: int = 5,
seed: int | None = None,
) -> Dict[str, Any]:
"""
히트맵 기반 가중치 추천:
- 과거 추천 번호들의 적중률을 분석하여 잘 맞춘 번호에 가중치 부여
- 기존 통계 기반 추천과 결합
Args:
draws: 실제 당첨 번호 리스트 [(회차, [번호들]), ...]
past_recommendations: 과거 추천 데이터 [{"numbers": [...], "correct_count": N, "based_on_draw": M}, ...]
heatmap_window: 히트맵 분석할 최근 추천 개수
heatmap_weight: 히트맵 가중치 (높을수록 과거 적중 번호 선호)
"""
if seed is not None:
random.seed(seed)
# 1. 기존 통계 기반 가중치 계산
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)
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)
# 2. 히트맵 생성: 과거 추천에서 적중한 번호들의 빈도
heatmap = Counter()
recent_recs = past_recommendations[-heatmap_window:] if len(past_recommendations) >= heatmap_window else past_recommendations
for rec in recent_recs:
if rec.get("correct_count", 0) > 0: # 적중한 추천만
# 적중 개수에 비례해서 가중치 부여 (많이 맞춘 추천일수록 높은 가중)
weight = rec["correct_count"] ** 1.5 # 제곱으로 강조
for num in rec["numbers"]:
heatmap[num] += weight
# 3. 최종 가중치 = 기존 통계 + 히트맵
weights = {}
for n in range(1, 46):
w = freq_all[n] + recent_weight * freq_recent[n]
# 히트맵 가중치 추가
if n in heatmap:
w += heatmap_weight * heatmap[n]
# 최근 출현 번호 패널티
if n in last_k_nums:
w *= 0.6
weights[n] = max(w, 0.1)
# 4. 가중 샘플링으로 6개 선택
chosen_sorted = sorted(weighted_sample_6(weights))
# 5. 설명 데이터
explain = {
"recent_window": recent_window,
"recent_weight": recent_weight,
"avoid_recent_k": avoid_recent_k,
"heatmap_window": heatmap_window,
"heatmap_weight": heatmap_weight,
"top_all": [n for n, _ in freq_all.most_common(10)],
"top_recent": [n for n, _ in freq_recent.most_common(10)],
"top_heatmap": [n for n, _ in heatmap.most_common(10)],
"last_k_draws": [d for d, _ in last_k],
"analyzed_recommendations": len(recent_recs),
}
return {"numbers": chosen_sorted, "explain": explain}

View File

View File

@@ -0,0 +1,53 @@
"""브리핑 저장/조회 + 큐레이터 사용량 엔드포인트."""
from typing import Any, Dict, List
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from .. import db
router = APIRouter(prefix="/api/lotto")
class BriefingRequest(BaseModel):
draw_no: int
picks: List[Dict[str, Any]]
narrative: Dict[str, Any]
confidence: int = Field(ge=0, le=100)
model: str
tokens_input: int = 0
tokens_output: int = 0
cache_read: int = 0
cache_write: int = 0
latency_ms: int = 0
source: str = "auto"
@router.post("/briefing", status_code=201)
def save_briefing(body: BriefingRequest):
bid = db.save_briefing(body.model_dump())
return {"ok": True, "id": bid}
@router.get("/briefing/latest")
def latest():
b = db.get_latest_briefing()
if not b:
raise HTTPException(404, "no briefing yet")
return b
@router.get("/briefing/{draw_no}")
def get_one(draw_no: int):
b = db.get_briefing(draw_no)
if not b:
raise HTTPException(404, f"no briefing for draw {draw_no}")
return b
@router.get("/briefing")
def history(limit: int = 10):
return {"briefings": db.list_briefings(limit)}
@router.get("/curator/usage")
def usage(days: int = 30):
return db.get_curator_usage(days)

View File

@@ -0,0 +1,24 @@
"""큐레이터 입력 엔드포인트 — agent-office에서만 호출."""
from fastapi import APIRouter
from ..curator_helpers import collect_candidates, build_context
from .. import db
router = APIRouter(prefix="/api/lotto/curator")
@router.get("/candidates")
def candidates(n: int = 20):
ctx = build_context()
hot = set(ctx["hot_numbers"])
cold = set(ctx["cold_numbers"])
latest = db.get_latest_draw()
draw_no = (latest["drw_no"] + 1) if latest else 0
items = collect_candidates(n, hot, cold)
return {"draw_no": draw_no, "candidates": items}
@router.get("/context")
def context():
latest = db.get_latest_draw()
draw_no = (latest["drw_no"] + 1) if latest else 0
return {"draw_no": draw_no, **build_context()}

View File

@@ -0,0 +1,277 @@
"""
전략 진화 엔진 — EMA + Softmax 기반 적응형 가중치 관리.
"""
import math
import json
import logging
from typing import Dict, List, Any
logger = logging.getLogger("lotto-backend")
# ── Constants (importable without DB) ─────────────────────────────────────────
ALPHA = 0.3 # EMA 감쇠율
TEMPERATURE = 2.0 # Softmax 온도
MIN_WEIGHT = 0.05 # 최소 가중치
INITIAL_EMA = 0.15 # 콜드스타트 초기값
MIN_DATA_DRAWS = 10 # 학습 최소 회차
STRATEGIES = ["combined", "simulation", "heatmap", "manual", "custom"]
RANK_BONUS = {5: 0.1, 4: 0.3, 3: 0.6, 2: 0.8, 1: 1.0}
# ── Pure functions (no DB dependency) ─────────────────────────────────────────
def calc_draw_score(results: List[Dict]) -> float:
"""구매 결과 리스트 → 평균 성과 점수"""
if not results:
return 0.0
scores = []
for r in results:
s = r.get("correct", 0) / 6.0
s += RANK_BONUS.get(r.get("rank", 0), 0)
scores.append(s)
return sum(scores) / len(scores)
def _softmax_weights(ema_scores: Dict[str, float]) -> Dict[str, float]:
"""EMA 점수 → Softmax → 최소 가중치 보장 → 정규화"""
raw = {s: math.exp(ema / TEMPERATURE) for s, ema in ema_scores.items()}
total = sum(raw.values())
weights = {s: v / total for s, v in raw.items()}
clamped = {}
surplus = 0.0
unclamped = []
for s, w in weights.items():
if w < MIN_WEIGHT:
clamped[s] = MIN_WEIGHT
surplus += MIN_WEIGHT - w
else:
unclamped.append(s)
clamped[s] = w
if surplus > 0 and unclamped:
unclamped_total = sum(clamped[s] for s in unclamped)
for s in unclamped:
clamped[s] -= surplus * (clamped[s] / unclamped_total)
final_total = sum(clamped.values())
return {s: round(v / final_total, 4) for s, v in clamped.items()}
# ── DB-dependent functions (use lazy imports) ─────────────────────────────────
def _db():
"""Lazy import to avoid circular/relative import issues in tests"""
from . import db as _db_mod
return _db_mod
def _recommender():
from . import recommender as _rec_mod
return _rec_mod
def _analyzer():
from . import analyzer as _ana_mod
return _ana_mod
def update_ema_for_strategy(strategy: str, draw_score: float) -> float:
db = _db()
weights = db.get_strategy_weights()
current = next((w for w in weights if w["strategy"] == strategy), None)
old_ema = current["ema_score"] if current else INITIAL_EMA
new_ema = ALPHA * draw_score + (1 - ALPHA) * old_ema
return new_ema
def recalculate_weights() -> Dict[str, float]:
db = _db()
weights_rows = db.get_strategy_weights()
ema_scores = {w["strategy"]: w["ema_score"] for w in weights_rows}
for s in STRATEGIES:
if s not in ema_scores:
ema_scores[s] = INITIAL_EMA
new_weights = _softmax_weights(ema_scores)
for s, w in new_weights.items():
row = next((r for r in weights_rows if r["strategy"] == s), None)
db.update_strategy_weight(
strategy=s,
weight=w,
ema_score=ema_scores[s],
total_sets=row["total_sets"] if row else 0,
total_hits_3plus=row["total_hits_3plus"] if row else 0,
)
logger.info(f"[strategy_evolver] 가중치 재계산: {new_weights}")
return new_weights
def evolve_after_check(strategy: str, draw_no: int, results: List[Dict]) -> None:
db = _db()
draw_score = calc_draw_score(results)
new_ema = update_ema_for_strategy(strategy, draw_score)
weights_rows = db.get_strategy_weights()
current = next((w for w in weights_rows if w["strategy"] == strategy), None)
hits_3plus = sum(1 for r in results if r.get("correct", 0) >= 3)
db.update_strategy_weight(
strategy=strategy,
weight=current["weight"] if current else 0.2,
ema_score=new_ema,
total_sets=(current["total_sets"] if current else 0) + len(results),
total_hits_3plus=(current["total_hits_3plus"] if current else 0) + hits_3plus,
)
recalculate_weights()
def get_weights_with_trend() -> Dict[str, Any]:
db = _db()
weights = db.get_strategy_weights()
perfs = db.get_strategy_performance()
strat_perfs = {}
for p in perfs:
s = p["strategy"]
if s not in strat_perfs:
strat_perfs[s] = []
strat_perfs[s].append(p)
result = []
for w in weights:
sp = strat_perfs.get(w["strategy"], [])
if len(sp) >= 5:
recent_avg = sum(p["avg_score"] for p in sp[-3:]) / 3
older_avg = sum(p["avg_score"] for p in sp[-5:-2]) / 3
delta = recent_avg - older_avg
trend = "up" if delta > 0.02 else ("down" if delta < -0.02 else "stable")
else:
trend = "stable"
result.append({
"strategy": w["strategy"],
"weight": w["weight"],
"ema_score": w["ema_score"],
"total_sets": w["total_sets"],
"hits_3plus": w["total_hits_3plus"],
"trend": trend,
})
all_draws = set()
for p in perfs:
all_draws.add(p["draw_no"])
return {
"weights": result,
"last_evolved": weights[0]["updated_at"] if weights else None,
"min_data_draws": MIN_DATA_DRAWS,
"current_data_draws": len(all_draws),
"status": "active" if len(all_draws) >= MIN_DATA_DRAWS else "learning",
}
def generate_smart_recommendation(sets: int = 5) -> Dict[str, Any]:
db = _db()
rec = _recommender()
ana = _analyzer()
weights_data = db.get_strategy_weights()
weight_map = {w["strategy"]: w["weight"] for w in weights_data}
draws = db.get_all_draw_numbers()
if not draws:
return {"error": "No draw data"}
latest = db.get_latest_draw()
cache = ana.build_analysis_cache(draws)
past_recs = db.list_recommendations_ex(limit=100, sort="id_desc")
candidates = []
seen_keys = set()
def _add_candidate(nums: list, strategy: str, raw_score: float = None):
key = tuple(sorted(nums))
if key in seen_keys:
return
seen_keys.add(key)
if raw_score is None:
sc = ana.score_combination(nums, cache)
raw_score = sc["score_total"]
meta = raw_score * weight_map.get(strategy, 0.1)
candidates.append({
"numbers": sorted(nums),
"raw_score": round(raw_score, 4),
"strategy": strategy,
"meta_score": round(meta, 4),
})
# combined: 10세트
for _ in range(10):
try:
r = ana.generate_combined_recommendation(draws)
if "final_numbers" in r:
_add_candidate(r["final_numbers"], "combined")
except Exception:
pass
# simulation: best_picks 상위 10개
best = db.get_best_picks(limit=10)
for b in best:
nums = json.loads(b["numbers"]) if isinstance(b["numbers"], str) else b["numbers"]
_add_candidate(nums, "simulation", b.get("score_total"))
# heatmap: 10세트
for _ in range(10):
try:
r = rec.recommend_with_heatmap(draws, past_recs)
_add_candidate(r["numbers"], "heatmap")
except Exception:
pass
# manual: 10세트
for _ in range(10):
try:
r = rec.recommend_numbers(draws)
_add_candidate(r["numbers"], "manual")
except Exception:
pass
candidates.sort(key=lambda c: -c["meta_score"])
top = candidates[:sets]
result_sets = []
for c in top:
sc = ana.score_combination(c["numbers"], cache)
contributions = {}
for strat in STRATEGIES:
contributions[strat] = round(weight_map.get(strat, 0) * sc["score_total"], 4)
contrib_total = sum(contributions.values()) or 1
contributions = {s: round(v / contrib_total, 3) for s, v in contributions.items()}
result_sets.append({
"numbers": c["numbers"],
"meta_score": c["meta_score"],
"source_strategy": c["strategy"],
"contribution": contributions,
"individual_scores": {k: round(v, 4) for k, v in sc.items()},
})
perfs = db.get_strategy_performance()
data_draws = len(set(p["draw_no"] for p in perfs))
status = "active" if data_draws >= MIN_DATA_DRAWS else "learning"
return {
"sets": result_sets,
"strategy_weights_used": weight_map,
"learning_status": {
"draws_learned": data_draws,
"status": status,
"message": "" if status == "active" else f"{MIN_DATA_DRAWS}회차 이상 데이터 필요 (현재 {data_draws}회차)",
},
"based_on_latest_draw": latest["drw_no"] if latest else None,
}

80
backend/app/utils.py Normal file
View File

@@ -0,0 +1,80 @@
import random
from typing import List, Dict, Any, Tuple
def weighted_sample_6(weights: Dict[int, float]) -> List[int]:
"""
가중 확률 샘플링으로 중복 없이 6개 번호 추출.
weights: {1: w1, 2: w2, ..., 45: w45}
"""
pool = list(range(1, 46))
chosen: List[int] = []
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
return chosen
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,
}

View File

@@ -0,0 +1,61 @@
# backend/tests/test_integration.py
"""checker.py → purchase_manager 연동 통합 테스트"""
import sys, os
import sqlite3
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app"))
import pytest
from unittest.mock import patch
def _make_mem_conn():
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
return conn
def test_check_results_triggers_purchase_check():
"""check_results_for_draw가 purchase 체크도 트리거하는지 검증"""
import db
import backend.app.purchase_manager as pm
mem = _make_mem_conn()
with patch("db._conn", return_value=mem):
db.init_db()
# 당첨번호 삽입
mem.execute(
"INSERT INTO draws (drw_no, drw_date, n1, n2, n3, n4, n5, n6, bonus) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(1124, "2026-03-28", 1, 2, 3, 4, 5, 6, 7)
)
mem.execute(
"INSERT INTO draws (drw_no, drw_date, n1, n2, n3, n4, n5, n6, bonus) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(1125, "2026-04-04", 10, 20, 30, 35, 40, 44, 15)
)
mem.commit()
# 1125회차 대상 구매 등록
db.add_purchase(
draw_no=1125, amount=1000, sets=1,
numbers=[[10, 20, 30, 1, 2, 3]],
is_real=True, source_strategy="combined",
)
# purchase_manager의 check_purchases_for_draw<61><77><EFBFBD> 직접 호출하여 연동 검증
with patch("db._conn", return_value=mem), \
patch("backend.app.purchase_manager.get_draw", side_effect=lambda drw: db.get_draw(drw)), \
patch("backend.app.purchase_manager.get_purchases", side_effect=lambda **kw: db.get_purchases(**kw)), \
patch("backend.app.purchase_manager.update_purchase_results", side_effect=lambda *a, **kw: db.update_purchase_results(*a, **kw)), \
patch("backend.app.purchase_manager.upsert_strategy_performance", side_effect=lambda **kw: db.upsert_strategy_performance(**kw)):
purchase_count = pm.check_purchases_for_draw(1125)
assert purchase_count == 1
# purchase가 체크되었는지 확인
with patch("db._conn", return_value=mem):
purchases = db.get_purchases(draw_no=1125)
assert purchases[0]["checked"] == 1
assert purchases[0]["results"][0]["correct"] == 3 # 10, 20, 30 맞음
mem.close()

View File

@@ -0,0 +1,309 @@
# backend/tests/test_purchase_manager.py
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app"))
# Also insert the backend root so that "backend.app" package is importable
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
import sqlite3
import pytest
from unittest.mock import patch, MagicMock
# ":memory:" 공유 커넥션 — 각 테스트에서 독립적으로 생성
def _make_mem_conn():
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
return conn
def test_purchase_history_has_new_columns():
"""purchase_history 테이블에 신규 컬럼이 존재하는지 검증"""
import db
mem = _make_mem_conn()
with patch("db._conn", return_value=mem):
db.init_db()
cols = {r["name"] for r in mem.execute("PRAGMA table_info(purchase_history)").fetchall()}
assert "numbers" in cols
assert "is_real" in cols
assert "source_strategy" in cols
assert "source_detail" in cols
assert "checked" in cols
assert "results" in cols
assert "total_prize" in cols
# 기존 컬럼도 유지
assert "draw_no" in cols
assert "amount" in cols
assert "sets" in cols
assert "prize" in cols
assert "note" in cols
mem.close()
def test_strategy_performance_table_exists():
"""strategy_performance 테이블이 생성되는지 검증"""
import db
mem = _make_mem_conn()
with patch("db._conn", return_value=mem):
db.init_db()
cols = {r["name"] for r in mem.execute("PRAGMA table_info(strategy_performance)").fetchall()}
assert "strategy" in cols
assert "draw_no" in cols
assert "sets_count" in cols
assert "total_correct" in cols
assert "avg_score" in cols
mem.close()
def test_strategy_weights_table_exists():
"""strategy_weights 테이블이 생성되고 초기값이 있는지 검증"""
import db
mem = _make_mem_conn()
with patch("db._conn", return_value=mem):
db.init_db()
rows = mem.execute("SELECT * FROM strategy_weights ORDER BY strategy").fetchall()
strategies = {r["strategy"] for r in rows}
assert strategies == {"combined", "simulation", "heatmap", "manual", "custom"}
# 가중치 합이 1.0
total_weight = sum(r["weight"] for r in rows)
assert abs(total_weight - 1.0) < 0.01
mem.close()
def test_add_purchase_with_numbers():
"""번호 포함 구매 등록"""
import db
mem = _make_mem_conn()
with patch("db._conn", return_value=mem):
db.init_db()
result = db.add_purchase(
draw_no=1150,
amount=5000,
sets=5,
numbers=[[1, 2, 3, 4, 5, 6], [7, 8, 9, 10, 11, 12]],
is_real=False,
source_strategy="simulation",
source_detail={"run_id": 42},
)
assert result["draw_no"] == 1150
assert result["amount"] == 5000
assert result["is_real"] == 0
assert result["source_strategy"] == "simulation"
assert result["numbers"] == [[1, 2, 3, 4, 5, 6], [7, 8, 9, 10, 11, 12]]
assert result["source_detail"] == {"run_id": 42}
mem.close()
def test_get_purchases_filter_is_real():
"""is_real 필터 동작"""
import db
mem = _make_mem_conn()
with patch("db._conn", return_value=mem):
db.init_db()
db.add_purchase(draw_no=1150, amount=5000, sets=5, is_real=True)
db.add_purchase(draw_no=1150, amount=1000, sets=1, is_real=False)
real_only = db.get_purchases(is_real=True)
virtual_only = db.get_purchases(is_real=False)
assert len(real_only) == 1
assert real_only[0]["is_real"] == 1
assert len(virtual_only) == 1
assert virtual_only[0]["is_real"] == 0
mem.close()
def test_get_purchase_stats_by_type():
"""실제/가상 분리 통계"""
import db
mem = _make_mem_conn()
with patch("db._conn", return_value=mem):
db.init_db()
db.add_purchase(draw_no=1150, amount=5000, sets=5, is_real=True, source_strategy="manual")
db.add_purchase(draw_no=1150, amount=1000, sets=1, is_real=False, source_strategy="simulation")
stats = db.get_purchase_stats()
assert "total" in stats
assert "real" in stats
assert "virtual" in stats
assert "by_strategy" in stats
assert stats["total"]["sets"] == 6
assert stats["real"]["sets"] == 5
assert stats["virtual"]["sets"] == 1
assert "manual" in stats["by_strategy"]
assert "simulation" in stats["by_strategy"]
# 하위호환 필드
assert "total_records" in stats
assert stats["total_records"] == 2
mem.close()
def test_upsert_strategy_performance():
"""전략 성과 upsert"""
import db
mem = _make_mem_conn()
with patch("db._conn", return_value=mem):
db.init_db()
# 최초 insert
db.upsert_strategy_performance(
strategy="simulation",
draw_no=1150,
sets_count=10,
total_correct=30,
max_correct=5,
prize_total=5000,
avg_score=3.0,
)
rows = db.get_strategy_performance(strategy="simulation")
assert len(rows) == 1
assert rows[0]["sets_count"] == 10
assert rows[0]["avg_score"] == 3.0
# upsert (동일 strategy+draw_no)
db.upsert_strategy_performance(
strategy="simulation",
draw_no=1150,
sets_count=20,
total_correct=60,
max_correct=6,
prize_total=10000,
avg_score=4.5,
)
rows = db.get_strategy_performance(strategy="simulation")
assert len(rows) == 1 # 중복 없이 1개
assert rows[0]["sets_count"] == 20
assert rows[0]["avg_score"] == 4.5
mem.close()
def test_update_strategy_weight():
"""전략 가중치 업데이트"""
import db
mem = _make_mem_conn()
with patch("db._conn", return_value=mem):
db.init_db()
# 초기값 확인
weights_before = db.get_strategy_weights()
combined_before = next(w for w in weights_before if w["strategy"] == "combined")
original_weight = combined_before["weight"]
# 업데이트
db.update_strategy_weight(
strategy="combined",
weight=0.5,
ema_score=0.75,
total_sets=100,
total_hits_3plus=20,
)
weights_after = db.get_strategy_weights()
combined_after = next(w for w in weights_after if w["strategy"] == "combined")
assert combined_after["weight"] == 0.5
assert combined_after["ema_score"] == 0.75
assert combined_after["total_sets"] == 100
assert combined_after["total_hits_3plus"] == 20
mem.close()
# ── purchase_manager 테스트 ───────────────────────────────────────────────────
def _import_purchase_manager_with_mem(mem_conn):
"""purchase_manager를 메모리 DB에 연결된 상태로 임포트."""
import db
import importlib
# backend.app 패키지로 로드해 상대 임포트가 동작하게 함
import backend.app.purchase_manager as pm
return pm
def test_check_purchases_for_draw():
"""특정 회차 구매 건들의 결과 체크"""
import db
import backend.app.purchase_manager as pm
mem = _make_mem_conn()
with patch("db._conn", return_value=mem):
db.init_db()
# 당첨번호 삽입: 1125회 [3,12,23,34,38,45] bonus=7
mem.execute(
"""INSERT INTO draws (drw_no, drw_date, n1, n2, n3, n4, n5, n6, bonus)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(1125, "2024-12-01", 3, 12, 23, 34, 38, 45, 7),
)
mem.commit()
# 구매 등록: 1등 번호 세트 + 낙첨 세트
purchase = db.add_purchase(
draw_no=1125,
amount=2000,
sets=2,
numbers=[[3, 12, 23, 34, 38, 45], [1, 2, 3, 4, 5, 6]],
is_real=False,
source_strategy="simulation",
)
with patch("db._conn", return_value=mem), \
patch("backend.app.purchase_manager.get_draw", side_effect=lambda drw: db.get_draw(drw)), \
patch("backend.app.purchase_manager.get_purchases", side_effect=lambda **kw: db.get_purchases(**kw)), \
patch("backend.app.purchase_manager.update_purchase_results", side_effect=lambda *a, **kw: db.update_purchase_results(*a, **kw)), \
patch("backend.app.purchase_manager.upsert_strategy_performance", side_effect=lambda **kw: db.upsert_strategy_performance(**kw)):
count = pm.check_purchases_for_draw(1125)
assert count == 1
# 결과 확인
with patch("db._conn", return_value=mem):
checked = db.get_purchases(draw_no=1125, checked=True)
assert len(checked) == 1
results = checked[0]["results"]
assert results is not None
assert len(results) == 2
# 첫 번째 세트: 6개 일치 → 1등
assert results[0]["rank"] == 1
assert results[0]["correct"] == 6
# 두 번째 세트: 3 하나만 일치 → 낙첨(correct=1)
assert results[1]["rank"] == 0
assert results[1]["correct"] == 1
mem.close()
def test_check_purchases_updates_strategy_performance():
"""결과 체크 후 strategy_performance가 갱신되는지 검증"""
import db
import backend.app.purchase_manager as pm
mem = _make_mem_conn()
with patch("db._conn", return_value=mem):
db.init_db()
# 당첨번호 삽입: 1126회
mem.execute(
"""INSERT INTO draws (drw_no, drw_date, n1, n2, n3, n4, n5, n6, bonus)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(1126, "2024-12-08", 1, 2, 3, 4, 5, 6, 7),
)
mem.commit()
db.add_purchase(
draw_no=1126,
amount=5000,
sets=5,
numbers=[[1, 2, 3, 4, 5, 6], [10, 20, 30, 40, 41, 42]],
is_real=False,
source_strategy="simulation",
)
with patch("db._conn", return_value=mem), \
patch("backend.app.purchase_manager.get_draw", side_effect=lambda drw: db.get_draw(drw)), \
patch("backend.app.purchase_manager.get_purchases", side_effect=lambda **kw: db.get_purchases(**kw)), \
patch("backend.app.purchase_manager.update_purchase_results", side_effect=lambda *a, **kw: db.update_purchase_results(*a, **kw)), \
patch("backend.app.purchase_manager.upsert_strategy_performance", side_effect=lambda **kw: db.upsert_strategy_performance(**kw)):
count = pm.check_purchases_for_draw(1126)
assert count == 1
with patch("db._conn", return_value=mem):
perf = db.get_strategy_performance(strategy="simulation")
assert len(perf) >= 1
entry = next((p for p in perf if p["draw_no"] == 1126), None)
assert entry is not None, "draw_no=1126 에 대한 strategy_performance 없음"
assert entry["strategy"] == "simulation"
assert entry["sets_count"] == 2 # 2개 세트
mem.close()

View File

@@ -0,0 +1,72 @@
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app"))
import math
import pytest
def test_calc_draw_score_basic():
"""세트별 결과 → draw_score 계산"""
from strategy_evolver import calc_draw_score
results = [
{"correct": 3, "rank": 5}, # 3/6 + 0.1 = 0.6
{"correct": 1, "rank": 0}, # 1/6 + 0 = 0.167
]
score = calc_draw_score(results)
expected = ((3/6 + 0.1) + (1/6)) / 2
assert abs(score - expected) < 0.01
def test_calc_draw_score_empty():
"""빈 결과 → 0"""
from strategy_evolver import calc_draw_score
assert calc_draw_score([]) == 0.0
def test_recalculate_weights_softmax():
"""EMA → Softmax 가중치 변환"""
from strategy_evolver import _softmax_weights
ema_scores = {
"combined": 0.30,
"simulation": 0.25,
"heatmap": 0.15,
"manual": 0.10,
"custom": 0.05,
}
weights = _softmax_weights(ema_scores)
assert abs(sum(weights.values()) - 1.0) < 0.001
assert weights["combined"] > weights["simulation"]
assert weights["simulation"] > weights["heatmap"]
assert all(w >= 0.049 for w in weights.values())
def test_recalculate_weights_min_weight():
"""한 전략의 EMA가 매우 낮아도 최소 5% 보장"""
from strategy_evolver import _softmax_weights
ema_scores = {
"combined": 0.50,
"simulation": 0.01,
"heatmap": 0.01,
"manual": 0.01,
"custom": 0.01,
}
weights = _softmax_weights(ema_scores)
assert weights["simulation"] >= 0.049
assert weights["custom"] >= 0.049
assert abs(sum(weights.values()) - 1.0) < 0.001
def test_update_ema():
"""EMA 갱신 공식 검증"""
from strategy_evolver import ALPHA
old_ema = 0.15
draw_score = 0.40
new_ema = ALPHA * draw_score + (1 - ALPHA) * old_ema
expected = 0.3 * 0.40 + 0.7 * 0.15 # = 0.225
assert abs(new_ema - expected) < 0.001

4
blog-lab/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
__pycache__
*.pyc
.env
data/

15
blog-lab/Dockerfile Normal file
View File

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

0
blog-lab/app/__init__.py Normal file
View File

15
blog-lab/app/config.py Normal file
View File

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

View File

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

789
blog-lab/app/db.py Normal file
View File

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

440
blog-lab/app/main.py Normal file
View File

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

105
blog-lab/app/marketer.py Normal file
View File

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

View File

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

View File

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

View File

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

3
blog-lab/pytest.ini Normal file
View File

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

View File

@@ -0,0 +1,6 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
requests==2.32.3
anthropic==0.52.0
beautifulsoup4>=4.12
httpx>=0.27

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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,8 +1,11 @@
version: "3.8"
name: webpage
services:
backend:
build: ./backend
build:
context: ./backend
args:
APP_VERSION: ${APP_VERSION:-dev}
container_name: lotto-backend
restart: unless-stopped
ports:
@@ -12,36 +15,200 @@ 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: 30s
timeout: 5s
retries: 3
stock-lab:
build:
context: ./stock-lab
args:
APP_VERSION: ${APP_VERSION:-dev}
container_name: stock-lab
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}
volumes:
- ${RUNTIME_PATH}/data/stock:/app/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
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:-}
- SUNO_API_KEY=${SUNO_API_KEY:-}
- MUSIC_MEDIA_BASE=${MUSIC_MEDIA_BASE:-/media/music}
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
volumes:
- ${RUNTIME_PATH}/data/music:/app/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
timeout: 5s
retries: 3
blog-lab:
build:
context: ./blog-lab
container_name: blog-lab
restart: unless-stopped
ports:
- "18700:8000"
environment:
- TZ=${TZ:-Asia/Seoul}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- NAVER_CLIENT_ID=${NAVER_CLIENT_ID:-}
- NAVER_CLIENT_SECRET=${NAVER_CLIENT_SECRET:-}
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
volumes:
- ${RUNTIME_PATH}/data/blog:/app/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
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}
volumes:
- ${RUNTIME_PATH}/data/realestate:/app/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
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_LAB_URL=http://stock-lab:8000
- MUSIC_LAB_URL=http://music-lab:8000
- BLOG_LAB_URL=http://blog-lab:8000
- REALESTATE_LAB_URL=http://realestate-lab:8000
- 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:-}
- LOTTO_BACKEND_URL=${LOTTO_BACKEND_URL:-http://lotto-backend: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}
volumes:
- ${RUNTIME_PATH:-.}/data/agent-office:/app/data
depends_on:
- stock-lab
- music-lab
- blog-lab
- realestate-lab
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
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: 30s
timeout: 5s
retries: 3
frontend:
image: nginx:alpine
container_name: lotto-frontend
restart: unless-stopped
depends_on:
- music-lab
- blog-lab
- realestate-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
extra_hosts:
- "host.docker.internal:host-gateway"
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:80/"]
interval: 30s
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

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,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 호출 일치.

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

View File

@@ -0,0 +1,402 @@
# Lotto 구매 연동 + 전략 진화 시스템 설계
> 작성일: 2026-04-05
> 상태: 승인 대기
---
## 1. 목표
로또 번호 추천 기능을 고도화하여:
1. **동행복권 실 구매 연동** — 추천 번호를 클립보드 복사 + 동행복권 바로가기로 실제 구매 지원
2. **가상 구매 모드** — 돈을 쓰지 않고 "이 번호로 구매한다"를 등록, 결과 발표 후 자동 가상 수익률 계산
3. **전략 진화 시스템** — 구매 이력 기반으로 각 추천 전략(combined, simulation, heatmap, manual, custom)의 성과를 추적하고, EMA + Softmax로 가중치를 자동 조정하는 메타 전략
4. **통합 구매 이력** — 실제/가상 구매를 하나의 리스트에서 관리하되, 실 구매는 시각적으로 강조
---
## 2. 접근 방식
**방식 1 (단일 확장) 채택**: 기존 `lotto-backend`(backend/) 서비스 내부에 모듈 추가.
- NAS Celeron J4025 환경에서 새 컨테이너 추가는 리소스 부담
- 기존 checker/recommender/DB와 자연스러운 연동 가능
- 파일 수준 모듈 분리로 유지보수성 확보
---
## 3. 데이터 모델
### 3.1 기존 `purchase_history` 테이블 마이그레이션
현재 스키마:
```sql
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 NOT NULL DEFAULT 0,
note TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
```
마이그레이션 전략: **ALTER TABLE로 컬럼 추가** (기존 데이터 보존)
```sql
ALTER TABLE purchase_history ADD COLUMN numbers TEXT NOT NULL DEFAULT '[]';
ALTER TABLE purchase_history ADD COLUMN is_real INTEGER NOT NULL DEFAULT 1;
ALTER TABLE purchase_history ADD COLUMN source_strategy TEXT NOT NULL DEFAULT 'manual';
ALTER TABLE purchase_history ADD COLUMN source_detail TEXT NOT NULL DEFAULT '{}';
ALTER TABLE purchase_history ADD COLUMN checked INTEGER NOT NULL DEFAULT 0;
ALTER TABLE purchase_history ADD COLUMN results TEXT NOT NULL DEFAULT '[]';
ALTER TABLE purchase_history ADD COLUMN total_prize INTEGER NOT NULL DEFAULT 0;
```
- 기존 레코드: `is_real=1`, `source_strategy='manual'`, `checked=0` (기본값)
- 기존 `prize` 컬럼은 하위호환용으로 유지. 신규 로직은 `total_prize` + `results` 사용
- 기존 `sets` 컬럼은 하위호환용으로 유지. 신규 로직은 `numbers` JSON 배열 길이로 세트 수 산출
### 3.2 신규 `strategy_performance` 테이블
```sql
CREATE TABLE IF NOT EXISTS strategy_performance (
id INTEGER PRIMARY KEY AUTOINCREMENT,
strategy TEXT NOT NULL,
draw_no INTEGER NOT NULL,
sets_count INTEGER NOT NULL DEFAULT 0,
total_correct INTEGER NOT NULL DEFAULT 0,
max_correct INTEGER NOT NULL DEFAULT 0,
prize_total INTEGER NOT NULL DEFAULT 0,
avg_score REAL NOT NULL DEFAULT 0.0,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
UNIQUE(strategy, draw_no)
);
```
### 3.3 신규 `strategy_weights` 테이블
```sql
CREATE TABLE IF NOT EXISTS strategy_weights (
id INTEGER PRIMARY KEY AUTOINCREMENT,
strategy TEXT NOT NULL UNIQUE,
weight REAL NOT NULL DEFAULT 0.2,
ema_score REAL NOT NULL DEFAULT 0.15,
total_sets INTEGER NOT NULL DEFAULT 0,
total_hits_3plus INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
```
초기 가중치 (첫 실행 시 seed):
| strategy | weight | ema_score |
|-----------|--------|-----------|
| combined | 0.30 | 0.15 |
| simulation | 0.25 | 0.15 |
| heatmap | 0.20 | 0.15 |
| manual | 0.15 | 0.15 |
| custom | 0.10 | 0.15 |
---
## 4. API 설계
### 4.1 구매 API (기존 경로 확장)
| 메서드 | 경로 | 변경 사항 |
|--------|------|----------|
| `POST` | `/api/lotto/purchase` | 요청 바디 확장 (numbers, is_real, source_strategy, source_detail 추가) |
| `GET` | `/api/lotto/purchase` | 필터 추가: `is_real`, `strategy`, `checked` |
| `GET` | `/api/lotto/purchase/stats` | 응답 확장: total/real/virtual + by_strategy 섹션 |
| `PUT` | `/api/lotto/purchase/{id}` | 기존 그대로 (allowed 필드 확장) |
| `DELETE` | `/api/lotto/purchase/{id}` | 기존 그대로 |
**POST 요청 바디:**
```json
{
"draw_no": 1125,
"numbers": [[3, 12, 23, 34, 38, 45], [7, 14, 21, 29, 36, 42]],
"is_real": true,
"amount": 2000,
"source_strategy": "combined",
"source_detail": {"recommendation_ids": [451, 452]},
"note": ""
}
```
하위호환: `numbers`가 빈 배열이면 기존 방식(sets + amount만)으로 동작. `is_real` 미지정 시 기본값 `true`.
**GET /purchase/stats 응답:**
```json
{
"total": {"sets": 48, "invested": 48000, "prize": 15000, "roi": -68.75, "win_rate": 12.5},
"real": {"sets": 20, "invested": 20000, "prize": 10000, "roi": -50.0, "win_rate": 15.0},
"virtual": {"sets": 28, "invested": 28000, "prize": 5000, "roi": -82.14, "win_rate": 10.7},
"by_strategy": {
"combined": {"sets": 15, "avg_correct": 1.8, "hits_3plus": 3, "roi": -45.0},
"simulation": {"sets": 12, "avg_correct": 2.1, "hits_3plus": 4, "roi": -30.0}
}
}
```
### 4.2 전략 진화 API (신규)
| 메서드 | 경로 | 설명 |
|--------|------|------|
| `GET` | `/api/lotto/strategy/weights` | 현재 전략별 가중치 + 성과 요약 + trend |
| `GET` | `/api/lotto/strategy/performance` | 전략별 회차 성과 이력 (차트용, `days` 파라미터) |
| `POST` | `/api/lotto/strategy/evolve` | 수동 가중치 재계산 트리거 |
**GET /strategy/weights 응답:**
```json
{
"weights": [
{"strategy": "combined", "weight": 0.32, "ema_score": 0.285, "total_sets": 15, "hits_3plus": 3, "trend": "up"},
{"strategy": "simulation", "weight": 0.28, "ema_score": 0.312, "total_sets": 12, "hits_3plus": 4, "trend": "up"},
{"strategy": "heatmap", "weight": 0.18, "ema_score": 0.195, "total_sets": 10, "hits_3plus": 1, "trend": "down"},
{"strategy": "manual", "weight": 0.14, "ema_score": 0.160, "total_sets": 8, "hits_3plus": 1, "trend": "stable"},
{"strategy": "custom", "weight": 0.08, "ema_score": 0.105, "total_sets": 3, "hits_3plus": 0, "trend": "stable"}
],
"last_evolved": "2026-04-05T09:10:00",
"min_data_draws": 10,
"current_data_draws": 32,
"status": "active"
}
```
### 4.3 스마트 추천 API (신규)
| 메서드 | 경로 | 설명 |
|--------|------|------|
| `GET` | `/api/lotto/recommend/smart` | 전략 가중치 기반 메타 전략 추천. `sets` 파라미터 (기본 5) |
**응답:**
```json
{
"sets": [
{
"numbers": [3, 12, 23, 34, 38, 45],
"meta_score": 0.847,
"source_strategy": "simulation",
"contribution": {"simulation": 0.42, "combined": 0.31, "heatmap": 0.27},
"individual_scores": {"frequency": 0.82, "fingerprint": 0.91, "gap": 0.78, "cooccur": 0.85, "diversity": 0.73}
}
],
"strategy_weights_used": {"combined": 0.32, "simulation": 0.28, "heatmap": 0.18, "manual": 0.14, "custom": 0.08},
"learning_status": {"draws_learned": 32, "status": "active", "message": ""}
}
```
---
## 5. 전략 진화 알고리즘
### 5.1 성과 점수 산출 (회차별, 세트별)
```python
set_score = correct_count / 6.0
# 당첨 등수별 보너스
RANK_BONUS = {5: 0.1, 4: 0.3, 3: 0.6, 2: 0.8, 1: 1.0}
set_score += RANK_BONUS.get(rank, 0)
# 한 구매 건의 draw_score = avg(set_scores)
```
### 5.2 EMA 갱신
```python
ALPHA = 0.3 # 최근 3~4회차가 EMA의 ~65% 차지
new_ema = ALPHA * draw_score + (1 - ALPHA) * old_ema
```
### 5.3 가중치 변환 (Softmax)
```python
TEMPERATURE = 2.0
MIN_WEIGHT = 0.05
raw = {s: exp(ema / TEMPERATURE) for s, ema in ema_scores.items()}
total = sum(raw.values())
weights = {s: max(v / total, MIN_WEIGHT) for s, v in raw.items()}
# 재정규화하여 합 = 1.0
remainder = 1.0 - sum(weights.values())
# ... 비례 배분으로 조정
```
### 5.4 재계산 타이밍
- **자동**: `check_results_for_draw()` → purchases 체크 → strategy_performance 갱신 → weights 재계산
- **수동**: `POST /api/lotto/strategy/evolve`
### 5.5 스마트 추천 흐름
1. `strategy_weights` 로드
2. 각 전략에서 후보 10세트 생성:
- `combined`: `generate_combined_recommendation()` x 10
- `simulation`: `get_best_picks()` 상위 10개
- `heatmap`: `recommend_with_heatmap()` x 10
- `manual`: `recommend_numbers()` x 10
- `custom`: 데이터 없으면 skip
3. `meta_score = original_score x strategy_weight`
4. 전체 풀에서 중복 제거 후 상위 N세트 선출
5. 각 세트에 출처 전략 + 기여도 breakdown 첨부
### 5.6 콜드 스타트
- 구매 이력 0건: 초기 가중치 그대로 사용
- 특정 전략 구매 0건: 해당 전략 EMA 초기값(0.15) 유지
- 10회차 미만: 스마트 추천 응답에 `status: "learning"` + 기존 combined 추천 병행
### 5.7 Trend 판정
```python
recent_delta = current_ema - ema_5_draws_ago
if recent_delta > 0.02: trend = "up"
elif recent_delta < -0.02: trend = "down"
else: trend = "stable"
```
---
## 6. 체커 연동 (자동 파이프라인)
기존 흐름에 purchase 체크를 연결:
```
Scheduler (09:10 / 21:10)
→ sync_latest()
→ 새 회차 감지 시:
→ check_results_for_draw() # 기존: recommendations 체크
→ check_purchases_for_draw() # 신규: purchases 체크
→ 각 세트별 rank/correct/bonus 계산 (checker._calc_rank 재사용)
→ purchases.results, total_prize, checked=1 갱신
→ strategy_performance upsert
→ strategy_evolver.recalculate_weights()
```
---
## 7. 백엔드 모듈 구조
### 7.1 신규 파일
| 파일 | 역할 |
|------|------|
| `purchase_manager.py` | 구매 이력 관리 + 결과 체크 |
| `strategy_evolver.py` | EMA 계산 + 가중치 진화 + 스마트 추천 |
### 7.2 수정 파일
| 파일 | 변경 내용 |
|------|----------|
| `db.py` | purchase_history ALTER + 신규 테이블 2개 + CRUD 함수 추가 |
| `main.py` | 신규 엔드포인트 9개 + Pydantic 모델 + import |
| `checker.py` | `check_results_for_draw()` 끝에 purchase 체크 호출 추가 |
### 7.3 기존 유지 파일 (변경 없음)
`recommender.py`, `generator.py`, `analyzer.py`, `collector.py`, `utils.py`
---
## 8. 프론트엔드 변경
### 8.1 신규 컴포넌트
| 컴포넌트 | 역할 |
|----------|------|
| `SmartRecommendPanel.jsx` | 전략 진화 기반 메타 추천 + 구매 버튼 |
| `PurchaseHub.jsx` | 통합 구매 이력 (기존 PurchasePanel 대체) |
| `StrategyDashboard.jsx` | 전략 가중치 시각화 + 성과 추이 차트 |
| `PurchaseButton.jsx` | 공통 구매 버튼 (실구매/가상구매) |
### 8.2 수정 컴포넌트
| 컴포넌트 | 변경 내용 |
|----------|----------|
| `CombinedRecommendPanel.jsx` | 구매 버튼(PurchaseButton) 추가 |
| `Functions.jsx` | 신규 패널 3개 추가 + import |
### 8.3 신규 훅
| 훅 | 역할 |
|----|------|
| `useStrategyWeights.js` | 전략 가중치/성과 데이터 fetch |
### 8.4 수정 훅
| 훅 | 변경 내용 |
|----|----------|
| `usePurchases.js` | 새 API 스키마 연동 (numbers, is_real, source_strategy 등) |
### 8.5 API 헬퍼 추가 (`api.js`)
```javascript
// 전략
getStrategyWeights() // GET /api/lotto/strategy/weights
getStrategyPerformance(days) // GET /api/lotto/strategy/performance
triggerStrategyEvolve() // POST /api/lotto/strategy/evolve
// 스마트 추천
getSmartRecommend(sets) // GET /api/lotto/recommend/smart
```
### 8.6 동행복권 바로가기
별도 API 없음. 프론트엔드 PurchaseButton에서:
1. 번호를 클립보드에 복사
2. `window.open('https://dhlottery.co.kr/gameResult.do?method=byWin')` — 새 탭
3. 확인 다이얼로그 "구매 완료했나요?" → 예 → `POST /api/lotto/purchase (is_real=1)`
### 8.7 UI 시각 구분
- 실 구매: 금색/강조 배경 + 지갑 아이콘
- 가상 구매: 기본 배경 + 게임패드 아이콘
- 미확인: 시계 아이콘
- 당첨: 초록 하이라이트 + 체크 아이콘
---
## 9. 전체 데이터 흐름
```
추천(기존) ──[구매 버튼]──→ POST /purchase
스마트 추천(신규) ──[구매 버튼]──┘
purchase_history 테이블
매주 토요일 추첨 결과 ──→ sync_latest()
check_results_for_draw()
├── recommendations 체크 (기존)
└── check_purchases_for_draw() (신규)
strategy_performance 갱신
recalculate_weights()
strategy_weights 갱신
다음 스마트 추천에 반영 ──→ 순환
```
---
## 10. 비기능 요구사항
- **하위호환**: 기존 purchase API 사용자(프론트 PurchasePanel)는 마이그레이션 중에도 동작해야 함
- **성능**: 스마트 추천은 각 전략 10세트 생성 → 총 50세트 중 상위 N개 선출. 1-2초 내 응답 목표
- **데이터 안전**: ALTER TABLE은 SQLite 트랜잭션으로 안전하게 실행. 기존 데이터 유실 없음
- **콜드 스타트**: 구매 데이터 없어도 스마트 추천 동작 (초기 가중치 사용)
---
## 11. 범위 외 (추후 고려)
- 동행복권 자동 로그인/자동 구매 (CAPTCHA + 보안 정책으로 불가)
- 번호 자동 입력 브라우저 확장 프로그램
- 푸시 알림 (당첨 결과 통보)
- 다중 사용자 지원

View File

@@ -0,0 +1,342 @@
# realestate-lab 설계 스펙
> 부동산 청약 공고 자동 수집 + 프로필 기반 자격 매칭 서비스
---
## 1. 개요
공공데이터포털(한국부동산원 청약홈 분양정보 API)에서 청약 공고를 자동 수집하고, 사용자 프로필 기반으로 지원 가능 여부를 자동 판별하는 독립 서비스.
**핵심 목표:**
- 수동 공고 등록 없이 자동 수집 → DB 저장
- 프로필 기반 자격 매칭 → 지원 가능한 청약만 필터링
- 프론트에서 "새 공고 N건" 확인 → 향후 텔레그램 알림 확장
---
## 2. 서비스 아키텍처
### 독립 서비스 구조
```
realestate-lab/ # 포트 18800
├── app/
│ ├── main.py # FastAPI 앱 + APScheduler
│ ├── db.py # SQLite CRUD (realestate.db)
│ ├── collector.py # 공공데이터포털 API 수집기
│ ├── matcher.py # 프로필 기반 자격 매칭 엔진
│ └── models.py # Pydantic 요청/응답 모델
├── Dockerfile
└── requirements.txt
```
### 수집 흐름
```
APScheduler (매일 09:00)
→ collector.py: 청약홈 API 5개 엔드포인트 호출
→ DB에 신규 공고 upsert (HOUSE_MANAGE_NO + PBLANC_NO 기준)
→ matcher.py: 프로필 매칭 → 적격 공고에 match_status 부여
→ 신규 매칭 공고 카운트 → (향후) 텔레그램 알림
```
---
## 3. 데이터 소스
### 공공데이터포털 — 한국부동산원_청약홈 분양정보 조회 서비스
- **Base URL**: `https://api.odcloud.kr/api`
- **서비스 키**: `DATA_GO_KR_API_KEY` 환경변수
- **일 호출 제한**: 40,000건
- **데이터 포맷**: JSON
### 수집 대상 API 엔드포인트
| 엔드포인트 | 설명 |
|-----------|------|
| `/ApplyhomeInfoDetailSvc/v1/getAPTLttotPblancDetail` | APT 분양정보 상세 |
| `/ApplyhomeInfoDetailSvc/v1/getUrbtyOfctlLttotPblancDetail` | 오피스텔/도시형/민간임대 상세 |
| `/ApplyhomeInfoDetailSvc/v1/getRemndrLttotPblancDetail` | 잔여세대 상세 |
| `/ApplyhomeInfoDetailSvc/v1/getPblPvtRentLttotPblancDetail` | 공공지원 민간임대 상세 |
| `/ApplyhomeInfoDetailSvc/v1/getOPTLttotPblancDetail` | 임의공급 상세 |
### 주택형별 상세 API (모델별 세대수·분양가)
| 엔드포인트 | 설명 |
|-----------|------|
| `/ApplyhomeInfoDetailSvc/v1/getAPTLttotPblancMdl` | APT 주택형별 |
| `/ApplyhomeInfoDetailSvc/v1/getUrbtyOfctlLttotPblancMdl` | 오피스텔 주택형별 |
| `/ApplyhomeInfoDetailSvc/v1/getRemndrLttotPblancMdl` | 잔여세대 주택형별 |
| `/ApplyhomeInfoDetailSvc/v1/getPblPvtRentLttotPblancMdl` | 공공지원 민간임대 주택형별 |
| `/ApplyhomeInfoDetailSvc/v1/getOPTLttotPblancMdl` | 임의공급 주택형별 |
### 공통 쿼리 파라미터
- `page` (기본: 1), `perPage` (기본: 100)
- `serviceKey` — 인코딩된 API 키
- `cond[RCRIT_PBLANC_DE::GTE]` / `cond[RCRIT_PBLANC_DE::LTE]` — 모집공고일 범위 필터
---
## 4. DB 스키마 (realestate.db)
### announcements (청약 공고)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | INTEGER PK | 자동 증가 |
| house_manage_no | TEXT NOT NULL | 주택관리번호 |
| pblanc_no | TEXT NOT NULL | 공고번호 |
| house_nm | TEXT | 주택명 |
| house_secd | TEXT | 주택구분코드 (01:APT, 02:오피스텔, 04:무순위 등) |
| house_dtl_secd | TEXT | 주택상세구분코드 (01:민영, 03:국민 등) |
| rent_secd | TEXT | 분양구분 (0:분양, 1:임대) |
| region_code | TEXT | 공급지역코드 |
| region_name | TEXT | 공급지역명 |
| address | TEXT | 공급위치 |
| total_units | INTEGER | 공급규모 |
| rcrit_date | TEXT | 모집공고일 |
| receipt_start | TEXT | 청약접수시작일 |
| receipt_end | TEXT | 청약접수종료일 |
| spsply_start | TEXT | 특별공급 접수시작일 |
| spsply_end | TEXT | 특별공급 접수종료일 |
| gnrl_rank1_start | TEXT | 1순위 접수시작일 |
| gnrl_rank1_end | TEXT | 1순위 접수종료일 |
| winner_date | TEXT | 당첨자발표일 |
| contract_start | TEXT | 계약시작일 |
| contract_end | TEXT | 계약종료일 |
| homepage_url | TEXT | 홈페이지 |
| pblanc_url | TEXT | 공고 URL |
| constructor | TEXT | 시공사 |
| developer | TEXT | 시행사 |
| move_in_month | TEXT | 입주예정월 |
| is_speculative_area | TEXT | 투기과열지구 |
| is_price_cap | TEXT | 분양가상한제 |
| contact | TEXT | 문의처 |
| status | TEXT | 청약예정/청약중/결과발표/완료 (자동 계산) |
| source | TEXT | auto/manual |
| created_at | TEXT | |
| updated_at | TEXT | |
- UNIQUE 제약: `(house_manage_no, pblanc_no)`
- INDEX: `idx_realestate_status` on `status`
- INDEX: `idx_realestate_region` on `region_name`
### announcement_models (주택형별 상세)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | INTEGER PK | |
| house_manage_no | TEXT | FK → announcements |
| pblanc_no | TEXT | FK → announcements |
| model_no | TEXT | 모델번호 |
| house_ty | TEXT | 주택형 (84A 등) |
| supply_area | REAL | 공급면적(㎡) |
| general_units | INTEGER | 일반공급 세대수 |
| special_units | INTEGER | 특별공급 세대수 |
| multi_child_units | INTEGER | 다자녀 |
| newlywed_units | INTEGER | 신혼부부 |
| first_life_units | INTEGER | 생애최초 |
| old_parent_units | INTEGER | 노부모부양 |
| institution_units | INTEGER | 기관추천 |
| youth_units | INTEGER | 청년 |
| newborn_units | INTEGER | 신생아 |
| top_amount | INTEGER | 분양최고금액(만원) |
- UNIQUE: `(house_manage_no, pblanc_no, model_no)`
### user_profile (사용자 청약 프로필)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | INTEGER PK | 항상 1 (단일 사용자) |
| name | TEXT | 이름 |
| age | INTEGER | 나이 |
| is_homeless | BOOLEAN | 무주택 여부 |
| is_householder | BOOLEAN | 세대주 여부 |
| subscription_months | INTEGER | 청약통장 가입개월수 |
| subscription_amount | INTEGER | 청약통장 납입총액(만원) |
| family_members | INTEGER | 세대원 수 |
| has_dependents | BOOLEAN | 부양가족 유무 |
| children_count | INTEGER | 미성년 자녀수 |
| is_newlywed | BOOLEAN | 신혼부부 여부 |
| marriage_months | INTEGER | 혼인기간(개월) |
| has_newborn | BOOLEAN | 2세 이하 자녀 유무 |
| is_first_home | BOOLEAN | 생애최초 해당 여부 |
| income_level | TEXT | 소득수준 (100%이하/100~130%/130~160%) |
| preferred_regions | TEXT | 관심지역 JSON 배열 |
| preferred_types | TEXT | 관심주택유형 JSON 배열 |
| min_area | REAL | 최소 희망면적(㎡) |
| max_area | REAL | 최대 희망면적(㎡) |
| max_price | INTEGER | 최대 분양가(만원) |
| updated_at | TEXT | |
### match_results (매칭 결과)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | INTEGER PK | |
| announcement_id | INTEGER | FK → announcements |
| model_id | INTEGER | FK → announcement_models (nullable) |
| match_score | INTEGER | 매칭 점수 (0~100) |
| match_reasons | TEXT | 매칭 사유 JSON 배열 |
| eligible_types | TEXT | 지원 가능 유형 JSON 배열 |
| is_new | BOOLEAN | 신규 매칭 여부 (알림용) |
| created_at | TEXT | |
- UNIQUE: `(announcement_id, model_id)`
---
## 5. API 엔드포인트
### 청약 공고
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/realestate/announcements` | 공고 목록 (필터: region, status, house_type, matched_only, sort, page, size) |
| GET | `/api/realestate/announcements/{id}` | 공고 상세 (주택형별 포함) |
| POST | `/api/realestate/announcements` | 수동 공고 등록 |
| PUT | `/api/realestate/announcements/{id}` | 공고 수정 |
| DELETE | `/api/realestate/announcements/{id}` | 공고 삭제 |
### 수집 관리
| 메서드 | 경로 | 설명 |
|--------|------|------|
| POST | `/api/realestate/collect` | 수동 수집 트리거 |
| GET | `/api/realestate/collect/status` | 마지막 수집 결과 (수집일시, 신규건수, 에러) |
### 프로필
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/realestate/profile` | 내 프로필 조회 |
| PUT | `/api/realestate/profile` | 프로필 수정 (upsert) |
### 매칭
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/realestate/matches` | 매칭 결과 목록 (점수순, 신규 우선) |
| POST | `/api/realestate/matches/refresh` | 매칭 재계산 |
| PATCH | `/api/realestate/matches/{id}/read` | 신규 알림 읽음 처리 |
### 대시보드
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/realestate/dashboard` | 요약 (진행중 공고수, 신규 매칭수, 다가오는 일정) |
---
## 6. 매칭 엔진
### 점수 산출 (0~100)
| 기준 | 가중치 | 로직 |
|------|--------|------|
| 지역 매칭 | 30 | preferred_regions에 포함 → 30점 |
| 주택유형 매칭 | 10 | preferred_types에 포함 → 10점 |
| 면적 매칭 | 15 | min_area~max_area 범위 내 주택형 존재 → 15점 |
| 가격 매칭 | 15 | max_price 이하 주택형 존재 → 15점 |
| 자격 매칭 | 30 | 지원 가능 공급유형 수에 비례 |
### 자격 매칭 세부
| 공급유형 | 판별 조건 |
|----------|----------|
| 일반 1순위 | 무주택 + 세대주 + 청약통장 가입기간 충족 (투기과열 24개월, 그 외 12개월) |
| 일반 2순위 | 1순위 미충족 시 |
| 특별-신혼부부 | is_newlywed + 무주택 + 소득기준 |
| 특별-생애최초 | is_first_home + 무주택 + 소득기준 |
| 특별-다자녀 | children_count >= 2 + 무주택 |
| 특별-노부모부양 | has_dependents + 무주택 |
| 특별-청년 | age 19~39 + 무주택 |
| 특별-신생아 | has_newborn + 무주택 |
- 1개 유형 → 10점, 2개 → 20점, 3개 이상 → 30점
- `eligible_types`: 지원 가능 유형 목록 저장
- `match_reasons`: 각 판별 사유 저장
### 상태 자동 계산
```
오늘 < receipt_start → 청약예정
receipt_start ≤ 오늘 ≤ receipt_end → 청약중
receipt_end < 오늘 ≤ winner_date → 결과발표
오늘 > winner_date → 완료
```
### 매칭 실행 시점
- 신규 공고 수집 후 자동 실행
- 프로필 변경 시 `POST /matches/refresh`로 재계산
- 매일 00:00 상태 갱신 시 재매칭
---
## 7. 인프라 통합
### Docker Compose
```yaml
realestate-lab:
build: ./realestate-lab
container_name: realestate-lab
ports:
- "18800:8000"
volumes:
- ${RUNTIME_PATH:-.}/data:/app/data
environment:
- DATA_GO_KR_API_KEY=${DATA_GO_KR_API_KEY}
restart: unless-stopped
```
### Nginx
```nginx
location /api/realestate/ {
proxy_pass http://realestate-lab:8000;
}
```
### APScheduler
| 시간 | Job | 설명 |
|------|-----|------|
| 매일 09:00 | `run_collection` | 5개 API 수집 → 매칭 |
| 매일 00:00 | `update_statuses` | 날짜 기반 상태 갱신 |
### 배포
- `scripts/deploy-nas.sh``realestate-lab/` rsync 대상 추가
---
## 8. lotto-backend 제거 대상
| 파일 | 제거 항목 |
|------|----------|
| `backend/app/db.py` | `realestate_complexes` 테이블 생성, CRUD 함수 5개 |
| `backend/app/main.py` | `ComplexCreate`/`ComplexUpdate` 모델, `/api/realestate/complexes` 라우트 4개 |
기존 `realestate_complexes` 테이블 데이터는 마이그레이션 불필요 (스키마 완전 상이).
---
## 9. 환경변수
| 변수 | 설명 | 필수 |
|------|------|------|
| `DATA_GO_KR_API_KEY` | 공공데이터포털 API 키 | 선택 (미설정 시 수동 등록만 가능) |
---
## 10. 향후 확장
- **텔레그램 알림**: 신규 매칭 공고 발생 시 텔레그램 봇으로 push (알림 모듈 분리 구조 대비)
- **경쟁률 조회**: 청약 접수 기간 중 경쟁률 실시간 수집
- **실거래가 비교**: 주변 시세와 분양가 비교 분석

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,398 @@
# Music Lab Suno API 전체 기능 확장 설계
> 작성일: 2026-04-08
> 범위: 백엔드 (web-backend/music-lab) + 프론트엔드 (web-ui/src/pages/music)
---
## 1. 목표
Suno API의 미사용 기능을 전부 활용하여 music-lab을 완전한 AI 음악 프로덕션 스튜디오로 업그레이드한다. 실용도 기준 3단계로 점진 확장.
## 2. 단계별 기능 목록
### Phase 1: 핵심 생성 강화
| # | 기능 | 설명 |
|---|------|------|
| 1-1 | 크레딧 잔액 상시 표시 | 헤더에 실시간 크레딧 배지, 10 이하 경고 |
| 1-2 | 보컬 성별 선택 | Male / Female / Auto 3버튼 |
| 1-3 | negativeTags | 제외 스타일 텍스트 + 프리셋 칩 |
| 1-4 | V5_5 모델 추가 | SUNO_MODELS 딕셔너리에 추가 |
| 1-5 | styleWeight / audioWeight | 0~1.0 슬라이더 2개 |
| 1-6 | 커버 이미지 생성 | 라이브러리 카드에서 앨범아트 2장 생성 |
### Phase 2: 후처리 파워업
| # | 기능 | 설명 |
|---|------|------|
| 2-1 | WAV 고음질 변환 | MP3→WAV 변환 다운로드 |
| 2-2 | 12스템 분리 | 드럼/베이스/기타 등 12개 개별 추출 |
| 2-3 | 타임스탬프 가사 | 가라오케 스타일 싱크 재생 |
| 2-4 | 스타일 부스트 | AI로 최적 스타일 프롬프트 자동 생성 |
### Phase 3: 고급 크리에이티브
| # | 기능 | 설명 |
|---|------|------|
| 3-1 | 오디오 업로드 + 커버 | 외부 음원을 Suno 스타일로 리메이크 |
| 3-2 | 오디오 업로드 + 확장 | 외부 음원 이어서 만들기 |
| 3-3 | 보컬 추가 | 인스트루멘탈에 AI 보컬 입히기 |
| 3-4 | 인스트루멘탈 추가 | 보컬에 AI 반주 입히기 |
| 3-5 | 뮤직비디오 생성 | MP4 자동 생성 |
---
## 3. 백엔드 API 설계
### 3.1 기존 엔드포인트 수정
#### GenerateRequest 스키마 확장 (main.py)
```python
class GenerateRequest(BaseModel):
# 기존 필드 유지
provider: str = "suno"
model: str = "V4"
title: str = ""
genre: str = ""
moods: list[str] = []
instruments: list[str] = []
duration_sec: int | None = None
bpm: int | None = None
key: str = ""
scale: str = ""
prompt: str = ""
lyrics: str = ""
instrumental: bool = False
# Phase 1 추가
vocal_gender: str | None = None # "m" | "f" | None(auto)
negative_tags: str | None = None # 제외 스타일
style_weight: float | None = None # 0.0~1.0
audio_weight: float | None = None # 0.0~1.0
```
#### SUNO_MODELS 확장 (suno_provider.py)
```python
SUNO_MODELS = {
"V4": {"name": "V4", "max_duration": 240},
"V4_5": {"name": "V4.5", "max_duration": 480},
"V4_5PLUS": {"name": "V4.5+", "max_duration": 480},
"V4_5ALL": {"name": "V4.5 All","max_duration": 480},
"V5": {"name": "V5", "max_duration": 480},
"V5_5": {"name": "V5.5", "max_duration": 480}, # 추가
}
```
#### _build_suno_payload 확장
새 파라미터를 Suno API 페이로드에 매핑:
- `vocal_gender``vocalGender`
- `negative_tags``negativeTags`
- `style_weight``styleWeight`
- `audio_weight``audioWeight`
None이 아닌 경우에만 페이로드에 포함.
### 3.2 신규 엔드포인트
#### Phase 1
```
POST /api/music/cover-image
Request: { "task_id": str, "suno_id": str }
Response: { "task_id": str } → 폴링 → { "images": [url1, url2] }
```
#### Phase 2
```
POST /api/music/wav
Request: { "task_id": str, "suno_id": str }
Response: { "task_id": str } → 폴링 → { "wav_url": str }
POST /api/music/stem-split
Request: { "task_id": str, "suno_id": str }
Response: { "task_id": str } → 폴링 → { "stems": { vocal: url, drums: url, ... } }
GET /api/music/timestamped-lyrics?task_id=...&suno_id=...
Response: { "aligned_words": [...], "waveform_data": [...] }
POST /api/music/style-boost
Request: { "content": str }
Response: { "result": str, "credits_consumed": float }
```
#### Phase 3
```
POST /api/music/upload-cover
Request: { "upload_url": str, "model": str, "custom_mode": bool,
"instrumental": bool, "prompt"?: str, "style"?: str, "title"?: str,
"vocal_gender"?: str, "negative_tags"?: str,
"style_weight"?: float, "audio_weight"?: float }
Response: { "task_id": str }
POST /api/music/upload-extend
Request: { "upload_url": str, "model": str, "continue_at"?: float,
"default_param_flag": bool, "prompt"?: str, "style"?: str, "title"?: str,
"vocal_gender"?: str, "negative_tags"?: str }
Response: { "task_id": str }
POST /api/music/add-vocals
Request: { "upload_url": str, "prompt": str, "title": str, "style": str,
"negative_tags": str, "vocal_gender"?: str, "model"?: str,
"style_weight"?: float, "audio_weight"?: float }
Response: { "task_id": str }
POST /api/music/add-instrumental
Request: { "upload_url": str, "title": str, "tags": str, "negative_tags": str,
"vocal_gender"?: str, "model"?: str,
"style_weight"?: float, "audio_weight"?: float }
Response: { "task_id": str }
POST /api/music/video
Request: { "task_id": str, "suno_id": str, "author"?: str, "domain_name"?: str }
Response: { "task_id": str } → 폴링 → { "video_url": str }
```
### 3.3 suno_provider.py 리팩토링
**공통 폴링 헬퍼 추출:**
```python
def _poll_suno_task(
record_info_url: str,
task_id: str,
max_attempts: int = 40,
interval: int = 8,
success_extractor: Callable[[dict], Any] = None
) -> dict:
"""
범용 Suno 작업 폴링.
record_info_url: 예) "/api/v1/generate/record-info"
success_extractor: SUCCESS 상태일 때 결과 추출 함수
"""
```
기존 `run_suno_generation`, `run_suno_extend`, `run_vocal_removal`도 이 헬퍼를 사용하도록 리팩토링.
**신규 함수 목록:**
| 함수 | Phase | Suno 엔드포인트 | 비동기 |
|------|-------|----------------|--------|
| `run_cover_image` | 1 | `POST /api/v1/suno/cover/generate` | 폴링 |
| `run_wav_convert` | 2 | `POST /api/v1/wav/generate` | 폴링 |
| `run_stem_split` | 2 | `POST /api/v1/vocal-removal/generate` (type=split_stem) | 폴링 |
| `get_timestamped_lyrics` | 2 | `POST /api/v1/generate/get-timestamped-lyrics` | 동기 |
| `generate_style_boost` | 2 | `POST /api/v1/style/generate` | 동기 |
| `run_upload_cover` | 3 | `POST /api/v1/generate/upload-cover` | 폴링 |
| `run_upload_extend` | 3 | `POST /api/v1/generate/upload-extend` | 폴링 |
| `run_add_vocals` | 3 | `POST /api/v1/generate/add-vocals` | 폴링 |
| `run_add_instrumental` | 3 | `POST /api/v1/generate/add-instrumental` | 폴링 |
| `run_video_generate` | 3 | `POST /api/v1/mp4/generate` | 폴링 |
### 3.4 DB 스키마 변경
**music_library 테이블 컬럼 추가 (ALTER TABLE 마이그레이션):**
```sql
ALTER TABLE music_library ADD COLUMN cover_images TEXT NOT NULL DEFAULT '[]';
ALTER TABLE music_library ADD COLUMN wav_url TEXT NOT NULL DEFAULT '';
ALTER TABLE music_library ADD COLUMN video_url TEXT NOT NULL DEFAULT '';
ALTER TABLE music_library ADD COLUMN stem_urls TEXT NOT NULL DEFAULT '{}';
```
**db.py 함수 추가:**
```python
def update_track_cover_images(track_id: int, images: list[str])
def update_track_wav_url(track_id: int, wav_url: str)
def update_track_video_url(track_id: int, video_url: str)
def update_track_stem_urls(track_id: int, stems: dict)
```
---
## 4. 프론트엔드 UI/UX 설계
### 4.1 파일 구조 (컴포넌트 분할)
```
web-ui/src/pages/music/
├── MusicStudio.jsx -- 메인 (탭 라우팅 + 공유 상태)
├── MusicStudio.css -- 전체 스타일
├── components/
│ ├── CreateTab.jsx -- 생성 폼 (6단계 + Phase 1 확장)
│ ├── LyricsTab.jsx -- 가사 관리
│ ├── LibraryTab.jsx -- 라이브러리 + 카드
│ ├── RemixTab.jsx -- Phase 3: 업로드/리믹스
│ ├── AudioPlayer.jsx -- 오디오 플레이어
│ ├── LibraryCard.jsx -- 트랙 카드 + 액션 메뉴
│ ├── StemModal.jsx -- 12스템 결과 모달
│ ├── CoverArtModal.jsx -- 커버 이미지 선택 모달
│ ├── SyncedLyricsPlayer.jsx -- 타임스탬프 가사 오버레이
│ └── CreditsBadge.jsx -- 크레딧 잔액 배지
```
### 4.2 Phase 1 UI 변경
#### 크레딧 배지 (CreditsBadge)
- 위치: 헤더 우측 상단, 탭 옆
- 표시: `⚡ 127 credits`
- 10 이하: 빨간색 + pulse 애니메이션
- 갱신 시점: 페이지 로드, 생성 완료 후, 30초 자동 갱신
#### Create 탭 Step 4 확장
**Vocal Gender (Suno 전용):**
- 3버튼 토글: `♂ Male` | `♀ Female` | `Auto`
- 기본값: Auto
- 스타일: `.ms-gender-toggle` (기존 duration-rail과 유사)
**Negative Tags:**
- 텍스트 입력 필드 + 프리셋 칩
- 프리셋: screaming, autotune, distortion, whisper, falsetto, rap
- 칩 클릭 시 텍스트에 추가/제거
- 스타일: `.ms-negative-tags` (기존 mood-rack과 유사)
**Style Weight / Audio Weight:**
- range 슬라이더 2개 (기존 BPM 슬라이더와 동일 스타일)
- 레이블: "Prompt ↔ Style Balance" / "Original ↔ AI Balance"
- 0~100 표시, API 전송 시 0.0~1.0 변환
- 기본값: 미설정 (슬라이더 중앙, 값 전송 안 함)
#### Library 카드 액션 메뉴 확장
기존 5개 버튼 → 6개 (Cover Art 추가)
4개 초과 시 `•••` 더보기 드롭다운 메뉴로 분기:
- 기본 노출: Play, Download, Delete
- 더보기: Extend, Vocal Split, Cover Art (+ Phase 2/3 추가분)
#### CoverArtModal
- 2장 이미지 좌우 비교 표시
- 각 이미지 아래 "이 이미지 사용" 버튼
- 선택 시 라이브러리 카드 썸네일 업데이트
### 4.3 Phase 2 UI 변경
#### Library 카드 더보기 메뉴 추가
- WAV 다운로드
- Stem Split (12스템)
- Synced Lyrics
- Style Boost (Create 탭 프롬프트로 전달)
#### StemModal
- 3×4 그리드 카드 레이아웃
- 각 스템: 이름 아이콘 + 미니 재생 버튼 + 다운로드 버튼
- 12개 스템: vocal, backing_vocals, drums, bass, guitar, keyboard, strings, brass, woodwinds, percussion, synth, fx
- 스타일: 기존 라이브러리 카드의 축소 버전
#### SyncedLyricsPlayer
- AudioPlayer 교체/오버레이 모드
- 재생 중 현재 단어를 accent 컬러로 하이라이트
- 하단에 waveformData 기반 파형 바
- 닫기 버튼으로 일반 플레이어 복귀
#### Style Boost 버튼
- Create 탭 장르 선택 영역에 `✨ Style Boost` 버튼
- 클릭 시: 현재 genre + moods 조합 → API 호출 → 결과를 프롬프트에 삽입
- 로딩 중 버튼 스피너
### 4.4 Phase 3 UI 변경
#### Remix 탭 (신규 4번째 탭)
- 탭 레이블: `REMIX`
- 상단: 오디오 URL 입력 필드 (또는 라이브러리에서 선택)
- 4개 액션 카드 그리드 (2×2):
- **AI Cover**: 아이콘 + 설명 + 파라미터 폼 (펼침)
- **Extend**: 아이콘 + 설명 + continue_at 입력
- **Add Vocals**: 아이콘 + 설명 + prompt/style 입력
- **Add Instrumental**: 아이콘 + 설명 + tags 입력
- 선택한 카드만 펼쳐서 세부 옵션 표시
- 하단: 뮤직비디오 생성 버튼 (라이브러리에서 선택한 곡 대상)
### 4.5 디자인 토큰 추가
```css
/* Phase 1 추가 토큰 */
--ms-danger: #e74c3c; /* 크레딧 경고 빨간색 */
--ms-male: #4a9eff; /* 남성 보컬 파란색 */
--ms-female: #ff6b9d; /* 여성 보컬 분홍색 */
```
---
## 5. api.js 추가 함수
```javascript
// Phase 1
export const generateCoverImage = (payload) => apiPost('/api/music/cover-image', payload);
// Phase 2
export const convertToWav = (payload) => apiPost('/api/music/wav', payload);
export const splitStems = (payload) => apiPost('/api/music/stem-split', payload);
export const getTimestampedLyrics = (taskId, sunoId) =>
apiGet(`/api/music/timestamped-lyrics?task_id=${taskId}&suno_id=${sunoId}`);
export const generateStyleBoost = (content) => apiPost('/api/music/style-boost', { content });
// Phase 3
export const uploadAndCover = (payload) => apiPost('/api/music/upload-cover', payload);
export const uploadAndExtend = (payload) => apiPost('/api/music/upload-extend', payload);
export const addVocals = (payload) => apiPost('/api/music/add-vocals', payload);
export const addInstrumental = (payload) => apiPost('/api/music/add-instrumental', payload);
export const generateVideo = (payload) => apiPost('/api/music/video', payload);
```
---
## 6. 폴링 패턴
모든 비동기 작업은 기존 음악 생성과 동일한 폴링 패턴:
1. POST 요청 → `{ task_id }` 반환
2. 프론트: 3초 간격 `GET /api/music/status/{task_id}` 폴링
3. 백엔드: BackgroundTask에서 Suno 폴링 → music_tasks 상태 업데이트
4. `status: succeeded` → 결과 반환 + 라이브러리 자동 갱신
동기 API (타임스탬프 가사, 스타일 부스트)는 즉시 응답.
---
## 7. 구현 순서
### Phase 1 (핵심 생성 강화)
1. 백엔드: SUNO_MODELS에 V5_5 추가 + 공통 폴링 헬퍼 추출
2. 백엔드: GenerateRequest 스키마 확장 + _build_suno_payload 매핑
3. 백엔드: 커버 이미지 생성 엔드포인트 + suno_provider 함수
4. 백엔드: DB 마이그레이션 (cover_images, wav_url, video_url, stem_urls)
5. 프론트: 컴포넌트 분할 (MusicStudio → CreateTab, LyricsTab, LibraryTab, etc.)
6. 프론트: CreditsBadge 구현
7. 프론트: Create 탭 Step 4 확장 (vocal gender, negative tags, weight 슬라이더)
8. 프론트: LibraryCard 더보기 메뉴 + CoverArtModal
9. 프론트: api.js 함수 추가
### Phase 2 (후처리 파워업)
10. 백엔드: WAV/스템/타임스탬프/스타일부스트 엔드포인트
11. 프론트: StemModal + SyncedLyricsPlayer + Style Boost 버튼
12. 프론트: Library 카드 Phase 2 액션 추가
### Phase 3 (고급 크리에이티브)
13. 백엔드: upload-cover/upload-extend/add-vocals/add-instrumental/video 엔드포인트
14. 프론트: RemixTab 구현
15. 프론트: Library 카드 Phase 3 액션 (Video)
---
## 8. 제약사항 및 주의점
- **Suno 파일 보관**: 14~15일 후 자동 삭제 → 로컬 다운로드 필수 (기존 패턴 유지)
- **동시 요청 제한**: 10초당 20건 → 배치 작업 시 rate limiting 고려
- **12스템 분리 비용**: 50크레딧 → UI에 비용 경고 표시
- **WAV 중복 변환**: 409 에러 → 이미 변환된 경우 기존 URL 반환
- **뮤직비디오**: taskId + audioId 필요 → 라이브러리에 task_id 저장 필수
- **V5_5 모델**: 커스텀 모델 → 문서상 제한사항 추가 확인 필요
- **크레딧 조회 엔드포인트**: 기존 `/api/v1/get-credits` vs 문서 `/api/v1/generate/credit` → 두 엔드포인트 폴백 시도
- **upload 계열 API**: upload_url은 외부 접근 가능한 URL이어야 함 → 로컬 파일은 NAS nginx URL로 변환

View File

@@ -0,0 +1,444 @@
# Agent Office - AI 에이전트 사무실 시각화 설계
## 개요
Lab 하위에 2D 픽셀아트 스타일의 가상 사무실을 구현하여, AI 에이전트들이 실시간으로 작업하는 모습을 게임처럼 시각화하고 상호작용하는 페이지.
### 핵심 컨셉
- **게임 같은 사무실**: 2D 픽셀아트 오픈 오피스에 에이전트 캐릭터들이 배치
- **실제 작업 수행**: 에이전트들이 기존 백엔드 서비스 API를 호출하여 실제 결과물 생성
- **직접 지시**: 에이전트 클릭 → 채팅/명령 패널로 지시, 승인 요청 시 알림 표시
- **텔레그램 양방향**: 알림 발송 + 인라인 버튼으로 승인/거절/수정
- **아이들 행동**: 장시간 명령 없으면 휴게실에서 커피, 졸기, 동료 잡담 등
### MVP 범위
- **에이전트 2개**: StockAgent (주식 뉴스/주가 알람), MusicAgent (작곡 파이프라인)
- **사무실**: 단일 오픈 오피스 (향후 방/층 확장 가능)
- **텔레그램**: 양방향 (알림 + 인라인 버튼 승인)
---
## 1. 아키텍처
```
┌─────────────────────────────────────────────────┐
│ Frontend (React) │
│ │
│ ┌──────────────┐ ┌─────────────────────────┐ │
│ │ OfficeCanvas │ │ React Overlay │ │
│ │ (Canvas 2D) │ │ - ChatPanel │ │
│ │ - 타일맵 렌더 │ │ - AgentStatus │ │
│ │ - 스프라이트 │ │ - TaskHistory │ │
│ │ - 클릭 히트맵 │ │ - ApprovalDialog │ │
│ └──────────────┘ └─────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ useAgentManager (상태 + WebSocket) │ │
│ └──────────────────────────────────────────┘ │
└──────────────────┬──────────────────────────────┘
│ WebSocket + REST
┌──────────────────▼──────────────────────────────┐
│ Backend: agent-office (새 서비스, 포트 18900) │
│ │
│ ┌────────────┐ ┌────────────┐ ┌──────────────┐ │
│ │ Scheduler │ │ Agent FSM │ │ Telegram Bot │ │
│ │(APScheduler)│ │ (상태머신) │ │ (양방향) │ │
│ └────────────┘ └────────────┘ └──────────────┘ │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ Service Proxy (기존 서비스 API 호출) │ │
│ │ stock-lab / music-lab 등 │ │
│ └──────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
```
### 핵심 결정
- **agent-office**: 새 백엔드 서비스 (포트 18900). 기존 서비스는 수정하지 않음
- **Service Proxy 패턴**: agent-office가 기존 서비스 API를 HTTP 호출
- **WebSocket**: 에이전트 상태 변화를 실시간 전달
- **Canvas + React 오버레이 하이브리드**: 게임 렌더링은 Canvas, UI 패널은 React DOM
---
## 2. 에이전트 상태 머신 (FSM)
### 상태 전이
```
┌──────┐ 스케줄/지시 ┌──────────┐ 완료 ┌──────────┐
│ idle │ ──────────────→ │ working │ ───────→ │ reporting│
└──┬───┘ └────┬─────┘ └────┬─────┘
│ │ 승인 필요 │
│ 장시간 idle ▼ │ 결과 전달 후
│ ┌───────────┐ │
▼ │ waiting │ │
┌──────┐ │ (승인대기) │ │
│ break│ └───────────┘ │
│ (휴식)│ │
└──┬───┘◄───────────────────────────────────────────┘
│ 새 작업 발생
└──────────→ idle
```
### 상태별 시각화
| 상태 | 캐릭터 행동 | 위치 | 오버레이 |
|------|------------|------|---------|
| `idle` | 모니터 보며 대기 애니메이션 | 자기 데스크 | 없음 |
| `working` | 타이핑 애니메이션, 모니터에 진행 표시 | 자기 데스크 | 작업명 말풍선 |
| `waiting` | 살짝 좌우 흔들림 | 자기 데스크 | `❗` 아이콘 (클릭 유도) |
| `reporting` | 결과물 들고 걸어감 | 데스크 → 회의 테이블 | 결과 요약 말풍선 |
| `break` | 커피 마시기/졸기/산책/잡담 | 휴게실/복도 | `☕`/`💤` 아이콘 |
### 아이들 행동 규칙
- idle 상태 5분 경과 → 50% 확률로 break 전환
- break 지속: 1~3분 랜덤 → idle 복귀
- break 중 에이전트끼리 근처에 있으면 잡담 애니메이션
- 새 작업 발생 시 즉시 break 종료 → idle → working
### 승인 흐름별 분류
| 에이전트 | 자동 실행 | 승인 필요 |
|---------|----------|----------|
| Stock | 뉴스 요약, 주가 알람 | - |
| Music | - | 작곡 (프롬프트 확인 후) |
| Lotto (향후) | 통계 분석, 추천번호 | 구매 관련 |
| Blog (향후) | - | 키워드 제시 후 글 생성 |
| Realestate (향후) | 공고 수집, 매칭 | - |
| Claude AI (향후) | - | 직접 지시 + 승인 |
---
## 3. 사무실 맵 & 렌더링
### 타일맵 구조 (MVP: 단일 오픈 오피스)
```
┌─────────────────────────────────────────┐
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │Stock│ │Music│ │Claude│ │ (빈) │ │
│ │Desk │ │Desk │ │Desk │ │향후용│ │
│ └─────┘ └─────┘ └─────┘ └─────┘ │
│ │
│ ┌───────────┐ │
│ │ 회의 테이블 │ │
│ │ (보고구역) │ │
│ └───────────┘ │
│ │
│ ┌──────────┐ ┌─────────────────┐ │
│ │ 휴게실 │ │ CEO 데스크 (나) │ │
│ │ coffee │ │ │ │
│ └──────────┘ └─────────────────┘ │
└─────────────────────────────────────────┘
```
### 렌더링 계층 (아래→위)
1. **바닥 타일**: 카펫, 나무 바닥
2. **가구**: 데스크, 의자, 소파, 화분, 커피머신
3. **캐릭터**: 에이전트 스프라이트 (상태별 애니메이션)
4. **오버레이**: 말풍선, 상태 아이콘, 이름표
### 스프라이트 에셋
- 무료 픽셀아트 에셋팩 활용 (타일셋, 가구)
- 에이전트 캐릭터: 기본 인물 스프라이트 + 액세서리로 구분
- Stock: 넥타이 + 차트 모니터
- Music: 헤드폰 + 음표 이펙트
- Claude: 보라색 톤 + AI 아이콘
- 스프라이트시트: 4방향 × 4프레임 (idle, walk, work, break)
### Canvas 렌더링 엔진
- **게임 루프**: `requestAnimationFrame` 기반, 60fps 타겟
- **카메라**: 고정 뷰 (MVP), 향후 줌/팬 추가 가능
- **클릭 히트맵**: 캐릭터 바운딩 박스 체크 → 클릭 시 React 이벤트 발생
- **이동**: 웨이포인트 기반 lerp (데스크↔회의실↔휴게실)
---
## 4. 백엔드: agent-office 서비스
### 파일 구조
```
agent-office/
├── app/
│ ├── main.py # FastAPI + WebSocket + lifespan
│ ├── db.py # SQLite (agent_tasks, agent_logs, agent_config)
│ ├── config.py # 환경변수, 서비스 URL 설정
│ ├── scheduler.py # APScheduler 스케줄 관리
│ ├── telegram_bot.py # Telegram Bot API 양방향
│ ├── websocket_manager.py # WebSocket 연결 관리 + 브로드캐스트
│ ├── service_proxy.py # 기존 서비스 API 호출 래퍼
│ ├── agents/
│ │ ├── base.py # BaseAgent (FSM, 공통 로직)
│ │ ├── stock.py # StockAgent
│ │ └── music.py # MusicAgent
│ └── models.py # Pydantic 모델
├── Dockerfile
└── requirements.txt
```
### DB 테이블 (agent_office.db)
**agent_config**
| 컬럼 | 타입 | 설명 |
|------|------|------|
| agent_id | TEXT PK | 에이전트 식별자 (stock, music, ...) |
| display_name | TEXT | 표시명 ("주식 트레이더") |
| enabled | BOOLEAN | 활성 상태 |
| schedule_config | TEXT (JSON) | 스케줄 설정 |
| custom_config | TEXT (JSON) | 에이전트별 커스텀 설정 (감시 종목 등) |
| created_at | TEXT | 생성 시각 |
| updated_at | TEXT | 수정 시각 |
**agent_tasks**
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | TEXT PK (UUID) | 작업 ID |
| agent_id | TEXT FK | 에이전트 |
| task_type | TEXT | 작업 유형 (news_summary, price_alert, compose, ...) |
| status | TEXT | pending / approved / working / succeeded / failed |
| input_data | TEXT (JSON) | 입력 파라미터 |
| result_data | TEXT (JSON) | 결과 데이터 |
| requires_approval | BOOLEAN | 승인 필요 여부 |
| approved_at | TEXT | 승인 시각 |
| approved_via | TEXT | 승인 경로 (web / telegram) |
| created_at | TEXT | 생성 시각 |
| completed_at | TEXT | 완료 시각 |
**agent_logs**
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | INTEGER PK | 자동 증가 |
| agent_id | TEXT FK | 에이전트 |
| task_id | TEXT FK | 관련 작업 (nullable) |
| level | TEXT | info / warn / error |
| message | TEXT | 로그 메시지 |
| created_at | TEXT | 시각 |
**telegram_state**
| 컬럼 | 타입 | 설명 |
|------|------|------|
| callback_id | TEXT PK | 텔레그램 콜백 ID |
| task_id | TEXT FK | 매핑된 작업 |
| agent_id | TEXT FK | 매핑된 에이전트 |
| action | TEXT | approve / reject / modify |
| responded | BOOLEAN | 응답 완료 여부 |
| created_at | TEXT | 생성 시각 |
### BaseAgent 인터페이스
```python
class BaseAgent:
agent_id: str
state: str # idle, working, waiting, reporting, break
async def on_schedule(self) -> None:
"""스케줄러에 의해 호출. 자동 작업 실행."""
async def on_command(self, command: str, params: dict) -> dict:
"""사용자 직접 지시 처리."""
async def on_approval(self, task_id: str, approved: bool, feedback: str) -> None:
"""승인/거절 콜백."""
async def get_status(self) -> dict:
"""현재 상태 + 최근 작업 요약."""
```
### MVP 에이전트 상세
**StockAgent:**
- 스케줄: 매일 08:00 `on_schedule()``stock-lab GET /api/stock/news` 호출
- AI 요약: 뉴스 데이터를 Ollama(192.168.45.59)로 요약 생성
- 텔레그램 전송: 요약 결과를 포맷팅하여 발송 (자동, 승인 불필요)
- 주가 알람: `agent_config.custom_config`에 감시 종목/조건 저장, 주기적 체크
- 상태 전이: idle → working(뉴스 수집) → reporting(텔레그램 전송) → idle
**MusicAgent:**
- 트리거: 사용자 웹/텔레그램 지시 → `on_command()`
- 프롬프트 확인: 사용자 입력 프롬프트를 텔레그램으로 전송 + 인라인 버튼
- 승인 시: `music-lab POST /api/music/generate` 호출
- 상태 폴링: `music-lab GET /api/music/status/{task_id}` → 완료까지 반복
- 결과 알림: 생성된 음악 URL을 텔레그램 + 웹에 전달
- 상태 전이: idle → waiting(프롬프트 승인 대기) → working(생성 중) → reporting(결과 전달) → idle
---
## 5. 텔레그램 봇
### 구성
- **Telegram Bot API** + **Webhook 수신** (NAS에서)
- agent-office 서비스 내부에 통합 (별도 프로세스 아님)
- Nginx: `/api/agent-office/telegram/webhook``agent-office:8000`
### 환경변수
- `TELEGRAM_BOT_TOKEN`: Bot Father에서 발급
- `TELEGRAM_CHAT_ID`: 사용자 채팅 ID (1:1 봇)
- `TELEGRAM_WEBHOOK_URL`: Webhook 수신 URL (NAS 외부 접근 가능 URL)
### 메시지 포맷
**자동 알림 (뉴스 요약):**
```
📈 [주식 에이전트] 아침 뉴스 요약
━━━━━━━━━━━━━━━━
• 삼성전자: 반도체 수출 호조...
• 코스피: 외인 순매수 전환...
• 미국 CPI 발표 예정...
📊 관심종목 현황
삼성전자 82,500원 (+2.1%)
AAPL $185.20 (+1.2%)
```
**승인 요청 (작곡):**
```
🎵 [음악 에이전트] 작곡 요청
━━━━━━━━━━━━━━━━
프롬프트: "Lo-fi hip hop, rainy day, piano"
스타일: Chill, Ambient
모델: V5.5
[✅ 승인] [❌ 거절] [✏️ 수정]
```
**주가 알람:**
```
🚨 [주식 에이전트] 주가 알림
━━━━━━━━━━━━━━━━
삼성전자 82,500원
조건: 82,000원 이상 → 도달!
현재 등락: +2.1%
```
### 양방향 흐름
1. 에이전트 → `telegram_bot.send_message()` → 텔레그램
2. 사용자 → 인라인 버튼 클릭 or 텍스트 입력
3. 텔레그램 → Webhook POST → `telegram_bot.handle_webhook()`
4. `handle_webhook()``telegram_state` 조회 → 에이전트 `on_approval()` 호출
5. 에이전트 FSM 상태 전이 → WebSocket 브로드캐스트 → 프론트엔드 반영
---
## 6. 프론트엔드 구조
### 파일 구조
```
src/pages/agent-office/
├── AgentOffice.jsx # 메인 페이지 (Canvas + Overlay 컨테이너)
├── AgentOffice.css # 스타일
├── canvas/
│ ├── OfficeRenderer.js # Canvas 렌더링 엔진 (게임루프)
│ ├── SpriteSheet.js # 스프라이트시트 로더 + 프레임 애니메이션
│ ├── TileMap.js # 타일맵 데이터 + 렌더링
│ └── AgentSprite.js # 에이전트 캐릭터 (위치, 상태, 이동, 애니메이션)
├── components/
│ ├── ChatPanel.jsx # 에이전트 채팅/명령 패널
│ ├── AgentBubble.jsx # 말풍선/상태 아이콘 오버레이
│ ├── TaskHistory.jsx # 작업 이력 사이드패널
│ └── ApprovalDialog.jsx # 승인 요청 다이얼로그
├── hooks/
│ ├── useAgentManager.js # WebSocket + 에이전트 상태 관리
│ └── useOfficeCanvas.js # Canvas 초기화 + 이벤트 바인딩
└── assets/
├── tileset.png # 사무실 타일셋 (16x16 or 32x32)
├── agents.png # 에이전트 스프라이트시트
└── office-map.json # 타일맵 데이터
```
### WebSocket 프로토콜
**서버 → 클라이언트:**
```json
{"type": "agent_state", "agent": "stock", "state": "working", "detail": "뉴스 수집 중..."}
{"type": "agent_state", "agent": "music", "state": "waiting", "detail": "프롬프트 승인 대기", "task_id": "abc-123"}
{"type": "task_complete", "agent": "stock", "task_id": "...", "result": {"summary": "..."}}
{"type": "agent_move", "agent": "stock", "target": "break_room"}
```
**클라이언트 → 서버:**
```json
{"type": "command", "agent": "music", "action": "compose", "params": {"prompt": "...", "style": "..."}}
{"type": "approval", "agent": "music", "task_id": "abc-123", "approved": true}
{"type": "query", "agent": "stock", "action": "status"}
```
### ChatPanel 기능
- 에이전트별 채팅 히스토리 표시
- 텍스트 입력 + 빠른 액션 버튼
- 승인 대기 중인 작업 강조 표시
- 최근 작업 결과 인라인 표시
---
## 7. 인프라 변경
### Docker Compose 추가
```yaml
agent-office:
build: ./agent-office
container_name: agent-office
ports:
- "18900:8000"
volumes:
- ${RUNTIME_PATH}/data:/app/data
environment:
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID}
- TELEGRAM_WEBHOOK_URL=${TELEGRAM_WEBHOOK_URL}
- STOCK_LAB_URL=http://stock-lab:8000
- MUSIC_LAB_URL=http://music-lab:8000
depends_on:
- stock-lab
- music-lab
restart: unless-stopped
```
### Nginx 라우팅 추가
```nginx
location /api/agent-office/ {
proxy_pass http://agent-office:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; # WebSocket 지원
}
```
### 라우팅 (React Router)
```javascript
// routes.jsx
{ path: 'agent-office', lazy: () => import('./pages/agent-office/AgentOffice') }
```
Lab 페이지(EffectLab.jsx)의 LAB_ITEMS에 Agent Office 항목 추가.
---
## 8. 향후 확장 (Phase 2+)
| 단계 | 내용 |
|------|------|
| Phase 2 | LottoAgent, BlogAgent, RealestateAgent 추가 |
| Phase 3 | Claude AI Agent (자연어 복합 지시) |
| Phase 4 | 방/층 확장 (부서별 공간 분리) |
| Phase 5 | 에이전트 간 협업 시각화 (회의 테이블에서 토론) |
| Phase 6 | 에이전트 커스텀 (이름, 외형, 성격 설정) |
---
## 9. 기술 스택 요약
| 레이어 | 기술 |
|--------|------|
| 사무실 렌더링 | HTML5 Canvas 2D (커스텀 엔진) |
| 프론트엔드 | React 18 + Vite |
| 실시간 통신 | WebSocket (FastAPI) |
| 백엔드 | FastAPI (Python 3.12) |
| DB | SQLite (agent_office.db) |
| 스케줄러 | APScheduler |
| 메시징 | Telegram Bot API (Webhook) |
| 서비스 연동 | HTTP Proxy (기존 서비스 API 호출) |

View File

@@ -0,0 +1,350 @@
# Lotto AI 큐레이터 — 설계 문서
> 작성일: 2026-04-15
> 목표: 난잡한 lotto 랩을 **주간 AI 브리핑**을 축으로 재정리. 매주 월요일 아침 자동으로 "이번 주 5세트 + 내러티브 리포트"를 생성해 구매 의사결정 참고.
---
## 1. 배경
- 현재 lotto 랩은 분석(5가지)·추천(통계/히트맵/메타)·시뮬레이션·전략진화 등 기능이 풍부하지만 출력이 분산되어 "결국 뭘 사야 하지"가 한눈에 들어오지 않음.
- `docs/lotto-premium-roadmap.md` Phase 1 방향(신뢰 기반 + 주간 리포트)을 AI 활용으로 압축 실행.
## 2. 핵심 결정사항
| 항목 | 결정 |
|------|------|
| AI 역할 | **큐레이터(Curator)** — 숫자 생성 X, 기존 엔진 후보 중 5세트 선별 + 내러티브 작성 |
| 브리핑 형식 | **A+B 조합** — 리포트형 내러티브 + 최종 5세트 카드 |
| 트리거 | **매주 월요일 07:00 자동 생성** (웹 UI 전용, 텔레그램 미전송) |
| 로직 위치 | **agent-office `lotto` 에이전트** (lotto-backend는 엔진·저장소 역할만) |
| 모델 | `claude-sonnet-4-5` (주 1회 호출, 품질 우선) — 환경변수 `LOTTO_CURATOR_MODEL` |
| 사용량 노출 | 브리핑 카드 + 큐레이터 사용량 API(월간 집계) |
---
## 3. 아키텍처
```
┌──────────────────────────────────────────────────────────────┐
│ 월요일 07:00 APScheduler (agent-office) │
│ → lotto 에이전트 curate_weekly 태스크 │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 1. GET /api/lotto/curator/candidates?n=20 │ │
│ │ 2. GET /api/lotto/curator/context │ │
│ │ 3. Claude Sonnet 4.5 호출 (strict JSON out) │ │
│ │ 4. 스키마·번호 검증 + 1회 재시도 │ │
│ │ 5. POST /api/lotto/briefing (저장) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 사용자는 웹에서: │
│ GET /api/lotto/briefing/latest (최신 표시) │
│ POST /api/agent-office/command {agent:"lotto", …} (수동) │
└──────────────────────────────────────────────────────────────┘
```
서비스 경계: **lotto-backend = 데이터·엔진 / agent-office = AI 판단**.
---
## 4. Backend (lotto-backend)
### 4.1 신규 API
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/lotto/curator/candidates` | 큐레이터용 후보 N세트 + 세트별 피처 |
| GET | `/api/lotto/curator/context` | 주간 맥락(핫/콜드·직전 회차 분석·내 최근 성과) |
| POST | `/api/lotto/briefing` | 큐레이터 결과 저장 |
| GET | `/api/lotto/briefing/latest` | 최신 브리핑 |
| GET | `/api/lotto/briefing/{draw_no}` | 특정 회차 브리핑 |
| GET | `/api/lotto/briefing?limit=10` | 브리핑 이력 |
| GET | `/api/lotto/curator/usage?days=30` | 큐레이터 토큰·비용 집계 |
### 4.2 `GET /curator/candidates` 응답 구조
```json
{
"draw_no": 1180,
"generated_at": "2026-04-13T07:00:00Z",
"candidates": [
{
"numbers": [3, 14, 22, 29, 35, 41],
"source": "simulation" | "meta" | "heatmap" | "statistics",
"features": {
"odd_count": 3,
"even_count": 3,
"low_count": 3, // 1~22
"high_count": 3, // 23~45
"range_distribution": [1,1,1,1,1,1], // 1-10,11-20,...,41-45
"has_consecutive": true,
"hot_number_count": 1, // context.hot_numbers 교집합
"cold_number_count": 2, // context.cold_numbers 교집합
"sum": 144,
"historical_match_avg": 2.3 // 이 세트가 과거 실제 회차와 평균 몇 개 일치
}
}
]
}
```
중복 제거: 6숫자 정렬 튜플 기준 set 해시. 각 세트의 `source`는 가장 먼저 포함시킨 엔진.
### 4.3 `GET /curator/context` 응답 구조
```json
{
"draw_no": 1180,
"hot_numbers": [3, 17, 28], // 최근 10회 과출현 top
"cold_numbers": [7, 22, 41], // 최근 30회 미출현 top
"last_draw_summary": "1179회: 7, 12, 18, 24, 31, 40 (홀4짝2, 저4고2)",
"recent_analysis": {
"avg_sum": 138,
"avg_odd_count": 2.8
},
"my_recent_performance": [
{ "draw_no": 1177, "purchased_sets": 5, "best_match": 3 },
{ "draw_no": 1178, "purchased_sets": 5, "best_match": 2 },
{ "draw_no": 1179, "purchased_sets": 5, "best_match": 4 }
]
}
```
### 4.4 신규 테이블 `lotto_briefings`
```sql
CREATE TABLE lotto_briefings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
draw_no INTEGER UNIQUE NOT NULL,
picks TEXT NOT NULL, -- JSON: 5세트 + reason + risk_tag
narrative TEXT NOT NULL, -- JSON: headline/summary_3lines/hot_cold/warnings
confidence INTEGER NOT NULL, -- 0~100
model TEXT NOT NULL,
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,
source TEXT NOT NULL DEFAULT 'auto', -- 'auto' | 'manual'
generated_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
);
CREATE INDEX idx_briefings_draw ON lotto_briefings(draw_no DESC);
```
### 4.5 파일 구조 정리
`backend/app/main.py` 933줄 → 라우터 분리:
- `backend/app/routers/briefing.py` — briefing CRUD + curator usage
- `backend/app/routers/curator.py` — candidates / context
- `backend/app/curator_helpers.py` — 후보 중복 제거, 피처 계산, 맥락 추출
기존 `main.py`는 라우터 등록과 앱 조립만 담당(목표 ~300줄).
---
## 5. agent-office `lotto` 에이전트
### 5.1 파일 구조
```
agent-office/app/
agents/lotto.py # LottoAgent (BaseAgent 상속)
curator/
__init__.py
pipeline.py # curate_weekly() 메인 플로우
prompt.py # system prompt + 출력 스키마 정의
schema.py # pydantic 응답 모델 + 검증
service.py # lotto-backend 호출 래퍼 (httpx)
```
`service_proxy.py``lotto_candidates()`, `lotto_context()`, `lotto_save_briefing()` 메서드 추가.
### 5.2 태스크 타입
- `curate_weekly` — 자동/수동 공통. 파라미터 없음(draw_no 자동 계산).
### 5.3 큐레이터 규칙 (system prompt 요지)
```
당신은 로또 번호 큐레이터입니다. 후보 20세트 중 5세트를 다음 규칙으로 선별합니다.
선별 규칙:
- 5세트의 리스크 분포: 안정 2 · 균형 2 · 공격 1 (유연 ±1)
- 홀짝 비율, 저/고 구간, 연속번호 포함 여부가 세트끼리 겹치지 않도록 다양성 확보
- hot_number_count와 cold_number_count 모두 0인 세트는 최소 1개
- 후보 외 번호 사용 절대 금지
- 각 세트 reason은 40자 이내 한 줄 (해당 세트 피처와 context 값만 근거)
출력은 반드시 아래 JSON 스키마로만:
{
"picks": [
{"numbers":[...], "risk_tag":"안정"|"균형"|"공격", "reason":"..."}
],
"narrative": {
"headline": "...",
"summary_3lines": ["...","...","..."],
"hot_cold_comment": "...",
"warnings": "..." // 없으면 빈 문자열
},
"confidence": 0-100
}
```
### 5.4 파이프라인 의사코드
```python
async def curate_weekly(draw_no: int) -> dict:
candidates = await service.lotto_candidates(n=20)
context = await service.lotto_context()
prompt = build_prompt(candidates, context, draw_no)
result, usage = await call_claude(prompt, model=LOTTO_CURATOR_MODEL)
parsed = validate(result) # 실패 시 1회 재시도
if parsed is None:
raise CuratorError("schema validation failed after retry")
await service.lotto_save_briefing({
"draw_no": draw_no,
"picks": parsed.picks,
"narrative": parsed.narrative,
"confidence": parsed.confidence,
"model": LOTTO_CURATOR_MODEL,
"tokens_input": usage.input,
"tokens_output": usage.output,
"cache_read": usage.cache_read,
"cache_write": usage.cache_write,
"latency_ms": usage.latency_ms,
"source": "auto" | "manual",
})
return {"ok": True, "draw_no": draw_no, ...}
```
### 5.5 검증 로직 (`schema.py`)
- pydantic 모델로 형식 검증
- 번호 제약: 각 세트 정확히 6개 · 중복 없음 · 1~45 범위
- 세트 수: 정확히 5
- 번호가 **candidates 내에 존재하는 조합인지** 대조 (환각 차단)
- risk_tag 분포가 규칙에서 ±1 이상 벗어나면 경고 로그(차단은 안 함)
- 실패 시 errors 리스트 담아 1회 재시도(프롬프트에 에러 피드백 포함)
### 5.6 스케줄러
`scheduler.py`에 추가:
```python
scheduler.add_job(_run_lotto_curate, "cron", day_of_week="mon", hour=7, minute=0, id="lotto_curate")
```
### 5.7 상태 표시
agent-office 메인 UI에 lotto 에이전트 카드가 추가되어 `idle` / `working` / `error` 상태 실시간 표시(기존 BaseAgent 패턴).
---
## 6. Frontend (web-ui)
### 6.1 새 탭 구조
```
Lotto
├─ 🗓 이번 주 브리핑 (기본)
├─ 📊 분석·통계
└─ 💰 구매·성과
```
`Functions.jsx` 460줄 → 탭 라우터 ~80줄로 축소. 각 탭은 `pages/lotto/tabs/BriefingTab.jsx`, `AnalysisTab.jsx`, `PurchaseTab.jsx`.
### 6.2 신규 컴포넌트 (`components/briefing/`)
- **BriefingHeader.jsx** — 회차 번호, 생성 시각, 신뢰도 바, 재생성 버튼, **사용 토큰 칩**(`42K in · 1.2K out · $0.18`)
- **BriefingSummary.jsx** — 3줄 요약 + 핫/콜드 블록 + 주의사항
- **PickSetCard.jsx** — 6볼 + risk 뱃지(🟢안정/🟡균형/🔴공격) + reason + "구매 기록" CTA
- **BriefingEmpty.jsx** — 브리핑 없을 때 placeholder + "지금 생성" 버튼
- **CuratorUsageFooter.jsx** — 페이지 하단 mini 카드. 최근 30일 호출 수·토큰·추정 비용·캐시 히트율
### 6.3 훅
- **useBriefing.js**
- `GET /api/lotto/briefing/latest`
- `regenerate()`: `POST /api/agent-office/command {agent:"lotto", action:"curate_now"}` → 3초 간격 최대 40회(=2분) 폴링으로 신규 briefing 확인
- 로딩/에러 상태 분리, 월요일 07:00 이후인데 브리핑 없으면 빈 상태 CTA
- **useCuratorUsage.js** — `GET /api/lotto/curator/usage?days=30`
### 6.4 기존 컴포넌트 처리
| 컴포넌트 | 조치 |
|---------|------|
| `FrequencyChart`, `MetricBlock`, `PersonalAnalysisPanel`, `ReportPanel` | 분석 탭으로 이동 |
| `PurchasePanel`, `PerformanceBanner` | 구매 탭으로 이동 |
| `CombinedRecommendPanel`, `ConfidenceRing` | 제거 후보 — 정리 패스에서 실제 참조 없으면 삭제 |
### 6.5 토큰·비용 노출 정책
- **브리핑 카드 헤더**: 이번 브리핑 1건의 in/out 토큰 + 추정 비용 (Sonnet 4.5 단가 기준 계산 — 상수로 프론트에 보유, `$3/$15 per 1M tokens`)
- **페이지 하단 푸터**: 최근 30일 누적 — 호출 수, 총 토큰, 추정 비용, 캐시 히트율
- **Agent Office 사이드**: 기존 `GET /api/agent-office/agents/lotto/token-usage` 자동 상속
### 6.6 모바일
브리핑 탭 세로 스택 기본. PickSetCard는 한 행 1카드 + 6볼 flex-wrap. 헤더 토큰 칩은 768px 이하에서 축약 표시(`$0.18`만).
---
## 7. 환경변수
| 변수 | 기본값 | 위치 |
|------|--------|------|
| `ANTHROPIC_API_KEY` | (없음) | agent-office (이미 존재) |
| `LOTTO_CURATOR_MODEL` | `claude-sonnet-4-5` | agent-office |
| `LOTTO_BACKEND_URL` | `http://lotto-backend:8000` | agent-office (service_proxy) |
---
## 8. 에러·폴백
| 상황 | 처리 |
|------|------|
| lotto-backend 후보 API 실패 | 에이전트 상태 `error` + 로그 + 슬랙/알림 없음(주 1회라 로그 충분) |
| Claude 호출 실패 | 1회 재시도 후 실패 시 error 저장, 기존 최신 브리핑 유지 |
| JSON 스키마 검증 실패 | 피드백 포함 1회 재시도 → 실패 시 error |
| 월요일 생성 자체가 누락 | 사용자가 웹에서 수동 재생성 버튼으로 보완 가능 |
---
## 9. 구현 순서
1. **Backend**: curator 엔드포인트 + briefing CRUD + 라우터 분리
2. **Agent-office**: lotto 에이전트 + curator pipeline + 월요일 스케줄러
3. **Frontend**: BriefingTab + 컴포넌트 + 훅 + 탭 재배치
4. **미사용 정리 패스**: 아래 "10. 정리 대상" 후보를 실제 참조 grep → 제거
---
## 10. 정리 대상 (최종 패스에서 검증 후 제거)
### Frontend
- `components/CombinedRecommendPanel.jsx`
- `components/ConfidenceRing.jsx`
- `Functions.jsx` 내 인라인 레이아웃 로직 (탭 분리 후 잔재)
### Backend
- `strategy_evolver.py` 중 실제 사용되지 않는 EMA 서브 함수
- 주간 리포트 관련 `weekly_reports` 테이블 — 브리핑이 대체하므로 드롭 후보
- `best_picks` 교체 로직 중 큐레이터 전환 후 사용 안 되는 경로
### DB 드롭 후보
- `weekly_reports` (브리핑이 대체)
- `simulation_candidates` (best_picks만 있으면 충분한지 사용처 grep 후 결정)
정리 패스는 **실제 import/참조 grep → 없으면 제거 → 테스트 → 커밋** 순서로 별도 커밋 분리.
---
## 11. 성공 기준
- 월요일 07:00 브리핑이 자동 생성되고, 웹 페이지 진입 1초 안에 5세트 + 3줄 요약이 보인다.
- 큐레이터는 candidates 내 세트만 선택한다(환각 0건).
- 브리핑 카드에 이번 건 토큰/비용, 페이지 하단에 30일 누적 사용량이 표시된다.
- 기존 난잡한 패널이 분석/구매 탭으로 정돈되고 브리핑 탭이 기본 진입점이다.
- 미사용 테이블·컴포넌트가 최종 정리 패스에서 제거된다.

View File

@@ -0,0 +1,360 @@
# 반응형 웹 UI/UX 전면 개선 설계
> 모바일에서 UI 짤림 현상 해결 + 풀 모바일 경험 적용
> 작성일: 2026-04-23
> 리뷰 반영: 2026-04-23 (라우트 경로 수정, breakpoint 예외 명시, 구현 복잡도 보완)
---
## 1. 목표
- 전체 15개 뷰(12개 라우트 + 3개 서브라우트)에서 모바일 UI 짤림 현상 해결
- 현재 다크 네온 사이버펑크 디자인 톤 유지
- 모바일 전용 UX 패턴 추가 (바텀 네비게이션, 스와이프, 풀다운 리프레시, FAB, 바텀시트)
- 기능적 손실 없이 반응형 적용
**대상 뷰 목록 (routes.jsx 기준):**
| # | 라우트 | 컴포넌트 | 비고 |
|---|--------|---------|------|
| 1 | `/` | Home | |
| 2 | `/lotto` | Lotto | 3탭 (Briefing/Analysis/Purchase) |
| 3 | `/stock` | Stock | |
| 4 | `/stock/trade` | StockTrade | 서브라우트 |
| 5 | `/travel` | Travel | |
| 6 | `/blog` | Blog | |
| 7 | `/blog-lab` | BlogMarketing | |
| 8 | `/realestate` | Subscription | |
| 9 | `/music` | MusicStudio | |
| 10 | `/todo` | Todo | |
| 11 | `/agent-office` | AgentOffice | |
| 12 | `/lab` | EffectLab | |
| 13 | `/lab/sword-stream` | SwordStream | 서브라우트 |
| 14 | `/lab/day-calc` | DayCalc | 서브라우트 |
> Note: `RealEstate.jsx` (`/realestate/property`)는 routes.jsx에 미등록 상태. 반응형 스코프에서 제외.
---
## 2. 접근 방식
**글로벌 모바일 시스템 구축 → 주요 페이지 적용 → 전체 페이지 확장**
1. 공통 모바일 인프라(컴포넌트, breakpoint, 앱 셸) 구축
2. 주요 4개 페이지 (홈, 로또, 주식, 여행) 우선 적용
3. 나머지 페이지 확장 적용
---
## 3. 글로벌 모바일 인프라
### 3-1. Breakpoint 시스템 통일
현재 53개 미디어 쿼리에서 다양한 값이 혼재. 4단계로 통일:
| 이름 | 값 | 용도 |
|------|-----|------|
| sm | 480px | 소형 폰 |
| md | 768px | 태블릿/대형 폰 (주요 분기점) |
| lg | 1024px | 소형 데스크톱 |
| xl | 1280px | 대형 데스크톱 |
기존 미디어 쿼리의 비표준 값(640px, 900px, 960px, 1100px 등)은 기능 손실 없이 가장 가까운 표준 breakpoint로 정리한다.
**허용 예외 (이동 시 시각적 회귀 발생):**
| 기존 값 | 파일 | 사유 |
|---------|------|------|
| 420px | Stock.css (4곳) | 소형 폰 전용 패딩/라벨 축소, 480px로 이동 시 중간 기기에서 불필요한 축소 |
| 520px | Stock.css (1곳) | 지표 카드 특수 레이아웃 |
| 700px | Stock.css (1곳) | AI 코치 설정 그리드, 768px로 이동 시 태블릿에서 조기 축소 |
위 값들은 해당 페이지 CSS에서 기존 값을 유지한다.
### 3-2. 바텀 네비게이션 바 (`BottomNav`)
- 768px 이하에서 사이드바 대신 표시
- 주요 5개 메뉴 아이콘 + "더보기" 메뉴 (나머지 페이지)
- 현재 페이지 활성 표시 — 네온 시안 글로우 유지
- 사이드바는 모바일에서 완전히 숨김 (기존 햄버거→슬라이드 방식 제거)
- 높이: 56~64px
- `env(safe-area-inset-bottom)` 대응 (노치/홈 인디케이터 기기)
- `index.html``viewport-fit=cover` 추가 필요: `<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">`
- 더보기 메뉴: 탭 시 위로 펼쳐지는 오버레이 패널
**사이드바→바텀네비 마이그레이션 상세:**
- `Navbar.jsx`: 768px 이하에서 사이드바 렌더링 제거, `sidebar-toggle` 버튼 제거
- `Navbar.css`: `.sidebar` transform/transition 미디어 쿼리 제거, `.sidebar__overlay` 제거
- `Navbar.jsx` useEffect: `body.overflow = 'hidden'` 토글 로직 정리
- `App.jsx`에서 `BottomNav` 컴포넌트 조건부 렌더링 (`useIsMobile()` 기반)
**더보기 메뉴 내용 (나머지 네비게이션 항목):**
| 순서 | 아이콘 | 라벨 | 경로 |
|------|--------|------|------|
| 1 | 음악 | 뮤직 | `/music` |
| 2 | 로봇 | 에이전트 | `/agent-office` |
| 3 | 블로그 | 블로그 | `/blog` |
| 4 | 마케팅 | 블로그랩 | `/blog-lab` |
| 5 | 건물 | 청약 | `/realestate` |
| 6 | 체크 | TODO | `/todo` |
| 7 | 실험 | 이펙트랩 | `/lab` |
**기본 5개 메뉴 구성:**
| 순서 | 아이콘 | 라벨 | 경로 |
|------|--------|------|------|
| 1 | 홈 | 홈 | `/` |
| 2 | 클로버 | 로또 | `/lotto` |
| 3 | 차트 | 주식 | `/stock` |
| 4 | 카메라 | 여행 | `/travel` |
| 5 | 더보기 | 메뉴 | 오버레이 |
### 3-3. 공통 모바일 컴포넌트
| 컴포넌트 | 파일 | 역할 |
|---------|------|------|
| `BottomNav` | `src/components/BottomNav.jsx` | 하단 고정 네비게이션 |
| `PullToRefresh` | `src/components/PullToRefresh.jsx` | 터치 풀다운 새로고침 래퍼 |
| `SwipeableView` | `src/components/SwipeableView.jsx` | 좌우 스와이프 탭/뷰 전환 |
| `FAB` | `src/components/FAB.jsx` | 플로팅 액션 버튼 (바텀 네비 위 배치) |
| `MobileSheet` | `src/components/MobileSheet.jsx` | 바텀시트 모달 (드래그 핸들, 스냅 포인트) |
**공통 훅 (신규 `src/hooks/` 디렉토리 생성):**
> 기존 훅은 페이지별 디렉토리에 colocate (`src/pages/lotto/hooks/` 등).
> 모바일 인프라 훅은 여러 페이지에서 공유하므로 `src/hooks/`에 배치한다.
| 훅 | 파일 | 역할 |
|----|------|------|
| `useIsMobile` | `src/hooks/useIsMobile.js` | 768px 이하 감지 (matchMedia) |
| `useSwipe` | `src/hooks/useSwipe.js` | 터치 스와이프 방향·거리 감지 |
**경량 라이브러리 활용:**
- `react-swipeable` (~3KB gzipped): SwipeableView/useSwipe 기반으로 활용 — 터치 velocity, threshold snap, 방향 판별을 직접 구현하지 않음
- PullToRefresh: 터치 이벤트 직접 구현하되, iOS Safari rubber-banding 및 `overscroll-behavior: contain` 대응 필수
- MobileSheet: CSS `transform` + `touch-action: none`으로 구현, 스냅 포인트 2단계 (50%, 90%)
### 3-4. 앱 셸 레이아웃 변경
```
데스크톱: [사이드바 240px] [콘텐츠]
모바일: [탑바 56px]
[콘텐츠 (padding-bottom: 바텀네비 높이)]
[바텀 네비 56-64px]
```
- 콘텐츠 영역에 `padding-bottom` 추가 (바텀 네비 겹침 방지)
- 탑바: 현재 구조 유지, 페이지 타이틀 + 액션 버튼 영역
- `body` overflow: 모바일에서 auto (현재와 동일)
---
## 4. 주요 페이지별 모바일 설계
### 4-1. 홈 (Home) — `/`
| 영역 | 데스크톱 | 모바일 (≤768px) |
|------|---------|-----------------|
| 히어로 | 2컬럼 그리드 | 1컬럼 스택, 타이틀 축소 |
| 네비 카드 그리드 | auto-fill minmax(180px) | 2컬럼 고정, 카드 높이 축소 |
| TODO 보드 | 3컬럼 칸반 | 스와이프 탭 (Todo/진행중/완료) |
| 블로그 포스트 | 카드 그리드 | 1컬럼 리스트 |
| 프로필 섹션 | 사이드 카드 | 하단 접이식 패널 |
- 풀다운 리프레시: 블로그 포스트 갱신
- FAB: 없음 (네비게이션 허브)
### 4-2. 로또 (Lotto) — `/lotto`
| 영역 | 데스크톱 | 모바일 |
|------|---------|--------|
| 3탭 구조 | 상단 탭바 | 스와이프 탭 전환 |
| 브리핑 탭 | 카드 레이아웃 | 1컬럼, 볼 크기 36→32px |
| 분석 탭 | 그리드 카드 | 1컬럼 스택 |
| 구매 이력 테이블 | 6컬럼 그리드 | 가로 스크롤 테이블 + 행 터치 바텀시트 |
| 번호 추천 카드 | 다중 그리드 | 1컬럼, 볼 간격 조정 |
| 전략 차트 | 넓은 차트 | 가로스크롤 또는 축소 |
- FAB: "추천받기" (빠른 번호 추천)
- 풀다운 리프레시: 브리핑/분석 데이터 갱신
### 4-3. 주식 (Stock / StockTrade) — `/stock`, `/stock/trade`
**Stock (뉴스/지표)**
| 영역 | 데스크톱 | 모바일 |
|------|---------|--------|
| 헤더 | 2컬럼 | 1컬럼 스택 |
| 뉴스 그리드 | auto-fit minmax(260px) | 1컬럼 카드 리스트 |
| 필터 | 가로 나열 | 가로 스크롤 칩 바 |
| 지표 카드 | 그리드 | 가로 스크롤 카드 캐러셀 |
**StockTrade (매매)**
| 영역 | 데스크톱 | 모바일 |
|------|---------|--------|
| 포트폴리오 테이블 | 넓은 테이블 | 카드형 리스트 (종목별 카드) |
| 매도 이력 | 테이블 | 가로 스크롤 + 행 터치 바텀시트 |
| 자산 차트 | 넓은 recharts | 풀 너비, 축 라벨 축소 |
| 예수금 섹션 | 인라인 | 접이식 카드 |
- FAB: "종목 추가" (Stock), "매도 기록" (StockTrade)
- 풀다운 리프레시: 뉴스/포트폴리오 갱신
### 4-4. 여행 (Travel) — `/travel`
| 영역 | 데스크톱 | 모바일 |
|------|---------|--------|
| 지역 선택 | Leaflet 지도 | 높이 50vh→35vh, 핀치 줌 |
| 사진 그리드 | 다중 컬럼 | 2컬럼 → 1컬럼 (≤480px) |
| 사진 상세 | 모달 | 풀스크린 뷰어 + 스와이프 넘기기 |
| 지역 필터 | 드롭다운 | 바텀시트 지역 선택 |
- 풀다운 리프레시: 사진 목록 갱신
- FAB: 없음
---
## 5. 나머지 페이지 모바일 설계
### 5-1. 블로그 (Blog) — `/blog`
| 영역 | 모바일 변경 |
|------|------------|
| 글 목록 | 1컬럼 리스트형 |
| 글 상세 | 풀 너비, 폰트 크기 조정 |
| 태그 필터 | 가로 스크롤 칩 바 |
| 작성/수정 폼 | 풀 너비, 툴바 축소 |
- FAB: "글 쓰기"
- 풀다운 리프레시: 글 목록 갱신
### 5-2. 블로그 마케팅 (BlogMarketing) — `/blog-lab`
| 영역 | 모바일 변경 |
|------|------------|
| 대시보드 지표 | 2컬럼 → 1컬럼 (≤480px) |
| 파이프라인 테이블 | 카드형 리스트 (상태 배지) |
| 키워드 분석 | 접이식 아코디언 |
| 수익 내역 | 가로 스크롤 테이블 |
- FAB: "키워드 분석"
- 풀다운 리프레시: 대시보드 갱신
### 5-3. 부동산 청약 (Subscription) — `/realestate`
| 영역 | 모바일 변경 |
|------|------------|
| 공고 목록 | 1컬럼 카드 리스트 |
| 필터 | 바텀시트 필터 패널 |
| 공고 상세 | 바텀시트 상세보기 |
| 매칭 결과 | 1컬럼, 점수 강조 |
| 대시보드 | 2컬럼 그리드 |
- FAB: "공고 등록"
- 풀다운 리프레시: 공고/매칭 갱신
### 5-4. 뮤직 스튜디오 (MusicStudio) — `/music`
| 영역 | 모바일 변경 |
|------|------------|
| 헤더 | 1컬럼, 타이틀 클램프 축소 |
| 생성 폼 | 풀 너비 스택 |
| 라이브러리 | 1컬럼 리스트 (앨범아트 + 제목) |
| 플레이어 | 미니 플레이어 바텀 고정 (높이 56px, 바텀 네비 위 = bottom: 64px) |
| 가사 에디터 | 풀 너비 |
| 레이더 위젯 | 중앙 정렬 |
- FAB: "음악 생성"
- 풀다운 리프레시: 라이브러리 갱신
- 미니 플레이어 표시 시 콘텐츠 padding-bottom: 바텀네비(64px) + 미니플레이어(56px) = 120px
### 5-5. TODO — `/todo`
| 영역 | 모바일 변경 |
|------|------------|
| 칸반 보드 | 스와이프 탭 (Todo/진행중/완료) |
| 할일 카드 | 스와이프로 상태 변경 |
| 입력 폼 | FAB → 바텀시트 입력 폼 |
- FAB: "할일 추가"
### 5-6. 에이전트 오피스 (AgentOffice) — `/agent-office`
| 영역 | 모바일 변경 |
|------|------------|
| 캔버스 오피스 | 풀스크린 캔버스, 핀치 줌/패닝 |
| 에이전트 패널 | 바텀시트 에이전트 상세 |
| 작업 로그 | 바텀시트 로그 뷰 |
| 명령 입력 | 하단 입력 바 (채팅 UX) |
| WebSocket 상태 | 탑바에 연결 상태 아이콘 |
### 5-7. 이펙트 랩 — `/lab`, `/lab/day-calc`, `/lab/sword-stream`
| 페이지 | 모바일 변경 |
|--------|------------|
| EffectLab 허브 | 카드 그리드 → 1컬럼 리스트 |
| DayCalc | 풀 너비 스택, 네이티브 날짜 피커 |
| SwordStream | 풀스크린 캔버스, 터치 인터랙션 유지, 오버레이 축소 |
---
## 6. 터치 타겟 가이드라인
- 모든 터치 타겟: 최소 44×44px (Apple HIG 기준)
- 버튼 간 간격: 최소 8px
- FAB 크기: 56×56px
- 바텀 네비 아이템: 최소 48×48px 터치 영역
---
## 7. 성능 고려사항
- 모바일에서 글로우/그라디언트 효과: box-shadow 개수 줄이기 (3중→1중)
- `background-attachment: fixed` → 모바일에서 `scroll` (현재 적용됨, 유지)
- 이미지: `loading="lazy"` 속성 확인
- 스와이프/터치 이벤트: passive listener 사용
- 바텀시트 애니메이션: `transform` + `will-change` 사용 (layout thrashing 방지)
- 신규 애니메이션(스와이프, 바텀시트, 풀다운)은 `prefers-reduced-motion: reduce` 쿼리 존중 — Travel.css, MusicStudio.css 기존 패턴과 통일
### 주의: Stock.css / StockTrade.jsx 커플링
`StockTrade.jsx``Stock.css`의 스타일을 공유한다. Stock.css의 반응형 수정은 StockTrade에도 영향을 미치므로, 반드시 두 페이지를 함께 검증해야 한다.
---
## 8. 구현 순서
### Phase 1: 글로벌 인프라
**Phase 1a: Breakpoint 정리 (기존 CSS만 수정, 신규 코드 없음)**
1. Breakpoint 시스템 통일 — 각 CSS 파일의 비표준 미디어 쿼리를 표준 값으로 정리
2. `index.html``viewport-fit=cover` 추가
3. 회귀 테스트: 정리 후 각 페이지 데스크톱/모바일 확인
**Phase 1b: 공통 컴포넌트 & 앱 셸**
4. `react-swipeable` 패키지 설치
5. `src/hooks/` 디렉토리 생성 + `useIsMobile`, `useSwipe` 훅 구현
6. `BottomNav` 컴포넌트 구현 + 사이드바 모바일 제거 마이그레이션 (Navbar.jsx/css 수정)
7. `PullToRefresh`, `SwipeableView`, `FAB`, `MobileSheet` 컴포넌트 구현
8. 앱 셸 레이아웃 수정 (App.jsx, App.css)
### Phase 2: 주요 페이지 적용
9. 홈 페이지 반응형 개선
10. 로또 페이지 반응형 개선
11. 주식 페이지 (Stock + StockTrade 함께 검증) 반응형 개선
12. 여행 페이지 반응형 개선
### Phase 3: 나머지 페이지 확장
13. 블로그 (`/blog`) + 블로그 마케팅 (`/blog-lab`)
14. 부동산 청약 (`/realestate`)
15. 뮤직 스튜디오 (`/music`)
16. TODO (`/todo`)
17. 에이전트 오피스 (`/agent-office`)
18. 이펙트 랩 (`/lab` + `/lab/day-calc` + `/lab/sword-stream`)
### Phase 4: 검증
19. 전체 뷰 모바일 UI 검증 — 대상 뷰포트: 360px (Galaxy S), 390px (iPhone 14), 768px (iPad), 1024px (데스크톱)
20. `prefers-reduced-motion` 동작 확인
21. 터치 타겟 크기 검증 (44×44px 최소)

View File

@@ -0,0 +1,313 @@
# Travel Gallery Redesign — Design Spec
## Goal
Travel 여행 기록 갤러리를 앨범 기반 진입 + Masonry 그리드 + HERO 확대 라이트박스로 리디자인한다. 모놀리식 1,024줄 컴포넌트를 7-8개 집중된 파일로 분리하고, 시네마틱 여행 감성을 강화한다.
## Scope
- **포함**: 프론트엔드 리디자인 (컴포넌트 분리 + 새 UX/UI)
- **포함**: 동영상 탭 UI 셸 (플레이스홀더)
- **제외**: 백엔드 동영상 API (별도 후속 스펙)
- **제외**: 핀치 줌 (복잡도 대비 효과 낮음)
## Architecture
점진적 리팩토링 — 기존 API 호출/캐싱/페이지네이션 로직을 `useTravelData` 훅으로 추출하여 재활용하고, UI 레이어를 새로 구성한다. 라우팅 변경 없이 React 상태 기반으로 앨범 진입/이탈을 관리한다.
## Tech Stack
- React 18 (기존)
- Leaflet + react-leaflet (기존, 미니맵으로 축소)
- react-swipeable (기존, 라이트박스 스와이프)
- SwipeableView 컴포넌트 (기존, 사진/영상 탭)
- CSS columns (Masonry 레이아웃)
- IntersectionObserver (무한스크롤 + 스크롤 리빌)
- Web Animations API / CSS transitions (shared element transition)
---
## 1. Component Structure & File Layout
```
src/pages/travel/
├── Travel.jsx # 메인 컨테이너 (미니맵 + 앨범 카드 리스트)
├── Travel.css # 전체 레이아웃 + CSS 변수
├── AlbumCard.jsx # 여행지 앨범 카드
├── AlbumCard.css
├── AlbumDetail.jsx # 앨범 상세 (탭 + Masonry)
├── AlbumDetail.css
├── MasonryGrid.jsx # Masonry 레이아웃 + 무한스크롤
├── MasonryGrid.css
├── HeroLightbox.jsx # HERO 확대 전환 라이트박스
├── HeroLightbox.css
├── MiniMap.jsx # Leaflet 미니맵
├── MiniMap.css
├── VideoTab.jsx # 영상 탭 UI 셸
├── VideoTab.css
└── useTravelData.js # API 호출 + 캐싱 + 페이지네이션 훅
```
### Responsibilities
| 파일 | 책임 |
|------|------|
| `Travel.jsx` | 페이지 레이아웃, 지역 필터 상태, 앨범 선택 상태 관리 |
| `useTravelData.js` | API fetch, 10분 TTL 캐시, 앨범별 그룹핑, 페이지네이션 |
| `MiniMap.jsx` | Leaflet 지도 렌더링, GeoJSON 폴리곤, 지역 클릭 이벤트 발행 |
| `AlbumCard.jsx` | 대표 사진 + 앨범명 + 사진 수 뱃지, 호버 효과 |
| `AlbumDetail.jsx` | 앨범 오버레이, 진입/이탈 애니메이션, 사진/영상 탭 전환 |
| `MasonryGrid.jsx` | CSS columns Masonry, IntersectionObserver 무한스크롤 + 스크롤 리빌 |
| `HeroLightbox.jsx` | shared element transition, 좌우 스와이프, 썸네일 스트립 |
| `VideoTab.jsx` | "영상 기능 준비 중" 플레이스홀더 |
### Page Flow
```
Travel.jsx (메인)
├── MiniMap (상단, 접기/펼치기 가능)
│ └── 지역 클릭 → selectedRegion 상태 변경 → 앨범 필터
├── AlbumCard[] (여행지 카드 리스트)
│ └── 클릭 → AlbumDetail (오버레이)
│ ├── [사진 탭] MasonryGrid
│ │ └── 사진 클릭 → HeroLightbox
│ └── [영상 탭] VideoTab
└── useTravelData (데이터 레이어)
```
---
## 2. Main View — MiniMap + Album Card List
### MiniMap
- 높이: 데스크톱 200px, 모바일 150px
- GeoJSON 지역 폴리곤 유지 (기존 MapLayer 로직 추출)
- 클릭 시 해당 지역 앨범만 필터링
- 선택된 지역: 지역별 악센트 컬러로 하이라이트
- "전체 보기" 버튼으로 필터 해제
- 접기/펼치기 토글 (기본: 펼침)
- 접힌 상태: 높이 0 + overflow hidden, 토글 버튼만 표시
### Album Card List
- **카드 구성**: 대표 사진 배경 (object-fit: cover) + 앨범 이름 + 사진 수 뱃지
- **대표 사진**: 앨범 첫 번째 사진의 썸네일 URL
- **카드 레이아웃**: `display: grid`
- 데스크톱 (>1024px): 3열
- 태블릿 (769px-1024px): 2열
- 모바일 (<=768px): 1열
- **카드 높이**: 데스크톱 240px, 모바일 200px
- **호버**: scale(1.03) + 지역 악센트 글로우
- **지역 필터 전환**: fade 애니메이션 (opacity 300ms)
### Album Data Grouping
백엔드 API 변경 없이 프론트에서 처리:
1. 각 region에 대해 `GET /api/travel/photos?region={id}&page=1&size=1` 호출
2. 응답의 `total` 필드로 사진 수 확보, `items[0]`으로 대표 사진 확보
3. region_map.json의 albums 목록에서 앨범명 추출
4. 기존 10분 TTL 캐시 로직 재활용
---
## 3. Album Detail — Masonry Grid + Tabs + Transitions
### Entry Animation (Shared Element Transition)
1. 앨범 카드 클릭 시 `getBoundingClientRect()`로 카드 시작 위치 캡처
2. 카드 clone을 `position: fixed`로 생성
3. clone을 `inset: 0` (풀스크린)으로 animate (400ms, cubic-bezier(0.4, 0, 0.2, 1))
4. 애니메이션 완료 → clone 제거, AlbumDetail 오버레이 표시
### Exit Animation
1. 뒤로가기/닫기 클릭
2. AlbumDetail을 숨기고, 원래 카드 위치로 역재생 (400ms)
3. 애니메이션 완료 → 앨범 카드 리스트로 복귀
### Photo/Video Tabs
- 앨범 상세 상단에 "사진 | 영상" 탭 바
- 기존 `SwipeableView` 컴포넌트 재활용 (모바일 스와이프 전환)
- 영상 탭: VideoTab 컴포넌트 (플레이스홀더)
### Masonry Grid (Photo Tab)
- **레이아웃**: CSS `column-count` 기반
- 데스크톱 (>1024px): 4열
- 태블릿 (769px-1024px): 3열
- 모바일 (<=768px): 2열
- **사진 비율**: 원본 유지 (`width: 100%`, `height: auto`)
- **갭**: `column-gap: 8px`, 각 사진 `margin-bottom: 8px`
- **break-inside**: `avoid` (사진이 컬럼 경계에 걸리지 않도록)
- **무한 스크롤**: IntersectionObserver 센티널, rootMargin 300px, page size 20
- **스크롤 리빌**: 뷰포트 진입 시 아래에서 20px 올라오며 fade-in, 사진마다 50ms 지연
- **lazy loading**: `loading="lazy"` 속성, 첫 8장은 `loading="eager"`
### Video Tab (Shell)
- 중앙 정렬된 비디오 아이콘 + "영상 기능 준비 중" 텍스트
- 앰버 톤 텍스트, 세리프 폰트
- 백엔드 동영상 API 완성 시 이 컴포넌트 내부만 교체
---
## 4. HERO Lightbox
### Shared Element Transition (Photo → Fullscreen)
1. Masonry에서 사진 클릭 → `getBoundingClientRect()`로 시작 위치 캡처
2. 사진 clone을 `position: fixed`로 생성
3. clone을 화면 중앙 + 최대 크기로 animate (350ms, cubic-bezier(0.4, 0, 0.2, 1))
4. 애니메이션 완료 → clone 제거, 라이트박스 UI 표시
5. 배경은 `#000` opacity 0→1 동시 전환
### Fullscreen Viewer
- **배경**: 순수 블랙 `#000`, z-index 3000
- **사진**: `max-width: 100%`, `max-height: calc(100vh - 140px)`, `object-fit: contain`
- **좌우 탐색**:
- 데스크톱: 좌우 화살표 버튼 (hover 시 표시)
- 모바일: react-swipeable로 좌우 스와이프
- 키보드: ArrowLeft/ArrowRight
- **하단 썸네일 스트립**:
- 높이 68px, 썸네일 52x52px
- 활성 썸네일: 앰버 테두리 (2px solid)
- 활성 썸네일 자동 센터링 (smooth scroll)
- 필름 퍼포레이션 장식 제거 (간소화)
- **메타 정보**: 사진 위 또는 아래에 앨범명 + 파일명 (앰버 텍스트, 14px)
- **닫기**:
- X 버튼 (우상단)
- 아래로 스와이프 (모바일, threshold 100px)
- ESC 키
- 닫기 시 역재생 transition → 원래 그리드 위치로 복귀
### Slide Animation (이전/다음)
- 좌우 전환 시 현재 사진이 나가고 새 사진이 들어오는 slide 애니메이션
- 280ms, cubic-bezier(0.25, 0.46, 0.45, 0.94)
- 방향에 따라 왼쪽/오른쪽에서 진입
---
## 5. Visual Design — Cinematic Travel Aesthetic
### Color System
- **베이스 배경**: `#0f0c09` (깊은 다크)
- **베이스 텍스트**: `#f5e6c8` (따뜻한 앰버)
- **뮤트 텍스트**: `rgba(245,230,200,0.5)`
- **라인/테두리**: `rgba(245,230,200,0.08)`
- **지역별 악센트**:
- 일본: `#c73e1d` (주홍)
- 유럽: `#2563eb` (코발트)
- 동남아: `#059669` (에메랄드)
- 국내: `#d97706` (호박)
- 기타: 기본 앰버 `#d4a574`
- 악센트 적용: 앨범 카드 호버 글로우, 미니맵 지역 하이라이트, 탭 활성 상태
### Typography
- **제목/앨범명**: `Cormorant Garamond`, serif (기존 유지)
- **메타 정보/뱃지**: `Space Mono`, monospace (기존 유지)
- **앨범 카드 제목**: 데스크톱 24px, 모바일 18px
- **사진 수 뱃지**: 11px 모노, `rgba(15,12,9,0.7)` 배경 위 앰버 텍스트
### Album Card Visual
- 대표 사진 위 하단 30% 그라디언트: `linear-gradient(transparent, rgba(15,12,9,0.85))`
- 그라디언트 위에 앨범명 + 사진 수
- `border-radius: 12px`
- `border: 1px solid rgba(245,230,200,0.08)`
- 호버: `box-shadow: 0 0 20px rgba({accent}, 0.15)` + `transform: scale(1.03)`
### Masonry Photo Style
- `border-radius: 4px`
- 호버: `filter: brightness(1.08)` + `cursor: zoom-in`
- 스크롤 리빌: translateY(20px) + opacity(0) → translateY(0) + opacity(1), 사진마다 50ms 지연
### Lightbox Visual
- 배경: `#000`
- 메타 텍스트: 앰버 `#f5e6c8`, 세리프 폰트, 14px
- 썸네일 스트립: 활성 아이템에 앰버 2px 테두리
- 카운터: "3 / 156" 형태, 우상단, 모노스페이스
---
## 6. Responsive Design
### Breakpoints
| 구간 | 앨범 카드 | Masonry 열 | 미니맵 높이 |
|------|----------|-----------|-----------|
| >1024px | 3열 | 4열 | 200px |
| 769-1024px | 2열 | 3열 | 200px |
| <=768px | 1열 | 2열 | 150px |
### Mobile Specifics
- 앨범 상세: `position: fixed; inset: 0` (풀스크린 오버레이)
- 라이트박스: 100dvh, 화살표 버튼 숨김 (스와이프로 대체)
- 미니맵: 기본 접힘 (모바일에서 공간 절약)
- 하단 네비게이션 고려: `padding-bottom: calc(var(--bottom-nav-h) + var(--safe-area-bottom))`
---
## 7. Reduced Motion
`prefers-reduced-motion: reduce` 적용 시:
- shared element transition (앨범 진입/이탈, 라이트박스 열기/닫기) → 즉시 fade (opacity 0→1, 150ms)
- 스크롤 리빌 애니메이션 → 즉시 표시 (opacity 1, transform none)
- 카드 호버 scale → 없음 (색상 변화만 유지)
- 슬라이드 전환 → 즉시 교체 (fade)
- 미니맵 접기/펼치기 → 즉시 전환
---
## 8. Data Flow
```
useTravelData hook
├── fetchRegions() → GET /api/travel/regions
├── fetchAlbums(region?) → GET /api/travel/photos?region={id}&page=1&size=1 (per region)
├── fetchPhotos(region, page) → GET /api/travel/photos?region={id}&page={n}&size=20
└── cache (Map, 10min TTL) → 기존 캐시 로직 재활용
State:
- regions: GeoJSON[]
- albums: { id, name, region, coverThumb, totalPhotos }[]
- selectedRegion: string | null
- selectedAlbum: string | null
- photos: Photo[]
- page, hasNext, loading, loadingMore
```
### API Contract (기존 유지, 변경 없음)
```
GET /api/travel/regions
→ GeoJSON FeatureCollection
GET /api/travel/photos?region=japan&page=1&size=20
→ { region, page, size, total, has_next, items: [{ album, file, url, thumb, mtime }] }
POST /api/travel/reload
→ { status: "ok" }
```
---
## 9. Performance Considerations
- **앨범 카드 대표 사진**: page=1&size=1로 최소 데이터만 요청
- **Masonry 이미지**: 썸네일(480x480) 사용, 라이트박스에서만 원본 로드
- **무한 스크롤**: 20개씩 점진적 로드, rootMargin 300px 선제 로드
- **lazy loading**: 브라우저 네이티브 `loading="lazy"`
- **캐시**: 10분 TTL, 리전 단위
- **스크롤 리빌**: IntersectionObserver 단일 인스턴스로 배치 감시
- **shared element transition**: `will-change: transform` 적용, 합성 레이어로 GPU 가속

View File

@@ -0,0 +1,203 @@
# Travel-Proxy 성능 개선 설계
## 목표
travel-proxy의 파일 스캔 기반 아키텍처를 SQLite 인덱스 DB로 전환하여 수천 장의 사진을 무난하게 처리하고, 앨범 커버 지정 + 썸네일 사전 생성을 지원한다.
## 배경
현재 travel-proxy는 `os.scandir`으로 NAS 폴더를 매번 스캔하고, 메모리 캐시(TTL 300초)로 결과를 보관한다. 사진 수백 장에서는 문제없지만, 수천 장이면:
- 캐시 만료 시 1~2초 스캔 지연
- 콜드 스타트(컨테이너 재시작) 시 첫 요청 느림
- 전체 리스트를 메모리에 상주
- 썸네일이 첫 요청 시 동기 생성되어 초기 로딩 지연
## 아키텍처
### 변경 전
```
API 요청 → os.scandir(폴더) → 메모리 캐시 → 슬라이싱 페이지네이션
썸네일 온디맨드 생성 (Pillow)
```
### 변경 후
```
수동 sync 버튼 → 폴더 스캔 → travel.db 동기화 + 썸네일 사전 생성
API 요청 → SQLite 쿼리 (인덱스) → 페이지네이션
```
### 파일 구조
| 파일 | 역할 |
|------|------|
| `main.py` | FastAPI 라우트 (기존 + 신규) |
| `db.py` (신규) | SQLite 스키마 정의, 쿼리 헬퍼 |
| `indexer.py` (신규) | 폴더 스캔 → DB 동기화 + 썸네일 생성 |
기존 `main.py``scan_album`, `ensure_thumb`, 메모리 캐시 로직이 `indexer.py``db.py`로 이동하고, `main.py`는 라우트만 남는다.
## DB 스키마
```sql
CREATE TABLE photos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
album TEXT NOT NULL,
filename TEXT NOT NULL,
mtime REAL NOT NULL,
has_thumb INTEGER DEFAULT 0,
indexed_at TEXT NOT NULL,
UNIQUE(album, filename)
);
CREATE INDEX idx_photos_album ON photos(album);
CREATE TABLE album_covers (
album TEXT PRIMARY KEY,
filename TEXT NOT NULL,
updated_at TEXT NOT NULL
);
```
### 설계 포인트
- `photos` 테이블에 URL/thumb 경로를 저장하지 않음 — 런타임에 `MEDIA_BASE` + album + filename으로 조합 (환경변수 변경에 유연)
- `mtime`으로 변경 감지 — 동기화 시 파일이 삭제됐거나 mtime이 바뀌면 갱신
- `album_covers`가 비어있으면 해당 앨범의 첫 번째 사진이 자동 커버
## API 설계
### 기존 API 변경
| 엔드포인트 | 변경 내용 |
|-----------|----------|
| `GET /api/travel/photos` | 내부 로직만 변경 (os.scandir → DB 쿼리). 응답 형식 동일 |
| `GET /api/travel/regions` | 변경 없음 |
| `POST /api/travel/reload` | 제거 (sync로 대체) |
| `GET /media/travel/.thumb/{album}/{filename}` | 유지 — 동기화 시 이미 썸네일 생성되므로 Pillow 호출 빈도 대폭 감소. 미생성 분 폴백으로 온디맨드 생성 유지 |
### 신규 API
| 메서드 | 경로 | 설명 |
|--------|------|------|
| `POST` | `/api/travel/sync` | 폴더 스캔 → DB 동기화 + 썸네일 생성 |
| `GET` | `/api/travel/albums` | 앨범 목록 + 사진 수 + 커버 정보 |
| `PUT` | `/api/travel/albums/{album}/cover` | 앨범 커버 지정 |
### POST /api/travel/sync
폴더를 스캔하여 DB와 동기화하고, 미생성 썸네일을 일괄 생성한다.
**요청**: 바디 없음
**응답**:
```json
{
"added": 42,
"removed": 3,
"thumbs_generated": 42,
"duration_sec": 12.5
}
```
**동기 실행** — 수동 트리거이므로 BackgroundTask 불필요, 응답에 결과 포함.
### GET /api/travel/albums
앨범 목록과 각 앨범의 사진 수, 커버 정보를 반환한다.
**응답**:
```json
[
{
"album": "오사카",
"count": 342,
"cover_url": "/media/travel/오사카/IMG_3281.jpg",
"cover_thumb": "/media/travel/.thumb/오사카/IMG_3281.jpg"
}
]
```
커버가 지정되지 않은 앨범은 첫 번째 사진(album + filename 정렬 기준)이 자동 커버.
### PUT /api/travel/albums/{album}/cover
특정 사진을 앨범 커버로 지정한다.
**요청**:
```json
{
"filename": "IMG_3281.jpg"
}
```
**응답**:
```json
{
"album": "오사카",
"filename": "IMG_3281.jpg",
"cover_url": "/media/travel/오사카/IMG_3281.jpg",
"cover_thumb": "/media/travel/.thumb/오사카/IMG_3281.jpg"
}
```
**검증**: 해당 album + filename 조합이 photos 테이블에 존재하는지 확인. 없으면 404.
## 동기화 로직 (indexer.py)
### sync 프로세스
1. `region_map.json`에서 전체 앨범 폴더 목록 수집
2. 각 폴더 `os.scandir``{album, filename, mtime}` 세트 수집
3. DB와 비교:
- DB에 없는 파일 → INSERT (`added`)
- DB에 있지만 폴더에 없는 파일 → DELETE (`removed`)
- mtime이 다른 파일 → UPDATE + `has_thumb=0` (변경됨)
4. `has_thumb=0`인 파일 → 썸네일 생성 → `has_thumb=1`로 갱신
5. 결과 반환: `{added, removed, thumbs_generated, duration_sec}`
### 삭제된 커버 처리
커버로 지정된 사진이 폴더에서 삭제되면 `album_covers`에서도 제거 → 자동으로 첫 번째 사진 폴백.
### 성능
- NAS Celeron J4025 기준, 2,000장 최초 동기화 + 썸네일 생성 예상: 3~5분
- 이후 동기화는 변경분만 처리 → 수초 이내
## 앨범 커버 지정 UX
프론트엔드 앨범 상세 페이지에서 사진을 길게 누르거나 우클릭 → "커버로 설정" 메뉴. `PUT /api/travel/albums/{album}/cover` 호출.
프론트엔드 변경은 이 스펙 범위 밖 — 백엔드 API만 제공하고, 프론트 연동은 별도 작업.
## 기존 API 호환성
- `GET /api/travel/photos` 응답 형식 (`items`, `total`, `has_next`, `matched_albums`) 완전히 유지
- 프론트엔드 `useTravelData` 훅은 수정 없이 동작
- `GET /api/travel/albums`는 선택적 개선용 — 프론트가 앨범 카드 커버를 표시할 때 활용
## Docker 변경
- `travel.db` 저장 위치: 썸네일 볼륨 내 `/data/thumbs/travel.db` (추가 볼륨 불필요)
- `requirements.txt``aiosqlite` 추가 불필요 — 동기 sqlite3 표준 라이브러리 사용
- Dockerfile 변경 없음
### docker-compose.yml 변경
기존 볼륨에 DB를 함께 저장하므로 추가 볼륨 불필요:
```yaml
volumes:
- ${PHOTO_PATH}:/data/travel:ro
- ${RUNTIME_PATH}\travel-thumbs:/data/thumbs:rw # travel.db도 여기에 저장
```
## 제거되는 코드
- `main.py``CACHE`, `CACHE_TTL`, `META_MTIME_CACHE` 딕셔너리 및 관련 로직
- `main.py``scan_album()` 함수 (indexer.py로 이동)
- `main.py``ensure_thumb()` 함수 (indexer.py로 이동, 온디맨드 폴백은 유지)
- `POST /api/travel/reload` 엔드포인트 (sync로 대체)

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