275 Commits

Author SHA1 Message Date
56fbe3fc4b fix(agent-office): realestate 에이전트 텔레그램 명령 등록
AGENT_META + AGENT_COMMAND_MAP에 realestate 누락으로 /realestate 명령 인식 불가.
- /realestate matches → fetch_matches
- /realestate dashboard → dashboard
- /help에 청약 에이전트 항목 추가

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

NAS .env에 TELEGRAM_WIFE_CHAT_ID 추가 필요.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Reviewed-on: #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
175 changed files with 49822 additions and 878 deletions

View File

@@ -1,17 +1,90 @@
# timezone # ---------------------------------------------------------------------------
# [Environment Configuration]
# 이 파일을 복사하여 .env 파일을 생성하고, 환경에 맞게 주석을 해제/수정하여 사용하세요.
# ---------------------------------------------------------------------------
# [COMMON]
APP_VERSION=dev
TZ=Asia/Seoul TZ=Asia/Seoul
COMPOSE_PROJECT_NAME=webpage
# backend lotto collector sources
LOTTO_ALL_URL=https://smok95.github.io/lotto/results/all.json LOTTO_ALL_URL=https://smok95.github.io/lotto/results/all.json
LOTTO_LATEST_URL=https://smok95.github.io/lotto/results/latest.json LOTTO_LATEST_URL=https://smok95.github.io/lotto/results/latest.json
# travel-proxy # [SECURITY]
TRAVEL_ROOT=/data/travel WEBHOOK_SECRET=change_this_secret_in_prod
TRAVEL_THUMB_ROOT=/data/thumbs
TRAVEL_MEDIA_BASE=/media/travel
TRAVEL_CACHE_TTL=300
# CORS (travel-proxy) # [PATHS]
CORS_ALLOW_ORIGINS=* # 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
# [REALESTATE LAB — agent-office push notify]
AGENT_OFFICE_URL=http://agent-office:8000
REALESTATE_LAB_URL=http://realestate-lab:8000
REALESTATE_DASHBOARD_URL=http://localhost:8080/realestate
REALESTATE_NOTIFY_TIMEOUT=15

3
.gitignore vendored
View File

@@ -63,3 +63,6 @@ uploads/
################################ ################################
tmp/ tmp/
temp/ temp/
# Git worktrees
.worktrees/

601
CLAUDE.md Normal file
View File

@@ -0,0 +1,601 @@
# CLAUDE.md — web-backend 프로젝트 가이드
> Claude Code가 이 프로젝트를 작업할 때 참조하는 설정 및 구조 문서.
---
## 1. 프로젝트 개요
Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
- **서비스**: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, personal, deployer (9개)
- **프론트엔드**: 별도 레포 (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 실행 위치)
│ ├── lotto/ # lotto 소스 (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` | 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 + 텔레그램 연동) |
| `personal` | 18850 | 개인 서비스 (포트폴리오·블로그·투두 통합) |
| `travel-proxy` | 19000 | 여행 사진 API + 썸네일 생성 |
| `frontend` (nginx) | 8080 | 정적 SPA 서빙 + API 리버스 프록시 |
| `webpage-deployer` | 19010 | Gitea Webhook 수신 → 자동 배포 |
---
## 5. Nginx 라우팅 규칙
| 경로 | 프록시 대상 | 비고 |
|------|------------|------|
| `/api/` | `lotto:8000` | lotto API (기본) |
| `/api/travel/` | `travel-proxy:8000` | travel API |
| `/api/stock/` | `stock-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/todos` | `personal:8000` | 투두 API |
| `/api/blog/` | `personal:8000` | 블로그 API |
| `/api/profile/` | `personal: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 (lotto/)
- DB: `/app/data/lotto.db`
- 데이터 소스: `smok95.github.io/lotto/results/`
- 파일 구조: `main.py`, `db.py`, `recommender.py`, `collector.py`, `checker.py`, `generator.py`, `analyzer.py`, `utils.py`, `purchase_manager.py`, `strategy_evolver.py`
**lotto.db 테이블**
| 테이블 | 설명 |
|--------|------|
| `draws` | 로또 당첨번호 |
| `recommendations` | 추천 이력 (즐겨찾기·태그·채점 포함) |
| `simulation_runs` | 시뮬레이션 실행 기록 |
| `simulation_candidates` | 시뮬레이션 후보 (점수 5종) |
| `best_picks` | 현재 활성 최적 번호 20개 (`is_active` 플래그로 교체) |
| `purchase_history` | 구매 이력 (실제/가상, 번호, 전략 출처, 결과) |
| `strategy_performance` | 전략별 회차 성과 (EMA 입력 데이터) |
| `strategy_weights` | 메타 전략 가중치 (EMA + Softmax) |
| `weekly_reports` | 주간 공략 리포트 캐시 |
| `lotto_briefings` | AI 큐레이터 주간 브리핑 (5세트 + 내러티브 + 토큰·비용 집계) |
| `todos` | 투두리스트 (UUID PK) — personal 서비스로 이전됨, 레거시 테이블 유지 |
| `blog_posts` | 블로그 글 (tags: JSON 배열) — personal 서비스로 이전됨, 레거시 테이블 유지 |
**스케줄러 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/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 연동: 한국부동산원 청약홈 분양정보 조회 + 자치구 5티어 매칭 + agent-office push 알림
- DB: `/app/data/realestate.db` (announcements, announcement_models, user_profile, match_results, collect_log 테이블)
- 파일 구조: `main.py`, `db.py`, `collector.py`, `matcher.py`, `notifier.py`, `models.py`
**환경변수**
- `DATA_GO_KR_API_KEY`: 공공데이터포털 API 키 (미설정 시 수동 등록만 가능)
- `AGENT_OFFICE_URL`: agent-office 내부 URL (기본 `http://agent-office:8000`) — 신규 매칭 push 대상
- `REALESTATE_NOTIFY_TIMEOUT`: agent-office push timeout 초 (기본 15)
**스케줄러 job (`scheduled_collect` 4단계 흐름)**
- 09:00 매일 — `collect → cleanup → match → notify`
1. `collect_all()` — 모집공고일 30일 윈도우(`RCRIT_PBLANC_DE_FROM`) 사전 좁힘 + 자치구 추출 + status='완료' skip
2. `delete_old_completed_announcements(grace_days=90)``winner_date + 90일` 경과한 완료 공고 정리 (FK CASCADE로 match_results도 삭제)
3. `run_matching()` — 자치구 5티어 가중치 + 자격 곡선 적용
4. `notify_new_matches()``notified_at IS NULL AND match_score >= profile.min_match_score AND profile.notify_enabled`인 매칭을 agent-office로 push
- 00:00 매일 — 상태 갱신 + 재매칭 (`scheduled_status_update`, notifier 미호출)
**매칭 점수 모델 (총 100점)**
- 지역 35점 — 광역 매칭 시 10점 + 자치구 5티어 가중치(S=25 / A=20 / B=15 / C=10 / D=5)
- `preferred_districts`가 모든 티어 비어있으면 광역 매칭만으로 35점 풀 점수 (legacy 호환)
- 주택유형 10점 — `preferred_types`에 매칭 (binary)
- 면적 15점 — `[min_area, max_area]` 범위 안 모델 1개 이상 (binary)
- 가격 15점 — `max_price` 이하 모델 1개 이상 (binary)
- 자격 25점 — `_check_eligible_types()` 결과 1개 이상이면 15점 + 추가당 5점, 최대 +10
- reasons 텍스트 예시: `"자치구 S티어: 강남구 (+25)"`, `"광역 일치: 서울"`, `"선호 지역 일치: 서울"` (legacy)
**user_profile 신규 컬럼 (Task 2026-04-28 마이그레이션)**
- `preferred_districts` TEXT — JSON `{"S":[...], "A":[...], "B":[...], "C":[...], "D":[...]}`. default `'{}'`
- `min_match_score` INTEGER — 알림 임계값. default 70
- `notify_enabled` INTEGER — 알림 ON/OFF. default 1
**announcements / match_results 신규 컬럼**
- `announcements.district` TEXT + `idx_ann_district` 인덱스 — collector가 주소/region_name에서 정규식 파싱
- `match_results.notified_at` TEXT NULL — agent-office push 성공 시 timestamp 기록 (멱등 마킹)
**notifier.py 흐름**
1. `get_profile()``notify_enabled=False`면 skip, `min_match_score` 가져옴
2. `get_unnotified_matches(min_score)` — JOIN으로 announcements 정보 포함 (district, status, receipt 등)
3. `POST {AGENT_OFFICE_URL}/api/agent-office/realestate/notify` body=`{"matches": [...]}`
4. 응답 `{sent_ids: [...]}``mark_matches_notified(sent_ids)` (notified_at = now)
5. RequestException 시 마킹 안 함 → 다음 사이클 재시도
**realestate-lab API 목록**
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/realestate/announcements` | 공고 목록. 응답에 `district`, `match_score`, `match_reasons`, `eligible_types` 포함 |
| GET | `/api/realestate/announcements/{id}` | 공고 상세 (주택형별 + district 포함) |
| POST | `/api/realestate/announcements` | 수동 공고 등록 |
| PUT | `/api/realestate/announcements/{id}` | 공고 수정 |
| PATCH | `/api/realestate/announcements/{id}/bookmark` | 북마크 토글 (텔레그램 인라인 키보드 콜백 대상) |
| DELETE | `/api/realestate/announcements/{id}` | 공고 삭제 |
| DELETE | `/api/realestate/announcements/closed` | status='완료' 공고 일괄 삭제 |
| POST | `/api/realestate/collect` | 수동 수집 트리거 (collect → cleanup → match → notify 전체 흐름) |
| GET | `/api/realestate/collect/status` | 마지막 수집 결과 |
| GET | `/api/realestate/profile` | 내 프로필 조회 (`preferred_districts`, `min_match_score`, `notify_enabled` 포함) |
| PUT | `/api/realestate/profile` | 프로필 수정 (upsert). body에 `preferred_districts: {S:[],...}`, `min_match_score: 0~100`, `notify_enabled: bool` 수용 |
| GET | `/api/realestate/matches` | 매칭 결과 목록 (응답에 `district`, `status` 포함) |
| POST | `/api/realestate/matches/refresh` | 매칭 재계산 |
| PATCH | `/api/realestate/matches/{id}/read` | 신규 알림 읽음 처리 |
| GET | `/api/realestate/dashboard` | 요약 (진행중 공고수, 신규 매칭수, 다가오는 일정) |
### travel-proxy (travel-proxy/)
- 원본 사진: `/data/travel/` (RO)
- 썸네일 캐시: `/data/thumbs/` (RW)
- DB: `/data/thumbs/travel.db` (photos, album_covers 테이블)
- 메타: `/data/travel/_meta/region_map.json`, `regions.geojson`
- 지역 오버라이드: `/data/thumbs/region_map_extra.json` (RW, `_regions_meta` 포함)
- 파일 구조: `main.py`, `db.py`, `indexer.py`
- 썸네일: 480×480 리사이징 (Pillow), 동기화 시 사전 생성 + 온디맨드 폴백
- 데이터 흐름: 수동 sync → 폴더 스캔 → SQLite 인덱싱 + 썸네일 일괄 생성
**travel.db 테이블**
| 테이블 | 설명 |
|--------|------|
| `photos` | 사진 인덱스 (album, filename, mtime, has_thumb) |
| `album_covers` | 앨범별 커버 사진 지정 |
**지역 관리 아키텍처**
- `region_map.json` (RO): 원본 지역→앨범 매핑 (`_meta/` 안에 위치)
- `region_map_extra.json` (RW): 사용자 수정분 오버라이드 (앨범 이동, 신규 지역)
- `_regions_meta`: 커스텀 지역의 이름·좌표 저장 (`{ "region_id": { "name": "...", "coordinates": [lng, lat] } }`)
- `regions.geojson` (RO): GeoJSON Polygon 지역 경계
- 커스텀 지역: `GET /api/travel/regions`에서 `region_map`에 있지만 GeoJSON에 없는 지역을 자동 추가 (Point geometry 또는 null)
**travel-proxy API 목록**
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/travel/regions` | 지역 GeoJSON (커스텀 지역 동적 추가 포함) |
| GET | `/api/travel/photos` | 사진 목록 (region, page=1, size=20) |
| POST | `/api/travel/sync` | 폴더 스캔 → DB 동기화 + 썸네일 생성 |
| GET | `/api/travel/albums` | 앨범 목록 + 사진 수 + 커버 + region/regionName |
| PUT | `/api/travel/albums/{album}/cover` | 앨범 커버 지정 |
| PUT | `/api/travel/albums/{album}/region` | 앨범 지역 변경 (region_map_extra 수정) |
| PUT | `/api/travel/regions/{region_id}` | 커스텀 지역 이름/좌표 수정 (지도 핀 표시용) |
### 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/realestate-lab 기존 API를 서비스 프록시로 호출 (직접 DB 접근 없음)
- 실시간 상태 동기화: WebSocket (`/api/agent-office/ws`)
- 텔레그램 봇: 양방향 알림 + 승인 (인라인 키보드)
- 청약 매칭 알림: realestate-lab이 신규 매칭 발견 시 push → `RealestateAgent.on_new_matches()` → 텔레그램 1통(인라인 [🔖 북마크]/[📄 공고] 또는 [전체 보기] 버튼)
- DB: `/app/data/agent_office.db` (agent_config, agent_tasks, agent_logs, telegram_state 테이블)
- 파일 구조: `main.py`, `db.py`, `config.py`, `models.py`, `websocket_manager.py`, `service_proxy.py`, `telegram_bot.py`, `scheduler.py`, `agents/base.py`, `agents/stock.py`, `agents/music.py`, `agents/realestate.py`, `telegram/realestate_message.py`
**에이전트 FSM 상태**: idle → working → waiting (승인 대기) → reporting → break (휴식)
**환경변수**
- `STOCK_LAB_URL`: stock-lab 내부 URL (기본 `http://stock-lab:8000`)
- `MUSIC_LAB_URL`: music-lab 내부 URL (기본 `http://music-lab:8000`)
- `REALESTATE_LAB_URL`: realestate-lab 내부 URL (기본 `http://realestate-lab:8000`) — 북마크 콜백 프록시 대상
- `REALESTATE_DASHBOARD_URL`: 텔레그램 [전체 보기] 버튼 URL (기본 `http://localhost:8080/realestate`)
- `TELEGRAM_BOT_TOKEN`: 텔레그램 봇 토큰 (미설정 시 알림 비활성화)
- `TELEGRAM_CHAT_ID`: 텔레그램 채팅 ID
- `TELEGRAM_WEBHOOK_URL`: 텔레그램 Webhook URL
- `TELEGRAM_WIFE_CHAT_ID`: 아내 chat.id (브리핑 공유 + 대화 허용)
- `ANTHROPIC_API_KEY`: 자연어 대화용 Claude API 키 (미설정 시 대화 비활성)
- `CONVERSATION_MODEL`: 대화 모델 (기본 `claude-haiku-4-5-20251001`)
- `CONVERSATION_HISTORY_LIMIT`: 이력 주입 수 (기본 20)
- `CONVERSATION_RATE_PER_MIN`: 채팅당 분당 최대 메시지 (기본 6)
- `LOTTO_BACKEND_URL`: 기본 `http://lotto:8000`
- `LOTTO_CURATOR_MODEL`: 기본 `claude-sonnet-4-5`
**텔레그램 자연어 대화 (옵션 B)**
- 슬래시 명령이 아닌 일반 문장을 보내면 Claude Haiku 4.5가 응답
- 프롬프트 캐싱: `system` 블록 + 히스토리 마지막 블록에 `cache_control: ephemeral` → 5분 TTL
- 허용 chat_id 화이트리스트: `TELEGRAM_CHAT_ID`, `TELEGRAM_WIFE_CHAT_ID`
- 평가 지표: `conversation_messages` 테이블에 tokens / cache_read / cache_write / latency 기록
- 조회: `GET /api/agent-office/conversation/stats?days=7`
**스케줄러 job**
- 07:30 매일 — 주식 뉴스 요약 (`stock_news_job`)
- 매주 월요일 07:00 — 로또 큐레이터 브리핑 (`lotto_curate`)
- 60초 간격 — 유휴 에이전트 휴식 체크 (`idle_check_job`)
- ~~09:15 매일 — 청약 매칭 데일리 리포트~~ (Task 2026-04-28에서 폐기. realestate-lab의 push 트리거로 전환)
**RealestateAgent (`agents/realestate.py`)**
- 진입점: `on_new_matches(matches: list[dict]) -> {sent, sent_ids, message_id}`
- realestate-lab의 push에서 트리거 → `format_realestate_matches()` + `build_match_keyboard()``messaging.send_raw()`
- 1~2건이면 풀 카드 + [🔖 북마크]/[📄 공고 보기] 행씩, 3건 이상이면 묶음 카드 + [📋 전체 보기] 단일 URL 버튼
- 인라인 키보드 콜백 `realestate_bookmark_{id}``webhook.py``_handle_realestate_bookmark``service_proxy.realestate_bookmark_toggle()` → realestate-lab의 `PATCH /announcements/{id}/bookmark`
- 송신 성공 시 sent_ids 반환 → realestate-lab이 match_results.notified_at 마킹 (멱등)
- 실패 시 sent=0/sent_ids=[]/error 반환 → 마킹 안 됨 → 다음 사이클 재시도
- `on_command("fetch_matches")`: 수동 트리거 — service_proxy로 매치 가져와 `on_new_matches` 호출
- `on_schedule`: 폐기 (cron 등록 제거됨)
**agent-office API 목록**
| 메서드 | 경로 | 설명 |
|--------|------|------|
| WS | `/api/agent-office/ws` | WebSocket (init, agent_state, task_complete, command_result) |
| GET | `/api/agent-office/agents` | 에이전트 목록 |
| GET | `/api/agent-office/agents/{id}` | 에이전트 상세 (설정 + 상태) |
| PUT | `/api/agent-office/agents/{id}` | 에이전트 설정 수정 |
| GET | `/api/agent-office/agents/{id}/tasks` | 에이전트 작업 이력 |
| GET | `/api/agent-office/agents/{id}/logs` | 에이전트 로그 |
| GET | `/api/agent-office/tasks/pending` | 승인 대기 작업 목록 |
| GET | `/api/agent-office/tasks/{id}` | 작업 상세 |
| POST | `/api/agent-office/command` | 에이전트에 명령 전송 |
| POST | `/api/agent-office/approve` | 작업 승인/거부 |
| POST | `/api/agent-office/telegram/webhook` | 텔레그램 Webhook 수신 (realestate_bookmark_* 콜백 포함) |
| POST | `/api/agent-office/realestate/notify` | realestate-lab 전용 push 수신 → 텔레그램 송신 |
| GET | `/api/agent-office/states` | 전체 에이전트 상태 조회 |
| GET | `/api/agent-office/conversation/stats` | 텔레그램 자연어 대화 토큰·캐시 통계 (`days` 필터) |
### personal (personal/)
- 개인 서비스 (포트폴리오 + 블로그 + 투두 통합)
- DB: `/app/data/personal.db` (profile, careers, projects, skills, introductions, todos, blog_posts 테이블)
- 편집 인증: `PORTFOLIO_EDIT_PASSWORD` 환경변수, Bearer 토큰 (24시간 TTL)
- 파일 구조: `main.py`, `db.py`, `models.py`, `auth.py`
**환경변수**
- `PORTFOLIO_EDIT_PASSWORD`: 편집 모드 비밀번호 (미설정 시 편집 불가)
**personal API 목록**
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/profile/public` | 공개 데이터 일괄 조회 |
| POST | `/api/profile/auth` | 비밀번호 인증 → 토큰 |
| GET | `/api/profile/profile` | 프로필 조회 (인증) |
| PUT | `/api/profile/profile` | 프로필 수정 (인증) |
| GET | `/api/profile/careers` | 경력 목록 (인증) |
| POST | `/api/profile/careers` | 경력 추가 (인증) |
| PUT | `/api/profile/careers/{id}` | 경력 수정 (인증) |
| DELETE | `/api/profile/careers/{id}` | 경력 삭제 (인증) |
| GET | `/api/profile/projects` | 프로젝트 목록 (인증) |
| POST | `/api/profile/projects` | 프로젝트 추가 (인증) |
| PUT | `/api/profile/projects/{id}` | 프로젝트 수정 (인증) |
| DELETE | `/api/profile/projects/{id}` | 프로젝트 삭제 (인증) |
| GET | `/api/profile/skills` | 기술 목록 (인증) |
| POST | `/api/profile/skills` | 기술 추가 (인증) |
| PUT | `/api/profile/skills/{id}` | 기술 수정 (인증) |
| DELETE | `/api/profile/skills/{id}` | 기술 삭제 (인증) |
| GET | `/api/profile/introductions` | 자기소개 목록 (인증) |
| POST | `/api/profile/introductions` | 자기소개 추가 (인증) |
| PUT | `/api/profile/introductions/{id}` | 자기소개 수정 (인증) |
| DELETE | `/api/profile/introductions/{id}` | 자기소개 삭제 (인증) |
| PATCH | `/api/profile/introductions/{id}/main` | 메인 자기소개 지정 (인증) |
| GET | `/api/todos` | 투두 전체 목록 |
| POST | `/api/todos` | 투두 생성 |
| PUT | `/api/todos/{id}` | 투두 수정 |
| DELETE | `/api/todos/done` | 완료 항목 일괄 삭제 |
| DELETE | `/api/todos/{id}` | 투두 개별 삭제 |
| GET | `/api/blog/posts` | 블로그 글 목록 |
| POST | `/api/blog/posts` | 블로그 글 생성 |
| PUT | `/api/blog/posts/{id}` | 블로그 글 수정 |
| DELETE | `/api/blog/posts/{id}` | 블로그 글 삭제 |
### deployer (deployer/)
- Webhook 검증: `X-Gitea-Signature` (HMAC SHA256, `compare_digest` 사용)
- `WEBHOOK_SECRET` 환경변수로 시크릿 관리
- Webhook 수신 즉시 `{"ok": True}` 응답 후 BackgroundTask로 배포 실행
- 배포 타임아웃: 10분 (`scripts/deploy.sh`)
---
## 10. 주의사항
- **Nginx trailing slash**: `/api/portfolio`는 trailing slash 없이도 매칭되도록 두 location 블록으로 처리
- **라우트 순서**: `DELETE /api/todos/done``DELETE /api/todos/{id}` 보다 **반드시 먼저** 등록 (personal 서비스, FastAPI prefix 매칭 순서)
- **PUID/PGID**: travel-proxy는 NAS 파일 권한을 위해 PUID/PGID를 환경변수로 주입
- **캐시 전략**: `index.html``no-store`, `assets/`는 1년 장기 캐시(immutable)
- **Frontend 배포**: git push로 자동 배포되지 않음. 로컬 빌드 후 NAS에 수동 업로드
- **.env 파일**: 절대 커밋 금지. `.env.example`만 레포에 포함
- **공휴일 목록**: `stock-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,77 @@
from .base import BaseAgent
from ..db import create_task, update_task_status, add_log
from .. import service_proxy
from ..telegram import messaging
from ..telegram.realestate_message import format_realestate_matches, build_match_keyboard
class RealestateAgent(BaseAgent):
"""부동산 청약 에이전트.
realestate-lab이 신규 매칭 발견 시 /realestate/notify로 push해 트리거됨.
on_new_matches가 메인 진입점. on_schedule은 사용하지 않음(cron 폐기).
"""
agent_id = "realestate"
display_name = "청약 애널리스트"
async def on_new_matches(self, matches: list[dict]) -> dict:
"""신규 매칭 N건을 텔레그램 1통으로 푸시.
성공 시 sent_ids 반환 → realestate-lab이 notified_at 마킹.
실패 시 sent=0, sent_ids=[] 반환 → 다음 사이클 재시도.
"""
if not matches:
return {"sent": 0, "sent_ids": []}
task_id = create_task(self.agent_id, "notify_matches", {"count": len(matches)})
try:
text = format_realestate_matches(matches)
keyboard = build_match_keyboard(matches)
await self.transition("reporting", f"매칭 {len(matches)}건 알림", task_id)
tg = await messaging.send_raw(text, reply_markup=keyboard)
if not tg.get("ok"):
update_task_status(task_id, "failed", {"error": tg.get("description")})
await self.transition("idle", "알림 실패")
return {"sent": 0, "sent_ids": [], "error": tg.get("description")}
sent_ids = [m["id"] for m in matches if "id" in m]
update_task_status(task_id, "succeeded", {
"sent": len(matches),
"telegram_message_id": tg.get("message_id"),
})
await self.transition("idle", f"매칭 {len(matches)}건 알림 완료")
return {
"sent": len(matches),
"sent_ids": sent_ids,
"message_id": tg.get("message_id"),
}
except Exception as e:
add_log(self.agent_id, f"on_new_matches failed: {e}", "error", task_id)
update_task_status(task_id, "failed", {"error": str(e)})
await self.transition("idle", f"오류: {e}")
return {"sent": 0, "sent_ids": [], "error": str(e)}
async def on_command(self, command: str, params: dict) -> dict:
if command == "fetch_matches":
try:
matches = await service_proxy.realestate_matches(limit=20)
if not matches:
return {"ok": True, "message": "매칭 없음"}
result = await self.on_new_matches(matches)
return {"ok": True, "result": result}
except Exception as e:
return {"ok": False, "message": str(e)}
if command == "dashboard":
try:
data = await service_proxy.realestate_dashboard()
return {"ok": True, "dashboard": data}
except Exception as e:
return {"ok": False, "message": str(e)}
return {"ok": False, "message": f"Unknown command: {command}"}
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
pass

View File

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

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

@@ -0,0 +1,201 @@
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)
# --- Realestate Agent Push Endpoint ---
from pydantic import BaseModel
from typing import List, Dict, Any
class RealestateNotifyBody(BaseModel):
matches: List[Dict[str, Any]]
@app.post("/api/agent-office/realestate/notify")
async def realestate_notify(body: RealestateNotifyBody):
agent = get_agent("realestate")
if agent is None:
from fastapi import HTTPException
raise HTTPException(status_code=503, detail="RealestateAgent not initialized")
return await agent.on_new_matches(body.matches)

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,32 @@
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_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_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,168 @@
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]]:
"""realestate-lab의 GET /api/realestate/matches 호출."""
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
f"{REALESTATE_LAB_URL}/api/realestate/matches",
params={"size": limit},
)
resp.raise_for_status()
data = resp.json()
return data.get("items", [])
async def realestate_dashboard() -> Dict[str, Any]:
resp = await _client.get(f"{REALESTATE_LAB_URL}/api/realestate/dashboard")
resp.raise_for_status()
return resp.json()
async def realestate_mark_read(match_id: int) -> Dict[str, Any]:
resp = await _client.patch(f"{REALESTATE_LAB_URL}/api/realestate/matches/{match_id}/read")
resp.raise_for_status()
return resp.json()
async def realestate_bookmark_toggle(announcement_id: int) -> Dict[str, Any]:
"""realestate-lab의 PATCH /api/realestate/announcements/{id}/bookmark 호출."""
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.patch(
f"{REALESTATE_LAB_URL}/api/realestate/announcements/{announcement_id}/bookmark"
)
resp.raise_for_status()
return resp.json()
# --- lotto-backend ---
async def lotto_candidates(n: int = 20) -> Dict[str, Any]:
from .config import LOTTO_BACKEND_URL
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/curator/candidates", params={"n": n})
resp.raise_for_status()
return resp.json()
async def lotto_context() -> Dict[str, Any]:
from .config import LOTTO_BACKEND_URL
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/curator/context")
resp.raise_for_status()
return resp.json()
async def lotto_save_briefing(payload: dict) -> Dict[str, Any]:
from .config import LOTTO_BACKEND_URL
resp = await _client.post(f"{LOTTO_BACKEND_URL}/api/lotto/briefing", json=payload)
resp.raise_for_status()
return resp.json()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,199 @@
"""텔레그램 Webhook 이벤트 처리."""
from typing import Optional
from ..db import get_telegram_callback, mark_telegram_responded
from .client import _enabled, api_call
async def handle_webhook(data: dict, agent_dispatcher=None) -> Optional[dict]:
"""텔레그램에서 들어오는 이벤트 처리.
- callback_query(인라인 버튼)는 항상 처리 → 승인/거절 dict 반환
- message(텍스트 슬래시 명령)는 `agent_dispatcher`가 주입된 경우에만 처리
agent_dispatcher: async (agent_id, command, params) -> dict
- agent_id == "__global__", command == "status" 특수 케이스는
{agent_id: {state, detail}} dict를 반환해야 함.
"""
callback_query = data.get("callback_query")
if callback_query:
return await _handle_callback(callback_query)
message = data.get("message")
if message:
chat = message.get("chat", {})
print(f"[TG-WEBHOOK] chat.id={chat.get('id')} type={chat.get('type')} text={message.get('text')!r}", flush=True)
if message and message.get("text") and agent_dispatcher is not None:
return await _handle_message(message, agent_dispatcher)
return None
async def _handle_callback(callback_query: dict) -> Optional[dict]:
"""승인/거절 및 realestate 북마크 콜백 처리."""
callback_id = callback_query.get("data", "")
# realestate 북마크 토글 콜백 — DB 조회 없이 직접 처리
if callback_id.startswith("realestate_bookmark_"):
return await _handle_realestate_bookmark(callback_query, callback_id)
cb = get_telegram_callback(callback_id)
if not cb:
return None
action = callback_id.split("_")[0]
mark_telegram_responded(callback_id, action)
feedback_text = {
"approve": "승인됨 ✅",
"reject": "거절됨 ❌",
}.get(action, f"처리됨: {action}")
await api_call(
"answerCallbackQuery",
{
"callback_query_id": callback_query["id"],
"text": feedback_text,
},
)
return {
"task_id": cb["task_id"],
"agent_id": cb["agent_id"],
"action": action,
"approved": action == "approve",
}
async def _handle_realestate_bookmark(callback_query: dict, callback_id: str) -> dict:
"""realestate_bookmark_{announcement_id} 콜백 처리."""
from .. import service_proxy
from .messaging import send_raw
# answerCallbackQuery 먼저 — 텔레그램 로딩 스피너 해제
await api_call(
"answerCallbackQuery",
{"callback_query_id": callback_query["id"], "text": "처리 중..."},
)
try:
ann_id = int(callback_id.removeprefix("realestate_bookmark_"))
except ValueError:
await send_raw("⚠️ 잘못된 북마크 콜백 데이터")
return {"ok": False, "error": "invalid_callback_data"}
try:
result = await service_proxy.realestate_bookmark_toggle(ann_id)
is_on = result.get("is_bookmarked")
if is_on == 1:
await send_raw(f"🔖 북마크 추가 완료 (#{ann_id})")
elif is_on == 0:
await send_raw(f"🔖 북마크 해제 완료 (#{ann_id})")
else:
await send_raw(f"🔖 북마크 토글 완료 (#{ann_id})")
return {"ok": True, "announcement_id": ann_id}
except Exception as e:
await send_raw(f"⚠️ 북마크 처리 실패: {e}")
return {"ok": False, "error": str(e)}
async def _handle_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)

View File

@@ -0,0 +1,99 @@
import os
import sys
import tempfile
_fd, _TMP = tempfile.mkstemp(suffix=".db")
os.close(_fd)
os.unlink(_TMP)
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import asyncio
from unittest.mock import AsyncMock, patch
import pytest
@pytest.fixture(autouse=True)
def _init_db():
import gc
gc.collect()
if os.path.exists(_TMP):
os.remove(_TMP)
from app.db import init_db
init_db()
yield
gc.collect()
def test_on_new_matches_returns_empty_when_no_matches():
from app.agents.realestate import RealestateAgent
agent = RealestateAgent()
result = asyncio.run(agent.on_new_matches([]))
assert result == {"sent": 0, "sent_ids": []}
def test_on_new_matches_sends_telegram_and_returns_ids():
from app.agents.realestate import RealestateAgent
from app.telegram import messaging
matches = [{
"id": 7, "match_score": 80, "house_nm": "단지A",
"region_name": "서울특별시", "district": "강남구",
"receipt_start": "2026-05-01", "receipt_end": "2026-05-05",
"match_reasons": [], "eligible_types": [], "pblanc_url": "https://x.test/7",
}]
fake_send = AsyncMock(return_value={"ok": True, "message_id": 123})
with patch.object(messaging, "send_raw", fake_send):
agent = RealestateAgent()
result = asyncio.run(agent.on_new_matches(matches))
assert result["sent"] == 1
assert result["sent_ids"] == [7]
assert result["message_id"] == 123
fake_send.assert_awaited_once()
args, kwargs = fake_send.call_args
text = args[0]
assert "단지A" in text
def test_on_new_matches_telegram_failure_returns_zero():
from app.agents.realestate import RealestateAgent
from app.telegram import messaging
matches = [{
"id": 8, "match_score": 80, "house_nm": "단지B",
"region_name": "서울", "district": "송파구",
"receipt_start": "", "receipt_end": "",
"match_reasons": [], "eligible_types": [], "pblanc_url": "",
}]
fake_send = AsyncMock(return_value={"ok": False, "description": "401"})
with patch.object(messaging, "send_raw", fake_send):
agent = RealestateAgent()
result = asyncio.run(agent.on_new_matches(matches))
assert result["sent"] == 0
assert result["sent_ids"] == []
assert "error" in result
def test_endpoint_calls_agent_on_new_matches():
from fastapi.testclient import TestClient
from app.main import app
from app.agents.realestate import RealestateAgent
fake = AsyncMock(return_value={"sent": 1, "sent_ids": [99], "message_id": 1})
with patch.object(RealestateAgent, "on_new_matches", fake):
with TestClient(app) as client:
resp = client.post(
"/api/agent-office/realestate/notify",
json={"matches": [{"id": 99, "match_score": 80}]},
)
assert resp.status_code == 200
body = resp.json()
assert body["sent"] == 1
assert body["sent_ids"] == [99]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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,9 +1,12 @@
version: "3.8" name: webpage
services: services:
backend: lotto:
build: ./backend build:
container_name: lotto-backend context: ./lotto
args:
APP_VERSION: ${APP_VERSION:-dev}
container_name: lotto
restart: unless-stopped restart: unless-stopped
ports: ports:
- "18000:8000" - "18000:8000"
@@ -12,36 +15,221 @@ services:
- LOTTO_ALL_URL=${LOTTO_ALL_URL:-https://smok95.github.io/lotto/results/all.json} - 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} - LOTTO_LATEST_URL=${LOTTO_LATEST_URL:-https://smok95.github.io/lotto/results/latest.json}
volumes: 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}
- AGENT_OFFICE_URL=${AGENT_OFFICE_URL:-http://agent-office:8000}
volumes:
- ${RUNTIME_PATH}/data/realestate:/app/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 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
- REALESTATE_DASHBOARD_URL=${REALESTATE_DASHBOARD_URL:-http://localhost:8080/realestate}
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
- TELEGRAM_WEBHOOK_URL=${TELEGRAM_WEBHOOK_URL:-}
- TELEGRAM_WIFE_CHAT_ID=${TELEGRAM_WIFE_CHAT_ID:-}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- LOTTO_BACKEND_URL=${LOTTO_BACKEND_URL:-http://lotto:8000}
- LOTTO_CURATOR_MODEL=${LOTTO_CURATOR_MODEL:-claude-sonnet-4-5}
- CONVERSATION_MODEL=${CONVERSATION_MODEL:-claude-haiku-4-5-20251001}
- CONVERSATION_HISTORY_LIMIT=${CONVERSATION_HISTORY_LIMIT:-20}
- CONVERSATION_RATE_PER_MIN=${CONVERSATION_RATE_PER_MIN:-6}
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
personal:
build:
context: ./personal
container_name: personal
restart: unless-stopped
ports:
- "18850:8000"
environment:
- TZ=${TZ:-Asia/Seoul}
- PORTFOLIO_EDIT_PASSWORD=${PORTFOLIO_EDIT_PASSWORD:-}
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
volumes:
- ${RUNTIME_PATH:-.}/data/personal:/app/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
timeout: 5s
retries: 3
travel-proxy: travel-proxy:
build: ./travel-proxy build: ./travel-proxy
container_name: travel-proxy container_name: travel-proxy
restart: unless-stopped restart: unless-stopped
user: "1026:100" user: "${PUID}:${PGID}"
ports: ports:
- "19000:8000" # 내부 확인용 - "19000:8000"
environment: environment:
- TZ=${TZ:-Asia/Seoul} - TZ=${TZ:-Asia/Seoul}
- TRAVEL_ROOT=${TRAVEL_ROOT:-/data/travel} - TRAVEL_ROOT=${TRAVEL_ROOT:-/data/travel}
- TRAVEL_THUMB_ROOT=${TRAVEL_THUMB_ROOT:-/data/thumbs} - TRAVEL_THUMB_ROOT=${TRAVEL_THUMB_ROOT:-/data/thumbs}
- TRAVEL_MEDIA_BASE=${TRAVEL_MEDIA_BASE:-/media/travel} - TRAVEL_MEDIA_BASE=${TRAVEL_MEDIA_BASE:-/media/travel}
- TRAVEL_CACHE_TTL=${TRAVEL_CACHE_TTL:-300} - TRAVEL_DB_PATH=${TRAVEL_DB_PATH:-/data/thumbs/travel.db}
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-*} - CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
volumes: volumes:
- /volume1/web/images/webPage/travel:/data/travel:ro - ${PHOTO_PATH}:/data/travel:ro
- /volume1/docker/webpage/travel-thumbs:/data/thumbs:rw - ${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: frontend:
image: nginx:alpine image: nginx:alpine
container_name: lotto-frontend container_name: frontend
restart: unless-stopped restart: unless-stopped
depends_on:
- music-lab
- blog-lab
- realestate-lab
ports: ports:
- "8080:80" - "8080:80"
volumes: volumes:
- /volume1/docker/webpage/frontend:/usr/share/nginx/html:ro - ${FRONTEND_PATH}:/usr/share/nginx/html:ro
- /volume1/docker/webpage/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro - ${RUNTIME_PATH}/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- /volume1/web/images/webPage/travel:/data/travel:ro - ${PHOTO_PATH}:/data/travel:ro
- /volume1/docker/webpage/travel-thumbs:/data/thumbs:ro - ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:ro
- ${RUNTIME_PATH}/data/music:/data/music:ro
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "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 업데이트"
```

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

File diff suppressed because it is too large Load Diff

View File

@@ -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로 대체)

View File

@@ -0,0 +1,497 @@
# Agent Office v2 — Pixel Office UX 대규모 업데이트 설계
> 참고 프로젝트: `pixel-agents` (VS Code 확장, React 19 + Canvas 2D)
> 대상: `web-ui/src/pages/agent-office/` (프론트엔드) + `web-backend/agent-office/` (백엔드)
---
## 1. 목표
기존 대시보드 칼럼 중심 UI를 **전체 화면 픽셀 오피스** 중심으로 전환하여, "가상 오피스를 사용한다"는 몰입감을 제공한다.
### 핵심 변경
- 캔버스가 메인 화면을 차지하고, 에이전트 클릭 시 사이드 패널로 상세 정보 표시
- BFS 경로 탐색 + 풀 배회 시스템으로 에이전트에 생동감 부여
- 3가지 오피스 테마 프리셋 (Modern / Retro / Minimal)
- 캐릭터 프로시저럴 고도화 + 스프라이트 로더 설계 (점진적 전환)
### 변경하지 않는 것
- 백엔드 FSM 5상태 (`idle`, `working`, `waiting`, `reporting`, `break`)
- WebSocket 프로토콜 메시지 타입 (init, agent_state, task_complete, agent_move, notification, command_result)
- REST API 엔드포인트
- 텔레그램 봇 연동
---
## 2. 화면 구성
### 2.1 데스크톱 레이아웃
```
┌──────────────────────────────────────────────────┬──────────────┐
│ [Agent Office] ● Connected [Theme ▾] [Zoom] │ │
├──────────────────────────────────────────────────┤ Side Panel │
│ │ 320px │
│ │ │
│ Pixel Office Canvas │ [Agent hdr] │
│ (flex: 1, 전체 높이) │ [Tabs····] │
│ │ [Content ] │
│ - 에이전트 클릭 → 패널 열림 │ [·········] │
│ - 빈 공간 클릭 → 패널 닫힘 │ │
│ │ │
└──────────────────────────────────────────────────┴──────────────┘
```
- **상단 바**: 타이틀, WebSocket 연결 상태(●), 테마 드롭다운, 줌 컨트롤 (1x~4x)
- **캔버스**: `flex: 1`로 남은 공간 전체 차지, `imageSmoothingEnabled = false`
- **사이드 패널**: 320px 고정폭, 에이전트 클릭 시 슬라이드 인, X 버튼 또는 빈 공간 클릭으로 닫힘
- **패널 닫힘 시**: 캔버스가 전체 너비로 확장
### 2.2 모바일 레이아웃 (< 768px)
```
┌──────────────────────────┐
│ [≡] Agent Office ● Conn │
├──────────────────────────┤
│ │
│ Pixel Office Canvas │
│ (전체 화면) │
│ 핀치 줌 + 패닝 │
│ │
│ │
├──────────────────────────┤ ← 바텀 시트 (드래그)
│ [Agent Header] │
│ [Tabs: Cmd|Task|Tok|Log]│
│ [Content area] │
└──────────────────────────┘
```
- 캔버스: 전체 화면, 터치 핀치 줌/패닝
- 사이드 패널 → 바텀 시트 (에이전트 탭 시 올라옴, 아래로 드래그 시 닫힘)
- 상단 바: 햄버거 메뉴로 테마/줌 접기
---
## 3. 사이드 패널 구조
### 3.1 헤더
```
┌─────────────────────────────┐
│ [🎵 32x32] 음악 프로듀서 │
│ ● working - ... │
└─────────────────────────────┘
```
- 에이전트 아이콘 (emoji 기반, 32x32 색상 배경)
- display_name + 현재 상태 + state_detail
### 3.2 탭 구성
| 탭 | 내용 |
|----|------|
| **Commands** (기본) | Quick Action 버튼 (에이전트별 고유), Custom Command 입력, Approval UI (waiting 상태 시) |
| **Tasks** | 최근 작업 이력 (상태 배지, 타임스탬프, 결과 펼치기) |
| **Tokens** | 일간/주간 토큰 사용량 차트, 캐시 히트율 |
| **Logs** | 에이전트 로그 스트림 (level별 색상, 자동 스크롤) |
### 3.3 에이전트별 Quick Actions
| 에이전트 | 버튼 |
|---------|------|
| Stock | Fetch News, Add Alert, Test Telegram |
| Music | Compose, Check Credits |
| Blog | Research, Add Keyword, List Keywords |
| Realestate | Fetch Matches, Dashboard |
| Lotto | Curate Now, Status |
---
## 4. 캔버스 엔진
### 4.1 타일맵
- **그리드**: 32 × 20 타일 (기존 20×14에서 확장)
- **타일 크기**: 32px × 32px (기본), 줌에 따라 스케일
- **타일 타입**: VOID(0), FLOOR(1), WALL(2), FURNITURE(3)
- **렌더링 순서**: 바닥 → 벽 → 가구 → 에이전트 (Y좌표 Z-sorting) → 오버레이
### 4.2 오피스 레이아웃 (고정)
```
WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW (W=Wall)
W..............................W
W...[Stock]...[Music]..........W
W...desk+mon..desk+inst........W
W..............................W
W...[Blog]....[RE]....[Lotto]..W
W...desk+mon..desk+mon.desk+monW
W..............................W
W..............................W
W..........[Meeting]...........W
W..........table 4x2...........W
W..............................W
W..............................W
W....[Coffee]...[Sofa]........W
W....machine....couch.........W
W..............................W
W...[Plants]......[Bookshelf]..W
W..............................W
W..............................W
WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW
```
- 각 에이전트 구역에 테마별 소품 (Stock: 모니터 3대, Music: 악기, Blog: 서류 등)
- 중앙: 회의 테이블 (4x2 타일)
- 하단: 휴게실 구역 (커피 머신 + 소파)
- waypoint 정의: `desk_stock`, `desk_music`, `desk_blog`, `desk_realestate`, `desk_lotto`, `meeting`, `break_room`, `coffee`
### 4.3 줌 & 패닝
- 줌 레벨: 1x, 2x, 3x, 4x (정수 배율만, 픽셀 선명도 유지)
- 데스크톱: 마우스 휠 줌, 드래그 패닝
- 모바일: 핀치 줌, 터치 패닝
- 기본값: 캔버스 크기에 맞춰 자동 fit
### 4.4 게임 루프
```javascript
function gameLoop(timestamp) {
const dt = (timestamp - lastTime) / 1000;
lastTime = timestamp;
update(dt); // 에이전트 이동, 애니메이션 프레임 업데이트
render(); // 타일맵 → 가구 → 에이전트(Y-sort) → 오버레이
requestAnimationFrame(gameLoop);
}
```
- 60fps requestAnimationFrame
- `imageSmoothingEnabled = false` (픽셀 선명도)
- devicePixelRatio 반영
---
## 5. 에이전트 캐릭터 시스템
### 5.1 프로시저럴 렌더링 (Phase 1)
- 해상도: 16 × 32px (기존 8×16에서 2배 확대)
- 에이전트별 고유 색상 (기존 유지)
- 애니메이션 프레임:
| 상태 | 프레임 수 | 속도 | 설명 |
|------|----------|------|------|
| idle | 2 | 0.8s/frame | 미세 움직임 (숨쉬기) |
| walk | 4 | 0.15s/frame | 걷기 사이클 [0,1,2,1] |
| type | 2 | 0.3s/frame | 타이핑 (팔 움직임) |
| wait | 2 | 0.5s/frame | 좌우 흔들림 (wobble) |
| break | 2 | 1.0s/frame | 커피 마시기 / 졸기 |
- 4방향 스프라이트: DOWN, UP, RIGHT, LEFT (LEFT = RIGHT 좌우반전)
### 5.2 스프라이트 로더 (Phase 2 준비)
```javascript
class SpriteLoader {
constructor() {
this.sprites = new Map(); // agent_id → spritesheet Image
this.fallback = 'procedural';
}
async load(agentId, sheetUrl) { /* PNG 로드 */ }
draw(ctx, agentId, state, direction, frame, x, y) {
if (this.sprites.has(agentId)) {
// 스프라이트시트에서 프레임 추출하여 그리기
} else {
// 프로시저럴 폴백
ProceduralSprite.draw(ctx, agentId, state, direction, frame, x, y);
}
}
}
```
- 스프라이트시트 규격: 각 프레임 16×32px, 가로로 프레임 나열
- 행: 방향 (DOWN/UP/RIGHT), 열: 상태별 프레임
- PNG 없으면 프로시저럴 폴백 → 에셋 제작 전에도 완전 동작
---
## 6. 이동 시스템
### 6.1 BFS 경로 탐색
```javascript
function findPath(grid, start, goal) {
// 4방향 BFS (상하좌우, 대각선 없음)
// blocked 타일(가구, 벽) 회피
// 반환: [{col, row}, ...] 경로 배열
}
```
- 가구 footprint → `blocked[]` 배열로 타일 마킹
- 의자/책상 뒤 타일은 walkable (backgroundTiles 개념)
- 경로 없으면 제자리 유지
### 6.2 이동 파라미터
| 파라미터 | 값 | 설명 |
|---------|-----|------|
| WALK_SPEED | 48 px/sec | pixel-agents 참고 |
| moveProgress | 0~1 | 현재 타일 → 다음 타일 선형 보간 |
| direction | DOWN/UP/RIGHT/LEFT | 이동 방향 → 스프라이트 방향 결정 |
### 6.3 배회 로직 (idle 상태)
```
idle 진입
→ 3~8초 대기 (seatTimer)
→ 자리에서 일어남
→ 인접 floor 타일로 랜덤 이동
→ 3~6회 반복 (wanderCount)
→ 자리로 BFS 복귀
→ 2~20초 자리에서 휴식 (restTimer)
→ 반복
```
### 6.4 상태 전환 시 이동 시퀀스
| 전환 | 동작 |
|------|------|
| `* → working` | 배회 중단, 자기 책상으로 BFS 이동 → 도착 후 type 애니메이션 |
| `* → waiting` | 자기 책상에서 wobble 애니메이션 + 말풍선 |
| `* → reporting` | 자기 책상에서 빠른 type 애니메이션 |
| `idle (배회 중)` | 랜덤 floor 타일로 이동, wanderCount 소진 시 복귀 |
| `* → break` | 휴게실(break_room/coffee) waypoint로 BFS 이동 → break 애니메이션 |
| `break → idle` | 자기 책상으로 BFS 이동 → idle 루프 시작 |
---
## 7. 오버레이 시스템
캔버스 위에 HTML이 아닌 Canvas 2D로 직접 렌더링.
### 7.1 항상 표시
- **이름 라벨**: 에이전트 아래, 에이전트 색상 텍스트, 12px
- **상태 배지**: 이름 아래, 배경색 + 텍스트 ("working", "idle", "break")
### 7.2 조건부 표시
- **말풍선**: `waiting` 상태에서만, 에이전트 위에 "승인 대기!" 텍스트
- 둥근 사각형 배경 (#fbbf24), 아래 삼각형 꼬리
- 2초 페이드인, 상태 변경 시 즉시 사라짐
- **알림 배지**: 미확인 notification 있을 때, 에이전트 우상단에 빨간 원 + 숫자
### 7.3 렌더링 순서
```
1. 타일맵 (바닥 + 벽)
2. 가구 (Y-sort)
3. 에이전트 (Y-sort, 가구와 혼합)
4. 오버레이 (말풍선, 이름, 배지) — 항상 최상위
```
---
## 8. 테마 시스템
### 8.1 테마 데이터 구조
```javascript
const THEMES = {
modern: {
name: 'Modern',
wall: { color: '#1a1a2e', border: '#333', accent: '#8b5cf6' },
floor: { color1: '#2a2a3e', color2: '#323248' },
furniture: { desk: '#3a3a5a', chair: '#4c1d95', monitor: '#5555aa', shelf: '#2a2a4e' },
decor: { plant: '#22c55e', pot: '#4a3a2a', lamp: '#fbbf24', ledStrip: '#8b5cf6' },
lighting: { ambient: 'rgba(139,92,246,0.05)', glow: 'rgba(139,92,246,0.15)' }
},
retro: {
name: 'Retro',
wall: { color: '#2a1a0a', border: '#6a4a2a', accent: '#cc8844' },
floor: { color1: '#4a3a1a', color2: '#3a2a10' },
furniture: { desk: '#6a4a1a', chair: '#8a5a2a', monitor: '#555555', shelf: '#5a3a1a' },
decor: { plant: '#44aa44', pot: '#6a4a2a', lamp: '#ffcc66', brick: '#8a5a2a' },
lighting: { ambient: 'rgba(255,200,100,0.05)', glow: 'rgba(255,200,100,0.2)' }
},
minimal: {
name: 'Minimal',
wall: { color: '#fafafa', border: '#ddd', accent: '#3b82f6' },
floor: { color1: '#e8e8e8', color2: '#f0f0f0' },
furniture: { desk: '#ffffff', chair: '#e0e0e0', monitor: '#333333', shelf: '#f5f5f5' },
decor: { plant: '#86efac', pot: '#ffffff', lamp: '#fbbf24', window: '#e0f0ff' },
lighting: { ambient: 'rgba(59,130,246,0.03)', glow: 'rgba(255,255,255,0.1)' }
}
};
```
### 8.2 테마 적용 방식
- `TileMap.render(theme)` — 바닥/벽 색상을 theme에서 읽어 렌더링
- `FurnitureRenderer.draw(type, theme)` — 가구별 프로시저럴 렌더링에 theme 팔레트 적용
- 테마 전환 시 전체 캔버스 리렌더 (레이아웃 변경 없음)
- 사용자 선택은 `localStorage`에 저장, 기본값: `modern`
### 8.3 테마별 고유 데코
| 테마 | 고유 요소 |
|------|----------|
| Modern | LED 스트립 (벽 하단), 네온 글로우, 미니멀 화분 |
| Retro | 벽돌 텍스처, CRT 모니터, 책장(컬러풀 책), 탁상 램프 |
| Minimal | 창문(자연광), 다육이, 깔끔한 화이트 선반 |
---
## 9. 히트 테스팅 & 인터랙션
### 9.1 클릭 처리
```javascript
canvas.onclick = (e) => {
const {col, row} = screenToTile(e.offsetX, e.offsetY, zoom, pan);
// 1. 에이전트 히트 테스트 (역순, 최상위 우선)
const agent = agents.findLast(a =>
Math.abs(a.x - col) < 1 && Math.abs(a.y - row) < 1.5
);
if (agent) {
openSidePanel(agent.id);
return;
}
// 2. 빈 공간 → 패널 닫기
closeSidePanel();
};
```
### 9.2 호버 (데스크톱만)
- 에이전트 위 호버 시 커서 `pointer`로 변경
- 툴팁 불필요 (이름+배지가 항상 표시되므로)
---
## 10. WebSocket 연동
기존 프로토콜 100% 유지. 프론트엔드에서 메시지 수신 시 캔버스 상태만 추가 업데이트.
| 메시지 타입 | 캔버스 반응 |
|------------|-----------|
| `agent_state` | 해당 에이전트 FSM 상태 전환 → 애니메이션/위치 변경 트리거 |
| `agent_move` | target에 따라 BFS 경로 계산 → 이동 시작 |
| `task_complete` | 에이전트 상태를 idle로 전환 |
| `notification` | 에이전트 위 알림 배지 카운트 증가 |
| `init` | 모든 에이전트 초기 위치/상태 설정 |
### agent_state 수신 시 이동 로직
```javascript
function onAgentState(agentId, newState) {
const agent = agents.get(agentId);
switch (newState) {
case 'working':
case 'waiting':
case 'reporting':
// 자리에 있지 않으면 자리로 이동
if (!agent.isAtDesk()) agent.moveTo(agent.deskWaypoint);
break;
case 'break':
agent.moveTo('break_room');
break;
case 'idle':
// 배회 루프 시작
agent.startWandering();
break;
}
agent.setState(newState);
}
```
---
## 11. 파일 구조 (프론트엔드)
```
src/pages/agent-office/
├── AgentOffice.jsx # 루트 컴포넌트 (재작성)
├── AgentOffice.css # 스타일 (재작성)
├── hooks/
│ ├── useAgentManager.js # WebSocket + 상태 (기존 확장)
│ └── useOfficeCanvas.js # 캔버스 셋업 (재작성)
├── components/
│ ├── TopBar.jsx # 상단 바 (신규)
│ ├── SidePanel.jsx # 사이드 패널 컨테이너 (신규)
│ ├── CommandTab.jsx # Commands 탭 (AgentColumn 리팩토링)
│ ├── TaskTab.jsx # Tasks 탭 (AgentColumn에서 분리)
│ ├── TokenTab.jsx # Tokens 탭 (신규)
│ ├── LogTab.jsx # Logs 탭 (신규)
│ ├── ApprovalCard.jsx # 승인 UI 카드 (신규)
│ └── MobileBottomSheet.jsx # 모바일 바텀 시트 (신규)
├── canvas/
│ ├── OfficeRenderer.js # 게임 루프 + 렌더 파이프라인 (재작성)
│ ├── TileMap.js # 타일맵 렌더링 + 테마 적용 (재작성)
│ ├── FurnitureRenderer.js # 가구 프로시저럴 렌더링 (신규)
│ ├── AgentSprite.js # 에이전트 이동 + 애니메이션 (재작성)
│ ├── ProceduralSprite.js # 프로시저럴 캐릭터 렌더링 (SpriteSheet 리팩토링)
│ ├── SpriteLoader.js # 스프라이트시트 로더 + 폴백 (신규)
│ ├── Pathfinder.js # BFS 경로 탐색 (신규)
│ ├── OverlayRenderer.js # 이름, 배지, 말풍선 (신규)
│ └── themes.js # 테마 데이터 (신규)
├── assets/
│ ├── office-map.json # 32x20 맵 데이터 (재작성)
│ └── sprites/ # Phase 2 스프라이트시트 PNG (빈 디렉토리)
```
### 삭제 대상
- `components/AgentColumn.jsx` → CommandTab + TaskTab으로 분리
- `components/CommandColumn.jsx` → SidePanel 내 CommandTab으로 통합
- `components/ChatPanel.jsx` → 미사용, 삭제
- `components/DocumentPanel.jsx` → LogTab으로 대체
- `canvas/SpriteSheet.js` → ProceduralSprite.js로 리팩토링
---
## 12. 백엔드 변경사항
**없음.** 기존 WebSocket 프로토콜과 REST API를 그대로 사용한다.
단, `agent_move` 메시지가 break 전환 시에도 정확히 발송되는지 확인 필요:
- `base.py``check_idle_break()``transition('break')` → WebSocket broadcast에 `agent_move` 포함 여부 확인
- 필요 시 `transition()` 메서드에서 break 상태 전환 시 `agent_move` 메시지 추가
---
## 13. 구현 순서 (Phase 개요)
| Phase | 내용 | 의존성 |
|-------|------|--------|
| **1. 캔버스 엔진** | 게임 루프, 타일맵, 줌/팬, 테마 시스템 | 없음 |
| **2. 에이전트 시스템** | 프로시저럴 캐릭터, BFS 경로 탐색, 상태별 애니메이션, 배회 로직 | Phase 1 |
| **3. 오버레이** | 이름 라벨, 상태 배지, 말풍선, 알림 배지 | Phase 2 |
| **4. 사이드 패널** | 4탭 구성, Quick Actions, Approval UI | Phase 1 |
| **5. 페이지 통합** | AgentOffice.jsx 재작성, WebSocket 연동, 히트 테스팅 | Phase 1-4 |
| **6. 모바일 대응** | 바텀 시트, 핀치 줌, 터치 이벤트, 반응형 | Phase 5 |
| **7. 스프라이트 로더** | SpriteLoader 구현, 폴백 연결 | Phase 2 |
---
## 14. 성공 기준
- [ ] 전체 화면 캔버스에서 5명의 에이전트가 상태에 맞게 애니메이션
- [ ] idle 에이전트가 사무실을 배회하다 자리로 복귀
- [ ] break 에이전트가 휴게실로 이동하여 휴식
- [ ] 에이전트 클릭 시 사이드 패널 열림, 4탭 모두 동작
- [ ] Commands 탭에서 명령 전송 + 승인/거부 동작
- [ ] 3가지 테마 전환 동작, localStorage에 저장
- [ ] 모바일에서 바텀 시트 + 핀치 줌 동작
- [ ] 기존 WebSocket 프로토콜과 100% 호환

View File

@@ -0,0 +1,220 @@
# Personal 서비스 마이그레이션 설계
## 개요
기존 `portfolio` 서비스를 `personal`로 리네이밍하고, lotto-backend에 있던 Blog/Todo 기능을 personal 서비스로 통합한다.
**목표**: 신규 컨테이너 없이, 개인 콘텐츠(포트폴리오 + 블로그 + 투두)를 하나의 서비스로 통합
**제약**: 기존 데이터 무손실 이전 필수
---
## 아키텍처
### 변경 전
```
lotto-backend (lotto.db)
├── 로또 API (/api/lotto/*)
├── 블로그 API (/api/blog/posts) ← 이전 대상
└── 투두 API (/api/todos) ← 이전 대상
portfolio (portfolio.db)
└── 포트폴리오 API (/api/profile/*)
```
### 변경 후
```
lotto-backend (lotto.db)
└── 로또 API (/api/lotto/*) ← Blog/Todo 라우트 제거
personal (personal.db)
├── 포트폴리오 API (/api/profile/*)
├── 블로그 API (/api/blog/posts) ← 통합
└── 투두 API (/api/todos) ← 통합
```
---
## 서비스 속성
| 항목 | 현재 (portfolio) | 변경 후 (personal) |
|------|-----------------|-------------------|
| 디렉토리 | `portfolio/` | `personal/` |
| 컨테이너명 | `portfolio` | `personal` |
| 포트 | 18850 | 18850 (유지) |
| DB 파일 | `data/portfolio/portfolio.db` | `data/personal/personal.db` |
| API prefix | `/api/profile/` | `/api/profile/` + `/api/todos` + `/api/blog/` |
---
## DB 스키마
personal.db에 기존 5테이블 + 신규 2테이블:
### 기존 테이블 (portfolio에서 이관)
- `profile` — 프로필 (id=1 싱글턴)
- `careers` — 경력
- `projects` — 프로젝트
- `skills` — 기술스택
- `introductions` — 자기소개
### 신규 추가 테이블 (lotto-backend에서 이관)
```sql
CREATE TABLE IF NOT EXISTS todos (
id TEXT PRIMARY KEY
DEFAULT (lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2)))),
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'todo'
CHECK(status IN ('todo','in_progress','done')),
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'))
);
CREATE INDEX IF NOT EXISTS idx_todos_created ON todos(created_at DESC);
CREATE TABLE IF NOT EXISTS blog_posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
excerpt TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]',
date TEXT NOT NULL DEFAULT (date('now','localtime')),
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'))
);
CREATE INDEX IF NOT EXISTS idx_blog_date ON blog_posts(date DESC);
```
---
## API 엔드포인트 (personal 서비스 전체)
### 포트폴리오 (기존 유지)
| 메서드 | 경로 | 인증 | 설명 |
|--------|------|------|------|
| GET | `/api/profile/public` | - | 공개 데이터 일괄 조회 |
| POST | `/api/profile/auth` | - | 비밀번호 인증 → 토큰 |
| GET/PUT | `/api/profile/profile` | Bearer | 프로필 조회/수정 |
| GET/POST | `/api/profile/careers` | Bearer | 경력 목록/추가 |
| PUT/DELETE | `/api/profile/careers/{id}` | Bearer | 경력 수정/삭제 |
| GET/POST | `/api/profile/projects` | Bearer | 프로젝트 목록/추가 |
| PUT/DELETE | `/api/profile/projects/{id}` | Bearer | 프로젝트 수정/삭제 |
| GET/POST | `/api/profile/skills` | Bearer | 기술 목록/추가 |
| PUT/DELETE | `/api/profile/skills/{id}` | Bearer | 기술 수정/삭제 |
| GET/POST | `/api/profile/introductions` | Bearer | 자기소개 목록/추가 |
| PUT/DELETE | `/api/profile/introductions/{id}` | Bearer | 자기소개 수정/삭제 |
| PATCH | `/api/profile/introductions/{id}/main` | Bearer | 메인 자기소개 지정 |
### 투두 (lotto-backend에서 이전, 인증 없음)
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/todos` | 전체 목록 |
| POST | `/api/todos` | 생성 |
| DELETE | `/api/todos/done` | 완료 일괄 삭제 |
| PUT | `/api/todos/{id}` | 수정 |
| DELETE | `/api/todos/{id}` | 삭제 |
### 블로그 (lotto-backend에서 이전, 인증 없음)
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/blog/posts` | 목록 (`{"posts": [...]}`) |
| POST | `/api/blog/posts` | 생성 |
| PUT | `/api/blog/posts/{id}` | 수정 |
| DELETE | `/api/blog/posts/{id}` | 삭제 |
---
## Nginx 라우팅 변경
```nginx
# 추가: /api/todos → personal
location /api/todos {
resolver 127.0.0.11 valid=10s;
set $personal_backend personal:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://$personal_backend$request_uri;
}
# 추가: /api/blog/ → personal
location /api/blog/ {
resolver 127.0.0.11 valid=10s;
set $personal_backend personal:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://$personal_backend$request_uri;
}
# 변경: portfolio → personal
location /api/profile/ {
resolver 127.0.0.11 valid=10s;
set $personal_backend personal:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://$personal_backend$request_uri;
}
```
기존 `/api/` catch-all은 lotto-backend로 유지 (todos/blog 요청은 위의 더 구체적인 location에서 먼저 매칭).
---
## 인프라 변경
### docker-compose.yml
- `portfolio` 서비스 → `personal`로 리네이밍
- 볼륨: `${RUNTIME_PATH}/data/personal:/app/data`
- 환경변수 동일 (PORTFOLIO_EDIT_PASSWORD 등)
### deploy.sh / deploy-nas.sh
- SERVICES, BUILD_TARGETS, CONTAINER_NAMES 등에서 `portfolio``personal` 변경
- DATA_DIRS에서 `portfolio``personal` 변경
### lotto-backend 정리
- `main.py`에서 Blog/Todo 라우트 + Pydantic 모델 제거 (약 100줄)
- `db.py`에서 Blog/Todo CRUD 함수 제거 (약 130줄)
- `db.py``init_db()`에서 todos/blog_posts 테이블 생성 코드는 유지 (기존 DB 호환)
---
## 배포 순서 (안전 우선)
1. **코드 개발** — personal 서비스 + lotto-backend 정리 + 인프라 변경
2. **git push** — 자동 배포 트리거
3. **NAS에서 데이터 디렉토리 준비**`mkdir -p data/personal`
4. **기존 portfolio.db 이동**`cp data/portfolio/portfolio.db data/personal/personal.db`
5. **lotto.db에서 Blog/Todo 데이터 복사**:
```bash
sqlite3 data/lotto.db ".dump todos" | sqlite3 data/personal/personal.db
sqlite3 data/lotto.db ".dump blog_posts" | sqlite3 data/personal/personal.db
```
6. **컨테이너 재시작** — `docker compose restart personal`
7. **검증** — API 호출로 데이터 건수 대조
8. **lotto.db 원본 테이블** — 삭제하지 않고 당분간 유지
---
## 프론트엔드
변경 없음. 모든 API 호출이 상대경로(`/api/todos`, `/api/blog/posts`, `/api/profile/`)이므로 nginx 라우팅 변경만으로 자동 적용.
---
## 리스크
- **낮음**: Blog/Todo는 lotto 테이블과 FK/공유 쿼리 없음
- **롤백**: lotto.db 원본 테이블 유지 + nginx 라우팅 원복으로 즉시 롤백 가능
- **다운타임**: nginx reload 순간 (~1초)

View File

@@ -0,0 +1,355 @@
# Portfolio Service Design Spec
> 개인 포트폴리오 정식 서비<EC849C><EBB984>. 취업/이직용 이력서 + 개인 브랜딩 쇼케이스 겸용.
---
## 1. 서비스 개요
| 항목 | 값 |
|------|-----|
| 서비스명 | portfolio |
| 경로 | `web-backend/portfolio/` |
| 컨테이너 | `portfolio` |
| 내부 포트 | 8000 |
| 외부 포트 | 18850 |
| DB | `/app/data/portfolio.db` (SQLite) |
| Nginx 프록시 | `/api/portfolio/``portfolio:8000` |
| 프레임워크 | FastAPI (Python 3.12) |
| 프론트 경로 | `/portfolio` |
### 목적
- 프로필, 경력, 프로젝트, 기술스택을 웹에서 관리하고 공개 전시
- 자기소개 글을 다중 버전으로 관리 (메인 1개 지정, 클립보드 복사)
- 이력서 PDF 내보내기
- 홈 페이지에 요약 카드로 연동
---
## 2. DB 스키마
### `profile` (1행, upsert)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | INTEGER PK | 항상 1 |
| name | TEXT | 이름 (한글) |
| name_en | TEXT | 이름 (영문) |
| role | TEXT | 직함 (한글) |
| role_en | TEXT | 직함 (영문) |
| email | TEXT | 이메일 |
| phone | TEXT | 전화번호 |
| github_url | TEXT | GitHub URL |
| blog_url | TEXT | 블로그 URL |
| photo_url | TEXT | 프로필 사진 URL |
| bio | TEXT | 간단 소개 (3줄 정도) |
| updated_at | TEXT | ISO8601 |
### `careers` (경력 이력)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | INTEGER PK AUTOINCREMENT | |
| category | TEXT | `company` \| `education` \| `etc` |
| organization | TEXT | 회사/기관명 |
| role | TEXT | 직함/전공 |
| description | TEXT | 설명 |
| start_date | TEXT | YYYY-MM |
| end_date | TEXT | YYYY-MM 또는 빈 문자열(현재) |
| sort_order | INTEGER | 정렬 순서 (낮을수록 위) |
| created_at | TEXT | ISO8601 |
| updated_at | TEXT | ISO8601 |
### `projects` (프로젝트)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | INTEGER PK AUTOINCREMENT | |
| category | TEXT | `company` \| `personal` \| `academy` |
| title | TEXT | 프로젝트명 |
| description | TEXT | 설명 |
| tech_stack | TEXT | JSON 배열 `["Python", "FastAPI", ...]` |
| role | TEXT | 담당 역할 |
| start_date | TEXT | YYYY-MM |
| end_date | TEXT | YYYY-MM 또는 빈 문자열 |
| url | TEXT | 프로젝트 URL (선택) |
| image_url | TEXT | 대표 이미지 URL (선택) |
| sort_order | INTEGER | 정렬 순서 |
| created_at | TEXT | ISO8601 |
| updated_at | TEXT | ISO8601 |
### `skills` (기술 스택)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | INTEGER PK AUTOINCREMENT | |
| category | TEXT | `language` \| `framework` \| `infra` \| `tool` |
| name | TEXT | 기술명 |
| level | INTEGER | 숙련도 1~5 |
| sort_order | INTEGER | 정렬 순서 |
### `introductions` (자기소개 글)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | INTEGER PK AUTOINCREMENT | |
| title | TEXT | 버전명 (예: "이직용 짧은 버전") |
| content | TEXT | 본문 |
| is_main | INTEGER | 0 \| 1 (메인 자기소개 지정, 항상 1개만 1) |
| created_at | TEXT | ISO8601 |
| updated_at | TEXT | ISO8601 |
---
## 3. API 설계
### 공개 API (인증 불필요)
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/portfolio/public` | 전체 공개 데이터 일괄 조회 (profile + careers + projects + skills + 메인 자기소개) |
응답 형태:
```json
{
"profile": { ... },
"careers": [ ... ],
"projects": [ ... ],
"skills": [ ... ],
"main_introduction": { "id": 1, "title": "...", "content": "..." }
}
```
### 인증 API
| 메서드 | 경로 | 설명 |
|--------|------|------|
| POST | `/api/portfolio/auth` | 비밀번호 검증 → 세션 토큰 반환 |
- 요청: `{ "password": "..." }`
- 응답: `{ "token": "uuid-string", "expires_in": 86400 }`
- 환경변수: `PORTFOLIO_EDIT_PASSWORD`
- 토큰: UUID, 서버 메모리 딕셔너리 저장, 24시간 TTL
- 실패: 401
### 편집 API (Authorization: Bearer {token} 필요)
**Profile:**
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/portfolio/profile` | 프로필 조회 |
| PUT | `/api/portfolio/profile` | 프로필 수정 (upsert) |
**Careers:**
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/portfolio/careers` | 경력 목록 |
| POST | `/api/portfolio/careers` | 경력 추가 |
| PUT | `/api/portfolio/careers/{id}` | 경력 수정 |
| DELETE | `/api/portfolio/careers/{id}` | 경력 삭제 |
**Projects:**
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/portfolio/projects` | 프로젝트 목록 |
| POST | `/api/portfolio/projects` | 프로젝트 추가 |
| PUT | `/api/portfolio/projects/{id}` | 프로젝트 수정 |
| DELETE | `/api/portfolio/projects/{id}` | 프로젝트 삭제 |
**Skills:**
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/portfolio/skills` | 기술 목록 |
| POST | `/api/portfolio/skills` | 기술 추가 |
| PUT | `/api/portfolio/skills/{id}` | 기술 수정 |
| DELETE | `/api/portfolio/skills/{id}` | 기술 삭제 |
**Introductions:**
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/portfolio/introductions` | 자기소개 전체 목록 |
| POST | `/api/portfolio/introductions` | 자기소개 추가 |
| PUT | `/api/portfolio/introductions/{id}` | 자기소개 수정 |
| DELETE | `/api/portfolio/introductions/{id}` | 자기소개 삭제 |
| PATCH | `/api/portfolio/introductions/{id}/main` | 메인 자기소개 지정 (기존 is_main=1 → 0 리셋) |
---
## 4. 인증 흐름
```
편집 버튼 클릭
→ 토큰 없음 → 비밀번호 모달 표시
→ POST /api/portfolio/auth { password }
→ 성공: 토큰을 React state에 저장 (새로고침 시 재인증)
→ 이후 편집 API 호출에 Authorization: Bearer {token} 포함
→ 토큰 만료/불일치 시 401 → 재인증 모달
```
서버 측:
- `_auth_tokens: dict[str, float]` 메모리 딕셔너리 (token → expiry timestamp)
- FastAPI Depends로 토큰 검증 미들웨어
- 서버 재시작 시 토큰 소멸 (재인증 필요, 보안상 적절)
---
## 5. 프론트엔드 구조
### 라우팅
`routes.jsx`에 추가:
- navLink: `{ id: 'portfolio', label: 'Portfolio', path: '/portfolio', subtitle: 'RESUME', accent: '#06b6d4' }`
- appRoute: `{ path: 'portfolio', element: <Portfolio /> }`
### 파일 구조
```
src/pages/portfolio/
Portfolio.jsx — 메인 페이지 (3탭 컨테이너)
Portfolio.css — 스타일
ProfileTab.jsx — 탭 1: 프로필 & 이력 & 기술스택
ProjectTab.jsx — 탭 2: 프로젝트
IntroTab.jsx — 탭 3: 자기소개 관리
usePortfolio.js — API 호출 + 인증 상태 관리 훅
PasswordModal.jsx — 비밀번호 입력 모달
ResumeView.jsx — PDF 출력 전용 레이아웃 (print CSS)
```
### 탭 1: 프로필 & 이력
**보기 모드:**
- 프로필 카드 (사진, 이름, 역할, 바이오, 연락처 아이콘 링크)
- 경력 타임라인 (category별 그룹: 회사 → 교육 → 기타, sort_order 순)
- 기술 스택 (category별 그룹, level 바 표시)
- "이력서 PDF 내보내기" 버튼
**편집 모드:**
- 프로필: 인라인 편집 (input/textarea)
- 경력: 추가/편집/삭제/순서 변경
- 기술: 추가/편집/삭제/순서 변경
### 탭 2: 프로젝트
**보기 모드:**
- 카테고리 필터 버튼 (전체 / 회사 / 개인 / 아카데미)
- 프로젝트 카드 그리드: 제목, 설명(2줄 clamp), 기술스택 태그, 기간, 링크 아이콘
**편집 모드:**
- 프로젝트 추가/편집/삭제 폼
- tech_stack: 태그 입력 UI (쉼표 또는 엔터로 추가)
### 탭 3: 자기소개 관리
- 자기소개 글 리스트 (메인 표시: 별 배지)
- 각 항목: 제목, 미리보기(3줄), 수정일
- 액션 버튼: 복사(클립보드) / 편집 / 메인 지정 / 삭제
- 상단: "새 글 작성" 버튼 → 인라인 폼 또는 MobileSheet
- 복사 버튼: `navigator.clipboard.writeText()` → "복사됨!" 피드백 1.5초
### 편집 모드 진입
- 각 탭 우상단 "편집" 토글 버튼
- 첫 클릭 시 PasswordModal 표시 → 인증 성공 → 편집 UI 노출
- 인증 토큰은 usePortfolio 훅에서 관리 (React state, 새로고침 시 소멸)
---
## 6. 홈 페이지 연동
### 변경 내용
현재 Home.jsx Profile 섹션(하드코딩)을 요약 카드로 교체:
- `GET /api/portfolio/public` fetch
- 성공 시: 이름, 역할, 바이오, 기술태그 상위 8개, 대표 프로젝트 3개 카드
- "포트폴리오 보기 →" 링크 버튼
- 실패 시: 기존 하드코딩 프로필 폴백 (서비스 미가동 대응)
---
## 7. PDF 내보내기
### 방식
`window.print()` + `@media print` 전용 CSS
- ResumeView.jsx: 이력서 레이아웃 전용 컴포넌트
- "PDF 내보내기" 버튼 → ResumeView를 화면에 렌더링 → `window.print()` → 숨김
- 프린트 CSS: 네비/탭/편집버튼 숨기고, A4 1~2페이지 레이아웃 렌더링
### 이력서 레이아웃 (A4)
```
┌──────────────────────────────┐
│ [사진] 박재오 │
│ Server Developer │
│ email | github │
├──────────────────────────────┤
│ ABOUT │
│ (메인 자기소개 또는 bio) │
├──────────────────────────────┤
│ EXPERIENCE │
│ - 현대오토에버 (2023~현재) │
│ - 롯데정보통신 (2020~2023) │
│ - SSAFY 1기 (2019) │
├──────────────────────────────┤
│ PROJECTS │
│ - 프로젝트 카드 목록 │
├──────────────────────────────┤
│ SKILLS │
│ [태그 나열] │
└──────────────────────────────┘
```
---
## 8. Docker / Nginx 변경
### docker-compose.yml 추가
```yaml
portfolio:
build: ./portfolio
container_name: portfolio
restart: unless-stopped
volumes:
- ${RUNTIME_PATH:-.}/data:/app/data
environment:
- PORTFOLIO_EDIT_PASSWORD=${PORTFOLIO_EDIT_PASSWORD}
ports:
- "18850:8000"
```
### Nginx 추가
```nginx
location /api/portfolio/ {
proxy_pass http://portfolio:8000/api/portfolio/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
```
---
## 9. Backlog (향후)
- Blog CRUD (`/api/blog/posts`) → portfolio 서비스로 이전
- Todo CRUD (`/api/todos`) → portfolio 서비스로 이전
- 이전 완료 후 lotto-backend에서 해당 테이블/라우트 제거
- Nginx 라우팅 변경 (`/api/blog/`, `/api/todos` → portfolio)
---
## 10. 모바일 대응
- 기존 프로젝트 패턴 그대로: `useIsMobile()` + SwipeableView 3탭
- 편집 모드: MobileSheet 활용
- 자기소개 복사: 모바일에서도 `navigator.clipboard` 동작
- PDF: 모바일에서는 "PDF 내보내기" 대신 "공유" 또는 브라우저 인쇄 기능 활용

View File

@@ -0,0 +1,397 @@
# 청약 타겟팅 프론트엔드 설계 — 자치구 5티어 + 알림 설정
> 대상: `web-ui/src/pages/subscription/`
> 백엔드 의존: 2026-04-28-realestate-targeting-enhancement-design.md (이미 배포됨)
> 후속 별도 스펙: Subscription.jsx 분할 리팩토링, 5축 progress bar, 추가 알림 채널
---
## 1. 목표
백엔드 청약 타겟팅 고도화로 추가된 3 프로필 필드(`preferred_districts`, `min_match_score`, `notify_enabled`)를 프론트 UI에 노출한다. 매칭 결과·공고 카드에는 자치구 + 5티어 뱃지를, 상세 모달에는 매칭 사유 텍스트를 추가해 사용자가 점수의 근거를 즉시 이해할 수 있게 한다.
### 핵심 변경
- **ProfileTab**: 자치구 5티어 분류(드래그&드롭, PC 전용) + 임계값 슬라이더 + 알림 토글
- **모바일**: 자치구 분류는 read-only — "PC에서 편집해주세요" 안내
- **카드 표시**: AnnouncementCard / 매칭 카드에 district 뱃지 + 5티어 뱃지(reasons에서 derive)
- **상세 모달**: AnnouncementDetail에 "매칭 분석" 섹션 (점수 + reasons 텍스트 + 자격)
### 변경하지 않는 것
- Subscription.jsx 자체 분할 — 본 스코프 외(별도 리팩토링)
- 백엔드 응답 형태 — 모든 필요 데이터는 이미 응답에 포함됨
- 5축 점수 분해 시각화 — 백엔드 응답 변경 필요(별도)
- 알림 채널 추가 — 텔레그램 외 이메일/Slack은 별도
---
## 2. 컴포넌트 분할
### 2.1 신규 컴포넌트 2개
| 파일 | 책임 | 추정 크기 |
|------|------|----------|
| `web-ui/src/pages/subscription/components/DistrictTierEditor.jsx` | 자치구 5티어 드래그&드롭 + 모바일 read-only | ~180줄 |
| `web-ui/src/pages/subscription/components/NotificationSettings.jsx` | 임계값 슬라이더 + 알림 토글 + 미리보기 | ~80줄 |
ProfileTab(현재 343줄)에 그대로 추가하면 단일 함수가 거대화되어 가독성·유지보수가 떨어진다. 의미 단위로 분할.
### 2.2 변경 받는 기존 컴포넌트
| 컴포넌트 (파일: Subscription.jsx) | 변경 |
|----|------|
| ProfileTab (956~1299줄) | 신규 컴포넌트 2개 import + 자치구 섹션 / 알림 설정 섹션 렌더 + handleSave에서 신규 3필드 송신 |
| AnnouncementCard (315~389줄) | district 뱃지 + 5티어 뱃지(`extractTier(reasons)`) |
| AnnouncementDetail (390~595줄) | "매칭 분석" 섹션 추가 (점수 + reasons + eligible_types) |
| MatchesTab (763~955줄) | 매치 카드에 district + 5티어 뱃지 + reasons 표시 |
| 모듈 상단 | `DEFAULT_PROFILE`에 신규 3필드 기본값 추가, `extractTier` 헬퍼 함수 |
### 2.3 스타일
- `Subscription.css`: 5티어 뱃지 5 클래스(`.sub-chip--tier-S`~`D`), 드래그&드롭 hover/dragover, 슬라이더, 토글, district 뱃지
### 2.4 기각된 대안
| 대안 | 기각 사유 |
|------|-----------|
| 단일 파일에 모든 신규 UI | ProfileTab이 500줄+ 거대화, 디버깅 어려움 |
| Subscription.jsx 자체 분할 | 본 작업 스코프 외, 별도 리팩토링이 적절 |
| `react-dnd` 도입 | 의존성 +50KB, 모바일 어차피 사용 안 함. YAGNI |
| 5칼럼 체크박스 그리드 | 모바일/데스크톱 둘 다 무난하지만 드래그&드롭이 더 직관적이라 채택 안 함 |
---
## 3. DistrictTierEditor 컴포넌트
### 3.1 인터페이스
```jsx
<DistrictTierEditor
value={preferredDistricts} // {"S":["강남구",...], "A":[...], "B":[...], "C":[...], "D":[...]}
onChange={(next) => setProfile({...profile, preferred_districts: next})}
/>
```
`value`가 비어있거나 누락되면 빈 객체 fallback. `onChange`는 새 객체를 항상 한 번 호출(부모는 setState만 처리).
### 3.2 상수
```jsx
const SEOUL_DISTRICTS = [
"강남구","강동구","강북구","강서구","관악구",
"광진구","구로구","금천구","노원구","도봉구",
"동대문구","동작구","마포구","서대문구","서초구",
"성동구","성북구","송파구","양천구","영등포구",
"용산구","은평구","종로구","중구","중랑구",
];
const TIERS = [
{ key: "S", label: "S", weight: "100%" },
{ key: "A", label: "A", weight: "80%" },
{ key: "B", label: "B", weight: "60%" },
{ key: "C", label: "C", weight: "40%" },
{ key: "D", label: "D", weight: "20%" },
];
const EMPTY_TIERS = { S:[], A:[], B:[], C:[], D:[] };
```
### 3.3 데스크톱 레이아웃 (≥768px)
```
┌─ 자치구 우선순위 ─────────────────────────────────────────┐
│ 미할당 (드래그해서 분류) │
│ [강서구] [노원구] [도봉구] [중랑구] [관악구] ... │
│ │
│ ┌─ S 100% ─┐ ┌─ A 80% ─┐ ┌─ B 60% ─┐ ┌─ C 40% ─┐ ┌─ D 20% ─┐│
│ │[강남구]× │ │[송파구]× │ │ │ │ │ │ ││
│ │[서초구]× │ │[마포구]× │ │ │ │ │ │ ││
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘│
└────────────────────────────────────────────────────────────┘
```
- 5티어는 가로 5칼럼 그리드(`grid-template-columns: repeat(5, 1fr)`)
- 미할당 풀은 그리드 위, 가로 wrap
- 자치구 칩은 `<span draggable="true">` + `<button>×</button>` (`×` 클릭 시 미할당으로 복귀)
- 각 티어 슬롯은 dropzone(`onDragOver` + `onDrop`)
- 미할당 풀도 dropzone(드래그해서 떨어뜨리면 해당 티어에서 제거)
### 3.4 모바일 레이아웃 (<768px) — read-only
```
┌─ 자치구 우선순위 ──────────────┐
│ S 100% 강남구, 서초구 │
│ A 80% 송파구, 마포구 │
│ B 60% (없음) │
│ C 40% (없음) │
│ D 20% (없음) │
│ │
│ ✏️ 자치구 분류는 PC에서 편집 │
└──────────────────────────────────┘
```
분기 로직:
```jsx
const [isDesktop, setIsDesktop] = useState(
typeof window !== "undefined" && window.matchMedia("(min-width: 768px)").matches
);
useEffect(() => {
const mq = window.matchMedia("(min-width: 768px)");
const handler = (e) => setIsDesktop(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
```
`isDesktop=false`면 read-only 뷰만 렌더, 드래그 핸들러는 등록하지 않음.
### 3.5 핵심 로직
```jsx
const handleDrop = (district, targetTier /* null = 미할당 */) => {
const current = value || EMPTY_TIERS;
const next = { ...EMPTY_TIERS };
for (const t of Object.keys(EMPTY_TIERS)) {
next[t] = (current[t] || []).filter(d => d !== district);
}
if (targetTier) {
next[targetTier] = [...next[targetTier], district];
}
onChange(next);
};
const unassigned = SEOUL_DISTRICTS.filter(d =>
!TIERS.some(t => (value?.[t.key] || []).includes(d))
);
```
`onChange`는 새 객체를 통째로 전달(immutable update).
### 3.6 드래그&드롭 이벤트 (HTML5 native)
| 이벤트 | 핸들러 |
|--------|--------|
| `onDragStart` (chip) | `e.dataTransfer.setData("district", districtName)` |
| `onDragOver` (zone) | `e.preventDefault()` (drop 허용) |
| `onDrop` (zone) | `e.preventDefault()` + `handleDrop(e.dataTransfer.getData("district"), tierKey)` |
외부 라이브러리 없음.
---
## 4. NotificationSettings 컴포넌트
### 4.1 인터페이스
```jsx
<NotificationSettings
minScore={profile.min_match_score} // number 0~100
notifyEnabled={profile.notify_enabled} // bool
onChange={(patch) => setProfile({...profile, ...patch})}
/>
```
`onChange` 호출 예시: 슬라이더 변경 시 `onChange({ min_match_score: 75 })`, 토글 시 `onChange({ notify_enabled: false })`.
### 4.2 레이아웃
```
┌─ 🔔 알림 설정 ────────────────────────────────┐
│ 텔레그램 알림 ●━━━○ ON │
│ 매칭 임계값 ▬▬▬▬▬▬●▬▬▬ 70점 │
│ 0 50 100 │
│ │
│ 💡 70점 이상 매치 시 텔레그램에 자동 알림합니다│
└────────────────────────────────────────────────┘
```
### 4.3 컨트롤
- 토글: `<input type="checkbox" className="sub-toggle">` + 사용자 정의 CSS (Subscription.css에 `.sub-toggle` 신설)
- 슬라이더: `<input type="range" min="0" max="100" step="5">` + 우측 숫자 라벨
- 미리보기: `notify_enabled === false` 일 때 경고 톤 메시지("알림 OFF — 메시지가 발송되지 않습니다")
### 4.4 저장 동작
각 컨트롤 변경 시 `onChange`로 부모 state만 업데이트. 실제 PUT 요청은 ProfileTab 기존 "저장" 버튼이 일괄 처리(다른 모든 필드와 동일 패턴).
### 4.5 카운트 미리보기 (스코프 외)
"현재 임계값 통과 매치 N건" 같은 카운트 미리보기는 본 스펙에서 다루지 않는다. `dashboard.new_match_count`는 "미확인 매칭"이라 임계값 통과와 의미가 다르고, 정확한 카운트를 위해서는 백엔드에 `dashboard.pass_count` 필드 신설이 필요하다. 후속 스펙으로 분리.
---
## 5. 카드 표시 변경
### 5.1 헬퍼 함수 (Subscription.jsx 모듈 상단)
```jsx
function extractTier(reasons) {
for (const r of reasons || []) {
const m = r.match(/자치구 ([SABCD])티어/);
if (m) return m[1];
}
return null;
}
```
- 백엔드 응답 변경 없이 reasons 배열에서 티어 도출
- reasons 형식 예시: `"자치구 S티어: 강남구 (+25)"` (백엔드 matcher.py의 fmt와 일치)
- 광역만 매칭(legacy 모드)이면 티어 없음 → `null`
### 5.2 AnnouncementCard
기존 카드 제목/지역 라인 옆 또는 메타 라인에 추가:
```jsx
{item.district && (
<span className="sub-chip sub-chip--district">{item.district}</span>
)}
{(() => {
const tier = extractTier(item.match_reasons);
return tier ? (
<span className={`sub-chip sub-chip--tier sub-chip--tier-${tier}`}>
{tier}티어
</span>
) : null;
})()}
```
`item.match_reasons`는 매칭 결과가 있는 경우만 존재. 없으면 뱃지 미표시(공고 목록 탭에서 매칭 결과 없는 카드).
### 5.3 AnnouncementDetail
상세 모달 하단에 새 섹션:
```
┌─ 매칭 분석 ─────────────────────────────────┐
│ ⭐ 점수: 90점 / 100점 │
│ │
│ 💡 매칭 사유 │
│ • 광역 일치: 서울특별시 │
│ • 자치구 S티어: 강남구 (+25) │
│ • 예산 범위 내 모델 존재 (최고가 7.2억원) │
│ • 자격 유형 2개: 일반1순위, 특별-신혼부부 │
│ │
│ ✓ 신청 자격 │
│ [일반1순위] [특별-신혼부부] │
└──────────────────────────────────────────────┘
```
`item.match_score`, `item.match_reasons`, `item.eligible_types`는 이미 응답에 포함됨(get_unnotified_matches는 물론 get_matches/get_announcement도 enrich_items 거침). 매칭 결과가 없는 공고에는 이 섹션 자체를 렌더하지 않음(`item.match_score` 존재 여부로 분기).
### 5.4 MatchesTab
매치 카드는 이미 매칭 데이터를 받지만 district + 5티어 뱃지 표시가 부족할 가능성 높음. AnnouncementCard와 동일한 helper(`extractTier`)로 일관 표시. 카드 클릭 시 AnnouncementDetail 모달이 reasons 노출.
### 5.5 5티어 뱃지 색상 (Subscription.css 신설)
```css
.sub-chip--tier-S { background:#fee2e2; color:#dc2626; border-color:#fca5a5; }
.sub-chip--tier-A { background:#fef3c7; color:#d97706; border-color:#fcd34d; }
.sub-chip--tier-B { background:#d1fae5; color:#059669; border-color:#6ee7b7; }
.sub-chip--tier-C { background:#dbeafe; color:#2563eb; border-color:#93c5fd; }
.sub-chip--tier-D { background:#ede9fe; color:#7c3aed; border-color:#c4b5fd; }
.sub-chip--district { background:#f3f4f6; color:#374151; border-color:#d1d5db; }
```
---
## 6. ProfileTab 통합
### 6.1 DEFAULT_PROFILE 갱신
Subscription.jsx 모듈 상단의 `DEFAULT_PROFILE` 상수에 3 필드 default 추가:
```jsx
const DEFAULT_PROFILE = {
// ... 기존 필드
preferred_regions: '',
preferred_types: '',
min_area: '',
max_area: '',
max_price: '',
// 신규
preferred_districts: {},
min_match_score: 70,
notify_enabled: true,
};
```
### 6.2 ProfileTab 렌더 추가 위치
자치구 섹션은 기존 "선호 조건" 섹션 다음, 알림 설정 섹션은 그 다음에 배치(저장 버튼 위):
```jsx
<DistrictTierEditor
value={profile.preferred_districts}
onChange={(next) => handleChange("preferred_districts", next)}
/>
<NotificationSettings
minScore={profile.min_match_score ?? 70}
notifyEnabled={profile.notify_enabled ?? true}
onChange={(patch) => setProfile(prev => ({ ...prev, ...patch }))}
/>
```
### 6.3 handleSave 변경
신규 3 필드는 변환 없이 그대로 PUT body에 포함:
```jsx
// 기존 변환 로직 다음에
payload.preferred_districts = profile.preferred_districts || {};
payload.min_match_score = profile.min_match_score ?? null;
payload.notify_enabled = profile.notify_enabled ?? null;
```
JSON 형태(객체)는 백엔드 ProfileUpdate 모델에서 `Dict[str, List[str]]`로 받음(이미 구현됨).
---
## 7. 테스트 전략
`web-ui` 레포는 단위 테스트 인프라가 빈약(컨벤션 확인 필요). 본 작업의 검증:
| 영역 | 검증 방식 |
|------|-----------|
| 빌드 | `npm run build` warning/error 없음 |
| 데스크톱 자치구 편집 | 미할당 풀 → S 슬롯 드래그 → 저장 → 새로고침 → 유지 확인 |
| 자치구 티어 이동 | S → A로 드래그 → S에서 사라지고 A에 등장 |
| 자치구 해제 | × 버튼 또는 미할당 풀로 드래그 → 미할당 풀에 복귀 |
| 모바일 read-only | 개발자 도구 < 768px → 편집 영역 숨김 + 안내 메시지 표시 |
| 임계값 슬라이더 | 0→100 조절, 즉시 미리보기 텍스트 갱신, 저장·새로고침 후 유지 |
| 알림 토글 | OFF 시 경고 톤 안내 표시 |
| 매칭 카드 | district 뱃지 + 5티어 뱃지 표시 (해당 데이터 있는 경우) |
| 상세 모달 | 매칭 분석 섹션의 점수 + reasons + 자격 표시 |
| 회귀 | 기존 프로필 필드(나이/청약통장/특공 등) 입력·저장 정상 |
`scripts/dev.bat` 또는 `cd web-ui && npm run dev`로 dev server 실행 후 브라우저에서 수동 검증.
배포는 frontend 별도 절차: `cd web-ui && npm run release:nas` (NAS Z 드라이브에 robocopy).
---
## 8. 스코프
### 본 스펙 범위
- ✅ DistrictTierEditor 신규 컴포넌트
- ✅ NotificationSettings 신규 컴포넌트
- ✅ ProfileTab 신규 3 필드 통합 + 저장
- ✅ AnnouncementCard / MatchesTab district + 5티어 뱃지
- ✅ AnnouncementDetail 매칭 분석 섹션
- ✅ Subscription.css 5티어 뱃지 + 드래그 영역 + 토글 + 슬라이더 스타일
- ✅ 모바일 read-only fallback
### 후속 별도 스펙
- ❌ Subscription.jsx 자체 분할 (1354줄 → 별도 리팩토링)
- ❌ 5축 점수 분해 progress bar (백엔드 응답에 5축 점수 추가 필요)
- ❌ 임계값 통과 매치 카운트 미리보기 (`dashboard.pass_count` 백엔드 신설 필요)
- ❌ 자치구 5티어 자동 추천 (사용자 가점·예산 기반)
- ❌ 알림 채널 추가 (이메일/Slack)
- ❌ 모바일 자치구 편집 지원 (touch backend 필요 시)

View File

@@ -0,0 +1,479 @@
# 청약 서비스 타겟팅 고도화 설계
> 대상: `web-backend/realestate-lab/` + `web-backend/agent-office/`
> 후속 별도 스펙: 프론트 자치구 입력 UI(`web-ui`), 청약 가점 vs 커트라인 비교, 서울 외 광역 자치구 파싱
---
## 1. 목표
현재 청약 서비스가 1) 완료된 공고까지 무차별 수집하고, 2) 매칭이 binary라 단지별 의미 있는 점수 차이가 없으며, 3) 데일리 리포트라 "발견 즉시"의 가치를 못 살리는 문제를 해결한다.
### 핵심 변경
- **수집**: 모집공고 30일 이전 + 이미 `완료` 상태인 공고는 저장하지 않음. 90일 경과 완료 공고 자동 정리.
- **단일 SoT**: `user_profile.preferred_regions`를 수집·조회·매칭의 단일 기준점으로 사용 (서울 default).
- **매칭**: 자치구 5티어 가중치(S=100% / A=80% / B=60% / C=40% / D=20%) 도입. 자격 점수 미세 조정.
- **알림**: 데일리 리포트 폐기. "신규 매칭 + 임계값 통과" 즉시 텔레그램 푸시. realestate-lab → agent-office HTTP push.
### 변경하지 않는 것
- 공공데이터 API 엔드포인트 5종 구성
- 매칭 총점 100점 체계
- 텔레그램 봇 토큰·formatter는 agent-office에 단일 보관
- realestate-lab의 09:00 / 00:00 cron 스케줄(기존 그대로 유지, 트리거 로직만 변경)
---
## 2. 아키텍처 변경 개요
### 2.1 변경 포인트
| # | 위치 | 변경 |
|---|------|------|
| 1 | `realestate-lab/collector.py` | API 호출 시 모집공고일 윈도우 사전 적용. 응답 시 `완료` 상태 skip. 자치구 파싱. 90일 경과 완료 공고 정리. |
| 2 | `realestate-lab/db.py` | `user_profile`에 3컬럼, `announcements``district`, `match_results``notified_at` 추가. `delete_old_completed_announcements()` 신규. |
| 3 | `realestate-lab/matcher.py` | 자치구 5티어 가중치 + 자격 점수 재배분. binary → 자치구 그라디언트. |
| 4 | `realestate-lab` 신규 모듈 | `notifier.py`: 임계값 통과 신규 매칭 추출 + agent-office push. `notified_at` 멱등 마킹. |
| 5 | `agent-office/agents/realestate.py` | 데일리 cron 폐기. `on_new_matches(matches)` 신규. 메시지 fmt + 인라인 키보드. |
| 6 | `agent-office/main.py` | `POST /api/agent-office/realestate/notify` 신규 엔드포인트. |
### 2.2 데이터 흐름
```
[09:00 cron] realestate-lab.scheduled_collect()
├─ collect_all()
│ ├─ API 호출 (RCRIT_PBLANC_DE_FROM = today 30일)
│ ├─ 응답 파싱 + district 추출
│ ├─ status='완료' skip → upsert
│ └─ delete_old_completed_announcements(grace_days=90)
├─ run_matching() // 5티어 가중치 적용
└─ notify_new_matches()
├─ SELECT match_results WHERE notified_at IS NULL
│ AND match_score >= profile.min_match_score
│ AND profile.notify_enabled = 1
├─ POST agent-office /api/agent-office/realestate/notify
└─ 성공 → UPDATE notified_at = now()
[agent-office] POST /api/agent-office/realestate/notify
└─ RealestateAgent.on_new_matches(matches)
├─ formatter로 텔레그램 텍스트 + 인라인 키보드 빌드
└─ telegram_bot.send_message()
```
### 2.3 기각된 대안
| 대안 | 기각 사유 |
|------|-----------|
| 매칭 로직을 agent-office에 이식 | 두 서비스에 매칭 코드 복제 → 동기화 부담 |
| 완료 공고 즉시 삭제 | 사용자가 회고 못 함. 90일 grace 채택 |
| agent-office가 realestate-lab을 폴링 | 트래픽 + 지연 |
| realestate-lab이 직접 텔레그램 호출 | 토큰·formatter 분산. 봇 단일 책임 위반 |
| 가격·면적 그라디언트 곡선 | 점수 해석 어려움. binary 유지 (자치구 1축에만 곡선 적용) |
---
## 3. DB 스키마 변경
### 3.1 `user_profile` — 3컬럼 추가
```sql
ALTER TABLE user_profile ADD COLUMN preferred_districts TEXT NOT NULL DEFAULT '{}';
ALTER TABLE user_profile ADD COLUMN min_match_score INTEGER NOT NULL DEFAULT 70;
ALTER TABLE user_profile ADD COLUMN notify_enabled INTEGER NOT NULL DEFAULT 1;
```
- **`preferred_districts`**: JSON. 5티어 분류.
```json
{"S": ["강남구", "서초구"], "A": ["송파구", "마포구"], "B": [], "C": [], "D": []}
```
모든 티어 비어있으면 자치구 기준 미설정으로 간주 (기존 호환 동작).
- **`min_match_score`**: 알림 트리거 임계값(0~100). 기본 70.
- **`notify_enabled`**: 텔레그램 푸시 ON/OFF. 0이면 알림 전체 차단.
### 3.2 `announcements` — `district` 컬럼 추가
```sql
ALTER TABLE announcements ADD COLUMN district TEXT;
CREATE INDEX IF NOT EXISTS idx_ann_district ON announcements(district);
```
- collector가 응답의 `HSSPLY_ADRES` / `region_name`을 정규식 파싱하여 채움.
- 서울 외 지역, 파싱 실패 → NULL.
### 3.3 `match_results` — `notified_at` 컬럼 추가
```sql
ALTER TABLE match_results ADD COLUMN notified_at TEXT;
```
- NULL이면 미알림. 알림 송신 후 `strftime('%Y-%m-%dT%H:%M:%fZ','now')` 기록.
- 기존 `is_new`(사용자가 UI에서 봤는지)와 의미 분리.
### 3.4 신규 함수
```python
def delete_old_completed_announcements(grace_days: int = 90) -> int:
"""winner_date + grace_days 경과한 status='완료' 공고를 삭제.
winner_date가 NULL인 행은 안전하게 보존(수동 검토 대상).
match_results는 FK CASCADE로 자동 삭제. 삭제된 건수 반환.
"""
```
```python
def get_unnotified_matches(min_score: int) -> list[dict]:
"""notified_at IS NULL AND match_score >= min_score 인 매칭 + 공고 정보 조인 반환."""
```
```python
def mark_matches_notified(match_ids: list[int]) -> None:
"""notified_at = now() 일괄 업데이트."""
```
### 3.5 마이그레이션 패턴
기존 db.py의 `init_db()` 안에서 try/except로 컬럼 존재 여부 검사 후 ALTER (운영 DB 무중단).
---
## 4. collector 변경
### 4.1 모집공고일 윈도우 사전 좁힘
```python
def collect_all() -> dict:
today = date.today()
date_from = (today - timedelta(days=30)).strftime("%Y%m%d")
for detail_ep, model_ep in DETAIL_ENDPOINTS:
rows = _api_call(detail_ep, params={
# 공공데이터 API 파라미터명은 엔드포인트별로 다를 수 있음.
# 구현 시 한국부동산원 API 스펙 확인 후 정확한 키 적용.
"RCRIT_PBLANC_DE_FROM": date_from,
})
# ...
```
> ⚠️ **구현 시 검증 필요**: `ApplyhomeInfoDetailSvc`의 5개 엔드포인트가 모두 모집공고일 필터 파라미터를 지원하지 않을 수 있음. 미지원 시 응답 수신 후 클라이언트 측에서 `parsed["rcrit_date"] < date_from` skip하는 fallback을 적용.
### 4.2 `완료` 상태 skip
```python
parsed = _parse_apt_detail(raw)
parsed["district"] = _extract_district(parsed)
status = compute_status(
parsed.get("receipt_start", ""),
parsed.get("receipt_end", ""),
parsed.get("winner_date", ""),
)
if status == "완료":
continue # DB 자원 절감
# 일정 정보 없는 공고 skip (기존 로직 유지)
has_dates = any(parsed.get(f) for f in (...))
if not has_dates:
continue
upsert_announcement(parsed)
```
### 4.3 자치구 추출
```python
DISTRICT_PATTERN = re.compile(r"(?:서울특별시|서울시|서울)\s+(\S+?(?:구|군))")
def _extract_district(parsed: dict) -> str | None:
for src in (parsed.get("address"), parsed.get("region_name")):
if not src:
continue
m = DISTRICT_PATTERN.search(src)
if m:
return m.group(1)
return None
```
### 4.4 정리 + 매칭 + 알림 트리거
```python
def collect_all() -> dict:
# ... 위 수집 로직
save_collect_log(new_count, total_count)
return {"new_count": new_count, "total_count": total_count}
def scheduled_collect():
"""09:00 cron — 수집 + 정리 + 매칭 + 알림"""
collect_all()
deleted = delete_old_completed_announcements(grace_days=90)
logger.info("정리: %d건 삭제", deleted)
run_matching()
notify_new_matches() # NEW
```
---
## 5. matcher 변경
### 5.1 가중치 재배분 (총 100점 유지)
| 축 | 기존 | 신규 |
|----|------|------|
| 지역 | 30 | **35** (광역 10 + 자치구 가중 0~25) |
| 주택유형 | 10 | 10 |
| 면적 | 15 | 15 |
| 가격 | 15 | 15 |
| 자격 | 30 | **25** |
### 5.2 지역 점수 (35점)
```python
TIER_WEIGHTS = {"S": 1.00, "A": 0.80, "B": 0.60, "C": 0.40, "D": 0.20}
def _region_score(profile: dict, ann: dict) -> tuple[int, list[str]]:
region_name = ann.get("region_name") or ""
district = ann.get("district") or ""
preferred_regions = profile.get("preferred_regions") or []
preferred_districts = profile.get("preferred_districts") or {}
region_match = bool(region_name and any(r in region_name for r in preferred_regions))
if not region_match:
return 0, []
# 자치구 기준 미설정 → 광역만으로 풀 점수 (기존 호환)
has_districts = any(preferred_districts.get(t) for t in TIER_WEIGHTS)
if not has_districts:
return 35, [f"선호 지역 일치: {region_name}"]
score = 10
reasons = [f"광역 일치: {region_name}"]
for tier, weight in TIER_WEIGHTS.items():
if district in (preferred_districts.get(tier) or []):
tier_score = round(25 * weight)
score += tier_score
reasons.append(f"자치구 {tier}티어: {district} (+{tier_score})")
break
return score, reasons
```
### 5.3 자격 점수 (25점)
```python
def _eligibility_score(eligible_types: list[str]) -> int:
if not eligible_types:
return 0
score = 15 # 첫 자격
score += min((len(eligible_types) - 1) * 5, 10) # 추가 자격당 +5, 최대 +10
return score
```
다른 축(주택유형 10, 면적 15, 가격 15)은 기존 binary 로직 유지.
### 5.4 매칭 결과 저장
`run_matching()`은 기존 흐름 유지. `match_results.notified_at`은 손대지 않음 (notifier가 관리).
---
## 6. 알림 흐름
### 6.1 realestate-lab 측 — `notifier.py`
```python
import os
import requests
from .db import get_unnotified_matches, mark_matches_notified, get_profile
AGENT_OFFICE_URL = os.getenv("AGENT_OFFICE_URL", "http://agent-office:8000")
def notify_new_matches() -> dict:
profile = get_profile()
if not profile or not profile.get("notify_enabled"):
return {"sent": 0, "skipped": "notify_disabled"}
threshold = profile.get("min_match_score", 70)
matches = get_unnotified_matches(threshold)
if not matches:
return {"sent": 0}
try:
resp = requests.post(
f"{AGENT_OFFICE_URL}/api/agent-office/realestate/notify",
json={"matches": matches},
timeout=15,
)
resp.raise_for_status()
body = resp.json()
sent_ids = body.get("sent_ids", [])
if sent_ids:
mark_matches_notified(sent_ids)
return body
except requests.RequestException as e:
logger.error("알림 push 실패: %s", e)
return {"sent": 0, "error": str(e)}
```
알림 push 실패 시 `notified_at`을 채우지 않아 다음 사이클에서 재시도된다.
### 6.2 agent-office 측 — 신규 엔드포인트
```python
# agent-office/main.py
@app.post("/api/agent-office/realestate/notify")
async def realestate_notify(body: dict):
matches = body.get("matches", [])
agent = registry.get("realestate")
result = await agent.on_new_matches(matches)
return result
```
```python
# agents/realestate.py
async def on_new_matches(self, matches: list[dict]) -> dict:
if not matches:
return {"sent": 0, "sent_ids": []}
text = telegram_formatter.format_realestate_matches(matches)
keyboard = telegram_formatter.build_match_keyboard(matches)
tg = await telegram_bot.send_message(text, reply_markup=keyboard)
if not tg.get("ok"):
return {"sent": 0, "sent_ids": [], "error": tg.get("error")}
sent_ids = [m["id"] for m in matches]
return {"sent": len(matches), "sent_ids": sent_ids, "message_id": tg.get("message_id")}
```
### 6.3 텔레그램 메시지 포맷
**3건 이상 — 묶음 카드**
```
🏢 새 청약 매칭 3건
⭐ 92점 — 디에이치 강남 [S]
📍 서울 강남구 (분양가상한제) · 32~45㎡ · 6.2~9.8억
📅 청약 05/15(수) ~ 05/19(일)
⭐ 78점 — 마포 푸르지오 [A]
📍 서울 마포구 · 59~84㎡ · 8.0~11.5억
📅 청약 05/22(수) ~ 05/26(일)
⭐ 72점 — 송파 데시앙 [A]
📍 서울 송파구 · 39~59㎡ · 5.8~7.9억
📅 청약 05/27(월) ~ 05/30(목)
[전체 보기]
```
**1~2건 — 풀 카드**
```
⭐ 90점 — 디에이치 강남 [S]
📍 서울 강남구 (분양가상한제)
🏠 32~45㎡ · 6.2~9.8억
📅 청약 05/15(수) ~ 05/19(일)
✓ 자격: 일반1순위, 특별-신혼부부
💡 광역 일치 / 자치구 S티어 / 예산 범위 / 자격 2개
[🔖 북마크] [📄 공고 보기]
```
### 6.4 인라인 키보드 콜백
| 버튼 | 콜백 동작 |
|------|-----------|
| `[🔖 북마크]` | `PATCH /api/realestate/announcements/{id}/bookmark` (기존 endpoint) |
| `[📄 공고 보기]` | `pblanc_url` (텔레그램 URL 버튼) |
| `[전체 보기]` | 대시보드 deep link (`/realestate?tab=matches`) |
agent-office의 텔레그램 webhook(`/api/agent-office/telegram/webhook`)이 callback_query를 받아 service_proxy로 realestate-lab API 호출.
### 6.5 기존 RealestateAgent 동작 정리
```python
# agent-office/scheduler.py — 09:15 데일리 cron 제거
# scheduler.add_job(realestate_agent.on_schedule, ...) ← REMOVE
```
`RealestateAgent.on_schedule()`은 호출 지점이 사라지므로 제거. `on_command("fetch_matches")`는 수동 트리거(텔레그램 슬래시 명령)용으로 보존하되 `on_new_matches()`를 직접 호출하도록 단순화.
### 6.6 환경변수
| 변수 | 위치 | 기본값 |
|------|------|--------|
| `AGENT_OFFICE_URL` | realestate-lab `.env` | `http://agent-office:8000` |
| `TELEGRAM_BOT_TOKEN` / `TELEGRAM_CHAT_ID` | agent-office (기존) | (기존) |
docker-compose의 사내 네트워크로 호출되므로 외부 노출 없음.
---
## 7. API 변경 요약
### 7.1 realestate-lab
| 메서드 | 경로 | 변경 |
|--------|------|------|
| PUT | `/api/realestate/profile` | body에 `preferred_districts`, `min_match_score`, `notify_enabled` 수용 |
| GET | `/api/realestate/profile` | 응답에 위 3필드 포함 |
| GET | `/api/realestate/announcements` | 응답 item에 `district` 포함 |
| GET | `/api/realestate/announcements/{id}` | 응답에 `district` 포함 |
| GET | `/api/realestate/matches` | 응답 item에 `notified_at` 포함 (디버깅용) |
### 7.2 agent-office
| 메서드 | 경로 | 변경 |
|--------|------|------|
| POST | `/api/agent-office/realestate/notify` | **신규** — realestate-lab 전용 push 수신 |
### 7.3 Pydantic 모델 확장
```python
# realestate-lab/app/models.py
class ProfileUpdate(BaseModel):
# ... 기존 필드
preferred_districts: Optional[Dict[str, List[str]]] = None
min_match_score: Optional[int] = Field(default=None, ge=0, le=100)
notify_enabled: Optional[bool] = None
```
---
## 8. 테스트 전략
| 영역 | 테스트 항목 |
|------|-------------|
| `_extract_district` | "서울특별시 강남구 도곡동" → `"강남구"`, "서울 송파구" → `"송파구"`, "부산 해운대구" → NULL, "" → NULL |
| `compute_status` | 변경 없음. 기존 테스트 유지 |
| `_region_score` | 광역 미매칭 / 광역만 매칭 + 자치구 미설정 / S~D 티어별 / 광역 매칭 + 비선호 자치구 — 5케이스 |
| `_eligibility_score` | 자격 0개 / 1개 / 3개 / 5개 — 점수 단조 증가 + 25 상한 |
| `delete_old_completed_announcements` | winner_date 91일 전 → 삭제, 89일 전 → 보존, status≠'완료' → 보존 |
| collector 사전 좁힘 | mock API 응답으로 30일 윈도우 외 데이터 skip 확인. `완료` skip 확인 |
| `notify_new_matches` 멱등성 | `notified_at` 채워진 매치는 push 후보 제외, push 실패 시 `notified_at` 미기록 → 다음 사이클 재시도 |
| agent-office push endpoint | mock telegram client로 `format_realestate_matches` 호출 + send 검증 |
| 알림 임계값 필터 | min_match_score=70, score=69 → push 대상 외 / score=70 → 포함 |
| `notify_enabled=0` | push 자체 skip |
NAS Docker는 git push 자동 배포이므로 별도 절차 없음. ALTER TABLE은 init_db에서 try/except 패턴으로 운영 DB 무중단 적용.
---
## 9. 스코프
### 본 스펙 범위
- ✅ realestate-lab: collector, matcher, db 변경, notifier 신규
- ✅ agent-office: `/realestate/notify` 엔드포인트, `on_new_matches` 메소드, 메시지 formatter
- ✅ 기존 데일리 RealestateAgent cron 폐기
### 후속 별도 스펙
- ❌ 프론트(`web-ui`) 자치구 5티어 입력 UI (별도 frontend 스펙)
- ❌ 청약 가점 vs 공고별 예상 커트라인 비교 (외부 데이터 의존성, 별도 연구)
- ❌ 서울 외 광역(부산 해운대구 등) 자치구 파싱 확장
- ❌ 매칭 임계값 변경 후 재발송 트리거 (`POST /notifications/resend`)
- ❌ 자치구별 매칭 분포 대시보드 위젯

View File

@@ -0,0 +1,359 @@
# music-lab YouTube 수익화 고도화 설계
> 작성일: 2026-05-01
> 범위: music-lab + agent-office 확장
---
## 1. 개요
Suno API로 생성한 음악을 YouTube 업로드 가능한 완성 영상으로 만들고, 시장 수요 분석을 통해 수익이 나는 콘텐츠를 정기적으로 생산하는 파이프라인 구축.
**핵심 목표:**
- 시장 조사 자동화 → 만들 만한 장르/스타일 추천
- 음악 + 영상 합성 → YouTube 업로드 패키지(MP4 + 메타데이터) 자동 생성
- 수익 추적 → 채널별·장르별·국가별 RPM 분석
- **Phase 1**: 파일 내보내기(수동 업로드) → **Phase 3**: YouTube API 자동 업로드
---
## 2. 결정 사항 요약
| 항목 | 결정 |
|------|------|
| 자동화 수준 | 반자동 — 수집·추천 자동, 생성·업로드 수동 트리거 |
| 업로드 방식 | Phase 1: 파일 내보내기, Phase 3: YouTube API |
| 영상 포맷 | 오디오 비주얼라이저 + AI 이미지 슬라이드쇼 |
| 시장 조사 데이터 | YouTube 트렌드 + Google Trends + Billboard (해외 시장 포함) |
| 음악 언어 전략 | 인스트루멘탈 + 영어 가사 혼합 |
| 이미지 소스 | Suno 커버이미지 + Pexels/Unsplash (추후 Stable Diffusion) |
| 주력 해외 시장 | 브라질, 인도네시아, 멕시코, 글로벌 |
---
## 3. 아키텍처
```
[외부 데이터 소스]
YouTube Data API v3 · Google Trends · Billboard · Pexels/Unsplash
↓ 매일 09:00 스케줄
[agent-office :18900]
YouTubeResearchAgent (신규)
- 국가별 트렌딩 수집·분석
- POST /api/music/market/ingest → music-lab push
- 매주 월요일 08:00 텔레그램 인사이트 리포트
[music-lab :18600]
기존: 음악 생성 · 라이브러리
신규: 시장 데이터 저장 · 영상 제작 파이프라인 · 수익화 추적
[내보내기 패키지]
output.mp4 + thumbnail.jpg + metadata.json
(Phase 3: YouTube API 자동 업로드)
```
**변경 없는 것:** 컨테이너 수, 포트 배정, Nginx 라우팅 (경로 1개 추가 제외)
---
## 4. DB 스키마 (신규)
### 4-1. music.db 신규 테이블
#### `market_trends`
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | INTEGER PK | |
| source | TEXT | `'youtube'` \| `'google_trends'` \| `'billboard'` |
| country | TEXT | `'BR'` \| `'ID'` \| `'MX'` \| `'US'` \| `'KR'` … |
| genre | TEXT | 장르 문자열 |
| keyword | TEXT | 검색 키워드 |
| score | REAL | 정규화 인기도 (0.0~1.0) |
| rank | INTEGER | 차트 순위 (nullable) |
| metadata | TEXT | JSON — 추가 원본 데이터 |
| collected_at | TEXT | ISO8601 |
인덱스: `(country, source, collected_at DESC)`
#### `trend_reports`
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | INTEGER PK | |
| report_date | TEXT UNIQUE | YYYY-MM-DD |
| top_genres | TEXT | JSON 배열 `[{genre, score, countries}]` |
| top_keywords | TEXT | JSON 배열 |
| recommended_styles | TEXT | JSON `[{genre, prompt, countries, reason}]` |
| insights | TEXT | AI 분석 텍스트 |
| created_at | TEXT | ISO8601 |
#### `video_projects`
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | INTEGER PK | |
| track_id | INTEGER FK | → music_library.id |
| format | TEXT | `'visualizer'` \| `'slideshow'` |
| status | TEXT | `'pending'` \| `'rendering'` \| `'done'` \| `'failed'` |
| output_path | TEXT | MP4 로컬 경로 |
| output_url | TEXT | `/media/videos/…` 서빙 URL |
| thumbnail_path | TEXT | JPG 로컬 경로 |
| target_countries | TEXT | JSON 배열 `['BR', 'ID']` |
| yt_title | TEXT | Claude API 생성 제목 (최대 100자) |
| yt_description | TEXT | Claude API 생성 설명 (해시태그 포함) |
| yt_tags | TEXT | JSON 배열 (10-15개, 국가별 현지화) |
| render_params | TEXT | JSON — 렌더링 파라미터 (색상, 전환 효과 등) |
| error | TEXT | 실패 시 에러 메시지 |
| created_at | TEXT | ISO8601 |
| completed_at | TEXT | ISO8601 (nullable) |
#### `revenue_records`
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | INTEGER PK | |
| video_project_id | INTEGER FK | → video_projects.id (nullable) |
| yt_video_id | TEXT | YouTube 영상 ID |
| record_month | TEXT | YYYY-MM |
| views | INTEGER | 조회수 |
| watch_hours | REAL | 시청 시간 (시간 단위) |
| revenue_usd | REAL | 수익 (USD) |
| rpm_usd | REAL | revenue / views * 1000 |
| country | TEXT | 국가별 분석용 (nullable) |
| source | TEXT | `'manual'` \| `'youtube_api'` |
| created_at | TEXT | ISO8601 |
### 4-2. agent_office.db 신규 테이블
#### `youtube_research_jobs`
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | INTEGER PK | |
| status | TEXT | `'running'` \| `'completed'` \| `'failed'` |
| countries | TEXT | JSON 배열 — 수집 대상 국가 |
| trends_collected | INTEGER | 수집된 트렌드 건수 |
| report_id | INTEGER | 생성된 trend_reports.id (nullable) |
| error | TEXT | 실패 시 에러 |
| started_at | TEXT | ISO8601 |
| completed_at | TEXT | ISO8601 (nullable) |
---
## 5. 신규 API 엔드포인트
### 5-1. music-lab — 시장 조사
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/music/market/trends` | 트렌드 목록 (`country`, `genre`, `source`, `days` 필터) |
| GET | `/api/music/market/report/latest` | 최신 분석 리포트 + 추천 스타일 |
| GET | `/api/music/market/report` | 리포트 이력 |
| POST | `/api/music/market/ingest` | agent-office → 트렌드 데이터 수신 |
| GET | `/api/music/market/suggest` | 트렌드 기반 제작 아이디어 추천 |
### 5-2. music-lab — 영상 제작
| 메서드 | 경로 | 설명 |
|--------|------|------|
| POST | `/api/music/video-project` | 프로젝트 생성 (`track_id`, `format`, `target_countries`) |
| GET | `/api/music/video-projects` | 프로젝트 목록 |
| GET | `/api/music/video-project/{id}` | 프로젝트 상세 + 렌더링 상태 |
| POST | `/api/music/video-project/{id}/render` | 렌더링 시작 (BackgroundTask) |
| GET | `/api/music/video-project/{id}/export` | 내보내기 패키지 (MP4 URL + metadata JSON) |
| DELETE | `/api/music/video-project/{id}` | 프로젝트 삭제 |
### 5-3. music-lab — 수익화 추적
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/music/revenue` | 수익 기록 (`yt_video_id`, `year_month` 필터) |
| POST | `/api/music/revenue` | 수익 기록 추가 |
| PUT | `/api/music/revenue/{id}` | 수익 기록 수정 |
| DELETE | `/api/music/revenue/{id}` | 수익 기록 삭제 |
| GET | `/api/music/revenue/dashboard` | 총수익·RPM·장르별·국가별 집계 |
### 5-4. agent-office — YouTube 리서치
| 메서드 | 경로 | 설명 |
|--------|------|------|
| POST | `/api/agent-office/youtube/research` | 수동 리서치 트리거 (`countries` 지정 가능) |
| GET | `/api/agent-office/youtube/research/status` | 마지막 실행 상태 + 수집 건수 |
---
## 6. 영상 제작 파이프라인
### 6-1. 오디오 비주얼라이저 (`format: 'visualizer'`)
```
MP3 (file_path) + 배경 이미지 (cover_images[0] 우선, 없으면 장르별 그라디언트 기본 배경)
→ FFmpeg showwaves 필터 (1920×1080, 음파 오버레이)
→ H.264 + AAC MP4
→ 썸네일 추출 (5초 지점 프레임)
→ Claude API 메타데이터 생성
```
핵심 FFmpeg 명령:
```bash
ffmpeg -loop 1 -i cover.jpg -i audio.mp3 \
-filter_complex \
"[1:a]showwaves=s=1920x200:mode=cline:colors=0xFF4444[wave]; \
[0:v][wave]overlay=0:880[out]" \
-map "[out]" -map 1:a \
-c:v libx264 -c:a aac -shortest output.mp4
```
적합 장르: Lo-fi, Ambient, Study Music, Phonk
### 6-2. AI 이미지 슬라이드쇼 (`format: 'slideshow'`)
```
① 키워드 추출 (genre + moods + prompt → 검색어)
② 이미지 수집
- Pexels API: 키워드 검색 4-6장 (무료 200req/시간)
- Suno 커버이미지: cover_images 필드에서 1-2장
③ 이미지당 표시 시간 = track.duration_sec / 이미지 수
④ FFmpeg xfade 전환 (fade, 1초)
⑤ H.264 + AAC MP4 출력
⑥ 썸네일 추출 + Claude API 메타데이터 생성
```
### 6-3. 공통 후처리
**Claude API 메타데이터 생성:**
- 입력: `genre`, `moods`, `lyrics`, `target_countries`
- 출력:
- `yt_title`: 최대 100자, SEO 최적화, 국가 감안
- `yt_description`: 해시태그 + 타임스탬프 + 링크 플레이스홀더
- `yt_tags`: 10-15개, 현지어 포함 (예: 브라질 타겟 → `"música relaxante"`, `"estudo música"`)
**내보내기 패키지:**
```
/data/videos/{project_id}/
output.mp4 ← 최종 영상
thumbnail.jpg ← 썸네일
metadata.json ← {title, description, tags, target_countries, category}
```
---
## 7. YouTubeResearchAgent (agent-office)
**파일:** `agents/youtube.py`
**데이터 수집 (매일 09:00):**
1. YouTube Data API v3 — 국가별 (`BR`, `ID`, `MX`, `US`, `KR`) 트렌딩 음악 카테고리 50개
2. pytrends — 장르별 Google Trends 점수 (최근 7일)
3. Billboard Hot 100 스크래핑 — 글로벌 차트 상위 20
**분석 → trend_reports 생성:**
- 소스별 score 정규화 후 장르 클러스터링
- `recommended_styles` 생성: `{genre, suno_prompt, target_countries, reason}`
- Claude API로 `insights` 텍스트 생성
**push → music-lab:**
```
POST http://music-lab:8000/api/music/market/ingest
body: {trends: [...], report: {...}}
```
**스케줄러:**
- 매일 09:00 — `youtube_research_job`
- 매주 월요일 08:00 — 주간 인사이트 텔레그램 발송
---
## 8. 인프라 변경사항
| 대상 | 변경 내용 |
|------|-----------|
| `music-lab/Dockerfile` | `RUN apt-get install -y ffmpeg` 추가 |
| `nginx/default.conf` | `/media/videos/``/data/videos/` 경로 추가 |
| `music-lab/requirements.txt` | `anthropic`, `Pillow` 추가 |
| `agent-office/requirements.txt` | `google-api-python-client`, `pytrends` 추가 |
| `.env` | `PEXELS_API_KEY`, `YOUTUBE_DATA_API_KEY` 추가 |
| `docker-compose.yml` | music-lab volume에 `/data/videos` 마운트 추가 |
**CLAUDE.md 업데이트 필요:**
- Nginx: `/media/videos/` 경로 추가
- music-lab API 목록에 신규 16개 추가 (시장조사 5 + 영상제작 6 + 수익화 5), agent-office 2개 추가
- agent-office 스케줄러에 youtube_research_job 추가
---
## 9. 수익화 전략
### 9-1. YouTube 광고 수익 (CPM 기준)
| 국가 | CPM 범위 |
|------|---------|
| 브라질 | $1.5 ~ $4 |
| 인도네시아 | $1.0 ~ $2.5 |
| 미국 | $3.0 ~ $8.0 |
| 한국 | $2.0 ~ $5.0 |
Lo-fi / Ambient은 긴 시청 시간 유도 → RPM 유리. 인스트루멘탈은 언어 장벽 없음.
### 9-2. 국가별 장르 전략
| 국가 | 주력 장르 |
|------|-----------|
| 브라질 | Funk, Phonk, Lo-fi |
| 인도네시아 | Pop, Study Music, Lo-fi |
| 멕시코 | Latin Pop, Reggaeton |
| 글로벌 | Ambient, Cinematic |
### 9-3. 업로드 목표
- **주 3-5개** 영상 업로드 (시스템 안정화 후 일 1개 목표)
- 영상 **50개** 누적 → 수익 활성화 (구독자 1,000 + 시청 4,000시간)
- 영상 **200개** 누적 → 월 $100+ 수동 수익 목표
---
## 10. 구현 로드맵
### Phase 1 — 영상 제작 파이프라인 (약 2-3주)
**music-lab 백엔드:**
- `video_producer.py` — FFmpeg 래퍼 (비주얼라이저 + 슬라이드쇼)
- `market.py` — 트렌드 데이터 수신·저장·조회·추천
- `monetization.py` — 수익화 추적 CRUD
- DB 마이그레이션: `video_projects`, `revenue_records`
- 신규 API 12개 (영상 제작 6 + 수익화 5 + market ingest 1)
- Dockerfile `ffmpeg` 추가
- Nginx `/media/videos/` 경로 추가
### Phase 2 — 시장 조사 자동화 (약 1-2주)
**agent-office:**
- `agents/youtube.py` (YouTubeResearchAgent)
- YouTube Data API v3 연동
- pytrends 연동
- Billboard 스크래핑
- 스케줄러 등록 (매일 09:00, 매주 월요일 08:00)
- `youtube_research_jobs` DB 테이블
- 신규 API 2개 + agent-office API 2개
**music-lab:**
- DB 마이그레이션: `market_trends`, `trend_reports`
- 신규 API 4개 (트렌드 조회 3 + 추천 1)
### Phase 3 — YouTube API 자동 업로드 (채널 안정화 후)
- YouTube Data API OAuth 2.0 인증
- 동영상 업로드·썸네일 설정 자동화
- YouTube Studio 수익 데이터 자동 수집 (`source: 'youtube_api'`)
- 텔레그램 업로드 완료 알림
---
## 11. 신규 파일 목록
### music-lab/app/
- `video_producer.py` — FFmpeg 비주얼라이저·슬라이드쇼 렌더링
- `market.py` — 시장 트렌드 수신·저장·조회·추천
- `monetization.py` — 수익 기록 CRUD·대시보드
### agent-office/app/agents/
- `youtube.py` — YouTubeResearchAgent
### agent-office/app/
- `youtube_researcher.py` — YouTube/Trends/Billboard 데이터 수집 로직

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