208 Commits

Author SHA1 Message Date
c7036212e2 merge: co-gahusb DNS-rebinding 421 핫픽스 2026-06-12 10:20:05 +09:00
756d9fccf3 fix(co-gahusb): DNS-rebinding 보호 비활성화 (public Host 421 해결)
- FastMCP가 기본 host(127.0.0.1)에서 DNS rebinding 보호를 자동 활성화 →
  allowed_hosts=localhost만 허용 → nginx가 넘기는 Host gahusb.synology.me가 421.
- 실 보안은 nginx 앞단 Bearer 인증(MCP 도달 전 401)이므로 Host 검증 비활성화.
- 재현/회귀 테스트 추가 + config.CO_BUS_KEY import-순서 격리 버그 수정 (23 통과).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:20:04 +09:00
ea5cf49cea merge: co-gahusb 세션 협업 팀 버스 (MCP + Redis + 어드바이저리 락)
- FastMCP streamable-http 서버(12툴) + Bearer 인증 + Redis 백엔드
- 메시지/작업보드/락/team_log, 동시쓰기 분리(소유권 파티션 + 락)
- compose(18920)/nginx(/api/co/)/deploy 등재 + 클라이언트 배선
- 22 테스트 (전부 통과)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 07:51:00 +09:00
d07a8dad76 feat(co-gahusb): BE 클라이언트 배선 (.mcp.json + 역할 블록 + 셋업 문서)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 07:34:08 +09:00
d74bc189b5 feat(co-gahusb): deploy SERVICES 화이트리스트 등재
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 07:32:10 +09:00
d4405204f9 feat(co-gahusb): nginx public /api/co/ 라우팅 (Authorization forward, no-buffer)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 07:31:44 +09:00
2c157334dc feat(co-gahusb): docker-compose 서비스 등재 (18920, depends_on redis)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 07:31:28 +09:00
d840859fc9 fix(co-gahusb): update_task 존재하지 않는 task_id not_found 가드 2026-06-12 07:30:03 +09:00
e115eee159 feat(co-gahusb): FastMCP 서버 (12 툴 + Bearer 인증 + health) 2026-06-12 07:25:47 +09:00
fc1ebf134d docs(checkpoint): oversight 프론트 배포 완료 반영
ActivityTimeline 프론트 NAS 라이브 반영 완료(SSH 직접 배포, Z: 매핑 우회).
56d0f5b 위 새 커밋 — feat/co-gahusb-team-bus가 56d0f5b를 base로 의존하므로
amend 대신 신규 커밋.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 07:23:45 +09:00
d71937b6ee feat(co-gahusb): team_log 활동 피드 (capped, TDD) 2026-06-12 07:23:14 +09:00
0cc4505af7 feat(co-gahusb): 작업 보드 (create/claim/update/list, TDD) 2026-06-12 07:22:55 +09:00
9c18f0a467 feat(co-gahusb): 메시지 inbox (post/read/mark_read, TDD) 2026-06-12 07:22:36 +09:00
8212a51f90 feat(co-gahusb): 어드바이저리 락 (acquire/release/heartbeat/list, TDD) 2026-06-12 07:20:30 +09:00
0d466b235c feat(co-gahusb): 스캐폴드 (Dockerfile·requirements·config) 2026-06-12 07:19:51 +09:00
1129600341 docs: co-gahusb 팀 버스 구현 플랜 (11 태스크, TDD)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 01:31:06 +09:00
2a0a2f3490 docs: co-gahusb 세션 협업 팀 버스 설계 spec
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 01:26:11 +09:00
56d0f5b8a8 docs(checkpoint): 5/25~6/12 작업 전면 반영 + 보드 재편
5/22 이후 누락분(tarot/saju 분리·신설, _shared 로그, lotto v3 백테스트,
stock 보유종목 인텔, nginx CVE, insta 카드뉴스 v2 + 자율발급, 에이전트
오버사이트, music 파이프라인 신뢰성) 완료 타임라인에 반영. 미완성 큰
기능(Video Studio 프론트) + 후속(music stuck 감지) + 백로그 재편.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 01:18:48 +09:00
796ac6d39f test(agent-office): test_init_and_seed stale 단언 수정 (고정 개수→subset)
에이전트 레지스트리가 2→7로 늘어 len==2/{stock,music} 고정 단언이 stale였음. 핵심 시드 subset 검증으로 변경(레지스트리 확장에 견고). 이번 세션 audit에서 반복 플래그된 부채.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:48:58 +09:00
18cea427be docs(music): 파이프라인 retry 엔드포인트 문서화
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:46:04 +09:00
6c178006d3 feat(agent-office): ytpub_retry 텔레그램 콜백 → music-lab retry 프록시
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:39:31 +09:00
084e4f1b4d feat(agent-office): youtube_publisher 파이프라인 실패 텔레그램 알림+재시도 버튼
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:36:38 +09:00
d048251a97 feat(agent-office): service_proxy pipeline_retry/list_failed_pipelines (+ music-lab status=failed 필터)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:33:28 +09:00
ef1a7a92fd fix(music-lab): retry 레이스 가드(retrying 전이) + failed_step 검증 + backoff 빈리스트 가드
- Fix 1: retry_pipeline이 bg.add_task 직전 상태를 'retrying'으로 전이 → 동시 retry 409 방지
- Fix 2: test_retry_failed_pipeline_retriggers에 called[pid/step] assert 추가
- Fix 3: failed_step이 STEPS에 없으면 409 (엉뚱한 prefix 방지)
- Fix 4: STEP_RETRY_BACKOFF_SEC 빈 리스트 시 IndexError → 0으로 폴백

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:31:19 +09:00
44dbe7c426 feat(music-lab): POST /pipeline/{id}/retry — 실패 step 수동 재개
terminal failed 파이프라인을 마지막 실패 step부터 재개.
publish + youtube_video_id 있으면 중복 업로드 방지 409.
pytest.ini에 pythonpath=.. 추가 (PYTHONPATH=.. 없이 TestClient 테스트 구동).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:23:24 +09:00
e90e25d78f feat(music-lab): orchestrator step 자동 재시도 (publish 제외)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:20:29 +09:00
d638666659 feat(music-lab): get_last_failed_step — 파이프라인 재개용 실패 step 판별
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:18:07 +09:00
51eff1538e docs(plan): music 파이프라인 신뢰성·복구 구현 계획 (7 tasks, TDD)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:12:33 +09:00
ffb96de61d docs(spec): music/YouTube 파이프라인 신뢰성·복구 설계
step 자동 재시도(publish 제외) + terminal failed의 실패 step 수동 재개(텔레그램 [재시도]). orchestrator + retry 엔드포인트 + youtube_publisher 실패 알림.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:08:01 +09:00
c8ce6cb617 fix(packs-lab): 일회성 토큰 jti 영속화 (SQLite) — 재시작 replay 방어 유지
인메모리 _used_jti set은 컨테이너 재시작 시 비워져 TTL 내 토큰 replay가 가능했음(webhook 배포가 잦아 실재 구멍). 영속 볼륨(PACK_BASE_DIR)의 jti_store.db에 사용 jti를 기록(PK 원자성), 만료 항목은 lazy 정리. verify_upload_token이 jti_store.consume 사용. TDD 3 + 기존 replay 테스트 보존.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:08:02 +09:00
3c11b75a5f fix(agent-office/lotto): deep CuratorError fallthrough + urgent 발송 재시도
결함1: deep signal-check에서 curate_weekly가 CuratorError면 전체 check가 abort돼 sim/drift 시그널이 미평가되던 문제 → try/except로 confidence만 포기하고 sim/drift는 계속(curate_result=None fallthrough).
결함2: send_urgent_signal 실패가 outer except로 빠져 task 실패+미마킹이던 문제 → _send_urgent_with_retry(3회/60s) 추출, 최종 실패해도 raise 안 함(시그널 평가·태스크 보존), 성공 시에만 mark_signal_notified. TDD 3 신규 테스트.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:48:08 +09:00
2c2828c8f0 feat(agent-office): /activity 통합 피드에 필터 추가 (agent_id/type/status/days)
오버사이트 UI용. get_activity_feed가 브랜치별 WHERE로 필터, total도 동일 반영. status는 task 전용(주면 log 제외). 값은 ? 바인딩, type은 브랜치 선택만이라 injection 안전. 신규 5 테스트.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:25:19 +09:00
c62e3e70b9 fix(insta-lab): ranked가 judge에 보낼 후보를 상위 30개로 cap
미사용 키워드 대량 누적 시 judge 프롬프트/응답이 토큰 한도를 넘어 파싱 실패→claude 신호 전부 null로 degrade되던 문제(프로덕션 확인됨) 해결. base score 상위 JUDGE_CANDIDATE_CAP(30)개만 judge·선별에 적용해 claude 신호 일관 보장.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 03:07:29 +09:00
e1b1944f43 feat(insta): dedup_window_days config end-to-end wiring (spec 6.4)
- insta-lab ranked_keywords: add dedup_window_days Query param (default 14, ge=1, le=90); pass to db.list_recent_issued_topics
- service_proxy.insta_ranked: add dedup_window_days param (default 14); include in GET params
- InstaAgent.on_schedule: read dedup_window_days from custom_config (default 14); pass to insta_ranked call
- test_ranked_respects_dedup_window: verifies window param gates eligible flag correctly

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:55:46 +09:00
149e7c40fe docs(insta): 자율 발급 API 2개 문서화 (ranked, decision)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:50:24 +09:00
28d489770a test(agent-office): 하위호환(비자율 경로) + issue_regen 콜백 테스트
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:48:18 +09:00
9d50aa4256 feat(agent-office): issue_* 텔레그램 콜백 디스패치
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:41:13 +09:00
bc0f583a0f feat(agent-office): issue_approve/reject/regen 콜백 처리
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:38:56 +09:00
7c5ca15b64 feat(agent-office): InstaAgent 자율 발급 경로 + 커버 프리뷰
- on_schedule에 autonomous_issue 분기 추가 (eligible 픽만 선별·max_per_day 제한)
- _generate_and_preview 메서드: 슬레이트 생성 → 커버 PNG → 인라인 승인 버튼
- messaging.send_photo 신규 추가 (multipart/form-data, reply_markup 지원)
- insta_get_preferences 실패를 warning으로 격리해 자율 경로 중단 방지

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:36:26 +09:00
9fc764a78c feat(agent-office): service_proxy insta_ranked/insta_decision
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:32:43 +09:00
83398c8413 fix(insta-lab): 선별 zero-pref 크래시 가드 + judge max_tokens 상향 + 404 테스트
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:31:18 +09:00
7d1857c8a4 feat(insta-lab): GET /keywords/ranked + POST /slates/{id}/decision
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:23:33 +09:00
c3a6e78954 feat(insta-lab): Claude Haiku 카드가치 판단(graceful)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:21:09 +09:00
5d0e80fb49 feat(insta-lab): selection.py 순수 선별 점수(4신호)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:19:32 +09:00
af2fb57760 feat(insta-lab): 발행 상태 컬럼 + set_slate_decision/list_recent_issued_topics
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:14:48 +09:00
4d02d9c321 docs(plan): insta 자율 카드 발급 구현 계획 (9 tasks, TDD)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:11:32 +09:00
c99017e68c docs(spec): insta 자율 카드 발급 (스마트 에이전트 3번) 설계
선별 지능(4신호)+카드별 승인 게이트+상태머신/발행이력. 접근법 A: insta-lab 선별·상태 소유, agent-office 오케스트레이션·텔레그램 승인.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:05:51 +09:00
ce6c8d8f7d docs(CLAUDE): 카탈로그 슬림화(966→484) + 서비스별 메모리 분담 + stale 수정
포트/nginx/API 엔드포인트 목록·cross-cutting 규칙만 CLAUDE.md에 유지. DB 스키마 세부·스케줄러·env·운영 히스토리는 service_<name>.md 메모리로 이관(§0 규칙 명시).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 03:11:37 +09:00
f017a61c79 feat(weight-evolver): DB 통합 진입점 (generate_weekly/apply_today/evaluate_weekly)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 03:08:56 +09:00
1694823129 feat(analyzer): score_combination에 weights 파라미터 추가 (None=기존 fixed)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 03:06:26 +09:00
a4614ebeae feat(weight-evolver): lotto.db에 weight_trials/auto_picks/weight_base_history + CRUD 2026-05-22 03:03:51 +09:00
875e750f77 feat(weight-evolver): 순수 함수 (clamp/perturb/Dirichlet/score/base-rule)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 02:59:38 +09:00
9cb40fb4e5 test(weight-evolver): 순수 함수 + base update rule 단위 테스트 2026-05-22 02:56:06 +09:00
215 changed files with 51189 additions and 937 deletions

9
.mcp.json Normal file
View File

@@ -0,0 +1,9 @@
{
"mcpServers": {
"co-gahusb": {
"type": "http",
"url": "https://gahusb.synology.me/api/co/mcp",
"headers": { "Authorization": "Bearer ${CO_BUS_KEY}" }
}
}
}

View File

@@ -1,209 +1,121 @@
# web-backend CHECK_POINT
> NAS Docker 11 컨테이너(9 백엔드 + frontend + deployer). Synology Celeron J4025 (2C 2.0GHz) 18GB.
> 2026-05-18 작성 — uvicorn CPU 폭주 진단 결과 정리.
## 🔴 즉시 (오늘, 총 1시간 5분)
### 1. 09:00 cron 5분 스태거링 ⭐ 가장 큰 효과
**파일**: `agent-office/app/scheduler.py:72-76`
```python
# 변경 전 — 09:00 동시 실행 (CPU 폭주 원인 #1)
scheduler.add_job(_run_insta_trends_collect, "cron", hour=9, minute=0)
scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=9, minute=0)
scheduler.add_job(_run_youtube_research, "cron", hour=9, minute=0)
# 변경 후 — 5분 스태거링
scheduler.add_job(_run_insta_trends_collect, "cron", hour=9, minute=0, id="insta_trends")
scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=9, minute=5, id="lotto_curate")
scheduler.add_job(_run_youtube_research, "cron", hour=9, minute=10, id="youtube_research")
```
**파일**: `realestate-lab/app/main.py:51`
```python
# 변경 전
scheduler.add_job(scheduled_collect, "cron", hour=9, minute=0, id="collect")
# 변경 후
scheduler.add_job(scheduled_collect, "cron", hour=9, minute=15, id="collect")
```
- [x] agent-office scheduler.py 수정 (2026-05-18)
- [x] realestate-lab main.py 수정 (2026-05-18)
- [ ] git commit + push (Gitea Webhook 자동 빌드)
> NAS Docker (Synology Celeron J4025 2C 2.0GHz, 18GB). 16+ 컨테이너(14 서비스 + Redis + frontend + deployer).
> 2026-06-12 갱신 — 5/18 CPU 진단·NAS↔Windows 분산부터 6/12 음악 파이프라인 신뢰성까지 반영.
> 운영 세부(DB·스케줄러·env·함정)는 `memory/service_<name>.md`가 authoritative. 이 파일은 **무엇이 끝났고 다음에 뭘 하나**의 보드.
---
### 2. insta-lab Playwright Semaphore(1) ⭐
## ✅ 완료 타임라인 (5/18 → 6/12)
**파일**: `insta-lab/app/main.py` (모듈 레벨 추가)
```python
import asyncio
### 5/18~22 — CPU 진단 + NAS↔Windows 분산 + 로또 자율화
- **CPU 폭주 즉시 5건**: 09:00 cron 5분 스태거링(insta/lotto/youtube/realestate) · lotto Monte Carlo 08:30 이동 · insta Playwright Semaphore(1) · healthcheck 60s · uvicorn `--workers 1` · realestate 수집 병렬화
- **Redis 분산** (박재오 7결정): Redis 컨테이너 신설(7-alpine 256MB AOF) · insta/music/video-lab을 `queue:*-render` push 게이트웨이로 전환(렌더는 Windows web-ai 워커) · internal webhook + nginx 3-layer 차단 · stock webai_cache TTLCache
- **video-lab 신설** (18801) — Windows video-render의 NAS 짝 (sora/veo/kling/seedance)
- **로또 능동 시그널 v1** — lotto_signals/baselines, z-score, urgent/digest 텔레그램, cron 4종
- **weight-evolver 자율 학습 v2** — weight_trials/auto_picks, 주간 generate→apply→evaluate 루프
# 모듈 레벨에 한 번만 선언
RENDER_SEMAPHORE = asyncio.Semaphore(1) # Chromium 동시 실행 1개로 제한
### 5/25~26 — tarot/saju 분리·신설 + UI
- **tarot-lab 분리** (18250) — agent-office에서 독립, Claude 3-card
- **saju-lab 신설** (18300) — saju-web TS→Python 포팅, lunar↔solar 내장, 궁합 포함
- **saju UI v1 + v2 리디자인** + fortune_scores/lucky/monthly_flow 추가
- image-lab public gateway + `/media/image/` 정적 서빙 · tarot max_tokens 2800 truncation fix
# 카드 렌더 백그라운드 함수에 감싸기
async def _bg_render(task_id: str, slate_id: int):
async with RENDER_SEMAPHORE:
await card_renderer.render_slate(slate_id, ...)
```
### 5/28 — 공유 로그 인프라
- **`_shared/access_log` 공용 모듈** (lotto/stock/music/insta/realestate 5종) — ring buffer + middleware + `/logs/recent`
- agent-office `/agents/{id}/logs`가 서비스 로그 merge · 매일 03:00 agent_logs 90일 retention
- [x] card_renderer.render_slate를 Semaphore(1)로 감쌈 (2026-05-18, lazy init)
- [ ] 동시 2개 요청 테스트 (curl 동시 2회 → 순차 처리되는지 확인)
### 5/31 — 자율 인텔리전스 2종 (스마트에이전트 1·2번)
- **로또 자가학습 백테스트·캘리브레이션 v3** — backtest_runs/winner_calibration, forward 가상구매 3전략, ε-게이팅 lift 학습, 일요회고 cron. 역대 캘리브레이션 백필 1197/1197 (6/11)
- **주식 보유종목 인텔리전스** — holdings_signals, market_events/news_issues/portfolio_health, decide_action 매트릭스, EOD(16:50)+브리핑(08:30) cron
### 6/01~06 — 보안 + 인스타 카드뉴스
- nginx CVE 대응 (CVE-2026-42945 · CVE-2026-9256 → 1.30.2)
- **인스타 카드뉴스 품질 고도화 v2** + zip 패키지(10 PNG + caption.txt) + 글자수 가이드
### 6/11 — 자율 발급 + 오버사이트 (스마트에이전트 3번)
- **인스타 자율 카드 발급** — 4신호 선별(selection.py) + Claude Haiku 카드가치 판단 + 승인 게이트 + 발행 상태머신. 텔레그램 issue_approve/reject/regen 콜백. **autonomous_issue 기본 OFF**
- **에이전트 횡단 오버사이트(백엔드)** — `GET /api/agent-office/activity` 통합 피드 + 필터(agent_id/type/status/days). main `2c2828c` 배포
- CLAUDE.md 카탈로그 슬림화(966→484, 서비스별 메모리 분담) · packs jti SQLite 영속화 · lotto deep CuratorError fallthrough fix
### 6/12 — 음악 파이프라인 신뢰성·복구 (직전 작업)
- **자동 재시도**: orchestrator step 3회 backoff 재시도(publish 제외 — 업로드 비멱등)
- **수동 재개**: `POST /api/music/pipeline/{id}/retry` — 실패 step 판별·재개, retrying 레이스 가드, publish+업로드완료 시 409
- **실패 알림**: agent-office youtube_publisher가 신규 failed 감지 → 텔레그램 `⚠️실패` + `[🔄재시도]` 인라인 버튼 → music-lab retry 프록시
- 커밋·push·자동배포 완료 (main = origin/main)
> **스마트에이전트 3종 전부 가동**: stock(보유종목) · insta(자율발급) · lotto(진화). CEO 오버사이트(통합 활동 피드) 백엔드 완료.
---
### 3. healthcheck interval 60s
## 🔴 즉시 — 진행 중 / 대기
**파일**: `docker-compose.yml` (모든 9 컨테이너)
```yaml
# 변경 전
healthcheck:
interval: 30s
### 1. ✅ agent oversight 프론트 NAS 배포 — 완료 (2026-06-12)
- web-ui `ActivityTimeline`(AgentOffice 우측 기본 패널) main 머지(`d0bf5fd`) → NAS 라이브 반영·검증 완료 (index.html 갱신 + AgentOffice 번들 nginx 200)
- **배포 방법**: Z: 매핑이 `!` TTY로 안 돼서 **SSH 직접 배포**(`bgg8988@gahusb.synology.me:2300`, tar + `scp -O` → assets 교체). Synology SFTP off라 `scp -O` 필수, images/videos는 불변이라 미러 제외. 상세 → `memory/feedback_windows_frontend_ssh_deploy.md`
# 변경 후
healthcheck:
interval: 60s
```
- [x] docker-compose.yml 10개 healthcheck 일괄 변경 (9 백엔드 + frontend, 2026-05-18)
- [ ] `docker compose up -d` 재기동
- [ ] `docker stats` 로 CPU 5% 정도 감소 확인
### 2. 운영 검증 (분산·자율 학습)
- [ ] Redis 분산 E2E (NAS push → Windows 워커 → webhook 전체 흐름)
- [ ] lotto weight-evolver 주간 사이클(월 generate+apply → 토 evaluate) 정상 동작 + evolution report 텔레그램(토 22:15)
---
### 4. uvicorn --workers 1 명시
## 🟡 미완성 큰 기능
**모든 Dockerfile CMD**:
```dockerfile
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
```
영향 9 파일 (모두 2026-05-18 적용):
- [x] lotto/Dockerfile
- [x] stock/Dockerfile
- [x] music-lab/Dockerfile
- [x] insta-lab/Dockerfile
- [x] realestate-lab/Dockerfile
- [x] agent-office/Dockerfile
- [x] personal/Dockerfile
- [x] packs-lab/Dockerfile
- [x] travel-proxy/Dockerfile
`docker compose build --no-cache` 후 재기동.
### Video Studio 프론트 `/studio` — 백엔드 완료, UI 미구현
- **백엔드 완료·배포**: image-lab(NAS 18802) ✅ + image-render(Windows web-ai) ✅ + video-lab(기존) ✅ (`plans/2026-05-23-video-studio-backend.md` 전부)
- **빠진 것**: web-ui React Flow 노드 캔버스(ImageGenNode → ImageToVideoNode). 백엔드 plan이 "프론트는 Plan 2"로 미뤘으나 Plan 2 미생성
- spec: `docs/superpowers/specs/2026-05-23-video-studio-node-canvas-design.md` (untracked — 커밋 필요)
- 목적: 무신사·우리카드 AI 영상 공모전 실전 제작 도구
---
### 5. lotto Monte Carlo 08:05 → 08:30
## 🟡 후속 (직전 작업 범위 밖)
**파일**: `lotto/app/main.py:86`
```python
# 변경 전 — stock 08:00과 5분 차이로 겹침
scheduler.add_job(_run_simulation_job, "cron", hour="0,4,8,12,16,20", minute=5)
# 변경 후 — 25분 분리
scheduler.add_job(_run_simulation_job, "cron", hour="0,4,8,12,16,20", minute=30)
```
- [x] lotto/app/main.py 수정 (2026-05-18)
### music 파이프라인 stuck 감지
- 6/12 신뢰성 작업이 명시적으로 남긴 갭: `*_running` hang · `*_pending` 방치 · retrying 중 컨테이너 재시작 시 stuck(현 retry 가드가 state=failed라 재retry 불가)
- 상세: `memory/service_music.md` "파이프라인 신뢰성/복구 — 범위 밖"
---
## 🟡 중기 (1~2주)
## 🟢 백로그 아이디어
### 6. Chromium Browser Pool 재설계 (insta-lab) ✅ 2026-05-18
- 매번 launch X → 1개 인스턴스 재사용
- 카드 10장 렌더 시간 30% 단축 기대
- [x] `card_renderer.py` 내부에 모듈 레벨 `_PLAYWRIGHT`/`_BROWSER` + `init_browser`/`shutdown_browser` 함수 (별도 모듈 분리 안 함, 같은 파일에 인접 배치)
- [x] `_render_slate_locked` 본체에서 `_get_browser()` 재사용 (crashed 시 lazy 재초기화)
- [x] `main.py` startup hook에서 `init_browser()`, shutdown hook에서 `shutdown_browser()`
- **Redis 큐 통합 모니터링** — agent-office에 `queue:*-render`/`queue:paused` 길이·상태 패널 (NAS↔Windows 작업 흐름 가시화)
- **weight-evolver 성과 대시보드** — auto_picks 적중 추이 + weight_base 진화 그래프 (자율 학습 실효성 검증)
- **lotto-signals 패턴 확장** — adaptive baseline + z-score + urgent 텔레그램을 stock(이상치)·realestate(경쟁률 급변)에 재사용
- **nginx internal 차단 표준화** — insta/music/video/image 3-layer 차단을 공통 include로 추출
- **agent-office 레거시 정리** — tarot_readings 테이블 잔존(tarot-lab 분리 후), seed "blog" 죽은 에이전트
### 7. stock 뉴스 스크랩 비동기화 — ⚠️ 보류 2026-05-18
- **재진단**: stock은 `BackgroundScheduler` 사용 중 → main loop 블로킹 없음 (이미 별도 thread)
- `fetch_market_news`의 4개 동기 `requests.get`은 network I/O wait라 CPU 거의 사용 안 함
- `to_thread`로 wrap해도 BackgroundScheduler 환경에서 사실상 의미 없음
- 진짜 효과를 보려면 AsyncIOScheduler 전환 + scraper.py 4개 fetch를 `aiohttp` 병렬로 — **큰 리팩토링 vs 효과 불명확**
- [ ] 박재오 판단: 큰 리팩토링 진행 여부
### 8. realestate 수집 병렬화 ✅ 2026-05-18
- **파일**: `realestate-lab/app/main.py:scheduled_collect`
- `collect_all()` + `delete_old_completed_announcements()` 병렬
- BackgroundScheduler 환경이라 `asyncio.gather` 대신 `ThreadPoolExecutor(max_workers=2)` 사용 (효과 동일)
- 매칭은 순차 유지 (DB 일관성)
- [x] ThreadPoolExecutor 적용
### 9. lotto Monte Carlo 시뮬레이션 빈도 검토
- 현재 6회/일 (00·04·08·12·16·20)
- 실제 필요 빈도 박재오 결정 — 3회/일(아침·점심·저녁)로 줄이면 CPU 50% 감소
- [ ] 박재오 의사결정 후 cron 변경
---
## 🟢 장기 (1개월+)
### 10. 무거운 작업 Windows AI 서버로 이전 ✅ 이미 적용 상태 (2026-05-18 확인)
- **확인 결과**: NAS `.env`가 이미 `LLM_PROVIDER=claude` + `OLLAMA_URL=http://192.168.45.59:11435`로 설정됨
- 실 운영은 Anthropic Claude (원격 API) — NAS Celeron에서 LLM 추론 안 함
- Ollama fallback 사용 시에도 Windows AI 서버로 통일
- stock 외 다른 컨테이너에 ollama/qwen 호출 코드 없음
- 결론: 코드/설정 변경 불필요
### 11. 컨테이너 리소스 제한 — ❌ 진행 금지 (박재오 명시 2026-05-18)
- J4025 2C 환경에서 cpus 0.5 제한은 오히려 throughput 손해
- 향후 작업자 무심코 도입하지 말 것
### 12. NAS 업그레이드 검토 — ⏸️ 보류 (박재오 명시 2026-05-18)
- 현재: Celeron J4025 (2C 2.0GHz)
- 대안: Ryzen N5105 (4C 2.0GHz) NAS — 4코어로 병렬성 2배
- 자금·우선순위 결정 대기
---
## ✅ 최근 완료 (참고)
- 2026-05-15: insta-lab 신설 (포트 18700, Jinja2 + Playwright + Claude Sonnet)
- 2026-05-16: insta-lab Playwright 1080×1350 PNG 렌더 완성
- 2026-05-17: agent-office random idle 제거, ADMIN_API_KEY 강화 (stock)
- 2026-05-17: insta-lab minimal theme + design_importer 추가
- 2026-05-17: blog-lab 트랙 완전 폐기 (docker-compose에 없음, 위키 정정 완료)
- 2026-05-18: 🔴 즉시 5건 일괄 적용 — 09:00 cron 스태거링(insta/lotto/youtube/realestate), lotto Monte Carlo 08:30, insta-lab Semaphore(1), healthcheck 60s, uvicorn --workers 1 명시 (사용자 push + NAS deployer 재기동 대기)
- 2026-05-18: 🟡 중기 2건 적용 — #6 insta-lab Chromium Browser Pool (lifecycle hook), #8 realestate ThreadPoolExecutor 병렬 (collect/delete). #7 stock async는 BackgroundScheduler 사용 중이라 재진단 후 보류 (효과 미미). #9 Monte Carlo 빈도는 박재오 결정 대기.
- 2026-05-18: 🟢 장기 진단·결정 — #10은 이미 적용 상태 확인 (LLM_PROVIDER=claude, OLLAMA_URL=Windows AI). #11 컨테이너 리소스 제한 박재오 진행 금지. #12 NAS 업그레이드 보류. web-ai V1(:8000)+V2(:8001) 4개 process 종료 — NAS API polling 부담 즉시 감소.
### 보류 유지 (박재오 판단 대기)
- stock 뉴스 스크랩 비동기화 — BackgroundScheduler I/O wait라 CPU 미미, 큰 리팩토링 vs 효과 불명확
- lotto Monte Carlo 빈도(6→3회/일) — CPU 50%↓ vs 자율 학습 정확도 trade-off
- 컨테이너 리소스 제한 — ❌ 박재오 금지(J4025 2C throughput 손해) · NAS 업그레이드 ⏸️ 보류(Redis 분산으로 우선순위↓)
---
## 🔧 진단 커맨드 (NAS bash)
```bash
# 실시간 CPU 사용 (상위 15)
top -b -n 1 | head -25
# 프로세스별 CPU 정렬
ps aux --sort=-%cpu | head -15
# uvicorn·chromium·python 프로세스만
ps aux | grep -E "uvicorn|chromium|python" | grep -v grep
# 스케줄러 실행 로그 (최근 50)
top -b -n 1 | head -25 # CPU 상위
docker stats --no-stream # 컨테이너별 CPU/메모리
docker exec redis redis-cli PING # Redis 헬스
docker exec redis redis-cli KEYS 'queue:*' # 큐 키 목록
docker exec redis redis-cli LLEN queue:insta-render # 큐 길이
docker logs agent-office 2>&1 | grep -E "APScheduler|executing" | tail -50
# insta-lab Chromium 프로세스 개수
docker exec insta-lab ps aux | grep chromium | wc -l
# 컨테이너별 CPU/메모리 실시간
docker stats --no-stream
docker exec insta-lab ps aux | grep chromium | wc -l # (분할 후 0이어야 정상)
```
---
## 📚 참고
- 진단 풀 보고서: `C:\Users\jaeoh\Documents\Obsidian Vault\raw\2026-05-18-NAS-uvicorn-CPU-진단-개선안.md`
- 위키 페이지: [[사업-개인-웹-플랫폼]] (CPU 부하 진단 섹션 + 컨테이너 표)
- docker-compose.yml: 본 디렉토리 루트
- 메모리 인덱스: `memory/MEMORY.md` (14 서비스 × `service_<name>.md` authoritative)
- Windows 워커 짝: web-ai 레포 (insta/music/video/image-render)
- spec/plan: `docs/superpowers/specs|plans/`
- docker-compose.yml: 루트
## 변경 이력
- 2026-05-18: 페이지 신설. 즉시 5건 + 중기 4건 + 장기 3건. 진단 커맨드.
- 2026-05-18: 페이지 신설. CPU 진단 즉시 5건 + 7결정 분산 가이드.
- 2026-05-22: 분산·자율화 구현 반영. Redis 분할·lotto 능동시그널·weight-evolver.
- 2026-06-12: **5/25~6/12 전체 작업 반영** — tarot/saju 분리·신설, _shared 로그, lotto v3 백테스트, stock 보유종목 인텔, nginx CVE, insta 카드뉴스 v2 + 자율발급, 에이전트 오버사이트, music 파이프라인 신뢰성. 미완성 큰 기능(Video Studio 프론트) + 후속(music stuck 감지) + 백로그 재편. 현재 트랙(oversight 프론트 배포) 명시.

868
CLAUDE.md

File diff suppressed because it is too large Load Diff

1
_shared/__init__.py Normal file
View File

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

112
_shared/access_log.py Normal file
View File

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

View File

View File

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

View File

@@ -1,8 +1,6 @@
import time
from typing import Optional
from ..db import add_log
VALID_STATES = ("idle", "working", "waiting", "reporting")
class BaseAgent:
@@ -29,8 +27,6 @@ class BaseAgent:
if new_state == "idle":
self._idle_since = time.time()
add_log(self.agent_id, f"State: {old} -> {new_state} ({detail})")
if self._ws_manager:
await self._ws_manager.send_agent_state(self.agent_id, new_state, detail, task_id)
if new_state == "working" and old != "working":

View File

@@ -18,6 +18,26 @@ from ..telegram import messaging
logger = logging.getLogger(__name__)
# 텔레그램 후보 푸시 시 "확실한 것만" 보내기 위한 최소 신뢰도 (키워드 score 0~1)
KEYWORD_MIN_SCORE = 0.7
def _dedup_and_filter_keywords(
keywords: List[Dict[str, Any]], min_score: float = KEYWORD_MIN_SCORE,
) -> List[Dict[str, Any]]:
"""score >= min_score 인 키워드만 남기고, 동일 keyword 중복 제거(최고 score 유지).
결과는 score 내림차순. 텔레그램 후보 푸시 전 정리용."""
best: Dict[str, Dict[str, Any]] = {}
for k in keywords:
if float(k.get("score", 0)) < min_score:
continue
name = str(k.get("keyword", "")).strip()
if not name:
continue
if name not in best or k["score"] > best[name]["score"]:
best[name] = k
return sorted(best.values(), key=lambda k: -k["score"])
async def _send_media_group(media: List[Dict[str, Any]], caption: str = "") -> Dict[str, Any]:
"""텔레그램 sendMediaGroup. media는 InputMediaPhoto dicts.
@@ -51,14 +71,32 @@ class InstaAgent(BaseAgent):
config = get_agent_config(self.agent_id) or {}
custom = config.get("custom_config", {}) or {}
auto_select = bool(custom.get("auto_select", False))
autonomous = bool(custom.get("autonomous_issue", False))
threshold = float(custom.get("select_threshold", 0.6))
max_per_day = int(custom.get("max_per_day", 2))
dedup_window_days = int(custom.get("dedup_window_days", 14))
task_id = create_task(self.agent_id, "insta_daily", {"auto_select": auto_select},
requires_approval=False)
await self.transition("working", "뉴스 수집·키워드 추출", task_id)
try:
prefs = await service_proxy.insta_get_preferences()
add_log(self.agent_id, f"insta preferences: {prefs}", "info", task_id)
try:
prefs = await service_proxy.insta_get_preferences()
add_log(self.agent_id, f"insta preferences: {prefs}", "info", task_id)
except Exception as _pref_err:
add_log(self.agent_id, f"insta preferences unavailable: {_pref_err}", "warning", task_id)
await self._run_collect_and_extract()
if autonomous:
ranked = await service_proxy.insta_ranked(threshold=threshold, limit=20, dedup_window_days=dedup_window_days)
eligible = [r for r in ranked if r.get("eligible")][:max_per_day]
if not eligible:
await messaging.send_raw("📰 [인스타 큐레이터] 오늘은 발행할 가치 있는 주제가 없습니다.")
else:
for pick in eligible:
await self._generate_and_preview(pick)
update_task_status(task_id, "succeeded", {"issued": len(eligible)})
await self.transition("idle", "자율 발급 후보 프리뷰 완료")
return
kws = await service_proxy.insta_list_keywords(used=False)
if auto_select:
await self._auto_render(kws)
@@ -89,14 +127,18 @@ class InstaAgent(BaseAgent):
raise TimeoutError(f"{step} timeout {timeout_sec}s")
async def _push_keyword_candidates(self, keywords: List[Dict[str, Any]]) -> None:
by_cat: Dict[str, List[Dict[str, Any]]] = {}
for k in keywords:
by_cat.setdefault(k["category"], []).append(k)
if not by_cat:
await messaging.send_raw("📰 [인스타 큐레이터] 오늘은 추천 키워드가 없습니다.")
# 중복 제거 + 신뢰도(score) 임계값 이상만 — "확실한 것만" 정리해서 전송
filtered = _dedup_and_filter_keywords(keywords)
if not filtered:
await messaging.send_raw(
f"📰 [인스타 큐레이터] 오늘은 확실한 추천 키워드가 없습니다 (신뢰도 {KEYWORD_MIN_SCORE:.1f}+ 기준)."
)
return
by_cat: Dict[str, List[Dict[str, Any]]] = {}
for k in filtered:
by_cat.setdefault(k["category"], []).append(k)
rows: List[List[Dict[str, Any]]] = []
text_lines = ["📰 <b>[인스타 큐레이터]</b> 오늘의 키워드 후보"]
text_lines = [f"📰 <b>[인스타 큐레이터]</b> 오늘의 키워드 후보 (신뢰도 {KEYWORD_MIN_SCORE:.1f}+)"]
for cat, items in by_cat.items():
text_lines.append(f"\n<b>{cat}</b>")
for k in items[:5]:
@@ -137,6 +179,27 @@ class InstaAgent(BaseAgent):
full_caption = f"{caption}\n\n{hashtags}".strip()
await _send_media_group(media, caption=full_caption)
async def _generate_and_preview(self, pick: dict) -> None:
"""eligible 픽 → 슬레이트 생성·렌더 → 커버 프리뷰 + 승인 버튼."""
created = await service_proxy.insta_create_slate(
keyword=pick["keyword"], category=pick["category"], keyword_id=pick["id"],
)
st = await self._wait_task(created["task_id"], step="slate", timeout_sec=600)
slate_id = st["result_id"]
cover = await service_proxy.insta_get_asset_bytes(slate_id, 1)
bd = pick.get("breakdown", {})
caption = (f"🎴 <b>{pick['keyword']}</b> ({pick['category']})\n"
f"점수 {pick.get('final_score')} · fresh {bd.get('freshness')} "
f"fit {bd.get('account_fit')} claude {bd.get('claude')}\n승인하시겠어요?")
kb = {"inline_keyboard": [[
{"text": "✅ 승인", "callback_data": f"issue_approve_{slate_id}"},
{"text": "❌ 반려", "callback_data": f"issue_reject_{slate_id}"},
{"text": "🔄 재생성", "callback_data": f"issue_regen_{slate_id}"},
]]}
await messaging.send_photo(cover, caption=caption, reply_markup=kb)
create_task(self.agent_id, "insta_issue", {"slate_id": slate_id, "keyword_id": pick["id"]},
requires_approval=True)
async def on_command(self, command: str, params: dict) -> dict:
if command == "extract":
await self._run_collect_and_extract()
@@ -164,6 +227,38 @@ class InstaAgent(BaseAgent):
return {"ok": False}
await self._render_and_push(kid)
return {"ok": True}
if action in ("issue_approve", "issue_reject"):
sid = int(params.get("slate_id") or 0)
if not sid:
return {"ok": False}
decision = "approved" if action == "issue_approve" else "rejected"
await service_proxy.insta_decision(sid, decision)
if decision == "approved":
slate = await service_proxy.insta_get_slate(sid)
media = []
for a in slate["assets"][:10]:
data = await service_proxy.insta_get_asset_bytes(sid, a["page_index"])
media.append({"type": "photo", "_bytes": data})
cap = f"{slate.get('suggested_caption','')}\n\n{' '.join(slate.get('hashtags', []) or [])}".strip()
await _send_media_group(media, caption=cap)
await messaging.send_raw(f"✅ 발행 완료 (slate {sid})")
else:
await messaging.send_raw(f"❌ 반려됨 (slate {sid})")
return {"ok": True}
if action == "issue_regen":
sid = int(params.get("slate_id") or 0)
if not sid:
return {"ok": False}
slate = await service_proxy.insta_get_slate(sid)
await service_proxy.insta_decision(sid, "rejected")
await self._generate_and_preview({
"id": 0,
"keyword": slate["keyword"],
"category": slate["category"],
"final_score": None,
"breakdown": {},
})
return {"ok": True}
return {"ok": False}
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:

View File

@@ -2,6 +2,10 @@ from .base import BaseAgent
from ..db import create_task, update_task_status, add_log
from ..curator.pipeline import curate_weekly, CuratorError
# urgent 텔레그램 발송 재시도 (전송 실패가 시그널 평가/태스크를 중단시키지 않도록)
URGENT_SEND_MAX_ATTEMPTS = 3
URGENT_SEND_RETRY_SEC = 60
class LottoAgent(BaseAgent):
agent_id = "lotto"
@@ -22,38 +26,48 @@ class LottoAgent(BaseAgent):
return await self.run_signal_check(source=source)
if action == "daily_digest":
return await self.run_daily_digest()
if action == "sunday_review":
return await self.run_sunday_review()
return {"ok": False, "message": f"unknown action: {action}"}
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
pass
async def run_signal_check(self, source: str = "light") -> dict:
"""비-LLM 시그널 평가 (light/sim) 또는 deep_check (LLM 호출 후).
Phase 3 (Task 9): urgent 시그널 텔레그램 발송 + throttle/daily-cap 추가.
"""
"""비-LLM 시그널 평가. task_id wrap 적용."""
from ..curator.signal_runner import run_signal_check
from ..config import LOTTO_Z_NORMAL, LOTTO_Z_URGENT
from ..db import add_log
from ..config import (
LOTTO_Z_NORMAL, LOTTO_Z_URGENT,
LOTTO_THROTTLE_HOURS, LOTTO_URGENT_DAILY_MAX,
)
from ..db import (
create_task, update_task_status, add_log,
get_last_signal_notification, get_recent_urgent_count,
mark_signal_notified,
)
from ..notifiers.telegram_lotto import send_urgent_signal
from ..service_proxy import lotto_latest_draw
if self.state not in ("idle", "reporting"):
return {"ok": False, "message": f"busy ({self.state})"}
task_id = create_task("lotto", "signal_check", {"source": source})
try:
curate_result = None
# 회차 단위 메트릭(drift/confidence) 가드를 위해 항상 최신 회차 가져옴
from ..service_proxy import lotto_latest_draw
current_draw_no = await lotto_latest_draw()
if source == "deep":
from ..curator.pipeline import curate_weekly
cw = await curate_weekly(source="signal_deep")
# curate_weekly returns {"ok", "draw_no", "confidence", "tokens", "payload"}
curate_result = {"confidence": cw.get("confidence")}
# deep_check 시 curate_weekly가 반환하는 draw_no를 우선 사용 (직접 수집)
if cw.get("draw_no"):
current_draw_no = cw.get("draw_no")
try:
cw = await curate_weekly(source="signal_deep")
curate_result = {"confidence": cw.get("confidence")}
if cw.get("draw_no"):
current_draw_no = cw.get("draw_no")
except CuratorError as e:
# 큐레이션 실패는 confidence 시그널만 포기 — sim/drift 평가는 계속(fallthrough)
add_log("lotto", f"deep curate_weekly 실패 → sim/drift만 평가: {e}",
level="warning", task_id=task_id)
curate_result = None
outcome = await run_signal_check(
source=source,
@@ -62,35 +76,19 @@ class LottoAgent(BaseAgent):
curate_result=curate_result,
current_draw_no=current_draw_no,
)
add_log(
self.agent_id,
f"signal_check({source}) → overall={outcome['overall_fire']} results={len(outcome['results'])}",
)
# --- Throttle + 텔레그램 urgent 발송 ---
from ..config import LOTTO_THROTTLE_HOURS, LOTTO_URGENT_DAILY_MAX
from ..db import (
get_last_signal_notification, get_recent_urgent_count,
mark_signal_notified,
)
from ..notifiers.telegram_lotto import send_urgent_signal
# urgent 텔레그램 + throttle (기존 동작 유지)
if outcome["overall_fire"] == "urgent":
if get_recent_urgent_count(hours=24) >= LOTTO_URGENT_DAILY_MAX:
add_log(
self.agent_id,
"urgent daily cap 도달 → normal로 강등 (digest 합류)",
level="warning",
)
add_log("lotto", "urgent daily cap 도달 → normal로 강등", level="warning", task_id=task_id)
else:
blocked = False
for r in outcome["results"]:
if r["fire_level"] in ("normal", "urgent"):
last = get_last_signal_notification(
if get_last_signal_notification(
metric=r["metric"], fire_level=r["fire_level"],
hours=LOTTO_THROTTLE_HOURS,
)
if last:
):
blocked = True
break
if not blocked:
@@ -100,52 +98,198 @@ class LottoAgent(BaseAgent):
"triggered_at": datetime.now(timezone.utc).isoformat(),
"results": outcome["results"],
}
await send_urgent_signal(event)
for r in outcome["results"]:
if r["fire_level"] in ("normal", "urgent"):
mark_signal_notified(r["signal_id"])
add_log(self.agent_id, f"urgent 텔레그램 발송 완료 (시그널 {len(outcome['results'])}개 마킹)")
await self._send_urgent_with_retry(event, outcome["results"], task_id)
fired_metrics = [
r["metric"] for r in outcome["results"]
if r["fire_level"] not in ("noop", "warmup")
]
update_task_status(task_id, "succeeded", result_data={
"source": source,
"overall_fire": outcome["overall_fire"],
"n_results": len(outcome["results"]),
"fired_metrics": fired_metrics,
})
add_log("lotto", f"signal_check({source}) → {outcome['overall_fire']} results={len(outcome['results'])}", task_id=task_id)
return {"ok": True, **outcome}
except Exception as e:
add_log(self.agent_id, f"signal_check 예외: {e}", level="error")
update_task_status(task_id, "failed", result_data={"error": str(e)})
add_log("lotto", f"signal_check 예외: {e}", level="error", task_id=task_id)
return {"ok": False, "message": f"{type(e).__name__}: {e}"}
async def _send_urgent_with_retry(self, event: dict, results: list, task_id: str) -> bool:
"""urgent 텔레그램 발송 + 실패 시 재시도. 최종 실패해도 raise하지 않음(시그널 평가·태스크 보존).
성공 시 fired 시그널을 notified로 마킹. 반환: 발송 성공 여부."""
import asyncio
from ..db import add_log, mark_signal_notified
from ..notifiers.telegram_lotto import send_urgent_signal
for attempt in range(1, URGENT_SEND_MAX_ATTEMPTS + 1):
try:
await send_urgent_signal(event)
for r in results:
if r["fire_level"] in ("normal", "urgent"):
mark_signal_notified(r["signal_id"])
add_log("lotto", f"urgent 텔레그램 발송 ({len(results)}개 시그널, attempt {attempt})", task_id=task_id)
return True
except Exception as e:
if attempt < URGENT_SEND_MAX_ATTEMPTS:
add_log("lotto", f"urgent 발송 실패(attempt {attempt}) → {URGENT_SEND_RETRY_SEC}s 후 재시도: {e}",
level="warning", task_id=task_id)
await asyncio.sleep(URGENT_SEND_RETRY_SEC)
else:
add_log("lotto", f"urgent 발송 {URGENT_SEND_MAX_ATTEMPTS}회 실패 — 미발송: {e}",
level="error", task_id=task_id)
return False
return False
async def run_daily_digest(self) -> dict:
"""일일 요약 — 지난 24h normal/urgent 발화를 묶어 텔레그램 1통."""
"""일일 요약 — 지난 24h normal/urgent 발화 텔레그램 1통. task_id wrap."""
from ..db import (
get_recent_lotto_signals, get_signals_history, add_log,
get_baseline,
create_task, update_task_status, add_log,
get_recent_lotto_signals, get_signals_history, get_baseline,
)
from ..notifiers.telegram_lotto import send_signal_summary
sigs = get_recent_lotto_signals(hours=24, min_fire="normal")
total_24h = get_signals_history(days=1)
evaluated = len(total_24h)
# weights_trend: drift_weights_cache의 prev/curr 차이
trend = {}
task_id = create_task("lotto", "daily_digest", {})
try:
cache = get_baseline("drift_weights_cache")
if cache and isinstance(cache["window_values"], list) and len(cache["window_values"]) >= 2:
prev_w = cache["window_values"][-2]
curr_w = cache["window_values"][-1]
trend = {
k: curr_w.get(k, 0.0) - prev_w.get(k, 0.0)
for k in (set(prev_w) | set(curr_w))
}
except Exception as e:
add_log(self.agent_id, f"weights_trend 계산 실패: {e}", level="warning")
sigs = get_recent_lotto_signals(hours=24, min_fire="normal")
total_24h = get_signals_history(days=1)
evaluated = len(total_24h)
digest = {
"evaluated": evaluated,
"fired": len(sigs),
"signals": sigs,
"weights_trend": trend,
}
await send_signal_summary(digest)
add_log(self.agent_id, f"daily_digest 발송: 평가 {evaluated} / 발화 {len(sigs)}")
return {"ok": True, **digest}
trend = {}
try:
cache = get_baseline("drift_weights_cache")
if cache and isinstance(cache["window_values"], list) and len(cache["window_values"]) >= 2:
prev_w = cache["window_values"][-2]
curr_w = cache["window_values"][-1]
trend = {
k: curr_w.get(k, 0.0) - prev_w.get(k, 0.0)
for k in (set(prev_w) | set(curr_w))
}
except Exception as e:
add_log("lotto", f"weights_trend 계산 실패: {e}", level="warning", task_id=task_id)
digest = {
"evaluated": evaluated,
"fired": len(sigs),
"signals": sigs,
"weights_trend": trend,
}
await send_signal_summary(digest)
update_task_status(task_id, "succeeded", result_data={
"evaluated": evaluated,
"fired": len(sigs),
"signals_count": len(sigs),
})
add_log("lotto", f"daily_digest 발송: 평가 {evaluated} / 발화 {len(sigs)}", task_id=task_id)
return {"ok": True, **digest}
except Exception as e:
update_task_status(task_id, "failed", result_data={"error": str(e)})
add_log("lotto", f"daily_digest 예외: {e}", level="error", task_id=task_id)
return {"ok": False, "message": f"{type(e).__name__}: {e}"}
async def run_sunday_review(self) -> dict:
"""일 09:00 — 최신 회차 forward+calibration 보장 후 회고 텔레그램."""
from ..service_proxy import lotto_latest_draw, lotto_backtest_review
from ..notifiers.telegram_lotto import send_sunday_review
from ..db import create_task, update_task_status, add_log
task_id = create_task("lotto", "sunday_review", {})
try:
draw_no = await lotto_latest_draw()
if not draw_no:
update_task_status(task_id, "failed", result_data={"reason": "no_draw"})
return {"ok": False, "message": "no latest draw"}
# forward는 lotto cron이 이미 돌렸을 수 있으나 멱등이라 안전 — review만 호출
payload = await lotto_backtest_review(draw_no)
await send_sunday_review(payload)
update_task_status(task_id, "succeeded", result_data={"draw_no": draw_no})
add_log("lotto", f"sunday_review 발송: #{draw_no}", task_id=task_id)
return {"ok": True, "draw_no": draw_no}
except Exception as e:
update_task_status(task_id, "failed", result_data={"error": str(e)})
add_log("lotto", f"sunday_review 예외: {e}", level="error", task_id=task_id)
return {"ok": False, "message": f"{type(e).__name__}: {e}"}
async def run_weekly_evolution_report(self) -> dict:
"""토 22:15 — lotto-lab evaluate-now 트리거 후 텔레그램 리포트. task_id wrap."""
from ..service_proxy import lotto_evolver_evaluate, lotto_evolver_status
from ..notifiers.telegram_lotto import send_evolution_report
from ..db import create_task, update_task_status, add_log
task_id = create_task("lotto", "weekly_evolution_report", {})
try:
eval_result = await lotto_evolver_evaluate()
status = await lotto_evolver_status()
current_base = status.get("current_base") or [0.2] * 5
await send_evolution_report(eval_result, current_base)
winner = eval_result.get("winner") or {}
update_task_status(task_id, "succeeded", result_data={
"draw_no": eval_result.get("draw_no"),
"update_reason": eval_result.get("update_reason"),
"winner_day_of_week": winner.get("day_of_week"),
"winner_max_correct": winner.get("max_correct"),
})
add_log("lotto", f"weekly_evolution_report 발송: draw={eval_result.get('draw_no')} reason={eval_result.get('update_reason')}", task_id=task_id)
return {"ok": True, **eval_result}
except Exception as e:
update_task_status(task_id, "failed", result_data={"error": str(e)})
add_log("lotto", f"weekly_evolution_report 예외: {e}", level="error", task_id=task_id)
return {"ok": False, "message": f"{type(e).__name__}: {e}"}
async def sync_evolver_activity(self) -> dict:
"""매일 09:30 — lotto-lab evolver 상태 polling → agent_office.db에 task+log 거울. 멱등."""
from datetime import datetime, timezone, timedelta
from ..service_proxy import lotto_evolver_status
from ..db import (
create_task, update_task_status, add_log,
get_tasks_by_agent_date_kind,
)
KST = timezone(timedelta(hours=9))
today_kst = datetime.now(KST).date()
# created_at은 UTC로 저장되므로 idempotency guard는 UTC 날짜 기준
today_utc_iso = datetime.now(timezone.utc).date().isoformat()
dow = today_kst.weekday()
if dow == 6:
dow = 5
try:
status = await lotto_evolver_status()
except Exception as e:
add_log("lotto", f"sync_evolver_activity: lotto-lab status fetch 실패: {e}", level="warning")
return {"ok": False, "reason": "status_fetch_failed", "error": str(e)}
results = {"created": []}
today_trial = next((t for t in status.get("trials", []) if t.get("day_of_week") == dow), None)
if today_trial and today_trial.get("picks"):
if not get_tasks_by_agent_date_kind("lotto", today_utc_iso, "evolver_apply"):
tid = create_task("lotto", "evolver_apply", {
"date": today_utc_iso,
"trial_id": today_trial["id"],
"day_of_week": dow,
"weight": today_trial["weight"],
})
update_task_status(tid, "succeeded", result_data={
"n_picks": len(today_trial["picks"]),
"meta_scores": [p.get("meta_score") for p in today_trial["picks"]],
})
add_log("lotto", f"evolver_apply: 오늘({dow}) W로 {len(today_trial['picks'])}세트 추출", task_id=tid)
results["created"].append("evolver_apply")
if today_kst.weekday() == 0 and len(status.get("trials", [])) == 6:
if not get_tasks_by_agent_date_kind("lotto", today_utc_iso, "evolver_generate"):
tid = create_task("lotto", "evolver_generate", {"week_start": status.get("week_start")})
update_task_status(tid, "succeeded", result_data={
"trials_count": 6,
"candidates_per_source": {"perturb": 4, "dirichlet": 2},
})
add_log("lotto", f"evolver_generate: {status.get('week_start')} 주의 6 trials 생성", task_id=tid)
results["created"].append("evolver_generate")
return {"ok": True, **results}
async def _run(self, source: str) -> dict:
task_id = create_task(self.agent_id, "curate_weekly", {"source": source})

View File

@@ -336,7 +336,48 @@ class StockAgent(BaseAgent):
await self.transition("idle", "AI 뉴스 완료")
async def run_holdings_eod(self) -> dict:
"""평일 16:50 — 보유종목 시그널 계산·저장."""
# idle 가드 없음(의도적): 스크리너 진행 중에도 EOD/브리핑은 독립적으로 실행되어야 함
from ..service_proxy import stock_holdings_run
from ..db import create_task, update_task_status, add_log
task_id = create_task(self.agent_id, "holdings_eod", {})
try:
res = await stock_holdings_run()
update_task_status(task_id, "succeeded", res)
add_log(self.agent_id, f"holdings_eod: {res}", "info", task_id)
return {"ok": True, **res}
except Exception as e:
update_task_status(task_id, "failed", {"error": str(e)})
add_log(self.agent_id, f"holdings_eod 실패: {e}", "error", task_id)
return {"ok": False, "message": str(e)}
async def run_holdings_brief(self) -> dict:
"""평일 08:30 — 저장된 시그널 브리핑 텔레그램."""
# idle 가드 없음(의도적): 스크리너 진행 중에도 EOD/브리핑은 독립적으로 실행되어야 함
from ..service_proxy import stock_holdings_brief
from ..notifiers.telegram_stock import send_holdings_brief
from ..db import create_task, update_task_status, add_log
task_id = create_task(self.agent_id, "holdings_brief", {})
try:
payload = await stock_holdings_brief()
await send_holdings_brief(payload)
update_task_status(task_id, "succeeded", {"date": payload.get("date"),
"count": len(payload.get("holdings", []))})
add_log(self.agent_id, f"holdings_brief 발송: {payload.get('date')}", "info", task_id)
return {"ok": True}
except Exception as e:
update_task_status(task_id, "failed", {"error": str(e)})
add_log(self.agent_id, f"holdings_brief 실패: {e}", "error", task_id)
return {"ok": False, "message": str(e)}
async def on_command(self, command: str, params: dict) -> dict:
if command == "holdings_eod":
return await self.run_holdings_eod()
if command == "holdings_brief":
return await self.run_holdings_brief()
if command == "run_screener":
await self.on_screener_schedule()
return {"ok": True, "message": "스크리너 실행 트리거 완료"}

View File

@@ -26,6 +26,7 @@ class YoutubePublisherAgent(BaseAgent):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._notified_state_per_pipeline: dict[int, tuple] = {}
self._notified_failed: set[int] = set()
async def poll_state_changes(self) -> None:
"""주기적으로 호출되어 *_pending 신규 진입 시 텔레그램 발송."""
@@ -48,6 +49,32 @@ class YoutubePublisherAgent(BaseAgent):
await self._notify_step(p)
self._notified_state_per_pipeline[pid] = key
try:
failed = await service_proxy.list_failed_pipelines()
except Exception as e:
logger.warning("failed 폴링 실패: %s", e)
failed = []
for p in failed:
pid = p.get("id")
if pid is None:
continue
if pid not in self._notified_failed:
await self._notify_failed(p)
self._notified_failed.add(pid)
# 재개되어 failed에서 벗어난 파이프라인은 재알림 가능하도록 해제
failed_ids = {p.get("id") for p in failed}
self._notified_failed &= failed_ids
async def _notify_failed(self, p: dict) -> None:
reason = p.get("failed_reason") or "?"
step = reason.split(":", 1)[0].strip()
title = p.get("track_title") or f"Pipeline #{p['id']}"
text = f"⚠️ [{title}] 파이프라인 #{p['id']} '{step}' 실패\n사유: {reason}"
kb = {"inline_keyboard": [[{"text": "🔄 재시도", "callback_data": f"ytpub_retry_{p['id']}"}]]}
sent = await send_raw(text=text, reply_markup=kb)
if sent.get("ok"):
add_log(self.agent_id, f"pipeline {p['id']} 실패 알림", "warning")
async def _notify_step(self, pipeline: dict) -> None:
state = pipeline["state"]
title_name, step = _STEP_TITLES[state]

View File

@@ -38,3 +38,16 @@ LOTTO_DIGEST_HOUR = int(os.getenv("LOTTO_DIGEST_HOUR", "9"))
LOTTO_DIGEST_MIN = int(os.getenv("LOTTO_DIGEST_MIN", "25"))
LOTTO_THROTTLE_HOURS = int(os.getenv("LOTTO_THROTTLE_HOURS", "6"))
LOTTO_URGENT_DAILY_MAX = int(os.getenv("LOTTO_URGENT_DAILY_MAX", "3"))
import re as _re
# 에이전트 → (container_host, port, path_prefix_regex)
# path_prefix_regex: lotto 컨테이너에 personal/blog/todo 도 같이 있어
# /api/lotto 만 골라내기 위한 정규식. business log (source='log') 는 모두 통과.
AGENT_CONTAINER_MAP: dict[str, tuple[str, int, _re.Pattern]] = {
"lotto": ("lotto", 8000, _re.compile(r"^/api/lotto")),
"stock": ("stock", 8000, _re.compile(r"^/api/(stock|trade|portfolio)")),
"music": ("music-lab", 8000, _re.compile(r"^/api/music")),
"insta": ("insta-lab", 8000, _re.compile(r"^/api/insta")),
"realestate": ("realestate-lab", 8000, _re.compile(r"^/api/realestate")),
}

View File

@@ -131,6 +131,33 @@ def init_db() -> None:
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS tarot_readings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
spread_type TEXT NOT NULL,
category TEXT,
question TEXT,
cards TEXT NOT NULL,
interpretation_json TEXT,
summary TEXT,
model TEXT,
tokens_in INTEGER,
tokens_out INTEGER,
cost_usd REAL,
confidence TEXT,
favorite INTEGER NOT NULL DEFAULT 0,
note TEXT
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_tarot_created
ON tarot_readings(created_at DESC)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_tarot_favorite
ON tarot_readings(favorite, created_at DESC)
""")
# Seed default agent configs
for agent_id, name in [
("stock", "주식 트레이더"),
@@ -236,12 +263,24 @@ def get_task(task_id: str) -> Optional[Dict[str, Any]]:
return _task_to_dict(r) if r else None
def get_agent_tasks(agent_id: str, limit: int = 20) -> List[Dict[str, Any]]:
def get_agent_tasks(
agent_id: str,
limit: int = 20,
task_type: Optional[str] = None,
days: Optional[int] = None,
) -> List[Dict[str, Any]]:
sql = "SELECT * FROM agent_tasks WHERE agent_id=?"
params: List[Any] = [agent_id]
if task_type is not None:
sql += " AND task_type=?"
params.append(task_type)
if days is not None and days > 0:
sql += " AND created_at >= datetime('now', ?)"
params.append(f"-{int(days)} days")
sql += " ORDER BY created_at DESC LIMIT ?"
params.append(limit)
with _conn() as conn:
rows = conn.execute(
"SELECT * FROM agent_tasks WHERE agent_id=? ORDER BY created_at DESC LIMIT ?",
(agent_id, limit),
).fetchall()
rows = conn.execute(sql, params).fetchall()
return [_task_to_dict(r) for r in rows]
@@ -282,7 +321,13 @@ def add_log(agent_id: str, message: str, level: str = "info", task_id: str = Non
def get_logs(agent_id: str, limit: int = 50) -> List[Dict[str, Any]]:
with _conn() as conn:
rows = conn.execute(
"SELECT * FROM agent_logs WHERE agent_id=? ORDER BY created_at DESC LIMIT ?",
"""
SELECT * FROM agent_logs
WHERE agent_id = ?
AND message NOT LIKE 'State: %'
ORDER BY created_at DESC
LIMIT ?
""",
(agent_id, limit),
).fetchall()
return [
@@ -293,6 +338,7 @@ def get_logs(agent_id: str, limit: int = 50) -> List[Dict[str, Any]]:
"level": r["level"],
"message": r["message"],
"created_at": r["created_at"],
"source": "agent",
}
for r in rows
]
@@ -488,33 +534,58 @@ def get_conversation_stats(days: int = 7) -> Dict[str, Any]:
}
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
def get_activity_feed(limit: int = 50, offset: int = 0, agent_id: str = None,
type: str = None, status: str = None, days: int = None) -> dict:
# 브랜치별 WHERE (값은 ? 바인딩, type은 브랜치 선택용). status는 task 전용 → 주면 log 제외.
task_where, task_params = [], []
log_where, log_params = [], []
if agent_id:
task_where.append("agent_id=?"); task_params.append(agent_id)
log_where.append("agent_id=?"); log_params.append(agent_id)
if status:
task_where.append("status=?"); task_params.append(status)
if days and days > 0:
task_where.append("created_at >= datetime('now', ?)"); task_params.append(f"-{int(days)} days")
log_where.append("created_at >= datetime('now', ?)"); log_params.append(f"-{int(days)} days")
include_tasks = type in (None, "task")
include_logs = type in (None, "log") and not status
rows = conn.execute("""
task_clause = (" WHERE " + " AND ".join(task_where)) if task_where else ""
log_clause = (" WHERE " + " AND ".join(log_where)) if log_where else ""
branches, branch_params = [], []
if include_tasks:
branches.append(f"""
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
COALESCE(json_extract(result_data, '$.summary'), task_type) AS message,
created_at, completed_at, result_data
FROM agent_tasks{task_clause}""")
branch_params += task_params
if include_logs:
branches.append(f"""
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()
NULL AS status, level, message,
created_at, NULL AS completed_at, NULL AS result_data
FROM agent_logs{log_clause}""")
branch_params += log_params
if not branches:
return {"items": [], "total": 0}
union_sql = " UNION ALL ".join(branches) + " ORDER BY created_at DESC LIMIT ? OFFSET ?"
with _conn() as conn:
total = 0
if include_tasks:
total += conn.execute(
f"SELECT COUNT(*) AS c FROM agent_tasks{task_clause}", task_params
).fetchone()["c"]
if include_logs:
total += conn.execute(
f"SELECT COUNT(*) AS c FROM agent_logs{log_clause}", log_params
).fetchone()["c"]
rows = conn.execute(union_sql, branch_params + [limit, offset]).fetchall()
items = []
for r in rows:
@@ -549,6 +620,20 @@ def get_activity_feed(limit: int = 50, offset: int = 0) -> dict:
return {"items": items, "total": total}
import datetime as _dt
def delete_old_logs(days: int = 90) -> int:
"""retention 정책: N일 이전 agent_logs 삭제. 매일 03:00 스케줄러가 호출."""
cutoff = (_dt.datetime.utcnow() - _dt.timedelta(days=days)).isoformat()
with _conn() as conn:
c = conn.execute(
"DELETE FROM agent_logs WHERE created_at < ?",
(cutoff,),
)
return c.rowcount
# ── youtube_research_jobs CRUD ────────────────────────────────────────────────
def add_youtube_research_job(countries: list) -> int:
@@ -739,3 +824,20 @@ def get_all_baselines() -> List[Dict[str, Any]]:
d["window_values"] = json.loads(d["window_values"])
out.append(d)
return out
def get_tasks_by_agent_date_kind(agent_id: str, date_iso: str, task_type: str) -> List[Dict[str, Any]]:
"""같은 (agent, date, task_type)으로 이미 생성된 task 조회. 멱등 guard."""
with _conn() as conn:
rows = conn.execute(
"""
SELECT * FROM agent_tasks
WHERE agent_id = ? AND task_type = ?
AND substr(created_at, 1, 10) = ?
ORDER BY created_at DESC
""",
(agent_id, task_type, date_iso),
).fetchall()
return [_task_to_dict(r) for r in rows]

View File

@@ -1,5 +1,6 @@
import os
import json
from typing import Optional
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
@@ -104,12 +105,29 @@ def update_agent(agent_id: str, body: AgentConfigUpdate):
return {"ok": True}
@app.get("/api/agent-office/agents/{agent_id}/tasks")
def agent_tasks(agent_id: str, limit: int = 20):
return {"tasks": get_agent_tasks(agent_id, limit)}
def agent_tasks(
agent_id: str,
limit: int = 20,
task_type: Optional[str] = None,
days: Optional[int] = None,
):
tasks_list = get_agent_tasks(agent_id, limit=limit, task_type=task_type, days=days)
# Backward compat: 기존 client는 'tasks', 신규 client는 'items' 사용
return {"tasks": tasks_list, "items": tasks_list}
@app.get("/api/agent-office/agents/{agent_id}/logs")
def agent_logs(agent_id: str, limit: int = 50):
return {"logs": get_logs(agent_id, limit)}
async def agent_logs(agent_id: str, limit: int = 50):
from .service_proxy import fetch_service_logs
agent_items = get_logs(agent_id, limit=limit)
service_items = await fetch_service_logs(agent_id, limit=limit)
def _sort_key(x):
# agent_logs: created_at, service: ts
return x.get("ts") or x.get("created_at") or ""
merged = sorted(agent_items + service_items, key=_sort_key, reverse=True)
return {"logs": merged[:limit]}
@app.get("/api/agent-office/tasks/pending")
def pending_tasks():
@@ -180,8 +198,9 @@ def conversation_stats(days: int = 7):
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)
def activity_feed(limit: int = 50, offset: int = 0, agent_id: str | None = None,
type: str | None = None, status: str | None = None, days: int | None = None):
return get_activity_feed(limit, offset, agent_id=agent_id, type=type, status=status, days=days)
# --- Realestate Agent Push Endpoint ---

View File

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

View File

@@ -1,6 +1,6 @@
"""로또 큐레이션·당첨 알림 — 텔레그램 푸시."""
import logging
from typing import Dict, Any
from typing import Dict, Any, List
# 기존 에이전트들과 동일한 패턴: send_raw(text, reply_markup=None, chat_id=None)
# chat_id 생략 시 기본 TELEGRAM_CHAT_ID로 자동 발송.
@@ -159,3 +159,108 @@ async def send_signal_summary(digest: Dict[str, Any]) -> None:
await send_raw(text)
except Exception as e:
logger.warning(f"[telegram_lotto] digest send failed: {e}")
# ---------- Weight Evolver 주간 리포트 ----------
_DAY_NAMES = ["", "", "", "", "", ""]
_METRIC_NAMES = ["freq", "finger", "gap", "cooccur", "divers"]
_REASON_LABEL = {
"winner_4plus": "4개 이상 일치 → base 교체",
"ema_blend": "3개 일치 → EMA blend (0.3)",
"unchanged": "유효 성과 없음 → base 유지",
"cold_start": "초기 균등 적용",
}
def _format_evolution_report(eval_result: Dict[str, Any], current_base: List[float]) -> str:
"""주간 weight evolution 텔레그램 메시지. ok=False 또는 winner 없으면 빈 문자열."""
if not eval_result or "winner" not in eval_result:
return ""
draw_no = eval_result.get("draw_no", "?")
winner = eval_result["winner"]
new_base = eval_result.get("new_base") or [0.0] * 5
reason = eval_result.get("update_reason", "")
dow = winner.get("day_of_week", 0)
day_name = _DAY_NAMES[dow] if 0 <= dow < len(_DAY_NAMES) else "?"
lines = [
f"🧬 로또 학습 주간 리포트 ({draw_no}회차)",
"",
f"이번주 시도: 6일 × {winner.get('n_picks', 5)}세트",
"",
f"🏆 Winner: {day_name}요일",
f" W = [" + ", ".join(
f"{name} {w:.2f}" for name, w in zip(_METRIC_NAMES, winner["weight"])
) + "]",
f" 최고 적중: {winner.get('max_correct', 0)}개 일치 (max={winner.get('max_correct', 0)})",
f" 평균 점수: {winner.get('avg_score', 0):.2f}",
"",
f"📊 다음주 base 변경 ({reason}):",
]
# 우선순위: eval_result.previous_base > current_base (eval 직후 stale) > 균등 fallback
base_now = eval_result.get("previous_base") or current_base or [0.2] * 5
for i, (cur, new) in enumerate(zip(base_now, new_base)):
diff = new - cur
if abs(diff) < 0.005:
marker = "="
elif diff > 0:
marker = "+" if diff < 0.05 else "++"
else:
marker = "-" if diff > -0.05 else "--"
lines.append(f" {_METRIC_NAMES[i]:8s} {cur:.2f}{new:.2f} ({marker})")
lines.append("")
lines.append(f"{_REASON_LABEL.get(reason, reason)}")
lines.append("")
lines.append(f"[웹에서 차트 보기] ({LOTTO_URL}/evolver)")
return "\n".join(lines)
async def send_evolution_report(eval_result: Dict[str, Any], current_base: List[float]) -> None:
text = _format_evolution_report(eval_result, current_base)
if not text:
return
try:
await send_raw(text)
except Exception as e:
logger.warning(f"[telegram_lotto] evolution report send failed: {e}")
# ---------- 일요 회고 브리핑 ----------
def format_sunday_review(payload: Dict[str, Any]) -> str:
"""일요 회고 브리핑 텍스트 (HTML parse_mode)."""
wa = payload.get("winner_analysis") or {}
draw_no = payload.get("draw_no") or "?"
pct = wa.get("percentile")
pct_txt = f"{pct*100:.0f}%" if pct is not None else ""
lines = [f"🔍 <b>로또 #{draw_no} 일요 회고</b>", ""]
if wa:
lines.append(f"이번 당첨조합 분석치: <b>{wa.get('score_total',0):.2f}</b> "
f"(무작위 분포 상위 {pct_txt})")
lines.append(f" 빈도 {wa.get('score_frequency',0):.2f} · 지문 {wa.get('score_fingerprint',0):.2f} "
f"· 갭 {wa.get('score_gap',0):.2f} · 공동출현 {wa.get('score_cooccur',0):.2f} "
f"· 다양성 {wa.get('score_diversity',0):.2f}")
lines.append("")
if payload.get("forward"):
lines.append("📊 <b>이번 회차 가상구매 성적</b>")
for f in payload.get("forward", []):
p = f.get("prizes") or {}
name = {"engine_w": f"엔진({f.get('label','')})", "random_null": "무작위", "coverage": "커버리지"}.get(
f.get("strategy", ""), f.get("strategy", "?"))
lines.append(f" {name}: 최고 {f.get('best_match','?')}일치 / "
f"4등 {p.get('4th', 0)} · 5등 {p.get('5th', 0)}")
else:
lines.append("📊 <b>이번 회차 가상구매 성적</b>: 데이터 없음 (아직 집계 전)")
lines.append("")
lines.append(" 무작위 대비 우위가 통계적으로 의미있을 때만 가중치가 진화합니다.")
return "\n".join(lines)
async def send_sunday_review(payload: Dict[str, Any]) -> None:
text = format_sunday_review(payload)
try:
await send_raw(text)
except Exception as e:
logger.warning(f"[telegram_lotto] sunday review send failed: {e}")

View File

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

View File

@@ -1,7 +1,9 @@
import asyncio
import logging
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from .agents import AGENT_REGISTRY
from .db import delete_old_logs
scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
@@ -20,6 +22,16 @@ async def _run_stock_ai_news():
if agent:
await agent.on_ai_news_schedule()
async def _run_stock_holdings_eod():
agent = AGENT_REGISTRY.get("stock")
if agent:
await agent.run_holdings_eod()
async def _run_stock_holdings_brief():
agent = AGENT_REGISTRY.get("stock")
if agent:
await agent.run_holdings_brief()
async def _run_insta_schedule():
agent = AGENT_REGISTRY.get("insta")
if agent:
@@ -56,6 +68,21 @@ async def _run_lotto_daily_digest():
if agent:
await agent.run_daily_digest()
async def _run_lotto_weekly_evolution_report():
agent = AGENT_REGISTRY.get("lotto")
if agent:
await agent.run_weekly_evolution_report()
async def _run_lotto_sync_evolver_activity():
agent = AGENT_REGISTRY.get("lotto")
if agent:
await agent.sync_evolver_activity()
async def _run_lotto_sunday_review():
agent = AGENT_REGISTRY.get("lotto")
if agent:
await agent.run_sunday_review()
async def _run_youtube_research():
agent = AGENT_REGISTRY.get("youtube")
if agent:
@@ -71,6 +98,11 @@ async def _poll_pipelines():
if agent:
await agent.poll_state_changes()
def _cleanup_old_logs():
n = delete_old_logs(days=90)
if n:
logging.getLogger(__name__).info("delete_old_logs: %d rows removed", n)
def init_scheduler():
scheduler.add_job(_run_stock_schedule, "cron", hour=7, minute=30, id="stock_news")
scheduler.add_job(
@@ -89,15 +121,26 @@ def init_scheduler():
minute=0,
id="stock_ai_news_sentiment",
)
scheduler.add_job(_run_stock_holdings_eod, "cron", day_of_week="mon-fri", hour=16, minute=50, id="stock_holdings_eod") # 16:50: 스크리너 snapshot(16:30) 완료 후 — 부분 일봉 읽기 방지
scheduler.add_job(_run_stock_holdings_brief, "cron", day_of_week="mon-fri", hour=8, minute=30, id="stock_holdings_brief")
scheduler.add_job(_run_insta_schedule, "cron", hour=9, minute=30, id="insta_pipeline")
# 09:00 cron 스태거링 — Celeron 2C/2.0GHz에서 동시 실행 시 CPU 폭주 (CHECK_POINT FU-A)
scheduler.add_job(_run_insta_trends_collect, "cron", hour=9, minute=0, id="insta_trends_collect")
# 외부 트렌드 수집은 장 마감 후 16:40 — 9시 주식 활발 시간대 NAS 자원 회피.
# screener(16:30)와 10분 스태거: Celeron 2C/2.0GHz 동시 실행 시 CPU 폭주 방지 (CHECK_POINT FU-A)
scheduler.add_job(_run_insta_trends_collect, "cron", hour=16, minute=40, id="insta_trends_collect")
scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=9, minute=5, id="lotto_curate")
scheduler.add_job(_run_lotto_light_check, "cron", hour=9, minute=15, id="lotto_light_check")
scheduler.add_job(_run_lotto_sim_check, "cron", minute=15, hour="0,4,8,12,16,20", id="lotto_sim_check")
scheduler.add_job(_run_lotto_deep_check, "cron", day_of_week="sun,wed", hour=21, minute=15, id="lotto_deep_check")
scheduler.add_job(_run_lotto_daily_digest, "cron", hour=9, minute=25, id="lotto_digest")
scheduler.add_job(_run_lotto_weekly_evolution_report, "cron", day_of_week="sat", hour=22, minute=15, id="lotto_evolution_weekly")
scheduler.add_job(_run_lotto_sunday_review, "cron", day_of_week="sun", hour=9, minute=0, id="lotto_sunday_review")
scheduler.add_job(
_run_lotto_sync_evolver_activity,
"cron", hour=9, minute=30,
id="lotto_evolver_activity_sync",
)
scheduler.add_job(_run_youtube_research, "cron", hour=9, minute=10, id="youtube_research")
scheduler.add_job(_send_youtube_weekly_report, "cron", day_of_week="mon", hour=8, minute=0, id="youtube_weekly_report")
scheduler.add_job(_poll_pipelines, "interval", seconds=30, id="pipeline_poll")
scheduler.add_job(_cleanup_old_logs, "cron", hour=3, minute=0, id="cleanup_old_logs", replace_existing=True)
scheduler.start()

View File

@@ -1,8 +1,11 @@
import httpx
import logging
from typing import Any, Dict, List, Optional
from .config import STOCK_URL, MUSIC_LAB_URL, INSTA_LAB_URL, REALESTATE_LAB_URL
logger = logging.getLogger(__name__)
_client = httpx.AsyncClient(timeout=30.0)
async def fetch_stock_news(limit: int = 10, category: str = None) -> List[Dict[str, Any]]:
@@ -85,6 +88,29 @@ async def scrape_stock_news() -> Dict[str, Any]:
resp.raise_for_status()
return resp.json()
async def stock_holdings_run() -> Dict[str, Any]:
"""보유종목 시그널 계산 트리거 (EOD, use_llm=True).
stock BackgroundTask 등록 후 즉시 {ok, queued} 반환.
실제 계산은 stock 컨테이너 백그라운드에서 진행 — 여유있게 120s.
"""
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.post(
f"{STOCK_URL}/api/stock/holdings/intel/run",
params={"use_llm": True},
)
resp.raise_for_status()
return resp.json()
async def stock_holdings_brief() -> Dict[str, Any]:
"""보유종목 최신 브리핑 payload 조회 (GET, 모듈 레벨 _client 사용)."""
resp = await _client.get(f"{STOCK_URL}/api/stock/holdings/intel")
resp.raise_for_status()
return resp.json()
async def generate_music(payload: dict) -> Dict[str, Any]:
resp = await _client.post(f"{MUSIC_LAB_URL}/api/music/generate", json=payload)
resp.raise_for_status()
@@ -202,6 +228,26 @@ async def insta_put_preferences(weights: Dict[str, float]) -> Dict[str, Any]:
return resp.json()
async def insta_ranked(threshold: float = 0.6, limit: int = 20, dedup_window_days: int = 14) -> list:
async with httpx.AsyncClient(timeout=120) as client:
r = await client.get(
f"{INSTA_LAB_URL}/api/insta/keywords/ranked",
params={"threshold": threshold, "limit": limit, "dedup_window_days": dedup_window_days},
)
r.raise_for_status()
return r.json()["items"]
async def insta_decision(slate_id: int, decision: str) -> dict:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.post(
f"{INSTA_LAB_URL}/api/insta/slates/{slate_id}/decision",
json={"decision": decision},
)
r.raise_for_status()
return r.json()
# --- realestate-lab ---
async def realestate_collect() -> Dict[str, Any]:
@@ -306,6 +352,25 @@ async def list_active_pipelines() -> list[dict]:
return resp.json().get("pipelines", [])
async def list_failed_pipelines() -> list[dict]:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(f"{MUSIC_LAB_URL}/api/music/pipeline?status=failed")
resp.raise_for_status()
data = resp.json()
return data if isinstance(data, list) else data.get("items", data.get("pipelines", []))
async def pipeline_retry(pid: int) -> dict:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(f"{MUSIC_LAB_URL}/api/music/pipeline/{pid}/retry")
out = {"status_code": resp.status_code}
try:
out.update(resp.json())
except Exception:
pass
return out
async def get_pipeline(pid: int) -> dict:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(f"{MUSIC_LAB_URL}/api/music/pipeline/{pid}")
@@ -377,3 +442,63 @@ async def lotto_latest_draw() -> Optional[int]:
return None
except Exception:
return None
async def lotto_evolver_status() -> Dict[str, Any]:
"""GET /api/lotto/evolver/status — 이번주 trials + 다음주 base 정보."""
from .config import LOTTO_BACKEND_URL
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/evolver/status")
resp.raise_for_status()
return resp.json()
async def lotto_evolver_evaluate() -> Dict[str, Any]:
"""POST /api/lotto/evolver/evaluate-now — 회고 트리거 (텔레그램 리포트용)."""
from .config import LOTTO_BACKEND_URL
async with httpx.AsyncClient(timeout=60.0) as client:
resp = await client.post(f"{LOTTO_BACKEND_URL}/api/lotto/evolver/evaluate-now")
resp.raise_for_status()
return resp.json()
async def lotto_backtest_review(draw_no: int) -> Dict[str, Any]:
from .config import LOTTO_BACKEND_URL
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/backtest/review/{draw_no}")
resp.raise_for_status()
return resp.json()
from .config import AGENT_CONTAINER_MAP
async def fetch_service_logs(
agent_id: str,
since: Optional[str] = None,
limit: int = 200,
) -> List[Dict[str, Any]]:
"""해당 에이전트가 가리키는 컨테이너의 /logs/recent 를 호출해서
path_prefix 정규식으로 필터한 결과를 반환.
네트워크 실패 시 빈 리스트를 반환하고 warning 만 남김 (LogTab 이 죽지 않게).
"""
mapping = AGENT_CONTAINER_MAP.get(agent_id)
if not mapping:
return []
host, port, path_re = mapping
url = f"http://{host}:{port}/logs/recent"
params: Dict[str, Any] = {"limit": limit}
if since:
params["since"] = since
try:
async with httpx.AsyncClient(timeout=3.0) as client:
resp = await client.get(url, params=params)
data = resp.json().get("logs", [])
except Exception as e:
logger.warning("fetch_service_logs(%s) 실패: %s", agent_id, e)
return []
return [
x for x in data
if x.get("source") == "log"
or path_re.match(x.get("path", "") or "")
]

View File

@@ -1,8 +1,11 @@
"""고수준 메시지 전송 API."""
import json
import uuid
from typing import Optional
from ..config import TELEGRAM_CHAT_ID
import httpx
from ..config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID
from ..db import save_telegram_callback
from .client import _enabled, api_call
from .formatter import MessageKind, format_agent_message
@@ -81,3 +84,26 @@ async def send_approval_request(
{"label": "❌ 거절", "action": "reject"},
],
)
async def send_photo(
photo_bytes: bytes,
caption: str = "",
reply_markup: Optional[dict] = None,
chat_id: Optional[str] = None,
) -> dict:
"""PNG/JPEG 바이트를 sendPhoto로 전송. reply_markup으로 인라인 키보드 첨부 가능."""
if not TELEGRAM_BOT_TOKEN:
return {"ok": False, "reason": "no token"}
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendPhoto"
data: dict = {
"chat_id": chat_id or TELEGRAM_CHAT_ID,
"caption": caption[:1024],
"parse_mode": "HTML",
}
if reply_markup:
data["reply_markup"] = json.dumps(reply_markup, ensure_ascii=False)
files = {"photo": ("cover.png", photo_bytes, "image/png")}
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(url, data=data, files=files)
return resp.json()

View File

@@ -40,6 +40,12 @@ async def _handle_callback(callback_query: dict) -> Optional[dict]:
if callback_id.startswith("render_"):
return await _handle_insta_render(callback_query, callback_id)
if callback_id.startswith("issue_"):
return await _handle_insta_issue(callback_query, callback_id)
if callback_id.startswith("ytpub_retry_"):
return await _handle_ytpub_retry(callback_query, callback_id)
cb = get_telegram_callback(callback_id)
if not cb:
return None
@@ -132,6 +138,64 @@ async def _handle_insta_render(callback_query: dict, callback_id: str) -> dict:
return {"ok": False, "error": str(e)}
async def _handle_insta_issue(callback_query: dict, callback_id: str) -> dict:
"""issue_{approve|reject|regen}_{slate_id} 콜백 → InstaAgent.on_callback.
callback_data 예시: issue_approve_8, issue_reject_8, issue_regen_8
InstaAgent.on_callback("issue_approve" | "issue_reject" | "issue_regen", {"slate_id": <int>}) 로 dispatch.
"""
from .messaging import send_raw
from ..agents import AGENT_REGISTRY
await api_call(
"answerCallbackQuery",
{"callback_query_id": callback_query["id"], "text": "처리 중..."},
)
try:
rest = callback_id.removeprefix("issue_") # 예: "approve_8"
verb, sid = rest.rsplit("_", 1) # ("approve", "8")
slate_id = int(sid)
except (ValueError, AttributeError):
await send_raw("⚠️ 잘못된 issue 콜백 데이터")
return {"ok": False, "error": "invalid_callback_data"}
agent = AGENT_REGISTRY.get("insta")
if not agent:
await send_raw("⚠️ insta agent 미등록")
return {"ok": False, "error": "agent_missing"}
try:
return await agent.on_callback(f"issue_{verb}", {"slate_id": slate_id})
except Exception as e:
await send_raw(f"⚠️ issue 콜백 처리 실패: {e}")
return {"ok": False, "error": str(e)}
async def _handle_ytpub_retry(callback_query: dict, callback_id: str) -> dict:
"""ytpub_retry_{pipeline_id} 콜백 → music-lab pipeline retry 프록시."""
from .. import service_proxy
from .messaging import send_raw
await api_call(
"answerCallbackQuery",
{"callback_query_id": callback_query["id"], "text": "재시도 요청 중..."},
)
try:
pid = int(callback_id.removeprefix("ytpub_retry_"))
except (ValueError, AttributeError):
return {"ok": False, "error": "invalid_callback_data"}
res = await service_proxy.pipeline_retry(pid)
sc = res.get("status_code")
if sc in (200, 202):
await send_raw(text=f"🔄 파이프라인 #{pid} 재개: {res.get('retrying_step', '?')}")
else:
await send_raw(text=f"⚠️ 재개 불가 (#{pid}): {res.get('detail', sc)}")
return {"ok": True}
async def _handle_message(message: dict, agent_dispatcher) -> Optional[dict]:
"""슬래시 명령 메시지 처리."""
from .router import parse_command, resolve_agent_command, HELP_TEXT

View File

@@ -18,9 +18,11 @@ from app.db import (
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}"
# 시드된 핵심 에이전트 존재 검증 — 레지스트리 확장(insta/lotto/realestate/youtube 등)에 견고하도록
# 고정 개수/집합이 아닌 subset으로 단언 (이전 len==2/{stock,music} 고정 단언은 stale였음).
assert {"stock", "music"} <= ids, f"core agents missing: {ids}"
assert len(agents) >= 2
print(" [PASS] test_init_and_seed")
@@ -93,6 +95,41 @@ def test_telegram_state():
print(" [PASS] test_telegram_state")
def test_get_logs_excludes_state_messages():
init_db()
add_log("stock", "State: idle -> working (큐레이션 시작)")
add_log("stock", "뉴스 12건 스크랩 완료")
add_log("stock", "State: working -> idle ()")
logs = get_logs("stock", limit=10)
messages = [x["message"] for x in logs]
assert "뉴스 12건 스크랩 완료" in messages
assert not any(m.startswith("State: ") for m in messages)
def test_delete_old_logs_removes_beyond_retention():
import datetime as _dt
from app.db import delete_old_logs, _conn
init_db()
add_log("stock", "오래된 로그")
# 강제로 200일 전으로 옮김
cutoff = (_dt.datetime.utcnow() - _dt.timedelta(days=200)).isoformat()
with _conn() as conn:
conn.execute(
"UPDATE agent_logs SET created_at = ? WHERE message = '오래된 로그'",
(cutoff,),
)
add_log("stock", "최근 로그")
deleted = delete_old_logs(days=90)
assert deleted >= 1
msgs = [x["message"] for x in get_logs("stock", limit=20)]
assert "최근 로그" in msgs
assert "오래된 로그" not in msgs
if __name__ == "__main__":
test_init_and_seed()
test_agent_config_update()

View File

@@ -4,5 +4,6 @@ apscheduler==3.10.4
websockets>=12.0
httpx>=0.27
respx>=0.21
pytest-asyncio>=0.23
google-api-python-client>=2.100.0
pytrends>=4.9.2

View File

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

View File

@@ -0,0 +1,76 @@
# agent-office/tests/test_activity_feed_filters.py
import os
import sys
import tempfile
import gc
_fd, _TMP = tempfile.mkstemp(suffix=".db")
os.close(_fd)
os.unlink(_TMP)
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import pytest
from app import db
db.DB_PATH = _TMP
@pytest.fixture(autouse=True)
def fresh_db():
db.DB_PATH = _TMP
gc.collect()
if os.path.exists(_TMP):
os.remove(_TMP)
db.init_db()
yield
gc.collect()
if os.path.exists(_TMP):
try:
os.remove(_TMP)
except PermissionError:
pass
def test_filter_by_agent_id():
db.create_task("lotto", "curate", {})
db.create_task("stock", "brief", {})
db.add_log("stock", "stock 로그")
feed = db.get_activity_feed(limit=50, offset=0, agent_id="lotto")
assert feed["total"] == 1
assert all(i["agent_id"] == "lotto" for i in feed["items"])
def test_filter_type_task_excludes_logs():
db.create_task("lotto", "curate", {})
db.add_log("lotto", "로그 한 줄")
feed = db.get_activity_feed(limit=50, offset=0, type="task")
assert feed["total"] == 1
assert all(i["type"] == "task" for i in feed["items"])
def test_filter_type_log_excludes_tasks():
db.create_task("lotto", "curate", {})
db.add_log("lotto", "로그 한 줄")
feed = db.get_activity_feed(limit=50, offset=0, type="log")
assert feed["total"] == 1
assert all(i["type"] == "log" for i in feed["items"])
def test_filter_status_tasks_only():
t1 = db.create_task("lotto", "curate", {})
t2 = db.create_task("lotto", "curate", {})
db.update_task_status(t1, "succeeded", {})
db.update_task_status(t2, "failed", {})
db.add_log("lotto", "로그 한 줄") # status 필터 시 log는 제외돼야 함
feed = db.get_activity_feed(limit=50, offset=0, status="succeeded")
assert feed["total"] == 1
assert all(i["type"] == "task" and i["status"] == "succeeded" for i in feed["items"])
def test_no_filters_returns_all():
db.create_task("lotto", "curate", {})
db.add_log("stock", "로그")
feed = db.get_activity_feed(limit=50, offset=0)
assert feed["total"] == 2

View File

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

View File

@@ -0,0 +1,169 @@
import os
import sys
import tempfile
_fd, _TMP = tempfile.mkstemp(suffix=".db")
os.close(_fd)
os.unlink(_TMP)
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import pytest
from unittest.mock import AsyncMock
from app.agents.insta import InstaAgent
@pytest.fixture(autouse=True)
def _init_db():
import gc
gc.collect()
if os.path.exists(_TMP):
os.remove(_TMP)
from app.db import init_db
init_db()
yield
gc.collect()
@pytest.mark.asyncio
async def test_autonomous_issue_previews_eligible(monkeypatch):
agent = InstaAgent()
agent.state = "idle"
monkeypatch.setattr("app.agents.insta.get_agent_config",
lambda aid: {"custom_config": {"autonomous_issue": True,
"select_threshold": 0.5, "max_per_day": 2}})
monkeypatch.setattr(agent, "transition", AsyncMock())
monkeypatch.setattr(agent, "_run_collect_and_extract", AsyncMock())
monkeypatch.setattr("app.agents.insta.service_proxy.insta_ranked", AsyncMock(return_value=[
{"id": 1, "keyword": "금리", "category": "economy", "eligible": True, "final_score": 0.8, "breakdown": {}},
{"id": 2, "keyword": "x", "category": "economy", "eligible": False, "final_score": 0.1, "breakdown": {}},
]))
preview = AsyncMock()
monkeypatch.setattr(agent, "_generate_and_preview", preview)
monkeypatch.setattr("app.agents.insta.create_task", lambda *a, **k: "t1")
monkeypatch.setattr("app.agents.insta.update_task_status", lambda *a, **k: None)
monkeypatch.setattr("app.agents.insta.add_log", lambda *a, **k: None)
await agent.on_schedule()
assert preview.await_count == 1
assert preview.await_args.args[0]["id"] == 1
@pytest.mark.asyncio
async def test_callback_approve_publishes_and_delivers(monkeypatch):
agent = InstaAgent()
monkeypatch.setattr("app.agents.insta.service_proxy.insta_decision",
AsyncMock(return_value={"status": "published"}))
monkeypatch.setattr("app.agents.insta.service_proxy.insta_get_slate", AsyncMock(return_value={
"assets": [{"page_index": i} for i in range(1, 11)],
"suggested_caption": "cap", "hashtags": ["#a"]}))
monkeypatch.setattr("app.agents.insta.service_proxy.insta_get_asset_bytes", AsyncMock(return_value=b"png"))
monkeypatch.setattr("app.agents.insta._send_media_group", AsyncMock(return_value={"ok": True}))
monkeypatch.setattr("app.agents.insta.messaging.send_raw", AsyncMock())
res = await agent.on_callback("issue_approve", {"slate_id": 8})
assert res["ok"] is True
@pytest.mark.asyncio
async def test_callback_reject_marks_rejected(monkeypatch):
agent = InstaAgent()
dec = AsyncMock(return_value={"status": "rejected"})
monkeypatch.setattr("app.agents.insta.service_proxy.insta_decision", dec)
monkeypatch.setattr("app.agents.insta.messaging.send_raw", AsyncMock())
res = await agent.on_callback("issue_reject", {"slate_id": 8})
assert res["ok"] is True
dec.assert_awaited_once_with(8, "rejected")
@pytest.mark.asyncio
async def test_handle_insta_issue_dispatch(monkeypatch):
"""_handle_insta_issue: issue_approve_8 → on_callback('issue_approve', {slate_id:8})."""
import sys
# stub api_call so answerCallbackQuery doesn't hit real Telegram
import app.telegram.webhook as wh
monkeypatch.setattr(wh, "api_call", AsyncMock(return_value={"ok": True}))
agent = InstaAgent()
on_cb = AsyncMock(return_value={"ok": True})
monkeypatch.setattr(agent, "on_callback", on_cb)
from app.agents import AGENT_REGISTRY
old = AGENT_REGISTRY.get("insta")
AGENT_REGISTRY["insta"] = agent
try:
result = await wh._handle_insta_issue(
{"id": "cq1", "data": "issue_approve_8"},
"issue_approve_8",
)
finally:
if old is None:
AGENT_REGISTRY.pop("insta", None)
else:
AGENT_REGISTRY["insta"] = old
on_cb.assert_awaited_once_with("issue_approve", {"slate_id": 8})
assert result["ok"] is True
@pytest.mark.asyncio
async def test_handle_insta_issue_invalid_data(monkeypatch):
"""_handle_insta_issue: 잘못된 callback_data → ok=False, error=invalid_callback_data."""
import app.telegram.webhook as wh
monkeypatch.setattr(wh, "api_call", AsyncMock(return_value={"ok": True}))
monkeypatch.setattr("app.telegram.messaging.send_raw", AsyncMock())
result = await wh._handle_insta_issue(
{"id": "cq2", "data": "issue_bad"},
"issue_bad",
)
assert result["ok"] is False
assert result["error"] == "invalid_callback_data"
@pytest.mark.asyncio
async def test_backward_compat_non_autonomous_uses_legacy_path(monkeypatch):
"""autonomous_issue=False, auto_select=False → insta_ranked 미호출, _push_keyword_candidates 호출."""
agent = InstaAgent()
agent.state = "idle"
monkeypatch.setattr("app.agents.insta.get_agent_config",
lambda aid: {"custom_config": {"autonomous_issue": False, "auto_select": False}})
monkeypatch.setattr(agent, "transition", AsyncMock())
monkeypatch.setattr(agent, "_run_collect_and_extract", AsyncMock())
# insta_get_preferences는 try/except 안에 있으므로 예외를 던져도 안전하지만 깔끔하게 mock
monkeypatch.setattr("app.agents.insta.service_proxy.insta_get_preferences",
AsyncMock(return_value={}))
# 비자율 경로에서 insta_ranked는 호출되면 안 된다
ranked = AsyncMock()
monkeypatch.setattr("app.agents.insta.service_proxy.insta_ranked", ranked)
# insta_list_keywords: 비자율 경로에서 반드시 호출
monkeypatch.setattr("app.agents.insta.service_proxy.insta_list_keywords",
AsyncMock(return_value=[]))
# auto_select=False → _push_keyword_candidates 경로
push = AsyncMock()
monkeypatch.setattr(agent, "_push_keyword_candidates", push)
gen = AsyncMock()
monkeypatch.setattr(agent, "_generate_and_preview", gen)
monkeypatch.setattr("app.agents.insta.create_task", lambda *a, **k: "t1")
monkeypatch.setattr("app.agents.insta.update_task_status", lambda *a, **k: None)
monkeypatch.setattr("app.agents.insta.add_log", lambda *a, **k: None)
await agent.on_schedule()
ranked.assert_not_awaited() # 자율 경로(insta_ranked) 미진입 확인
gen.assert_not_awaited() # _generate_and_preview 미호출 확인
push.assert_awaited_once() # 기존 candidate-push 경로 진입 확인
@pytest.mark.asyncio
async def test_callback_regen_rejects_old_and_regenerates(monkeypatch):
"""issue_regen: 기존 슬레이트 rejected 처리 후 같은 키워드로 _generate_and_preview 재호출."""
agent = InstaAgent()
monkeypatch.setattr("app.agents.insta.service_proxy.insta_get_slate",
AsyncMock(return_value={"keyword": "금리", "category": "economy"}))
dec = AsyncMock(return_value={"status": "rejected"})
monkeypatch.setattr("app.agents.insta.service_proxy.insta_decision", dec)
gen = AsyncMock()
monkeypatch.setattr(agent, "_generate_and_preview", gen)
res = await agent.on_callback("issue_regen", {"slate_id": 8})
assert res["ok"] is True
dec.assert_awaited_once_with(8, "rejected") # 이전 슬레이트 폐기
gen.assert_awaited_once() # 같은 키워드로 재생성
assert gen.await_args.args[0]["keyword"] == "금리"

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,229 @@
# agent-office/tests/test_lotto_task_wrap.py
import os
import sys
import tempfile
import gc
_fd, _TMP = tempfile.mkstemp(suffix=".db")
os.close(_fd)
os.unlink(_TMP)
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import pytest
from app import db
db.DB_PATH = _TMP
@pytest.fixture(autouse=True)
def fresh_db():
# Re-patch DB_PATH at the start of every test (cross-file isolation)
db.DB_PATH = _TMP
gc.collect()
if os.path.exists(_TMP):
os.remove(_TMP)
db.init_db()
yield
gc.collect()
if os.path.exists(_TMP):
try:
os.remove(_TMP)
except PermissionError:
pass
@pytest.mark.asyncio
async def test_run_signal_check_creates_task_row(monkeypatch):
"""run_signal_check이 agent_tasks에 row를 만들고 result_data를 저장."""
from app.agents.lotto import LottoAgent
from app.curator import signal_runner
async def fake_run_signal_check(**kwargs):
return {
"overall_fire": "normal",
"results": [
{"signal_id": 1, "metric": "sim_signal",
"value": 0.6, "z_score": 1.7, "fire_level": "normal",
"baseline_mu": 0.5, "baseline_sigma": 0.05, "payload": {}},
],
}
monkeypatch.setattr(signal_runner, "run_signal_check", fake_run_signal_check)
from app import service_proxy
async def fake_latest():
return 1226
monkeypatch.setattr(service_proxy, "lotto_latest_draw", fake_latest)
from app.notifiers import telegram_lotto
async def fake_send(_event): pass
monkeypatch.setattr(telegram_lotto, "send_urgent_signal", fake_send)
agent = LottoAgent()
result = await agent.run_signal_check(source="light")
assert result["ok"] is True
tasks = db.get_agent_tasks("lotto", task_type="signal_check", days=1)
assert len(tasks) == 1
t = tasks[0]
assert t["status"] == "succeeded"
assert t["result_data"]["source"] == "light"
assert t["result_data"]["overall_fire"] == "normal"
assert "sim_signal" in t["result_data"]["fired_metrics"]
@pytest.mark.asyncio
async def test_run_signal_check_failure_marks_task_failed(monkeypatch):
from app.agents.lotto import LottoAgent
from app.curator import signal_runner
from app import service_proxy
async def boom(**kwargs):
raise RuntimeError("boom")
monkeypatch.setattr(signal_runner, "run_signal_check", boom)
async def fake_latest():
return 1226
monkeypatch.setattr(service_proxy, "lotto_latest_draw", fake_latest)
agent = LottoAgent()
result = await agent.run_signal_check(source="sim")
assert result["ok"] is False
tasks = db.get_agent_tasks("lotto", task_type="signal_check", days=1)
assert len(tasks) == 1
assert tasks[0]["status"] == "failed"
assert "boom" in tasks[0]["result_data"]["error"]
@pytest.mark.asyncio
async def test_deep_curate_error_still_evaluates_signals(monkeypatch):
"""deep: curate_weekly가 CuratorError여도 sim/drift 시그널 평가는 계속(fallthrough)."""
from app.agents.lotto import LottoAgent
from app.curator import signal_runner, pipeline
from app import service_proxy
from app.notifiers import telegram_lotto
async def boom_curate(**kwargs):
raise pipeline.CuratorError("curation 실패")
monkeypatch.setattr(pipeline, "curate_weekly", boom_curate)
called = {"signal": False, "curate_result": "UNSET"}
async def fake_signal(**kwargs):
called["signal"] = True
called["curate_result"] = kwargs.get("curate_result")
return {"overall_fire": "normal", "results": [
{"signal_id": 1, "metric": "sim_signal", "value": 0.6, "z_score": 1.7,
"fire_level": "normal", "baseline_mu": 0.5, "baseline_sigma": 0.05, "payload": {}}]}
monkeypatch.setattr(signal_runner, "run_signal_check", fake_signal)
async def fake_latest():
return 1226
monkeypatch.setattr(service_proxy, "lotto_latest_draw", fake_latest)
async def fake_send(_e):
pass
monkeypatch.setattr(telegram_lotto, "send_urgent_signal", fake_send)
agent = LottoAgent()
result = await agent.run_signal_check(source="deep")
assert result["ok"] is True # CuratorError로 중단되지 않음
assert called["signal"] is True # sim/drift 평가 계속됨
assert called["curate_result"] is None # confidence는 None으로 fallthrough
@pytest.mark.asyncio
async def test_urgent_send_retries_then_succeeds(monkeypatch):
"""urgent 발송이 실패하면 재시도하고, 성공하면 True."""
from app.agents.lotto import LottoAgent
from app.notifiers import telegram_lotto
import app.agents.lotto as lotto_mod
monkeypatch.setattr(lotto_mod, "URGENT_SEND_RETRY_SEC", 0) # 실대기 제거
attempts = {"n": 0}
async def flaky_send(_event):
attempts["n"] += 1
if attempts["n"] < 3:
raise RuntimeError("telegram down")
monkeypatch.setattr(telegram_lotto, "send_urgent_signal", flaky_send)
agent = LottoAgent()
results = [{"signal_id": 1, "fire_level": "urgent"}]
ok = await agent._send_urgent_with_retry({"x": 1}, results, task_id="t1")
assert ok is True
assert attempts["n"] == 3
@pytest.mark.asyncio
async def test_urgent_send_all_fail_returns_false_no_raise(monkeypatch):
"""urgent 발송이 끝까지 실패해도 raise하지 않고 False (시그널 평가/태스크 보존)."""
from app.agents.lotto import LottoAgent
from app.notifiers import telegram_lotto
import app.agents.lotto as lotto_mod
monkeypatch.setattr(lotto_mod, "URGENT_SEND_RETRY_SEC", 0)
async def always_fail(_event):
raise RuntimeError("telegram down")
monkeypatch.setattr(telegram_lotto, "send_urgent_signal", always_fail)
agent = LottoAgent()
ok = await agent._send_urgent_with_retry(
{"x": 1}, [{"signal_id": 1, "fire_level": "urgent"}], task_id="t1")
assert ok is False
@pytest.mark.asyncio
async def test_run_daily_digest_creates_task(monkeypatch):
"""run_daily_digest이 agent_tasks에 task 생성 + result_data 저장."""
from app.agents.lotto import LottoAgent
from app.notifiers import telegram_lotto
async def fake_send(_d): pass
monkeypatch.setattr(telegram_lotto, "send_signal_summary", fake_send)
agent = LottoAgent()
result = await agent.run_daily_digest()
assert result["ok"] is True
tasks = db.get_agent_tasks("lotto", task_type="daily_digest", days=1)
assert len(tasks) == 1
assert tasks[0]["status"] == "succeeded"
assert "fired" in tasks[0]["result_data"]
assert "evaluated" in tasks[0]["result_data"]
@pytest.mark.asyncio
async def test_run_weekly_evolution_report_creates_task(monkeypatch):
"""run_weekly_evolution_report이 task 생성 + result_data 저장."""
from app.agents.lotto import LottoAgent
from app import service_proxy
from app.notifiers import telegram_lotto
async def fake_eval():
return {
"ok": True, "draw_no": 1225,
"winner": {"day_of_week": 3, "weight": [0.18, 0.32, 0.20, 0.22, 0.08],
"avg_score": 0.42, "max_correct": 4, "n_picks": 5},
"new_base": [0.18, 0.32, 0.20, 0.22, 0.08],
"previous_base": [0.2] * 5,
"update_reason": "winner_4plus",
}
async def fake_status():
return {"current_base": [0.2] * 5}
async def fake_send(_e, _b): pass
monkeypatch.setattr(service_proxy, "lotto_evolver_evaluate", fake_eval)
monkeypatch.setattr(service_proxy, "lotto_evolver_status", fake_status)
monkeypatch.setattr(telegram_lotto, "send_evolution_report", fake_send)
agent = LottoAgent()
result = await agent.run_weekly_evolution_report()
assert result["ok"] is True
tasks = db.get_agent_tasks("lotto", task_type="weekly_evolution_report", days=1)
assert len(tasks) == 1
r = tasks[0]["result_data"]
assert tasks[0]["status"] == "succeeded"
assert r["draw_no"] == 1225
assert r["update_reason"] == "winner_4plus"
assert r["winner_day_of_week"] == 3
assert r["winner_max_correct"] == 4

View File

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

View File

@@ -40,6 +40,9 @@ async def test_poll_notifies_once_per_state():
with patch(
"app.agents.youtube_publisher.service_proxy.list_active_pipelines",
new=AsyncMock(return_value=pipelines),
), patch(
"app.agents.youtube_publisher.service_proxy.list_failed_pipelines",
new=AsyncMock(return_value=[]),
), patch(
"app.agents.youtube_publisher.send_raw",
new=AsyncMock(return_value={"ok": True, "message_id": 99}),
@@ -63,6 +66,8 @@ async def test_poll_renotifies_on_reject_regen(monkeypatch):
"track_title": "Test", "feedback_count_per_step": {"cover": 1}}]
list_mock = AsyncMock(side_effect=[pipelines_v1, pipelines_v2])
with patch("app.agents.youtube_publisher.service_proxy.list_active_pipelines", list_mock), \
patch("app.agents.youtube_publisher.service_proxy.list_failed_pipelines",
new=AsyncMock(return_value=[])), \
patch("app.agents.youtube_publisher.send_raw",
new=AsyncMock(return_value={"ok": True, "message_id": 99})), \
patch("app.agents.youtube_publisher.service_proxy.save_pipeline_telegram_msg",
@@ -83,7 +88,7 @@ async def test_on_telegram_reply_approve_calls_feedback():
new=AsyncMock(),
) as mock_fb, patch(
"app.agents.youtube_publisher.send_raw",
new=AsyncMock(),
new=AsyncMock(return_value={"ok": True, "message_id": 1}),
):
a = YoutubePublisherAgent()
await a.on_telegram_reply(pipeline_id=42, step="cover", user_text="승인")
@@ -99,7 +104,7 @@ async def test_on_telegram_reply_reject_with_feedback():
new=AsyncMock(),
) as mock_fb, patch(
"app.agents.youtube_publisher.send_raw",
new=AsyncMock(),
new=AsyncMock(return_value={"ok": True, "message_id": 1}),
):
a = YoutubePublisherAgent()
await a.on_telegram_reply(pipeline_id=43, step="meta", user_text="반려, 제목 짧게")

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,213 @@
import os
import sys
import tempfile
_fd, _TMP = tempfile.mkstemp(suffix=".db")
os.close(_fd)
os.unlink(_TMP)
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import pytest
from unittest.mock import AsyncMock, patch
@pytest.fixture(autouse=True)
def _init_db():
import gc
gc.collect()
if os.path.exists(_TMP):
os.remove(_TMP)
from app.db import init_db
init_db()
yield
gc.collect()
@pytest.mark.asyncio
async def test_failed_pipeline_notified_with_retry_button():
from app.agents.youtube_publisher import YoutubePublisherAgent
agent = YoutubePublisherAgent()
failed_pipeline = {
"id": 7,
"state": "failed",
"failed_reason": "video: boom",
"track_title": "T",
}
sent = AsyncMock(return_value={"ok": True, "message_id": 1})
with patch(
"app.agents.youtube_publisher.service_proxy.list_active_pipelines",
new=AsyncMock(return_value=[]),
), patch(
"app.agents.youtube_publisher.service_proxy.list_failed_pipelines",
new=AsyncMock(return_value=[failed_pipeline]),
), patch(
"app.agents.youtube_publisher.send_raw",
new=sent,
):
await agent.poll_state_changes()
assert sent.await_count == 1
_, kwargs = sent.await_args
assert "실패" in (kwargs.get("text") or "")
assert kwargs["reply_markup"]["inline_keyboard"][0][0]["callback_data"] == "ytpub_retry_7"
@pytest.mark.asyncio
async def test_failed_pipeline_no_duplicate_notification():
"""같은 failed 파이프라인은 두 번째 poll에서 알림 안 함."""
from app.agents.youtube_publisher import YoutubePublisherAgent
agent = YoutubePublisherAgent()
failed_pipeline = {
"id": 7,
"state": "failed",
"failed_reason": "video: boom",
"track_title": "T",
}
sent = AsyncMock(return_value={"ok": True, "message_id": 1})
with patch(
"app.agents.youtube_publisher.service_proxy.list_active_pipelines",
new=AsyncMock(return_value=[]),
), patch(
"app.agents.youtube_publisher.service_proxy.list_failed_pipelines",
new=AsyncMock(return_value=[failed_pipeline]),
), patch(
"app.agents.youtube_publisher.send_raw",
new=sent,
):
await agent.poll_state_changes()
await agent.poll_state_changes()
# 중복 방지: 같은 failed 파이프라인에 대해 1회만 알림
assert sent.await_count == 1
@pytest.mark.asyncio
async def test_failed_pipeline_renotify_after_recovery():
"""failed에서 벗어난 파이프라인이 다시 failed 되면 재알림."""
from app.agents.youtube_publisher import YoutubePublisherAgent
agent = YoutubePublisherAgent()
failed_pipeline = {
"id": 7,
"state": "failed",
"failed_reason": "video: boom",
"track_title": "T",
}
sent = AsyncMock(return_value={"ok": True, "message_id": 1})
# 첫 번째 poll: failed 존재 → 알림
with patch(
"app.agents.youtube_publisher.service_proxy.list_active_pipelines",
new=AsyncMock(return_value=[]),
), patch(
"app.agents.youtube_publisher.service_proxy.list_failed_pipelines",
new=AsyncMock(return_value=[failed_pipeline]),
), patch(
"app.agents.youtube_publisher.send_raw",
new=sent,
):
await agent.poll_state_changes()
assert sent.await_count == 1
# 두 번째 poll: failed 목록에서 사라짐(재개됨) → _notified_failed에서 제거
with patch(
"app.agents.youtube_publisher.service_proxy.list_active_pipelines",
new=AsyncMock(return_value=[]),
), patch(
"app.agents.youtube_publisher.service_proxy.list_failed_pipelines",
new=AsyncMock(return_value=[]),
), patch(
"app.agents.youtube_publisher.send_raw",
new=sent,
):
await agent.poll_state_changes()
assert sent.await_count == 1 # 아직 추가 알림 없음
# 세 번째 poll: 다시 failed → 재알림 가능
with patch(
"app.agents.youtube_publisher.service_proxy.list_active_pipelines",
new=AsyncMock(return_value=[]),
), patch(
"app.agents.youtube_publisher.service_proxy.list_failed_pipelines",
new=AsyncMock(return_value=[failed_pipeline]),
), patch(
"app.agents.youtube_publisher.send_raw",
new=sent,
):
await agent.poll_state_changes()
assert sent.await_count == 2 # 재알림
@pytest.mark.asyncio
async def test_handle_ytpub_retry_calls_proxy():
from app import service_proxy
from app.telegram import webhook
retry = AsyncMock(return_value={"status_code": 202, "ok": True, "retrying_step": "video"})
fake_send = AsyncMock(return_value={"ok": True})
fake_api_call = AsyncMock(return_value={"ok": True})
with patch.object(service_proxy, "pipeline_retry", retry), \
patch("app.telegram.messaging.send_raw", fake_send), \
patch("app.telegram.webhook.api_call", fake_api_call):
res = await webhook._handle_ytpub_retry({"id": 1}, "ytpub_retry_7")
retry.assert_awaited_once_with(7)
assert res["ok"] is True
@pytest.mark.asyncio
async def test_handle_ytpub_retry_invalid_data():
from app.telegram import webhook
fake_send = AsyncMock(return_value={"ok": True})
fake_api_call = AsyncMock(return_value={"ok": True})
with patch("app.telegram.messaging.send_raw", fake_send), \
patch("app.telegram.webhook.api_call", fake_api_call):
res = await webhook._handle_ytpub_retry({"id": 1}, "ytpub_retry_abc")
assert res["ok"] is False
@pytest.mark.asyncio
async def test_failed_poll_exception_is_silent():
"""list_failed_pipelines 예외 시 poll이 조용히 넘어감 (active 알림에 영향 없음)."""
from app.agents.youtube_publisher import YoutubePublisherAgent
agent = YoutubePublisherAgent()
active_pipeline = {
"id": 1,
"state": "cover_pending",
"cover_url": "/x.jpg",
"track_title": "Track",
"feedback_count_per_step": {},
}
sent = AsyncMock(return_value={"ok": True, "message_id": 1})
with patch(
"app.agents.youtube_publisher.service_proxy.list_active_pipelines",
new=AsyncMock(return_value=[active_pipeline]),
), patch(
"app.agents.youtube_publisher.service_proxy.list_failed_pipelines",
new=AsyncMock(side_effect=Exception("network error")),
), patch(
"app.agents.youtube_publisher.service_proxy.save_pipeline_telegram_msg",
new=AsyncMock(),
), patch(
"app.agents.youtube_publisher.send_raw",
new=sent,
):
await agent.poll_state_changes()
# active 알림은 정상 발송
assert sent.await_count == 1

3
co-gahusb/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.venv/
__pycache__/
*.pyc

19
co-gahusb/CLIENT_SETUP.md Normal file
View File

@@ -0,0 +1,19 @@
# co-gahusb 클라이언트 설정
## 공통
1. `CO_BUS_KEY` 환경변수를 각 머신에 설정(서버 `.env`의 값과 동일).
2. 해당 repo 루트 `.mcp.json`에 co-gahusb HTTP MCP 등록(이 repo의 예시 참고).
3. CLAUDE.md 역할 블록의 `/loop` 폴링 규약을 따른다.
## web-ai (다른 머신)
web-ai 머신의 repo 루트에 아래 `.mcp.json` 생성, 역할 = **AI**:
```json
{ "mcpServers": { "co-gahusb": {
"type": "http",
"url": "https://gahusb.synology.me/api/co/mcp",
"headers": { "Authorization": "Bearer ${CO_BUS_KEY}" } } } }
```
web-ai CLAUDE.md에 역할 블록 추가(role="AI", 소유권=web-ai repo, 동일 락 규약).
## Producer (오케스트레이터 세션)
별도 repo 없이 조율 담당. `team_log()`로 전체 활동 감시, `create_task`로 분배, `acquire_lock`로 교차 작업 직렬화.

12
co-gahusb/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM python:3.12-slim-bookworm
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --timeout 600 --retries 5 -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.server:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

View File

21
co-gahusb/app/config.py Normal file
View File

@@ -0,0 +1,21 @@
# co-gahusb/app/config.py
import os
REDIS_URL = os.environ.get("REDIS_URL", "redis://redis:6379")
CO_BUS_KEY = os.environ.get("CO_BUS_KEY", "")
# 협업 역할 (세션별 1:1)
ROLES = ("FE", "BE", "AI", "Producer")
# 교차 리소스 어드바이저리 락 대상 (이 외 이름도 락은 가능하나, 규약상 명시 대상)
LOCKABLE_RESOURCES = (
"nas-deploy",
"stock-db-schema",
"lotto-db-schema",
"memory-mirror",
"nginx-conf",
"compose",
)
DEFAULT_LOCK_TTL = 300
TEAM_LOG_MAXLEN = 500

66
co-gahusb/app/locks.py Normal file
View File

@@ -0,0 +1,66 @@
# co-gahusb/app/locks.py
from redis.exceptions import WatchError
LOCK_PREFIX = "co:lock:"
async def acquire_lock(r, resource, role, ttl_sec=300):
key = LOCK_PREFIX + resource
ok = await r.set(key, role, nx=True, ex=ttl_sec)
if ok:
return {"acquired": True}
held_by = await r.get(key)
ttl = await r.ttl(key)
return {"acquired": False, "held_by": held_by, "ttl_remaining": max(ttl, 0)}
async def release_lock(r, resource, role):
key = LOCK_PREFIX + resource
async with r.pipeline() as pipe:
while True:
try:
await pipe.watch(key)
owner = await pipe.get(key)
if owner != role:
await pipe.unwatch()
return {"released": False, "held_by": owner}
pipe.multi()
pipe.delete(key)
await pipe.execute()
return {"released": True}
except WatchError:
continue
async def heartbeat_lock(r, resource, role, ttl_sec=300):
key = LOCK_PREFIX + resource
async with r.pipeline() as pipe:
while True:
try:
await pipe.watch(key)
owner = await pipe.get(key)
if owner != role:
await pipe.unwatch()
return {"renewed": False, "held_by": owner}
pipe.multi()
pipe.expire(key, ttl_sec)
await pipe.execute()
return {"renewed": True}
except WatchError:
continue
async def list_locks(r):
keys = await r.keys(LOCK_PREFIX + "*")
out = []
for key in keys:
held_by = await r.get(key)
if held_by is None:
continue
ttl = await r.ttl(key)
out.append({
"resource": key[len(LOCK_PREFIX):],
"held_by": held_by,
"ttl_remaining": max(ttl, 0),
})
return {"locks": out}

138
co-gahusb/app/server.py Normal file
View File

@@ -0,0 +1,138 @@
# co-gahusb/app/server.py
import logging
import redis.asyncio as aioredis
from mcp.server.fastmcp import FastMCP
from mcp.server.transport_security import TransportSecuritySettings
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from starlette.routing import Mount, Route
from app import config, locks, store
log = logging.getLogger("co-gahusb")
_auth_failed_logged = False
_redis = aioredis.from_url(config.REDIS_URL, decode_responses=True)
# DNS-rebinding 보호 비활성화: 실 보안은 nginx 앞단 Bearer 인증(MCP 도달 전 401)이다.
# 원격 HTTPS + 정적키 모델이라 Host 화이트리스트는 보안가치 ~0이고, 도메인 변경 시 또 깨진다.
mcp = FastMCP(
"co-gahusb",
transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False),
)
# ---- 메시지 ----
@mcp.tool()
async def post_message(from_role: str, to_role: str, body: str, thread_id: str = "") -> dict:
"""다른 역할의 우편함에 메시지를 보낸다."""
res = await store.post_message(_redis, from_role, to_role, body, thread_id or None)
await store.log_event(_redis, "message", f"{from_role}{to_role}: {body[:60]}")
return res
@mcp.tool()
async def read_inbox(role: str, after_id: int = 0, mark_read: bool = False) -> dict:
"""내 역할 우편함을 커서 기반으로 읽는다."""
return await store.read_inbox(_redis, role, after_id, mark_read)
# ---- 작업 ----
@mcp.tool()
async def create_task(title: str, assignee_role: str, created_by: str, detail: str = "") -> dict:
"""작업을 만들어 특정 역할에 배정한다."""
res = await store.create_task(_redis, title, assignee_role, created_by, detail or None)
await store.log_event(_redis, "task", f"{created_by} created '{title}'{assignee_role}")
return res
@mcp.tool()
async def claim_task(task_id: int, role: str) -> dict:
"""open 작업을 점유(in_progress)한다. 이미 점유면 거부."""
res = await store.claim_task(_redis, task_id, role)
if res.get("ok"):
await store.log_event(_redis, "task", f"{role} claimed task#{task_id}")
return res
@mcp.tool()
async def update_task(task_id: int, status: str, role: str, note: str = "") -> dict:
"""작업 상태를 갱신한다 (open/in_progress/blocked/done)."""
res = await store.update_task(_redis, task_id, status, role, note or None)
await store.log_event(_redis, "task", f"{role} set task#{task_id}{status}")
return res
@mcp.tool()
async def list_tasks(status: str = "", assignee_role: str = "") -> dict:
"""작업 목록을 조회한다(상태/담당 필터)."""
return await store.list_tasks(_redis, status or None, assignee_role or None)
# ---- 락 ----
@mcp.tool()
async def acquire_lock(resource: str, role: str, ttl_sec: int = config.DEFAULT_LOCK_TTL) -> dict:
"""공유 리소스 변경 전 어드바이저리 락을 획득한다. 점유 중이면 acquired=false."""
res = await locks.acquire_lock(_redis, resource, role, ttl_sec)
if res.get("acquired"):
await store.log_event(_redis, "lock", f"{role} acquired {resource}")
return res
@mcp.tool()
async def release_lock(resource: str, role: str) -> dict:
"""소유한 락을 해제한다."""
res = await locks.release_lock(_redis, resource, role)
if res.get("released"):
await store.log_event(_redis, "lock", f"{role} released {resource}")
return res
@mcp.tool()
async def heartbeat_lock(resource: str, role: str, ttl_sec: int = config.DEFAULT_LOCK_TTL) -> dict:
"""긴 작업 중 락 TTL을 갱신한다(소유자만)."""
return await locks.heartbeat_lock(_redis, resource, role, ttl_sec)
@mcp.tool()
async def list_locks() -> dict:
"""현재 점유 중인 모든 락을 조회한다."""
return await locks.list_locks(_redis)
# ---- 가시성 ----
@mcp.tool()
async def team_log(after_id: int = 0) -> dict:
"""팀 전체 최근 활동 피드(메시지·작업·락)를 조회한다."""
return await store.read_team_log(_redis, after_id)
# ---- Bearer 인증 미들웨어 ----
class BearerAuth(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
global _auth_failed_logged
if request.url.path.startswith("/health"):
return await call_next(request)
expected = f"Bearer {config.CO_BUS_KEY}"
if not config.CO_BUS_KEY or request.headers.get("authorization") != expected:
if not _auth_failed_logged:
log.error("co-gahusb 인증 실패 (이후 동일 로그 생략)")
_auth_failed_logged = True
return JSONResponse({"error": "unauthorized"}, status_code=401)
return await call_next(request)
async def _health(request):
return JSONResponse({"status": "ok"})
_mcp_app = mcp.streamable_http_app()
app = Starlette(
routes=[Route("/health", _health), Mount("/", app=_mcp_app)],
middleware=[Middleware(BearerAuth)],
lifespan=_mcp_app.router.lifespan_context,
)

157
co-gahusb/app/store.py Normal file
View File

@@ -0,0 +1,157 @@
# co-gahusb/app/store.py
import json
import time
from app.config import TEAM_LOG_MAXLEN
MSG_SEQ = "co:msgseq"
INBOX_PREFIX = "co:inbox:" # list of message ids per role
MSG_PREFIX = "co:msg:" # hash per message
READ_PREFIX = "co:read:" # last-read cursor per role
def _now_iso():
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
async def post_message(r, from_role, to_role, body, thread_id=None):
mid = await r.incr(MSG_SEQ)
payload = {
"id": str(mid),
"from_role": from_role,
"to_role": to_role,
"body": body,
"thread_id": thread_id or "",
"ts": _now_iso(),
}
await r.set(MSG_PREFIX + str(mid), json.dumps(payload))
await r.rpush(INBOX_PREFIX + to_role, mid)
return {"message_id": mid}
async def read_inbox(r, role, after_id=0, mark_read=False):
ids = await r.lrange(INBOX_PREFIX + role, 0, -1)
ids = [int(x) for x in ids if int(x) > int(after_id)]
messages = []
for mid in ids:
raw = await r.get(MSG_PREFIX + str(mid))
if raw:
d = json.loads(raw)
d["id"] = int(d["id"])
messages.append(d)
cursor = ids[-1] if ids else int(after_id)
if mark_read and ids:
await r.set(READ_PREFIX + role, cursor)
return {"messages": messages, "cursor": cursor}
TASK_SEQ = "co:taskseq"
TASK_PREFIX = "co:task:" # hash per task
TASK_SET = "co:tasks" # set of task ids
VALID_STATUS = ("open", "in_progress", "blocked", "done")
async def create_task(r, title, assignee_role, created_by, detail=None):
tid = await r.incr(TASK_SEQ)
task = {
"id": str(tid),
"title": title,
"assignee_role": assignee_role,
"status": "open",
"detail": detail or "",
"created_by": created_by,
"note": "",
"ts": _now_iso(),
}
await r.hset(TASK_PREFIX + str(tid), mapping=task)
await r.sadd(TASK_SET, tid)
return {"task_id": tid}
async def _get_task(r, task_id):
d = await r.hgetall(TASK_PREFIX + str(task_id))
if not d:
return None
d["id"] = int(d["id"])
return d
async def claim_task(r, task_id, role):
key = TASK_PREFIX + str(task_id)
async with r.pipeline() as pipe:
while True:
try:
await pipe.watch(key)
status = await pipe.hget(key, "status")
if status is None:
await pipe.unwatch()
return {"ok": False, "error": "not_found"}
if status != "open":
held = await pipe.hget(key, "assignee_role")
await pipe.unwatch()
return {"ok": False, "held_by": held}
pipe.multi()
pipe.hset(key, mapping={"status": "in_progress", "assignee_role": role})
await pipe.execute()
return {"ok": True, "task": await _get_task(r, task_id)}
except Exception as e:
from redis.exceptions import WatchError
if isinstance(e, WatchError):
continue
raise
async def update_task(r, task_id, status, role, note=None):
if status not in VALID_STATUS:
raise ValueError(f"invalid status: {status}")
key = TASK_PREFIX + str(task_id)
if not await r.exists(key):
return {"ok": False, "error": "not_found"}
mapping = {"status": status}
if note is not None:
mapping["note"] = note
await r.hset(key, mapping=mapping)
return {"ok": True, "task": await _get_task(r, task_id)}
async def list_tasks(r, status=None, assignee_role=None):
ids = sorted(int(x) for x in await r.smembers(TASK_SET))
tasks = []
for tid in ids:
t = await _get_task(r, tid)
if t is None:
continue
if status and t["status"] != status:
continue
if assignee_role and t["assignee_role"] != assignee_role:
continue
tasks.append(t)
return {"tasks": tasks}
LOG_SEQ = "co:logseq"
LOG_LIST = "co:log" # list of event ids (capped)
LOG_PREFIX = "co:logitem:"
async def log_event(r, kind, text):
eid = await r.incr(LOG_SEQ)
item = {"id": eid, "kind": kind, "text": text, "ts": _now_iso()}
await r.set(LOG_PREFIX + str(eid), json.dumps(item))
await r.rpush(LOG_LIST, eid)
await r.ltrim(LOG_LIST, -TEAM_LOG_MAXLEN, -1)
return {"event_id": eid}
async def read_team_log(r, after_id=0, limit=100):
ids = [int(x) for x in await r.lrange(LOG_LIST, 0, -1)]
ids = [i for i in ids if i > int(after_id)]
ids = ids[-limit:]
events = []
for eid in ids:
raw = await r.get(LOG_PREFIX + str(eid))
if raw:
events.append(json.loads(raw))
cursor = ids[-1] if ids else int(after_id)
return {"events": events, "cursor": cursor}

3
co-gahusb/pytest.ini Normal file
View File

@@ -0,0 +1,3 @@
[pytest]
asyncio_mode = auto
testpaths = tests

View File

@@ -0,0 +1,7 @@
mcp>=1.2.0
starlette>=0.37
uvicorn[standard]==0.34.0
redis>=5.0
pytest>=8.0
pytest-asyncio>=0.24
fakeredis>=2.21

View File

View File

@@ -0,0 +1,11 @@
# co-gahusb/tests/conftest.py
import pytest_asyncio
import fakeredis.aioredis
@pytest_asyncio.fixture
async def r():
client = fakeredis.aioredis.FakeRedis(decode_responses=True)
await client.flushall()
yield client
await client.aclose()

View File

@@ -0,0 +1,51 @@
# co-gahusb/tests/test_locks.py
from app import locks
async def test_acquire_succeeds_then_blocks_other(r):
res = await locks.acquire_lock(r, "nas-deploy", "BE", ttl_sec=300)
assert res["acquired"] is True
res2 = await locks.acquire_lock(r, "nas-deploy", "FE", ttl_sec=300)
assert res2["acquired"] is False
assert res2["held_by"] == "BE"
assert res2["ttl_remaining"] > 0
async def test_release_only_by_owner(r):
await locks.acquire_lock(r, "compose", "BE", ttl_sec=300)
bad = await locks.release_lock(r, "compose", "FE")
assert bad["released"] is False
ok = await locks.release_lock(r, "compose", "BE")
assert ok["released"] is True
again = await locks.acquire_lock(r, "compose", "FE", ttl_sec=300)
assert again["acquired"] is True
async def test_heartbeat_only_by_owner_renews_ttl(r):
await locks.acquire_lock(r, "nginx-conf", "BE", ttl_sec=10)
bad = await locks.heartbeat_lock(r, "nginx-conf", "FE", ttl_sec=300)
assert bad["renewed"] is False
ok = await locks.heartbeat_lock(r, "nginx-conf", "BE", ttl_sec=300)
assert ok["renewed"] is True
assert await r.ttl("co:lock:nginx-conf") > 100
async def test_expired_lock_is_reacquirable(r):
await locks.acquire_lock(r, "memory-mirror", "AI", ttl_sec=1)
await r.delete("co:lock:memory-mirror")
res = await locks.acquire_lock(r, "memory-mirror", "FE", ttl_sec=300)
assert res["acquired"] is True
async def test_list_locks(r):
await locks.acquire_lock(r, "nas-deploy", "BE", ttl_sec=300)
await locks.acquire_lock(r, "compose", "FE", ttl_sec=300)
listed = await locks.list_locks(r)
held = {l["resource"]: l["held_by"] for l in listed["locks"]}
assert held == {"nas-deploy": "BE", "compose": "FE"}

View File

@@ -0,0 +1,47 @@
# co-gahusb/tests/test_messages.py
from app import store
async def test_post_and_read_ordering(r):
id1 = (await store.post_message(r, "Producer", "BE", "first"))["message_id"]
id2 = (await store.post_message(r, "Producer", "BE", "second"))["message_id"]
assert id2 > id1
res = await store.read_inbox(r, "BE")
bodies = [m["body"] for m in res["messages"]]
assert bodies == ["first", "second"]
assert res["cursor"] == id2
async def test_read_inbox_after_id(r):
id1 = (await store.post_message(r, "Producer", "BE", "first"))["message_id"]
await store.post_message(r, "Producer", "BE", "second")
res = await store.read_inbox(r, "BE", after_id=id1)
assert [m["body"] for m in res["messages"]] == ["second"]
async def test_inboxes_isolated_per_role(r):
await store.post_message(r, "Producer", "BE", "for-be")
await store.post_message(r, "Producer", "FE", "for-fe")
be = await store.read_inbox(r, "BE")
fe = await store.read_inbox(r, "FE")
assert [m["body"] for m in be["messages"]] == ["for-be"]
assert [m["body"] for m in fe["messages"]] == ["for-fe"]
async def test_mark_read_advances_cursor(r):
await store.post_message(r, "Producer", "BE", "first")
res = await store.read_inbox(r, "BE", mark_read=True)
last = res["cursor"]
await store.post_message(r, "Producer", "BE", "second")
res2 = await store.read_inbox(r, "BE", after_id=last)
assert [m["body"] for m in res2["messages"]] == ["second"]
async def test_message_fields(r):
await store.post_message(r, "Producer", "BE", "hi", thread_id="t1")
res = await store.read_inbox(r, "BE")
m = res["messages"][0]
assert m["from_role"] == "Producer"
assert m["thread_id"] == "t1"
assert "ts" in m and "id" in m

View File

@@ -0,0 +1,54 @@
# co-gahusb/tests/test_server.py
import os
os.environ["CO_BUS_KEY"] = "test-key"
# config.CO_BUS_KEY는 import 시점에 한 번 읽히므로, 다른 테스트 모듈이 app.config를
# 먼저 import하면 빈 값으로 굳는다. import 순서와 무관하게 모듈 속성을 직접 강제한다.
from app import config
config.CO_BUS_KEY = "test-key"
from starlette.testclient import TestClient
from app.server import app
def test_health_open_without_auth():
client = TestClient(app)
res = client.get("/health")
assert res.status_code == 200
assert res.json()["status"] == "ok"
def test_mcp_requires_bearer():
client = TestClient(app)
res = client.post("/mcp", json={})
assert res.status_code == 401
def test_mcp_wrong_key_rejected():
client = TestClient(app)
res = client.post("/mcp", json={}, headers={"Authorization": "Bearer wrong"})
assert res.status_code == 401
def test_mcp_valid_auth_passes_dns_host_check():
# 유효한 키는 인증 게이트를 통과하고, MCP DNS-rebinding Host 검증에 막혀선 안 된다.
# TestClient 기본 Host="testserver"는 localhost가 아니므로, 보호가 켜져 있으면 421.
# 컨텍스트 매니저로 써야 lifespan(세션 매니저 task group)이 기동되어 MCP 핸들러까지 도달.
with TestClient(app) as client:
res = client.post(
"/mcp",
headers={
"Authorization": "Bearer test-key",
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
},
json={
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"protocolVersion": "2024-11-05", "capabilities": {},
"clientInfo": {"name": "smoke", "version": "0"},
},
},
)
assert res.status_code != 401 # 인증 통과
assert res.status_code != 421 # Host 검증에 막히면 안 됨

View File

@@ -0,0 +1,56 @@
# co-gahusb/tests/test_tasks.py
import pytest
from app import store
async def test_create_and_list(r):
res = await store.create_task(r, "deploy FE", "FE", created_by="Producer", detail="ship it")
tid = res["task_id"]
listed = await store.list_tasks(r)
t = [t for t in listed["tasks"] if t["id"] == tid][0]
assert t["title"] == "deploy FE"
assert t["assignee_role"] == "FE"
assert t["status"] == "open"
assert t["created_by"] == "Producer"
async def test_claim_then_duplicate_claim_rejected(r):
tid = (await store.create_task(r, "x", "FE", created_by="Producer"))["task_id"]
ok = await store.claim_task(r, tid, "FE")
assert ok["ok"] is True
assert ok["task"]["status"] == "in_progress"
dup = await store.claim_task(r, tid, "BE")
assert dup["ok"] is False
assert dup["held_by"] == "FE"
async def test_update_status(r):
tid = (await store.create_task(r, "x", "FE", created_by="Producer"))["task_id"]
await store.claim_task(r, tid, "FE")
res = await store.update_task(r, tid, "done", "FE", note="finished")
assert res["ok"] is True
assert res["task"]["status"] == "done"
assert res["task"]["note"] == "finished"
async def test_list_filters(r):
t1 = (await store.create_task(r, "a", "FE", created_by="Producer"))["task_id"]
await store.create_task(r, "b", "BE", created_by="Producer")
await store.claim_task(r, t1, "FE")
fe = await store.list_tasks(r, assignee_role="FE")
assert [t["title"] for t in fe["tasks"]] == ["a"]
in_prog = await store.list_tasks(r, status="in_progress")
assert [t["title"] for t in in_prog["tasks"]] == ["a"]
async def test_invalid_status_rejected(r):
tid = (await store.create_task(r, "x", "FE", created_by="Producer"))["task_id"]
with pytest.raises(ValueError):
await store.update_task(r, tid, "bogus", "FE")
async def test_update_nonexistent_task_returns_not_found(r):
res = await store.update_task(r, 999, "done", "FE")
assert res["ok"] is False
assert res["error"] == "not_found"

View File

@@ -0,0 +1,25 @@
# co-gahusb/tests/test_teamlog.py
from app import store
async def test_log_event_and_read(r):
await store.log_event(r, "message", "Producer→BE: hi")
await store.log_event(r, "lock", "BE acquired nas-deploy")
res = await store.read_team_log(r)
msgs = [e["text"] for e in res["events"]]
assert msgs == ["Producer→BE: hi", "BE acquired nas-deploy"]
async def test_team_log_after_id(r):
e1 = (await store.log_event(r, "message", "a"))["event_id"]
await store.log_event(r, "message", "b")
res = await store.read_team_log(r, after_id=e1)
assert [e["text"] for e in res["events"]] == ["b"]
async def test_team_log_capped(r):
for i in range(10):
await store.log_event(r, "message", f"m{i}")
res = await store.read_team_log(r, limit=3)
assert len(res["events"]) == 3
assert res["events"][-1]["text"] == "m9"

View File

@@ -14,8 +14,15 @@ services:
- TZ=${TZ:-Asia/Seoul}
- LOTTO_ALL_URL=${LOTTO_ALL_URL:-https://smok95.github.io/lotto/results/all.json}
- LOTTO_LATEST_URL=${LOTTO_LATEST_URL:-https://smok95.github.io/lotto/results/latest.json}
- PYTHONPATH=/app:/shared
volumes:
- ${RUNTIME_PATH}/data:/app/data
- ${RUNTIME_PATH}/_shared:/shared/_shared:ro
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 60s
@@ -44,8 +51,15 @@ services:
- OLLAMA_MODEL=${OLLAMA_MODEL:-qwen3:14b}
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
- WEBAI_API_KEY=${WEBAI_API_KEY:-}
- PYTHONPATH=/app:/shared
volumes:
- ${RUNTIME_PATH}/data/stock:/app/data
- ${RUNTIME_PATH}/_shared:/shared/_shared:ro
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 60s
@@ -79,9 +93,16 @@ services:
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
- MUSIC_RENDER_URL=${MUSIC_RENDER_URL:-http://192.168.45.59:18711}
- PYTHONPATH=/app:/shared
volumes:
- ${RUNTIME_PATH}/data/music:/app/data
- ${RUNTIME_PATH:-.}/data/videos:/app/data/videos
- ${RUNTIME_PATH}/_shared:/shared/_shared:ro
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
depends_on:
- redis
healthcheck:
@@ -113,6 +134,28 @@ services:
timeout: 5s
retries: 3
image-lab:
build: ./image-lab
container_name: image-lab
restart: unless-stopped
ports:
- "18802:8000"
environment:
- TZ=${TZ:-Asia/Seoul}
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
- IMAGE_DATA_DIR=/app/data
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
volumes:
- ${RUNTIME_PATH}/data/image:/app/data
depends_on:
- redis
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 60s
timeout: 5s
retries: 3
insta-lab:
build:
context: ./insta-lab
@@ -134,8 +177,15 @@ services:
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
- PYTHONPATH=/app:/shared
volumes:
- ${RUNTIME_PATH}/data/insta:/app/data
- ${RUNTIME_PATH}/_shared:/shared/_shared:ro
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
depends_on:
- redis
healthcheck:
@@ -156,8 +206,34 @@ services:
- DATA_GO_KR_API_KEY=${DATA_GO_KR_API_KEY:-}
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
- AGENT_OFFICE_URL=${AGENT_OFFICE_URL:-http://agent-office:8000}
- PYTHONPATH=/app:/shared
volumes:
- ${RUNTIME_PATH}/data/realestate:/app/data
- ${RUNTIME_PATH}/_shared:/shared/_shared:ro
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 60s
timeout: 5s
retries: 3
co-gahusb:
build:
context: ./co-gahusb
container_name: co-gahusb
restart: unless-stopped
ports:
- "18920:8000"
environment:
- TZ=${TZ:-Asia/Seoul}
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
- CO_BUS_KEY=${CO_BUS_KEY:-}
depends_on:
- redis
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 60s
@@ -205,6 +281,54 @@ services:
timeout: 5s
retries: 3
tarot-lab:
build:
context: ./tarot-lab
container_name: tarot-lab
restart: unless-stopped
ports:
- "18250:8000"
environment:
- TZ=${TZ:-Asia/Seoul}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- TAROT_MODEL=${TAROT_MODEL:-claude-sonnet-4-6}
- TAROT_COST_INPUT_PER_M=${TAROT_COST_INPUT_PER_M:-3.0}
- TAROT_COST_OUTPUT_PER_M=${TAROT_COST_OUTPUT_PER_M:-15.0}
- TAROT_TIMEOUT_SEC=${TAROT_TIMEOUT_SEC:-180}
- TAROT_DATA_PATH=/app/data
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
volumes:
- ${RUNTIME_PATH:-.}/data/tarot:/app/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 60s
timeout: 5s
retries: 3
saju-lab:
build:
context: ./saju-lab
container_name: saju-lab
restart: unless-stopped
ports:
- "18300:8000"
environment:
- TZ=${TZ:-Asia/Seoul}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- SAJU_MODEL=${SAJU_MODEL:-claude-sonnet-4-6}
- SAJU_COST_INPUT_PER_M=${SAJU_COST_INPUT_PER_M:-3.0}
- SAJU_COST_OUTPUT_PER_M=${SAJU_COST_OUTPUT_PER_M:-15.0}
- SAJU_TIMEOUT_SEC=${SAJU_TIMEOUT_SEC:-240}
- SAJU_DATA_PATH=/app/data
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
volumes:
- ${RUNTIME_PATH:-.}/data/saju:/app/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 60s
timeout: 5s
retries: 3
personal:
build:
context: ./personal
@@ -275,7 +399,11 @@ services:
retries: 3
frontend:
image: nginx:alpine
# ngx_http_rewrite_module 힙 오버플로우 2건 대응 (미고정 nginx:alpine → 패치 stable 고정)
# - CVE-2026-42945 (NGINX Rift, CVSS 9.2): fixed in 1.30.1+ / 1.31.0+
# - CVE-2026-9256 (nginx-poolslip, 영향 ~1.31.0): fixed in 1.30.2+ / 1.31.1+
# → 둘 다 커버하는 최소 stable = 1.30.2
image: nginx:1.30.2-alpine
container_name: frontend
restart: unless-stopped
depends_on:
@@ -289,6 +417,7 @@ services:
- packs-lab
- travel-proxy
- video-lab
- image-lab
ports:
- "8080:80"
volumes:

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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,408 @@
# 인스타 카드뉴스 품질 고도화 + 업로드 친화 패키지 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:** 인스타 카드를 모던 미니멀 디자인 시스템으로 격상하고(렌더 견고화로 known-issue 해결), 완성 패키지를 zip으로 받아 인스타에 쉽게 업로드(반자동)할 수 있게 한다.
**Architecture:** 디자인 시스템 Jinja 템플릿(페이지 타입별 레이아웃)을 web-ai insta-render 워커(authoritative)와 insta-lab(참조 복사본)에 작성. 워커 `card_renderer.py``document.fonts.ready` 대기 + PNG 검증 추가. card_writer 프롬프트에 글자수 가이드. insta-lab에 zip 패키지 API + web-ui 다운로드 버튼. Graph API 미사용(반자동).
**Tech Stack:** Jinja2 + HTML/CSS, Playwright(Chromium), FastAPI, pytest / React+Vite(web-ui).
**Spec:** `docs/superpowers/specs/2026-06-02-insta-cardnews-upgrade-design.md`
**⚠️ 3 repo 작업** (커밋·배포 경로 다름):
- `web-backend/insta-lab` — git push → Gitea webhook 자동배포 (NAS)
- `web-ai/services/insta-render`**별도 repo(ai-trade.git), Windows 머신 구동** — 워커가 실제 렌더하는 authoritative 템플릿 위치
- `web-ui`**별도 repo**, `npm run release:nas` 수동 배포
---
## 검증된 컨텍스트
- 워커 렌더: `web-ai/services/insta-render/card_renderer.py``_build_pages(slate)`가 10 spec 생성(cover page_no=1 / body page_no=2~9 / cta page_no=10, 각 `page_type`/`headline`/`body`/`accent_color`/`cta`/`page_no`/`total_pages`). `CARD_TEMPLATE_DIR`(기본 `/app/templates`)에서 `{theme}/card.html.j2` 로드 → `page.goto(file://, networkidle)``screenshot(full_page=False)` @viewport 1080×1350.
- 워커 템플릿 실제 위치: `web-ai/services/insta-render/templates/default/card.html.j2` (현재 insta-lab과 동일한 55줄 기본형). **이게 렌더에 쓰이는 authoritative 파일.**
- 카피: `insta-lab/app/card_writer.py` `DEFAULT_PROMPT`(DB `slate_writer` 오버라이드 가능). 산출: cover_copy{headline,body,accent_color}/body_copies[8]{headline,body}/cta_copy{headline,body,cta}/suggested_caption/hashtags[].
- 슬레이트 PNG: 워커가 `INSTA_MEDIA_ROOT/{slate_id}/{page_no:02d}.png` 저장. NAS에서 `card_assets` 테이블 + `db.list_card_assets(slate_id)`(page_index + 파일경로)로 추적. `GET /api/insta/slates/{id}/assets/{page}`가 단일 PNG 서빙(파일경로 읽어 반환).
- 슬레이트 데이터: `db.get_card_slate(slate_id)` + `db.list_card_assets(slate_id)`. `GET /api/insta/slates/{id}`가 slate + assets 반환.
---
# Phase 1 — 모던 미니멀 디자인 시스템 템플릿 (web-ai authoritative + insta-lab 복사본)
## Task 1.1: 디자인 시스템 card.html.j2 작성
**Files:**
- Modify: `web-ai/services/insta-render/templates/default/card.html.j2` (**렌더 authoritative**)
- Modify: `web-backend/insta-lab/app/templates/default/card.html.j2` (참조 복사본 — 동일 내용 유지)
> 두 파일을 **동일 내용**으로 작성한다. 워커가 web-ai 쪽을 렌더하지만 insta-lab 복사본도 일관성 위해 갱신.
- [ ] **Step 1: 디자인 시스템 템플릿 작성** — 아래 전체 내용으로 두 파일을 교체:
```html
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<style>
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css');
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 1080px; height: 1350px; }
body {
font-family: 'Pretendard', 'Noto Sans KR', sans-serif;
background: #F7F7FA; color: #14171A;
-webkit-font-smoothing: antialiased;
}
.card {
position: relative; width: 1080px; height: 1350px; overflow: hidden;
padding: 96px 84px 72px;
display: flex; flex-direction: column;
background: #FFFFFF;
}
.accent-bar { position: absolute; top: 0; left: 0; width: 100%; height: 14px; background: {{ accent_color }}; }
.badge {
align-self: flex-start; padding: 10px 24px; border-radius: 999px;
background: {{ accent_color }}; color: #fff;
font-size: 30px; font-weight: 700; letter-spacing: -0.02em;
}
.idx { font-size: 120px; font-weight: 800; line-height: 1; color: {{ accent_color }}; letter-spacing: -0.04em; }
.content { flex: 1; display: flex; flex-direction: column; justify-content: center; gap: 36px; }
.headline {
font-weight: 800; line-height: 1.18; letter-spacing: -0.04em; color: #14171A;
display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden;
}
.cover .headline { font-size: 104px; -webkit-line-clamp: 4; }
.body-page .headline { font-size: 76px; -webkit-line-clamp: 3; }
.cta .headline { font-size: 88px; -webkit-line-clamp: 3; }
.sub {
font-size: 42px; font-weight: 400; line-height: 1.5; color: #3A4047;
display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; -webkit-line-clamp: 8;
white-space: pre-wrap;
}
.footer {
display: flex; justify-content: space-between; align-items: center;
font-size: 28px; color: #8A9099; font-weight: 600; margin-top: 40px;
}
.cta-pill {
align-self: flex-start; margin-top: 8px; padding: 18px 40px; border-radius: 16px;
background: {{ accent_color }}; color: #fff; font-size: 40px; font-weight: 700;
}
.progress { display: flex; gap: 10px; }
.progress i { width: 14px; height: 14px; border-radius: 50%; background: #D8DCE0; display: inline-block; }
.progress i.on { background: {{ accent_color }}; }
</style>
</head>
<body>
<div class="card {{ 'cover' if page_type=='cover' else ('cta' if page_type=='cta' else 'body-page') }}">
<div class="accent-bar"></div>
{% if page_type == 'cover' %}
<span class="badge">{{ category_label|default(headline[:0]) }}{{ '오늘의 이슈' if not category_label }}</span>
<div class="content">
<h1 class="headline">{{ headline }}</h1>
<p class="sub">{{ body }}</p>
</div>
{% elif page_type == 'cta' %}
<div class="content">
<h1 class="headline">{{ headline }}</h1>
<p class="sub">{{ body }}</p>
{% if cta %}<div class="cta-pill">{{ cta }}</div>{% endif %}
</div>
{% else %}
<span class="idx">{{ '%02d'|format(page_no - 1) }}</span>
<div class="content">
<h1 class="headline">{{ headline }}</h1>
<p class="sub">{{ body }}</p>
</div>
{% endif %}
<div class="footer">
{% if page_type == 'cover' or page_type == 'cta' %}
<span>{{ brand_handle|default('') }}</span><span>{{ page_no }} / {{ total_pages }}</span>
{% else %}
<div class="progress">{% for n in range(2, total_pages) %}<i class="{{ 'on' if n <= page_no }}"></i>{% endfor %}</div>
<span>{{ page_no }} / {{ total_pages }}</span>
{% endif %}
</div>
</div>
</body>
</html>
```
> 디자인 노트: 페이지 타입별 분기(cover 대형 헤드라인+서브+배지 / body 좌상단 인덱스 `01~08`(page_no-1)+헤드라인+본문+진행 점 / cta 요약+CTA pill). `-webkit-line-clamp`로 오버플로우 2차 방어(글자수 가이드가 1차). `accent_color`는 기존 데이터. `brand_handle`은 미설정 시 빈칸(추후 핸들 주입 가능). Pretendard CDN(@import) — Phase 2의 fonts.ready 대기와 짝.
- [ ] **Step 2: 렌더 스모크 확인 (web-ai)** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render && python -c "from jinja2 import Environment, FileSystemLoader; e=Environment(loader=FileSystemLoader('templates')); t=e.get_template('default/card.html.j2'); [print(pt, len(t.render(page_type=pt, page_no=n, total_pages=10, headline='테스트 헤드라인', body='본문 테스트입니다.', accent_color='#0F62FE', cta='팔로우')) > 0) for pt,n in [('cover',1),('body',3),('cta',10)]]"`
Expected: `True` 3줄 (3 페이지 타입 모두 렌더 예외 없음).
- [ ] **Step 3: Commit (2 repo 각각)**
```bash
# web-ai repo
cd /c/Users/jaeoh/Desktop/workspace/web-ai && git add services/insta-render/templates/default/card.html.j2 && git commit -m "feat(insta-render): 모던 미니멀 디자인 시스템 템플릿"
# insta-lab repo (참조 복사본)
cd /c/Users/jaeoh/Desktop/workspace/web-backend && git add insta-lab/app/templates/default/card.html.j2 && git commit -m "feat(insta-lab): default 템플릿 디자인 시스템 동기화(참조용)"
```
> 커밋 메시지 trailer 각각에 `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>` 추가.
---
# Phase 2 — 렌더 견고화 (web-ai 워커, known-issue 해결)
## Task 2.1: fonts.ready 대기 + PNG 비어있음 검증
**Files:**
- Modify: `web-ai/services/insta-render/card_renderer.py` (`_render_slate_locked`)
- Test: `web-ai/services/insta-render/tests/test_worker.py` (또는 기존 테스트 파일에 추가)
- [ ] **Step 1: 실패 테스트**`tests/test_worker.py`에 추가 (실제 Chromium 렌더 + 검증). 워커 테스트 관례 확인 후 맞출 것; pytest-asyncio 사용 가정:
```python
import os
import pytest
from card_renderer import render_slate, init_browser, shutdown_browser
@pytest.mark.asyncio
async def test_render_produces_nonempty_1080x1350(tmp_path, monkeypatch):
monkeypatch.setattr("card_renderer.INSTA_MEDIA_ROOT", str(tmp_path))
await init_browser()
try:
slate = {
"cover_copy": {"headline": "헤드라인", "body": "서브", "accent_color": "#0F62FE"},
"body_copies": [{"headline": f"포인트{i}", "body": "본문"} for i in range(8)],
"cta_copy": {"headline": "요약", "body": "마무리", "cta": "팔로우"},
}
paths = await render_slate(slate, slate_id=99999)
assert len(paths) == 10
for p in paths:
assert os.path.getsize(p) > 1000 # 비어있지 않음
finally:
await shutdown_browser()
```
- [ ] **Step 2: 실패/현황 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render && python -m pytest tests/test_worker.py::test_render_produces_nonempty_1080x1350 -v`
Expected: 현재 코드로도 통과할 수 있으나(렌더 자체는 동작), 폰트/검증 보강 전이므로 FAIL이 아니면 다음 Step에서 검증 로직 추가가 의미를 갖도록 진행. (Playwright/Chromium 미설치 환경이면 `playwright install chromium` 필요 — 안 되면 DONE_WITH_CONCERNS로 보고)
- [ ] **Step 3: card_renderer 보강**`_render_slate_locked`의 페이지 루프에서 `page.goto` 직후·`screenshot` 직전에 폰트 대기 추가, screenshot 후 비어있음 검증:
```python
try:
await page.goto(f"file://{html_path}", wait_until="networkidle")
await page.evaluate("document.fonts.ready") # 웹폰트 로딩 완료까지 대기
out_path = os.path.join(out_dir, f"{spec['page_no']:02d}.png")
await page.screenshot(path=out_path, full_page=False, omit_background=False)
if os.path.getsize(out_path) < 1000: # 빈/깨진 PNG 방어
raise RuntimeError(f"rendered PNG too small: {out_path}")
paths.append(out_path)
finally:
...
```
- [ ] **Step 4: 통과 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render && python -m pytest tests/test_worker.py -v` Expected: PASS
- [ ] **Step 5: Commit (web-ai repo)**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-ai && git add services/insta-render/card_renderer.py services/insta-render/tests/test_worker.py && git commit -m "fix(insta-render): fonts.ready 대기 + PNG 비어있음 검증 (렌더 known-issue 해결)"
```
---
# Phase 3 — 카피 글자수 가이드 (insta-lab)
## Task 3.1: card_writer 프롬프트에 글자수 상한 추가
**Files:**
- Modify: `web-backend/insta-lab/app/card_writer.py` (`DEFAULT_PROMPT`)
- Test: `web-backend/insta-lab/app/test_card_writer_prompt.py` (NEW)
- [ ] **Step 1: 실패 테스트**
`insta-lab/app/test_card_writer_prompt.py`:
```python
from app import card_writer
def test_default_prompt_has_length_guidance():
p = card_writer.DEFAULT_PROMPT
# 글자수 가이드가 프롬프트에 포함됐는지
assert "22자" in p and "120자" in p
# 포맷 placeholder는 유지
assert "{category}" in p and "{keyword}" in p and "{articles}" in p
```
- [ ] **Step 2: 실패 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-backend/insta-lab && python -m pytest app/test_card_writer_prompt.py -v` Expected: FAIL
- [ ] **Step 3: DEFAULT_PROMPT에 가이드 추가**`DEFAULT_PROMPT` 문자열의 JSON 스키마 안내 뒤(닫는 `}}` 다음)에 글자수 가이드 문단 추가:
```python
DEFAULT_PROMPT = """너는 인스타그램 카드 뉴스 카피라이터다.
카테고리: {category}
키워드: {keyword}
참고 기사:
{articles}
10페이지 인스타 카드용 카피를 다음 JSON 한 객체로만 출력해라 (코드펜스 금지):
{{
"cover_copy": {{"headline": "<훅 한 줄>", "body": "<서브카피 1~2줄>", "accent_color": "#hex"}},
"body_copies": [
{{"headline": "<포인트 헤드라인>", "body": "<2~4문장 본문>"}},
... (총 8개)
],
"cta_copy": {{"headline": "<요약 한 줄>", "body": "<마무리 1~2줄>", "cta": "팔로우/저장 등"}},
"suggested_caption": "<인스타 캡션 본문>",
"hashtags": ["#태그1", "#태그2", ...]
}}
[글자수 제약 — 카드 디자인 박스에 맞게 반드시 준수]
- cover_copy.headline: 22자 이내
- body_copies[].headline: 26자 이내
- body_copies[].body: 120자 이내 (2~4문장)
- cta_copy.headline: 22자 이내
초과하면 잘리므로 간결하고 임팩트 있게 작성한다.
"""
```
- [ ] **Step 4: 통과 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-backend/insta-lab && python -m pytest app/test_card_writer_prompt.py -v` Expected: PASS
- [ ] **Step 5: Commit (insta-lab)**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-backend && git add insta-lab/app/card_writer.py insta-lab/app/test_card_writer_prompt.py && git commit -m "feat(insta-lab): card_writer 프롬프트에 글자수 가이드(오버플로우 예방)"
```
> 주의: 운영 DB에 `slate_writer` prompt_template 오버라이드가 있으면 DEFAULT_PROMPT 대신 그게 쓰임 → 배포 후 필요 시 `PUT /api/insta/templates/prompts/slate_writer`로 동일 가이드 반영(plan §검증에서 안내).
---
# Phase 4 — zip 패키지 다운로드 API (insta-lab)
## Task 4.1: GET /api/insta/slates/{id}/package
**Files:**
- Modify: `web-backend/insta-lab/app/main.py` (엔드포인트 추가)
- Test: `web-backend/insta-lab/app/test_package_api.py` (NEW)
- [ ] **Step 1: (확인됨) asset 스키마**`card_assets(slate_id, page_index, file_path, file_hash)`. `db.list_card_assets(slate_id)` → 각 row에 `file_path`·`page_index`. `db.add_card_asset(slate_id, page_index, file_path, file_hash="")`. `db.add_card_slate(row: dict)`. 기존 `/assets/{page}``FileResponse(match["file_path"], media_type="image/png")`. zip 엔드포인트는 동일하게 `a["file_path"]`를 읽는다.
- [ ] **Step 2: 실패 테스트**
`insta-lab/app/test_package_api.py`:
```python
import io, os, tempfile, zipfile, sys
from fastapi.testclient import TestClient
def _client(monkeypatch):
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from app import config, db
tmp = tempfile.mkdtemp()
monkeypatch.setattr(config, "INSTA_DATA_PATH", tmp, raising=False)
monkeypatch.setattr(db, "DB_PATH", os.path.join(tmp, "insta.db"), raising=False)
db.init_db()
from app.main import app
return TestClient(app), db, tmp
def test_package_zip_contains_pngs_and_caption(monkeypatch):
client, db, tmp = _client(monkeypatch)
# 슬레이트 + 2개 asset(실제 PNG 파일) 시드
sid = db.add_card_slate({"keyword":"k","category":"economy","status":"rendered",
"cover_copy":{"headline":"h"}, "body_copies":[{"headline":"b","body":"x"}]*8,
"cta_copy":{}, "suggested_caption":"캡션입니다", "hashtags":["#a","#b"]})
cards_dir = os.path.join(tmp, "insta_cards", str(sid)); os.makedirs(cards_dir, exist_ok=True)
for pg in (1,2):
fp = os.path.join(cards_dir, f"{pg:02d}.png")
with open(fp, "wb") as f: f.write(b"\x89PNG\r\n" + b"0"*2000)
db.add_card_asset(slate_id=sid, page_index=pg, file_path=fp)
r = client.get(f"/api/insta/slates/{sid}/package")
assert r.status_code == 200
assert r.headers["content-type"] == "application/zip"
z = zipfile.ZipFile(io.BytesIO(r.content))
names = z.namelist()
assert any(n.endswith(".png") for n in names)
assert "caption.txt" in names
cap = z.read("caption.txt").decode("utf-8")
assert "캡션입니다" in cap and "#a" in cap
```
> `db.add_card_slate`/`add_card_asset`/`list_card_assets`의 실제 시그니처·컬럼명은 db.py 확인 후 맞출 것. asset 경로 컬럼이 `path`가 아니면 테스트·구현 모두 조정.
- [ ] **Step 3: 실패 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-backend/insta-lab && python -m pytest app/test_package_api.py -v` Expected: FAIL (404)
- [ ] **Step 4: 엔드포인트 구현**`insta-lab/app/main.py`에 추가 (`/assets/{page}` 엔드포인트 근처, 동일한 asset 파일경로 접근 방식 사용. `import io, zipfile`은 상단에 추가):
```python
@app.get("/api/insta/slates/{slate_id}/package")
def download_package(slate_id: int):
slate = db.get_card_slate(slate_id)
if not slate:
raise HTTPException(404, "slate not found")
assets = sorted(db.list_card_assets(slate_id), key=lambda a: a["page_index"])
if not assets:
raise HTTPException(409, "아직 렌더된 카드가 없습니다")
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
for a in assets:
fp = a["file_path"]
if os.path.exists(fp):
z.write(fp, arcname=f"{a['page_index']:02d}.png")
caption = (slate.get("suggested_caption") or "").strip()
tags = slate.get("hashtags") or []
if isinstance(tags, str):
import json as _json
try: tags = _json.loads(tags)
except Exception: tags = []
caption_full = caption + ("\n\n" + " ".join(tags) if tags else "")
z.writestr("caption.txt", caption_full)
buf.seek(0)
from fastapi.responses import StreamingResponse
return StreamingResponse(buf, media_type="application/zip", headers={
"Content-Disposition": f'attachment; filename="insta_slate_{slate_id}.zip"'})
```
> `HTTPException`/`os`는 main.py에 이미 import됨. `slate.get("hashtags")`가 JSON 문자열일 수 있어 방어 파싱.
- [ ] **Step 5: 통과 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-backend/insta-lab && python -m pytest app/test_package_api.py -v` Expected: PASS
- [ ] **Step 6: Commit (insta-lab)**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-backend && git add insta-lab/app/main.py insta-lab/app/test_package_api.py && git commit -m "feat(insta-lab): 슬레이트 zip 패키지 다운로드 API (10 PNG + caption.txt)"
```
---
# Phase 5 — web-ui 패키지 다운로드 버튼 (별도 repo: web-ui)
## Task 5.1: 슬레이트 상세에 다운로드 버튼
**Files:**
- Modify: `web-ui/src/api.js` (헬퍼)
- Modify: insta 카드 페이지 (`web-ui/src/pages/insta/InstaCards.jsx` 또는 슬레이트 상세 컴포넌트)
- [ ] **Step 1: 구조 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ui && git checkout -b feat/insta-package-download && grep -rln "insta\|슬레이트\|slate" src/pages/insta/ src/api.js 2>/dev/null | head` 로 슬레이트 상세 UI + apiGet 패턴 확인.
- [ ] **Step 2: api.js 헬퍼 + 다운로드**`src/api.js`에 패키지 URL 헬퍼 추가(파일 다운로드는 새 탭/anchor로):
```javascript
export const instaPackageUrl = (slateId) => `/api/insta/slates/${slateId}/package`;
```
슬레이트 상세 컴포넌트에 버튼 추가 (기존 버튼 스타일 맞춤):
```jsx
<a className="insta-pkg-btn" href={instaPackageUrl(slate.id)} download>
📦 패키지 다운로드 (10 + 캡션)
</a>
```
> import에 `instaPackageUrl` 추가. 실제 슬레이트 객체의 id 필드명·버튼 클래스는 Step 1 확인 결과에 맞출 것.
- [ ] **Step 3: 빌드 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ui && npm run build` Expected: exit 0
- [ ] **Step 4: Commit (web-ui repo)**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-ui && git add src/ && git commit -m "feat: 인스타 슬레이트 패키지 다운로드 버튼"
```
---
# Phase 6 — 통합 검증
## Task 6.1: 회귀 + 배포 안내
- [ ] **Step 1: insta-lab 테스트** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-backend/insta-lab && python -m pytest app/ -q` (Playwright 의존 테스트는 web-ai에만 있음). 신규 통과 + 회귀 없음. (`_shared` import로 main 로드 시 PYTHONPATH 필요하면 test에 sys.path.insert 적용 — Phase 4 test가 이미 처리)
- [ ] **Step 2: web-ai 테스트** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render && python -m pytest -q` (Chromium 필요; 미설치 시 `playwright install chromium`).
- [ ] **Step 3: 배포 안내** — 3 repo 각각 push/배포:
- insta-lab: `git push origin main` → webhook 자동배포(NAS).
- web-ai: Windows 머신에서 워커 repo pull + 재시작 (insta-render 서비스). **신규 템플릿이 워커 CARD_TEMPLATE_DIR에 반영돼야 효과 발생.**
- web-ui: `npm run release:nas`.
- 배포 후 슬레이트 1건 생성 → 카드 PNG 육안 확인(디자인 시스템 적용·폰트 정상) → `/package` zip 다운로드 확인. DB `slate_writer` 오버라이드 존재 시 글자수 가이드 반영.
---
## Self-Review 체크리스트 결과
- **Spec 커버리지**: 디자인 시스템 템플릿(Task 1.1) / 렌더 견고화 fonts.ready+검증(2.1) / 카피 글자수 가이드(3.1) / zip 패키지(4.1) / web-ui 버튼(5.1) / 검증(6.1). known-issue(폰트·오버플로우)=2.1+템플릿 clamp. 모두 매핑.
- **Placeholder**: 모든 코드 step에 실제 코드. db asset 컬럼명·web-ui 슬레이트 필드·워커 테스트 관례는 "Step에서 확인 후 맞춤" 명시(코드베이스 의존, 합리적). brand_handle 기본 빈칸(미설정 허용).
- **타입 일관성**: 템플릿이 쓰는 spec 키(page_type/page_no/total_pages/headline/body/accent_color/cta)가 워커 `_build_pages` 산출과 일치. zip 엔드포인트가 쓰는 `list_card_assets`/`get_card_slate`/`suggested_caption`/`hashtags`는 기존 db/슬레이트 스키마와 일치(Step 1에서 asset 경로 컬럼명만 확인).
- **3 repo 경로**: 각 Task에 repo별 cd + 커밋 분리 명시.

View File

@@ -0,0 +1,980 @@
# insta 자율 카드 발급 (스마트 에이전트 3번) 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:** InstaAgent가 매일 09:30 발행 가치 있는 주제만 자율 선별(4신호)해 카드를 생성·렌더하고, 카드별 텔레그램 승인 게이트로 사람이 최종 결정한 뒤 발급하며, 발행 상태·이력을 추적한다.
**Architecture:** insta-lab이 선별 점수(`selection.py` + `GET /keywords/ranked`)와 발행 상태머신(`card_slates` 컬럼 + `POST /slates/{id}/decision`)을 소유. agent-office `InstaAgent`가 cron 오케스트레이션 + 텔레그램 승인을 담당. 기존 슬레이트 생성·렌더·전달 흐름 재사용.
**Tech Stack:** Python 3.12 / FastAPI / SQLite / anthropic SDK(Haiku) / httpx / pytest. 기존 패턴: `card_writer.py`(Anthropic 클라이언트), `service_proxy.py`(insta httpx 헬퍼), `telegram/webhook.py`(콜백 prefix 디스패치).
**Spec:** `docs/superpowers/specs/2026-06-11-insta-autonomous-card-issuance-design.md`
---
## File Structure
| 파일 | 변경 | 책임 |
|------|------|------|
| `insta-lab/app/db.py` | Modify | `card_slates``published_at`/`decision_at` ALTER + `set_slate_decision`/`list_recent_issued_topics` 헬퍼 |
| `insta-lab/app/selection.py` | Create | 순수 선별 점수(dedup/freshness/account_fit/combine+threshold) |
| `insta-lab/app/selection_judge.py` | Create | Claude Haiku 일괄 카드가치 판단(외부 IO 격리) |
| `insta-lab/app/main.py` | Modify | `GET /api/insta/keywords/ranked`, `POST /api/insta/slates/{id}/decision` |
| `insta-lab/tests/test_selection.py` | Create | selection 순수 단위테스트 |
| `insta-lab/tests/test_ranked_decision_api.py` | Create | ranked·decision 엔드포인트 테스트 |
| `agent-office/app/service_proxy.py` | Modify | `insta_ranked`, `insta_decision` 헬퍼 |
| `agent-office/app/agents/insta.py` | Modify | 자율 `on_schedule` 분기 + 프리뷰 + `issue_*` 콜백 |
| `agent-office/app/telegram/webhook.py` | Modify | `issue_approve_/issue_reject_/issue_regen_` 디스패치 |
| `agent-office/tests/test_insta_autonomous.py` | Create | 자율 on_schedule + 콜백 테스트 |
| `web-backend/CLAUDE.md` + `memory/service_insta.md` | Modify | API 목록 + 메모리 갱신 |
---
## Task 1: insta-lab DB — 발행 상태 컬럼 + 헬퍼
**Files:**
- Modify: `insta-lab/app/db.py`
- Test: `insta-lab/tests/test_db_decision.py` (Create)
- [ ] **Step 1: 실패하는 테스트 작성**
`insta-lab/tests/test_db_decision.py`:
```python
import os
import pytest
from app import db, config
@pytest.fixture
def fresh_db(tmp_path, monkeypatch):
monkeypatch.setattr(config, "DB_PATH", str(tmp_path / "insta.db"))
monkeypatch.setattr(db, "DB_PATH", str(tmp_path / "insta.db"))
db.init_db()
def test_set_slate_decision_approved_publishes(fresh_db):
sid = db.add_card_slate({"keyword": "금리", "category": "economy"})
db.set_slate_decision(sid, "approved")
s = db.get_card_slate(sid)
assert s["status"] == "published"
assert s["published_at"] is not None
assert s["decision_at"] is not None
def test_set_slate_decision_rejected(fresh_db):
sid = db.add_card_slate({"keyword": "환율", "category": "economy"})
db.set_slate_decision(sid, "rejected")
s = db.get_card_slate(sid)
assert s["status"] == "rejected"
assert s["decision_at"] is not None
assert s["published_at"] is None
def test_set_slate_decision_idempotent(fresh_db):
sid = db.add_card_slate({"keyword": "주식", "category": "economy"})
db.set_slate_decision(sid, "approved")
first = db.get_card_slate(sid)["published_at"]
db.set_slate_decision(sid, "approved") # 재호출 no-op
assert db.get_card_slate(sid)["published_at"] == first
def test_list_recent_issued_topics(fresh_db):
a = db.add_card_slate({"keyword": "금리", "category": "economy"})
b = db.add_card_slate({"keyword": "우울증", "category": "psychology"})
db.set_slate_decision(a, "published") if False else db.set_slate_decision(a, "approved")
db.set_slate_decision(b, "rejected")
topics = db.list_recent_issued_topics(window_days=14)
pairs = {(t["keyword"], t["category"]) for t in topics}
assert ("금리", "economy") in pairs
assert ("우울증", "psychology") in pairs
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `cd insta-lab && PYTHONPATH=.. python -m pytest tests/test_db_decision.py -q`
Expected: FAIL — `db.set_slate_decision` 미존재 + `published_at` 컬럼 없음.
- [ ] **Step 3: `init_db()`에 idempotent ALTER 추가**
`insta-lab/app/db.py``init_db()` 함수 끝(account_preferences seed 직후)에 추가:
```python
# 발행 상태 컬럼 (idempotent ALTER) — 자율 발급 파이프라인
cs_cols = [r[1] for r in conn.execute("PRAGMA table_info(card_slates)").fetchall()]
if "published_at" not in cs_cols:
conn.execute("ALTER TABLE card_slates ADD COLUMN published_at TEXT")
if "decision_at" not in cs_cols:
conn.execute("ALTER TABLE card_slates ADD COLUMN decision_at TEXT")
```
- [ ] **Step 4: 헬퍼 함수 추가**
`insta-lab/app/db.py`의 card_slates 섹션(예: `update_slate_status` 아래)에 추가:
```python
def set_slate_decision(slate_id: int, decision: str) -> None:
"""승인/반려 결정 기록. approved→published(+published_at), rejected→rejected.
멱등: 이미 published면 published_at 유지."""
now = "strftime('%Y-%m-%dT%H:%M:%fZ','now')"
with _conn() as conn:
if decision == "approved":
conn.execute(
f"UPDATE card_slates SET status='published', "
f"published_at=COALESCE(published_at, {now}), decision_at={now} "
f"WHERE id=?",
(slate_id,),
)
elif decision == "rejected":
conn.execute(
f"UPDATE card_slates SET status='rejected', decision_at={now} WHERE id=?",
(slate_id,),
)
else:
raise ValueError(f"invalid decision: {decision}")
def list_recent_issued_topics(window_days: int = 14) -> List[Dict[str, Any]]:
"""최근 window_days 내 published/rejected 슬레이트의 (keyword, category). dedup용."""
with _conn() as conn:
rows = conn.execute(
"SELECT keyword, category FROM card_slates "
"WHERE status IN ('published','rejected') "
"AND COALESCE(published_at, decision_at) >= datetime('now', ?)",
(f"-{int(window_days)} days",),
).fetchall()
return [dict(r) for r in rows]
```
- [ ] **Step 5: 테스트 통과 확인**
Run: `cd insta-lab && PYTHONPATH=.. python -m pytest tests/test_db_decision.py -q`
Expected: 4 PASS.
- [ ] **Step 6: 커밋**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git add insta-lab/app/db.py insta-lab/tests/test_db_decision.py
git commit -m "feat(insta-lab): 발행 상태 컬럼 + set_slate_decision/list_recent_issued_topics
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 2: insta-lab — `selection.py` 순수 점수
**Files:**
- Create: `insta-lab/app/selection.py`
- Test: `insta-lab/tests/test_selection.py`
- [ ] **Step 1: 실패하는 테스트 작성**
`insta-lab/tests/test_selection.py`:
```python
from app.selection import score_candidates
NOW = "2026-06-11T00:00:00Z"
def _cand(kid, kw, cat, score, suggested_at):
return {"id": kid, "keyword": kw, "category": cat, "score": score, "suggested_at": suggested_at}
def test_dedup_excludes_recent_issued():
cands = [_cand(1, "금리", "economy", 0.9, "2026-06-11T00:00:00Z")]
issued = [{"keyword": "금리", "category": "economy"}]
out = score_candidates(cands, issued, prefs={}, claude_scores=None, threshold=0.0, now_iso=NOW)
assert out[0]["eligible"] is False # 최근 발행 주제 제외
def test_freshness_recent_higher():
fresh = _cand(1, "A", "economy", 0.5, "2026-06-11T00:00:00Z") # 0h
stale = _cand(2, "B", "economy", 0.5, "2026-06-04T00:00:00Z") # 168h
out = {c["id"]: c for c in score_candidates([fresh, stale], [], {}, None, threshold=0.0, now_iso=NOW)}
assert out[1]["breakdown"]["freshness"] > out[2]["breakdown"]["freshness"]
def test_account_fit_uses_weight():
cands = [_cand(1, "A", "economy", 0.8, NOW), _cand(2, "B", "psychology", 0.8, NOW)]
prefs = {"economy": 2.0, "psychology": 1.0}
out = {c["id"]: c for c in score_candidates(cands, [], prefs, None, threshold=0.0, now_iso=NOW)}
assert out[1]["breakdown"]["account_fit"] > out[2]["breakdown"]["account_fit"]
def test_threshold_gate():
cands = [_cand(1, "A", "economy", 0.1, "2026-06-01T00:00:00Z")] # 낮은 score+오래됨
out = score_candidates(cands, [], {}, None, threshold=0.6, now_iso=NOW)
assert out[0]["eligible"] is False
def test_claude_missing_renormalizes():
# claude_scores=None이면 freshness+account_fit만으로 정규화 (claude 항 제외)
cands = [_cand(1, "A", "economy", 1.0, NOW)]
out = score_candidates(cands, [], {"economy": 1.0}, None, threshold=0.0, now_iso=NOW)
assert out[0]["breakdown"]["claude"] is None
assert 0.0 <= out[0]["final_score"] <= 1.0
def test_claude_included_when_provided():
cands = [_cand(1, "A", "economy", 0.5, NOW)]
out = score_candidates(cands, [], {"economy": 1.0}, {1: 1.0}, threshold=0.0, now_iso=NOW)
assert out[0]["breakdown"]["claude"] == 1.0
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `cd insta-lab && PYTHONPATH=.. python -m pytest tests/test_selection.py -q`
Expected: FAIL — `app.selection` 미존재.
- [ ] **Step 3: `selection.py` 작성**
`insta-lab/app/selection.py`:
```python
"""발행 가치 자율 선별 — 순수 점수 함수 (외부 IO 없음, 단위테스트 대상).
신호: dedup(게이트), freshness, account_fit, claude(선택).
final = 가중합(존재하는 신호만 정규화). eligible = dedup통과 and final>=threshold.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
DEFAULT_WEIGHTS = {"freshness": 0.3, "account_fit": 0.3, "claude": 0.4}
FRESH_WINDOW_HOURS = 168.0 # 7일 → 0
def _parse_iso(s: str) -> datetime:
return datetime.fromisoformat(s.replace("Z", "+00:00")).astimezone(timezone.utc)
def _norm(kw: str) -> str:
return (kw or "").strip().lower()
def _is_duplicate(keyword: str, category: str, issued: List[Dict[str, Any]]) -> bool:
n = _norm(keyword)
if not n:
return False
for it in issued:
if it.get("category") != category:
continue
m = _norm(it.get("keyword", ""))
if not m:
continue
if n == m or n in m or m in n:
return True
return False
def _freshness(suggested_at: str, now: datetime) -> float:
try:
hours = (now - _parse_iso(suggested_at)).total_seconds() / 3600.0
except Exception:
return 0.0
return max(0.0, min(1.0, 1.0 - hours / FRESH_WINDOW_HOURS))
def score_candidates(
candidates: List[Dict[str, Any]],
issued_topics: List[Dict[str, Any]],
prefs: Dict[str, float],
claude_scores: Optional[Dict[int, float]] = None,
weights: Optional[Dict[str, float]] = None,
threshold: float = 0.6,
now_iso: Optional[str] = None,
) -> List[Dict[str, Any]]:
w = weights or DEFAULT_WEIGHTS
now = _parse_iso(now_iso) if now_iso else datetime.now(timezone.utc)
max_w = max(prefs.values()) if prefs else 1.0
out: List[Dict[str, Any]] = []
for c in candidates:
cat = c.get("category", "")
dup = _is_duplicate(c.get("keyword", ""), cat, issued_topics)
freshness = _freshness(c.get("suggested_at", ""), now)
weight = prefs.get(cat, 1.0)
account_fit = max(0.0, min(1.0, (weight / max_w) * float(c.get("score", 0.0))))
claude = None
if claude_scores is not None and c["id"] in claude_scores:
claude = max(0.0, min(1.0, float(claude_scores[c["id"]])))
# 존재하는 신호만 가중 정규화
parts = [("freshness", freshness), ("account_fit", account_fit)]
if claude is not None:
parts.append(("claude", claude))
total_w = sum(w[name] for name, _ in parts)
final = sum(w[name] * val for name, val in parts) / total_w if total_w else 0.0
eligible = (not dup) and (final >= threshold)
out.append({
"id": c["id"], "keyword": c.get("keyword"), "category": cat,
"final_score": round(final, 4), "eligible": eligible,
"breakdown": {"dedup_excluded": dup, "freshness": round(freshness, 4),
"account_fit": round(account_fit, 4), "claude": claude},
})
out.sort(key=lambda x: (-x["eligible"], -x["final_score"]))
return out
```
- [ ] **Step 4: 테스트 통과 확인**
Run: `cd insta-lab && PYTHONPATH=.. python -m pytest tests/test_selection.py -q`
Expected: 6 PASS.
- [ ] **Step 5: 커밋**
```bash
git add insta-lab/app/selection.py insta-lab/tests/test_selection.py
git commit -m "feat(insta-lab): selection.py 순수 선별 점수(4신호)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 3: insta-lab — Claude 카드가치 판단 (`selection_judge.py`)
**Files:**
- Create: `insta-lab/app/selection_judge.py`
- Test: `insta-lab/tests/test_selection_judge.py`
- [ ] **Step 1: 실패하는 테스트 작성**
`insta-lab/tests/test_selection_judge.py`:
```python
from app import selection_judge
def test_parse_judge_response_ok():
raw = '[{"keyword_id": 1, "score": 0.8}, {"keyword_id": 2, "score": 0.3}]'
assert selection_judge.parse_judge_response(raw) == {1: 0.8, 2: 0.3}
def test_parse_judge_response_codefence():
raw = '```json\n[{"keyword_id": 5, "score": 0.5}]\n```'
assert selection_judge.parse_judge_response(raw) == {5: 0.5}
def test_parse_judge_response_garbage_returns_empty():
assert selection_judge.parse_judge_response("not json") == {}
def test_judge_candidates_no_key_returns_empty(monkeypatch):
monkeypatch.setattr(selection_judge, "ANTHROPIC_API_KEY", "")
assert selection_judge.judge_candidates([{"id": 1, "keyword": "x", "category": "economy"}]) == {}
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `cd insta-lab && PYTHONPATH=.. python -m pytest tests/test_selection_judge.py -q`
Expected: FAIL — 모듈 미존재.
- [ ] **Step 3: `selection_judge.py` 작성**
`insta-lab/app/selection_judge.py`:
```python
"""Claude Haiku 일괄 카드가치 판단. 실패/미설정 시 빈 dict (graceful)."""
from __future__ import annotations
import json
import logging
import re
from typing import Any, Dict, List
from anthropic import Anthropic
from .config import ANTHROPIC_API_KEY, ANTHROPIC_MODEL_HAIKU
logger = logging.getLogger(__name__)
PROMPT = """다음 인스타 카드뉴스 후보 키워드들을 카드로 만들 가치(흥미·시의성·정보성)와
리스크(민감·논란)를 종합해 0~1 점수로 평가해라. 코드펜스 없이 JSON 배열로만 출력:
[{{"keyword_id": <id>, "score": <0~1>}}, ...]
후보:
{items}"""
def _strip_codefence(s: str) -> str:
s = s.strip()
if s.startswith("```"):
s = re.sub(r"^```(?:json)?\s*|\s*```$", "", s).strip()
return s
def parse_judge_response(raw: str) -> Dict[int, float]:
try:
data = json.loads(_strip_codefence(raw))
return {int(d["keyword_id"]): float(d["score"]) for d in data}
except Exception:
logger.warning("judge 응답 파싱 실패")
return {}
def judge_candidates(candidates: List[Dict[str, Any]]) -> Dict[int, float]:
if not ANTHROPIC_API_KEY or not candidates:
return {}
items = "\n".join(f'- id={c["id"]}: {c["keyword"]} ({c["category"]})' for c in candidates)
try:
client = Anthropic(api_key=ANTHROPIC_API_KEY)
resp = client.messages.create(
model=ANTHROPIC_MODEL_HAIKU, max_tokens=512,
messages=[{"role": "user", "content": PROMPT.format(items=items)}],
)
return parse_judge_response(resp.content[0].text)
except Exception:
logger.exception("judge_candidates 호출 실패")
return {}
```
- [ ] **Step 4: 테스트 통과 확인**
Run: `cd insta-lab && PYTHONPATH=.. python -m pytest tests/test_selection_judge.py -q`
Expected: 4 PASS.
- [ ] **Step 5: 커밋**
```bash
git add insta-lab/app/selection_judge.py insta-lab/tests/test_selection_judge.py
git commit -m "feat(insta-lab): Claude Haiku 카드가치 판단(graceful)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 4: insta-lab — `GET /api/insta/keywords/ranked`
**Files:**
- Modify: `insta-lab/app/main.py`
- Test: `insta-lab/tests/test_ranked_decision_api.py` (Create)
- [ ] **Step 1: 실패하는 테스트 작성**
`insta-lab/tests/test_ranked_decision_api.py`:
```python
import pytest
from fastapi.testclient import TestClient
from app import db, config, selection_judge
@pytest.fixture
def client(tmp_path, monkeypatch):
monkeypatch.setattr(config, "DB_PATH", str(tmp_path / "insta.db"))
monkeypatch.setattr(db, "DB_PATH", str(tmp_path / "insta.db"))
monkeypatch.setattr(selection_judge, "judge_candidates", lambda c: {}) # Claude mock
db.init_db()
from app.main import app
return TestClient(app)
def test_ranked_returns_sorted_eligible(client, monkeypatch):
db.add_trending_keyword({"keyword": "금리", "category": "economy", "score": 0.9})
r = client.get("/api/insta/keywords/ranked?threshold=0.0&limit=10")
assert r.status_code == 200
items = r.json()["items"]
assert len(items) >= 1
assert "final_score" in items[0] and "eligible" in items[0]
def test_decision_approve_publishes(client):
sid = db.add_card_slate({"keyword": "금리", "category": "economy"})
r = client.post(f"/api/insta/slates/{sid}/decision", json={"decision": "approved"})
assert r.status_code == 200
assert db.get_card_slate(sid)["status"] == "published"
def test_decision_reject(client):
sid = db.add_card_slate({"keyword": "환율", "category": "economy"})
r = client.post(f"/api/insta/slates/{sid}/decision", json={"decision": "rejected"})
assert r.status_code == 200
assert db.get_card_slate(sid)["status"] == "rejected"
def test_decision_invalid_400(client):
sid = db.add_card_slate({"keyword": "x", "category": "economy"})
r = client.post(f"/api/insta/slates/{sid}/decision", json={"decision": "maybe"})
assert r.status_code == 400
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `cd insta-lab && PYTHONPATH=.. python -m pytest tests/test_ranked_decision_api.py -q`
Expected: FAIL — 라우트 미존재 (404).
- [ ] **Step 3: ranked 라우트 추가**
`insta-lab/app/main.py` import 블록에 추가:
```python
from datetime import datetime, timezone
from . import selection, selection_judge
```
`list_keywords` 엔드포인트 아래에 추가:
```python
@app.get("/api/insta/keywords/ranked")
def ranked_keywords(limit: int = Query(20, ge=1, le=100), threshold: float = Query(0.6, ge=0.0, le=1.0)):
candidates = db.list_trending_keywords(used=False)
if not candidates:
return {"items": []}
issued = db.list_recent_issued_topics(window_days=14)
prefs = {p["category"]: p["weight"] for p in db.get_preferences()}
claude_scores = selection_judge.judge_candidates(candidates)
now_iso = datetime.now(timezone.utc).isoformat()
scored = selection.score_candidates(
candidates, issued, prefs, claude_scores=claude_scores,
threshold=threshold, now_iso=now_iso,
)
return {"items": scored[:limit]}
```
- [ ] **Step 4: decision 라우트 추가**
`insta-lab/app/main.py`의 슬레이트 섹션(예: `delete_slate` 위)에 추가:
```python
class DecisionBody(BaseModel):
decision: str # "approved" | "rejected"
@app.post("/api/insta/slates/{slate_id}/decision")
def slate_decision(slate_id: int, body: DecisionBody):
if not db.get_card_slate(slate_id):
raise HTTPException(404, "slate not found")
if body.decision not in ("approved", "rejected"):
raise HTTPException(400, "decision must be approved|rejected")
db.set_slate_decision(slate_id, body.decision)
return db.get_card_slate(slate_id)
```
- [ ] **Step 5: 테스트 통과 확인**
Run: `cd insta-lab && PYTHONPATH=.. python -m pytest tests/test_ranked_decision_api.py -q`
Expected: 4 PASS.
- [ ] **Step 6: 전체 insta-lab 회귀 + 커밋**
```bash
cd insta-lab && PYTHONPATH=.. python -m pytest tests/ -q # 전부 PASS 확인
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git add insta-lab/app/main.py insta-lab/tests/test_ranked_decision_api.py
git commit -m "feat(insta-lab): GET /keywords/ranked + POST /slates/{id}/decision
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 5: agent-office — service_proxy 헬퍼
**Files:**
- Modify: `agent-office/app/service_proxy.py`
> 기존 `insta_*` 헬퍼와 동일 패턴(httpx로 `INSTA_LAB_URL` 호출)을 따른다. Task 3 작업 전 `insta_create_slate`(167행) 본문을 열어 base URL·timeout·client 사용 방식을 그대로 모방할 것.
- [ ] **Step 1: 헬퍼 2개 추가**
`agent-office/app/service_proxy.py`의 insta 헬퍼 묶음 끝(예: `insta_put_preferences` 아래)에 추가 — 기존 헬퍼의 `async with httpx.AsyncClient(...)` / base URL 변수명을 동일하게 사용:
```python
async def insta_ranked(threshold: float = 0.6, limit: int = 20) -> list:
async with httpx.AsyncClient(timeout=120) as client:
r = await client.get(
f"{INSTA_LAB_URL}/api/insta/keywords/ranked",
params={"threshold": threshold, "limit": limit},
)
r.raise_for_status()
return r.json()["items"]
async def insta_decision(slate_id: int, decision: str) -> dict:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.post(
f"{INSTA_LAB_URL}/api/insta/slates/{slate_id}/decision",
json={"decision": decision},
)
r.raise_for_status()
return r.json()
```
> 주의: 기존 헬퍼가 `INSTA_LAB_URL`이 아닌 다른 변수명(예: `_INSTA_BASE`)을 쓰면 그 이름으로 맞출 것. timeout(120s)은 ranked의 Claude 호출 대비 여유.
- [ ] **Step 2: import sanity**
Run: `cd agent-office && PYTHONPATH=.. python -c "from app import service_proxy; print('OK')"`
Expected: OK (httpx 미설치면 pip install httpx 후).
- [ ] **Step 3: 커밋**
```bash
git add agent-office/app/service_proxy.py
git commit -m "feat(agent-office): service_proxy insta_ranked/insta_decision
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 6: agent-office — InstaAgent 자율 발급 경로 + 프리뷰
**Files:**
- Modify: `agent-office/app/agents/insta.py`
- Test: `agent-office/tests/test_insta_autonomous.py` (Create)
- [ ] **Step 1: 실패하는 테스트 작성**
`agent-office/tests/test_insta_autonomous.py`:
```python
import pytest
from unittest.mock import AsyncMock, patch
from app.agents.insta import InstaAgent
@pytest.mark.asyncio
async def test_autonomous_issue_previews_eligible(monkeypatch):
agent = InstaAgent()
agent.state = "idle"
monkeypatch.setattr("app.agents.insta.get_agent_config",
lambda aid: {"custom_config": {"autonomous_issue": True,
"select_threshold": 0.5, "max_per_day": 2}})
monkeypatch.setattr(agent, "transition", AsyncMock())
monkeypatch.setattr(agent, "_run_collect_and_extract", AsyncMock())
sp = "app.agents.insta.service_proxy"
monkeypatch.setattr(f"{sp}.insta_ranked", AsyncMock(return_value=[
{"id": 1, "keyword": "금리", "category": "economy", "eligible": True, "final_score": 0.8,
"breakdown": {}},
{"id": 2, "keyword": "x", "category": "economy", "eligible": False, "final_score": 0.1,
"breakdown": {}},
]))
preview = AsyncMock()
monkeypatch.setattr(agent, "_generate_and_preview", preview)
monkeypatch.setattr("app.agents.insta.create_task", lambda *a, **k: "t1")
monkeypatch.setattr("app.agents.insta.update_task_status", lambda *a, **k: None)
monkeypatch.setattr("app.agents.insta.add_log", lambda *a, **k: None)
await agent.on_schedule()
# eligible 1건만 프리뷰
assert preview.await_count == 1
assert preview.await_args.args[0]["id"] == 1
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `cd agent-office && PYTHONPATH=.. python -m pytest tests/test_insta_autonomous.py -q`
Expected: FAIL — 자율 분기/`_generate_and_preview` 미존재.
- [ ] **Step 3: `on_schedule`에 자율 분기 추가**
`agent-office/app/agents/insta.py``on_schedule`에서 `auto_select` 분기 직전에 자율 경로를 추가. `custom` 읽은 직후:
```python
autonomous = bool(custom.get("autonomous_issue", False))
threshold = float(custom.get("select_threshold", 0.6))
max_per_day = int(custom.get("max_per_day", 2))
```
그리고 `add_log(...) → _run_collect_and_extract()` 다음의 분기를 교체:
```python
await self._run_collect_and_extract()
if autonomous:
ranked = await service_proxy.insta_ranked(threshold=threshold, limit=20)
eligible = [r for r in ranked if r.get("eligible")][:max_per_day]
if not eligible:
await messaging.send_raw("📰 [인스타 큐레이터] 오늘은 발행할 가치 있는 주제가 없습니다.")
else:
for pick in eligible:
await self._generate_and_preview(pick)
update_task_status(task_id, "succeeded", {"issued": len(eligible)})
await self.transition("idle", "자율 발급 후보 프리뷰 완료")
return
kws = await service_proxy.insta_list_keywords(used=False)
if auto_select:
... # 기존 유지
```
(기존 `kws = ... / if auto_select` 블록은 그대로 둔다.)
- [ ] **Step 4: `_generate_and_preview` 메서드 추가**
`insta.py`에 추가 — 슬레이트 생성·렌더(기존 흐름) 후 커버 프리뷰 발송:
```python
async def _generate_and_preview(self, pick: dict) -> None:
"""eligible 픽 → 슬레이트 생성·렌더 → 커버 프리뷰 + 승인 버튼."""
created = await service_proxy.insta_create_slate(
keyword=pick["keyword"], category=pick["category"], keyword_id=pick["id"],
)
st = await self._wait_task(created["task_id"], step="slate", timeout_sec=600)
slate_id = st["result_id"]
cover = await service_proxy.insta_get_asset_bytes(slate_id, 1)
bd = pick.get("breakdown", {})
caption = (f"🎴 <b>{pick['keyword']}</b> ({pick['category']})\n"
f"점수 {pick.get('final_score')} · fresh {bd.get('freshness')} "
f"fit {bd.get('account_fit')} claude {bd.get('claude')}\n승인하시겠어요?")
kb = {"inline_keyboard": [[
{"text": "✅ 승인", "callback_data": f"issue_approve_{slate_id}"},
{"text": "❌ 반려", "callback_data": f"issue_reject_{slate_id}"},
{"text": "🔄 재생성", "callback_data": f"issue_regen_{slate_id}"},
]]}
await messaging.send_photo(cover, caption=caption, reply_markup=kb)
create_task(self.agent_id, "insta_issue", {"slate_id": slate_id, "keyword_id": pick["id"]},
requires_approval=True)
```
> `messaging.send_photo(bytes, caption, reply_markup)`가 없으면 Task 6.5로 추가(아래). 있으면 그대로 사용.
- [ ] **Step 5: 테스트 통과 확인**
Run: `cd agent-office && PYTHONPATH=.. python -m pytest tests/test_insta_autonomous.py::test_autonomous_issue_previews_eligible -q`
Expected: PASS.
- [ ] **Step 6: 커밋**
```bash
git add agent-office/app/agents/insta.py agent-office/tests/test_insta_autonomous.py
git commit -m "feat(agent-office): InstaAgent 자율 발급 경로 + 커버 프리뷰
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 6.5: agent-office — `messaging.send_photo` (없을 경우만)
**Files:**
- Modify: `agent-office/app/telegram/messaging.py`
- [ ] **Step 1: 존재 확인**
Run: `grep -n "def send_photo" agent-office/app/telegram/messaging.py`
이미 있으면 이 Task 건너뜀.
- [ ] **Step 2: 없으면 추가**
`messaging.py``send_raw` 패턴(TELEGRAM_BOT_TOKEN/CHAT_ID 사용)을 따라 추가:
```python
async def send_photo(photo_bytes: bytes, caption: str = "", reply_markup: dict = None) -> dict:
if not TELEGRAM_BOT_TOKEN:
return {"ok": False, "reason": "no token"}
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendPhoto"
data = {"chat_id": TELEGRAM_CHAT_ID, "caption": caption[:1024], "parse_mode": "HTML"}
if reply_markup:
data["reply_markup"] = json.dumps(reply_markup, ensure_ascii=False)
files = {"photo": ("cover.png", photo_bytes, "image/png")}
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(url, data=data, files=files)
return resp.json()
```
(상단에 `import json`, `import httpx`, `from ..config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID` 필요분 확인.)
- [ ] **Step 3: 커밋**
```bash
git add agent-office/app/telegram/messaging.py
git commit -m "feat(agent-office): messaging.send_photo (인라인 키보드 첨부 사진)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 7: agent-office — `issue_*` 콜백 처리
**Files:**
- Modify: `agent-office/app/agents/insta.py` (`on_callback`)
- Test: `agent-office/tests/test_insta_autonomous.py` (추가)
- [ ] **Step 1: 실패하는 테스트 추가**
`test_insta_autonomous.py`에 추가:
```python
@pytest.mark.asyncio
async def test_callback_approve_publishes_and_delivers(monkeypatch):
agent = InstaAgent()
sp = "app.agents.insta.service_proxy"
dec = AsyncMock(return_value={"status": "published"})
monkeypatch.setattr(f"{sp}.insta_decision", dec)
monkeypatch.setattr(f"{sp}.insta_get_slate", AsyncMock(return_value={
"assets": [{"page_index": i} for i in range(1, 11)],
"suggested_caption": "cap", "hashtags": ["#a"]}))
monkeypatch.setattr(f"{sp}.insta_get_asset_bytes", AsyncMock(return_value=b"png"))
monkeypatch.setattr("app.agents.insta._send_media_group", AsyncMock(return_value={"ok": True}))
monkeypatch.setattr("app.agents.insta.messaging.send_raw", AsyncMock())
res = await agent.on_callback("issue_approve", {"slate_id": 8})
assert res["ok"] is True
dec.assert_awaited_once_with(8, "approved")
@pytest.mark.asyncio
async def test_callback_reject_marks_rejected(monkeypatch):
agent = InstaAgent()
dec = AsyncMock(return_value={"status": "rejected"})
monkeypatch.setattr("app.agents.insta.service_proxy.insta_decision", dec)
monkeypatch.setattr("app.agents.insta.messaging.send_raw", AsyncMock())
res = await agent.on_callback("issue_reject", {"slate_id": 8})
assert res["ok"] is True
dec.assert_awaited_once_with(8, "rejected")
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `cd agent-office && PYTHONPATH=.. python -m pytest tests/test_insta_autonomous.py -q`
Expected: FAIL — issue_* 액션 미처리.
- [ ] **Step 3: `on_callback`에 issue_* 분기 추가**
`insta.py``on_callback`을 확장:
```python
async def on_callback(self, action: str, params: dict) -> dict:
if action == "render":
kid = int(params.get("keyword_id") or 0)
if not kid:
return {"ok": False}
await self._render_and_push(kid)
return {"ok": True}
if action in ("issue_approve", "issue_reject"):
sid = int(params.get("slate_id") or 0)
if not sid:
return {"ok": False}
decision = "approved" if action == "issue_approve" else "rejected"
await service_proxy.insta_decision(sid, decision)
if decision == "approved":
slate = await service_proxy.insta_get_slate(sid)
media = []
for a in slate["assets"][:10]:
data = await service_proxy.insta_get_asset_bytes(sid, a["page_index"])
media.append({"type": "photo", "_bytes": data})
cap = f"{slate.get('suggested_caption','')}\n\n{' '.join(slate.get('hashtags',[]) or [])}".strip()
await _send_media_group(media, caption=cap)
await messaging.send_raw(f"✅ 발행 완료 (slate {sid})")
else:
await messaging.send_raw(f"❌ 반려됨 (slate {sid})")
return {"ok": True}
if action == "issue_regen":
sid = int(params.get("slate_id") or 0)
if not sid:
return {"ok": False}
slate = await service_proxy.insta_get_slate(sid)
await service_proxy.insta_decision(sid, "rejected") # 이전 폐기
await self._generate_and_preview({
"id": 0, "keyword": slate["keyword"], "category": slate["category"],
"final_score": None, "breakdown": {},
})
return {"ok": True}
return {"ok": False}
```
> `insta_create_slate`는 `keyword_id` 없이도 동작(기존 시그니처 `keyword_id: Optional`). regen은 keyword_id=0 → mark_keyword_used 생략.
- [ ] **Step 4: 테스트 통과 확인**
Run: `cd agent-office && PYTHONPATH=.. python -m pytest tests/test_insta_autonomous.py -q`
Expected: 모두 PASS.
- [ ] **Step 5: 커밋**
```bash
git add agent-office/app/agents/insta.py agent-office/tests/test_insta_autonomous.py
git commit -m "feat(agent-office): issue_approve/reject/regen 콜백 처리
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 8: agent-office — 텔레그램 콜백 디스패치
**Files:**
- Modify: `agent-office/app/telegram/webhook.py`
- [ ] **Step 1: `_handle_callback`에 issue_* 분기 추가**
`webhook.py``_handle_callback`에서 `render_` 분기 아래에 추가:
```python
if callback_id.startswith("issue_"):
return await _handle_insta_issue(callback_query, callback_id)
```
- [ ] **Step 2: `_handle_insta_issue` 추가**
`_handle_insta_render`(103행~)를 본떠 추가:
```python
async def _handle_insta_issue(callback_query: dict, callback_id: str) -> dict:
"""issue_{approve|reject|regen}_{slate_id} → InstaAgent.on_callback."""
from ..agents import AGENT_REGISTRY # _handle_insta_render와 동일 방식으로 에이전트 해석
try:
rest = callback_id.removeprefix("issue_") # "approve_8"
verb, sid = rest.rsplit("_", 1)
slate_id = int(sid)
except (ValueError, AttributeError):
return {"ok": False, "error": "invalid_callback_data"}
agent = AGENT_REGISTRY.get("insta")() if callable(AGENT_REGISTRY.get("insta")) else AGENT_REGISTRY.get("insta")
return await agent.on_callback(f"issue_{verb}", {"slate_id": slate_id})
```
> `_handle_insta_render`가 에이전트를 얻는 정확한 방식(레지스트리/팩토리)을 그대로 복사할 것. 위 `AGENT_REGISTRY` 줄은 그 방식으로 대체한다.
- [ ] **Step 3: import sanity + 수동 점검**
Run: `cd agent-office && PYTHONPATH=.. python -c "from app.telegram import webhook; print('OK')"`
Expected: OK.
- [ ] **Step 4: 커밋**
```bash
git add agent-office/app/telegram/webhook.py
git commit -m "feat(agent-office): issue_* 텔레그램 콜백 디스패치
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 9: 문서 + 배포 + 검증
**Files:**
- Modify: `web-backend/CLAUDE.md`, `memory/service_insta.md`
- [ ] **Step 1: CLAUDE.md insta API 목록에 2개 추가**
`### insta-lab` API 표에 추가:
```
| GET | `/api/insta/keywords/ranked` | 4신호 선별 점수 + eligible (자율 발급용) |
| POST | `/api/insta/slates/{id}/decision` | 승인/반려 (approved→published) |
```
- [ ] **Step 2: 전체 테스트 회귀**
Run:
```bash
cd insta-lab && PYTHONPATH=.. python -m pytest tests/ app/test_package_api.py -q
cd ../agent-office && PYTHONPATH=.. python -m pytest tests/ -q
```
Expected: 모두 PASS (사전존재 stale 제외).
- [ ] **Step 3: 커밋 + push (NAS 배포)**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git add CLAUDE.md docs/superpowers/plans/2026-06-11-insta-autonomous-card-issuance.md
git commit -m "docs(insta): 자율 발급 API 문서 + 구현 계획
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
git push origin main
```
- [ ] **Step 4: 활성화 + 프로덕션 검증**
배포 완료 후 (deployer rebuild ~3분):
```bash
# autonomous_issue 켜기 (agent_config custom_config)
curl -X PUT https://gahusb.synology.me/api/agent-office/agents/insta \
-H "Content-Type: application/json" \
-d '{"custom_config": {"autonomous_issue": true, "select_threshold": 0.6, "max_per_day": 2}}'
# 수동 트리거 대신 ranked 직접 확인
curl -s "https://gahusb.synology.me/api/insta/keywords/ranked?threshold=0.0&limit=5" | python -m json.tool
```
Expected: ranked 응답에 `final_score`/`eligible`/`breakdown`. 09:30 cron 또는 수동 command로 프리뷰가 텔레그램에 도착하는지 확인.
- [ ] **Step 5: 메모리 갱신**
`memory/service_insta.md`에 자율 발급 파이프라인(4신호 선별·승인 게이트·상태머신) 추가 + 스마트에이전트 3종 완료 표시.
---
## Self-Review
**Spec coverage:**
- 선별 4신호 → Task 2(freshness/account_fit/dedup) + Task 3(claude). ✓
- threshold 게이트 0~N → Task 2 + Task 6(max_per_day). ✓
- 승인 게이트 + 콜백 → Task 6(프리뷰) + Task 7(approve/reject/regen) + Task 8(디스패치). ✓
- 상태머신 + 발행이력 → Task 1. ✓
- 하위호환(autonomous_issue=false) → Task 6 Step 3(기존 블록 유지). ✓
- graceful Claude 실패 → Task 3(빈 dict) + Task 2(renormalize). ✓
- 성과지표 제외(YAGNI) → 계획에 없음. ✓
**Placeholder scan:** 모든 코드 스텝에 실제 코드 포함. 단 Task 5/8의 "기존 변수명/에이전트 해석 방식 모방"은 실제 파일 확인을 요구하는 의도적 지시(해당 파일이 코드 소유) — placeholder 아님.
**Type consistency:** `score_candidates(candidates, issued_topics, prefs, claude_scores, weights, threshold, now_iso)` Task2 정의 ↔ Task4 호출 일치. `set_slate_decision(slate_id, decision)` Task1 ↔ Task4 일치. `insta_ranked(threshold, limit)`/`insta_decision(slate_id, decision)` Task5 ↔ Task6/7 일치. 콜백 액션 `issue_approve/issue_reject/issue_regen` Task7 ↔ Task8 prefix 파싱 일치. `_generate_and_preview(pick)` Task6 정의 ↔ Task7(regen) 호출 일치.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,556 @@
# music/YouTube 파이프라인 신뢰성·복구 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:** 파이프라인 step 실패를 자동 재시도(일시적, publish 제외)로 흡수하고, 영구 실패는 terminal `failed`로 둔 뒤 실패 step부터 수동 재개(텔레그램 [🔄재시도])할 수 있게 한다.
**Architecture:** music-lab `orchestrator.run_step`에 bounded 재시도 루프 + `POST /pipeline/{id}/retry` 재개 엔드포인트 + `db.get_last_failed_step`. agent-office `youtube_publisher``failed` 감지 → 텔레그램 알림+버튼, `webhook``ytpub_retry_{pid}` 콜백을 music-lab retry로 프록시.
**Tech Stack:** Python 3.12 / FastAPI / SQLite / asyncio / pytest. 기존 패턴: `orchestrator.run_step`(BackgroundTask), `main.py` pipeline 엔드포인트(404/409 + `_db_module`), `service_proxy`(httpx + `MUSIC_LAB_URL`), `telegram/webhook.py`(callback prefix 디스패치).
**Spec:** `docs/superpowers/specs/2026-06-12-music-pipeline-reliability-design.md`
> **테스트 fixture 주의**: music-lab/agent-office 각 `tests/conftest.py`의 DB 격리 방식(`db.DB_PATH` monkeypatch + `init_db`)을 먼저 확인하고 아래 테스트의 fixture를 그 관례에 맞춰라. 아래 코드는 `db.DB_PATH`를 tmp로 monkeypatch하는 표준 패턴을 가정한다.
---
## File Structure
| 파일 | 변경 | 책임 |
|------|------|------|
| `music-lab/app/db.py` | Modify | `get_last_failed_step(pid)` 추가 |
| `music-lab/app/pipeline/orchestrator.py` | Modify | `_dispatch_step` 추출 + `run_step` 재시도 루프 |
| `music-lab/app/main.py` | Modify | `POST /api/music/pipeline/{pid}/retry` |
| `music-lab/tests/test_pipeline_retry.py` | Create | db + orchestrator + endpoint 테스트 |
| `agent-office/app/service_proxy.py` | Modify | `pipeline_retry(pid)`, `list_failed_pipelines()` |
| `agent-office/app/agents/youtube_publisher.py` | Modify | `failed` 감지 → 텔레그램 알림+버튼 |
| `agent-office/app/telegram/webhook.py` | Modify | `ytpub_retry_` 디스패치 |
| `agent-office/tests/test_youtube_publisher_retry.py` | Create | 알림 + 콜백 테스트 |
| `web-backend/CLAUDE.md` + `memory/service_music.md` | Modify | API 표 + 메모리 |
---
## Task 1: music-lab db — `get_last_failed_step`
**Files:** Modify `music-lab/app/db.py`; Test `music-lab/tests/test_pipeline_retry.py` (Create)
- [ ] **Step 1: 실패 테스트 작성**
`music-lab/tests/test_pipeline_retry.py` (fixture는 music-lab conftest 관례에 맞춰 조정):
```python
import pytest
from app import db
@pytest.fixture(autouse=True)
def _tmp_db(tmp_path, monkeypatch):
monkeypatch.setattr(db, "DB_PATH", str(tmp_path / "music.db"))
db.init_db()
def _make_pipeline_with_failed_step(step: str) -> int:
pid = db.create_pipeline(track_id=1) # 시그니처는 conftest/db 확인 후 맞출 것
job = db.create_pipeline_job(pid, step)
db.update_pipeline_job(job, status="failed", error="boom")
db.update_pipeline_state(pid, "failed", failed_reason=f"{step}: boom")
return pid
def test_get_last_failed_step_returns_step():
pid = _make_pipeline_with_failed_step("video")
assert db.get_last_failed_step(pid) == "video"
def test_get_last_failed_step_none_when_no_failure():
pid = db.create_pipeline(track_id=1)
db.create_pipeline_job(pid, "cover") # status 기본(running/succeeded), failed 아님
assert db.get_last_failed_step(pid) is None
```
- [ ] **Step 2: 실패 확인**
Run: `cd music-lab && PYTHONPATH=.. python -m pytest tests/test_pipeline_retry.py::test_get_last_failed_step_returns_step -v`
Expected: FAIL — `db.get_last_failed_step` 미존재. (create_pipeline 시그니처가 다르면 helper를 db의 실제 생성 함수에 맞춰 수정.)
- [ ] **Step 3: 구현**
`music-lab/app/db.py`의 pipeline_jobs 섹션(`list_pipeline_jobs` 근처)에 추가:
```python
def get_last_failed_step(pid: int) -> Optional[str]:
"""파이프라인의 가장 최근 status='failed' pipeline_job의 step. 없으면 None."""
with _connect() as conn: # music-lab의 커넥션 헬퍼 이름에 맞출 것
row = conn.execute(
"SELECT step FROM pipeline_jobs "
"WHERE pipeline_id = ? AND status = 'failed' "
"ORDER BY id DESC LIMIT 1",
(pid,),
).fetchone()
return row["step"] if row else None
```
(`_connect`/`_conn` 등 실제 커넥션 컨텍스트매니저 이름은 db.py 상단 확인 후 일치시킬 것.)
- [ ] **Step 4: 통과 확인**
Run: `cd music-lab && PYTHONPATH=.. python -m pytest tests/test_pipeline_retry.py -v -k get_last_failed`
Expected: 2 PASS.
- [ ] **Step 5: 커밋**
```bash
git add music-lab/app/db.py music-lab/tests/test_pipeline_retry.py
git commit -m "feat(music-lab): get_last_failed_step — 파이프라인 재개용 실패 step 판별
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 2: orchestrator 자동 재시도
**Files:** Modify `music-lab/app/pipeline/orchestrator.py`; Test `music-lab/tests/test_pipeline_retry.py`
- [ ] **Step 1: 실패 테스트 작성** (test_pipeline_retry.py에 추가)
```python
import asyncio
from app.pipeline import orchestrator
@pytest.fixture(autouse=True)
def _no_backoff(monkeypatch):
monkeypatch.setattr(orchestrator, "STEP_RETRY_BACKOFF_SEC", [0, 0])
@pytest.mark.asyncio
async def test_retryable_step_retries_then_succeeds(monkeypatch):
pid = db.create_pipeline(track_id=1)
calls = {"n": 0}
async def flaky(step, p, ctx, feedback):
calls["n"] += 1
if calls["n"] < 3:
raise RuntimeError("transient")
return {"next_state": "video_pending", "fields": {}}
monkeypatch.setattr(orchestrator, "_dispatch_step", flaky)
monkeypatch.setattr(orchestrator, "_resolve_input", lambda p: {"genre": "x", "title": "t", "moods": [], "tracks": [], "audio_path": "", "duration_sec": 0})
await orchestrator.run_step(pid, "cover")
assert calls["n"] == 3
assert db.get_pipeline(pid)["state"] == "video_pending"
@pytest.mark.asyncio
async def test_retryable_step_exhausts_to_failed(monkeypatch):
pid = db.create_pipeline(track_id=1)
async def always_fail(step, p, ctx, feedback):
raise RuntimeError("permanent")
monkeypatch.setattr(orchestrator, "_dispatch_step", always_fail)
monkeypatch.setattr(orchestrator, "_resolve_input", lambda p: {"genre": "x", "title": "t", "moods": [], "tracks": [], "audio_path": "", "duration_sec": 0})
await orchestrator.run_step(pid, "cover")
assert db.get_pipeline(pid)["state"] == "failed"
@pytest.mark.asyncio
async def test_publish_not_retried(monkeypatch):
pid = db.create_pipeline(track_id=1)
calls = {"n": 0}
async def fail_publish(step, p, ctx, feedback):
calls["n"] += 1
raise RuntimeError("upload error")
monkeypatch.setattr(orchestrator, "_dispatch_step", fail_publish)
monkeypatch.setattr(orchestrator, "_resolve_input", lambda p: {"genre": "x", "title": "t", "moods": [], "tracks": [], "audio_path": "", "duration_sec": 0})
await orchestrator.run_step(pid, "publish")
assert calls["n"] == 1 # 재시도 없음
assert db.get_pipeline(pid)["state"] == "failed"
```
- [ ] **Step 2: 실패 확인**
Run: `cd music-lab && PYTHONPATH=.. python -m pytest tests/test_pipeline_retry.py -v -k "retry or publish_not"`
Expected: FAIL — `_dispatch_step`/`STEP_RETRY_BACKOFF_SEC` 미존재.
- [ ] **Step 3: 구현 — `_dispatch_step` 추출 + 재시도 루프**
`orchestrator.py` 상단 상수 추가:
```python
STEP_MAX_RETRIES = 2 # 추가 재시도 횟수 (총 시도 = +1)
STEP_RETRY_BACKOFF_SEC = [5, 15]
NON_RETRY_STEPS = {"publish"}
```
기존 if/elif 분기(현재 `run_step` 내 lines 32-45)를 헬퍼로 추출:
```python
async def _dispatch_step(step: str, p: dict, ctx: dict, feedback: str) -> dict:
if step == "cover":
return await _run_cover(p, ctx, feedback)
if step == "video":
return await _run_video(p, ctx)
if step == "thumb":
return await _run_thumb(p, ctx, feedback)
if step == "meta":
return await _run_meta(p, ctx, feedback)
if step == "review":
return await _run_review(p, ctx)
if step == "publish":
return await _run_publish(p, ctx)
raise ValueError(f"unknown step: {step}")
```
`run_step`의 try 블록(step 실행부)을 재시도 루프로 교체:
```python
try:
ctx = _resolve_input(p)
except ValueError as e:
db.update_pipeline_job(job_id, status="failed", error=str(e))
db.update_pipeline_state(pipeline_id, "failed", failed_reason=f"{step}: {e}")
return
attempts = 1 if step in NON_RETRY_STEPS else (STEP_MAX_RETRIES + 1)
last_err = None
for i in range(attempts):
try:
result = await _dispatch_step(step, p, ctx, feedback)
db.update_pipeline_job(job_id, status="succeeded")
db.update_pipeline_state(pipeline_id, result["next_state"], **result.get("fields", {}))
return
except Exception as e:
last_err = e
logger.exception("step %s 실패 (pipeline %s, attempt %d/%d)", step, pipeline_id, i + 1, attempts)
if i < attempts - 1:
await asyncio.sleep(STEP_RETRY_BACKOFF_SEC[min(i, len(STEP_RETRY_BACKOFF_SEC) - 1)])
db.update_pipeline_job(job_id, status="failed", error=str(last_err))
db.update_pipeline_state(pipeline_id, "failed", failed_reason=f"{step}: {last_err}")
```
(`asyncio`는 이미 import됨.)
- [ ] **Step 4: 통과 확인**
Run: `cd music-lab && PYTHONPATH=.. python -m pytest tests/test_pipeline_retry.py -v -k "retry or publish_not"`
Expected: 3 PASS.
- [ ] **Step 5: 커밋**
```bash
git add music-lab/app/pipeline/orchestrator.py music-lab/tests/test_pipeline_retry.py
git commit -m "feat(music-lab): orchestrator step 자동 재시도 (publish 제외)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 3: retry 엔드포인트
**Files:** Modify `music-lab/app/main.py`; Test `music-lab/tests/test_pipeline_retry.py`
- [ ] **Step 1: 실패 테스트 작성**
```python
from fastapi.testclient import TestClient
@pytest.fixture
def client(monkeypatch):
from app.main import app
return TestClient(app)
def test_retry_failed_pipeline_retriggers(client, monkeypatch):
pid = db.create_pipeline(track_id=1)
job = db.create_pipeline_job(pid, "video")
db.update_pipeline_job(job, status="failed", error="boom")
db.update_pipeline_state(pid, "failed", failed_reason="video: boom")
called = {}
from app.pipeline import orchestrator
async def fake_run(p, step, *a):
called["pid"], called["step"] = p, step
monkeypatch.setattr(orchestrator, "run_step", fake_run)
r = client.post(f"/api/music/pipeline/{pid}/retry")
assert r.status_code in (200, 202)
assert r.json()["retrying_step"] == "video"
def test_retry_non_failed_409(client):
pid = db.create_pipeline(track_id=1) # state='created'
r = client.post(f"/api/music/pipeline/{pid}/retry")
assert r.status_code == 409
def test_retry_publish_with_video_id_rejected(client):
pid = db.create_pipeline(track_id=1)
job = db.create_pipeline_job(pid, "publish")
db.update_pipeline_job(job, status="failed", error="x")
db.update_pipeline_state(pid, "failed", failed_reason="publish: x", youtube_video_id="abc123")
r = client.post(f"/api/music/pipeline/{pid}/retry")
assert r.status_code == 409
```
- [ ] **Step 2: 실패 확인**
Run: `cd music-lab && PYTHONPATH=.. python -m pytest tests/test_pipeline_retry.py -v -k retry_`
Expected: FAIL — 라우트 404.
- [ ] **Step 3: 구현**
`music-lab/app/main.py``cancel_pipeline` 아래에 추가:
```python
@app.post("/api/music/pipeline/{pid}/retry", status_code=202)
async def retry_pipeline(pid: int, bg: BackgroundTasks):
p = _db_module.get_pipeline(pid)
if not p:
raise HTTPException(404)
if p["state"] != "failed":
raise HTTPException(409, f"재개 불가 (state={p['state']})")
failed_step = _db_module.get_last_failed_step(pid)
if not failed_step:
# 폴백: failed_reason "{step}: ..." prefix
reason = p.get("failed_reason") or ""
failed_step = reason.split(":", 1)[0].strip() or None
if not failed_step:
raise HTTPException(409, "실패 step을 판별할 수 없음")
if failed_step == "publish" and p.get("youtube_video_id"):
raise HTTPException(409, "이미 업로드됨 (중복 방지)")
bg.add_task(orchestrator.run_step, pid, failed_step)
return {"ok": True, "retrying_step": failed_step}
```
- [ ] **Step 4: 통과 확인 + 전체 회귀**
Run: `cd music-lab && PYTHONPATH=.. python -m pytest tests/test_pipeline_retry.py -v` → 모두 PASS
Run: `cd music-lab && PYTHONPATH=.. python -m pytest tests/ -q` → 회귀 0
- [ ] **Step 5: 커밋**
```bash
git add music-lab/app/main.py music-lab/tests/test_pipeline_retry.py
git commit -m "feat(music-lab): POST /pipeline/{id}/retry — 실패 step 수동 재개
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 4: agent-office service_proxy — pipeline_retry + list_failed
**Files:** Modify `agent-office/app/service_proxy.py`
> **먼저 확인**: `list_active_pipelines`가 호출하는 `GET /api/music/pipeline?status=active`가 failed를 포함하는지. 미포함이면 music-lab의 pipeline list 엔드포인트가 `status=failed`도 지원하는지 확인하고, 없으면 그 엔드포인트에 failed 필터를 추가(별도 작은 수정)하거나 `status` 화이트리스트에 'failed' 추가.
- [ ] **Step 1: 헬퍼 추가** — 기존 `list_active_pipelines`/`post_pipeline_feedback` 패턴(async with httpx.AsyncClient + MUSIC_LAB_URL) 그대로:
```python
async def list_failed_pipelines() -> list[dict]:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(f"{MUSIC_LAB_URL}/api/music/pipeline?status=failed")
resp.raise_for_status()
data = resp.json()
return data if isinstance(data, list) else data.get("items", data.get("pipelines", []))
async def pipeline_retry(pid: int) -> dict:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(f"{MUSIC_LAB_URL}/api/music/pipeline/{pid}/retry")
# 409(재개 불가/중복)도 본문 반환 위해 raise 안 함
return {"status_code": resp.status_code, **(resp.json() if resp.headers.get("content-type","").startswith("application/json") else {})}
```
(`list_active_pipelines`가 이미 failed를 포함하면 `list_failed_pipelines`는 생략하고 Task 5에서 active 목록에서 state=='failed' 필터.)
- [ ] **Step 2: import sanity**`cd agent-office && PYTHONPATH=.. python -c "from app import service_proxy; print('OK')"` → OK
- [ ] **Step 3: 커밋**
```bash
git add agent-office/app/service_proxy.py
git commit -m "feat(agent-office): service_proxy pipeline_retry + list_failed_pipelines
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 5: youtube_publisher — failed 감지 + 텔레그램 알림/버튼
**Files:** Modify `agent-office/app/agents/youtube_publisher.py`; Test `agent-office/tests/test_youtube_publisher_retry.py` (Create)
- [ ] **Step 1: 실패 테스트 작성**
`agent-office/tests/test_youtube_publisher_retry.py` (DB fixture는 agent-office conftest 관례 따름):
```python
import pytest
from unittest.mock import AsyncMock
from app.agents.youtube_publisher import YoutubePublisherAgent
@pytest.mark.asyncio
async def test_failed_pipeline_notified_with_retry_button(monkeypatch):
agent = YoutubePublisherAgent()
monkeypatch.setattr(
"app.agents.youtube_publisher.service_proxy.list_active_pipelines",
AsyncMock(return_value=[
{"id": 7, "state": "failed", "failed_reason": "video: boom", "track_title": "T"}
]),
)
sent = AsyncMock(return_value={"ok": True, "message_id": 1})
monkeypatch.setattr("app.agents.youtube_publisher.send_raw", sent)
await agent.poll_state_changes()
assert sent.await_count == 1
args, kwargs = sent.await_args
text = kwargs.get("text") or (args[0] if args else "")
assert "실패" in text
# 인라인 retry 버튼 callback_data
rm = kwargs.get("reply_markup") or {}
cb = rm["inline_keyboard"][0][0]["callback_data"]
assert cb == "ytpub_retry_7"
# 중복 방지: 같은 failed 재폴링 시 미발송
await agent.poll_state_changes()
assert sent.await_count == 1
```
(주의: `send_raw``reply_markup`을 지원하는지 messaging 확인 — 미지원 시 Task에 messaging.send_raw에 reply_markup 인자 추가 포함. insta는 send_photo로 했으나 여기선 텍스트+버튼이므로 send_raw에 reply_markup 필요.)
- [ ] **Step 2: 실패 확인**`cd agent-office && PYTHONPATH=.. python -m pytest tests/test_youtube_publisher_retry.py -v` → FAIL (failed 미처리)
- [ ] **Step 3: 구현**`poll_state_changes`에 failed 분기 추가:
```python
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._notified_state_per_pipeline: dict[int, tuple] = {}
self._notified_failed: set[int] = set()
```
`poll_state_changes` 루프 내, `*_pending` 처리 뒤:
```python
if state == "failed" and pid not in self._notified_failed:
await self._notify_failed(p)
self._notified_failed.add(pid)
if state != "failed":
self._notified_failed.discard(pid) # 재개 후 다시 실패하면 재알림
```
새 메서드:
```python
async def _notify_failed(self, p: dict) -> None:
reason = p.get("failed_reason") or "?"
step = reason.split(":", 1)[0].strip()
title = p.get("track_title") or f"Pipeline #{p['id']}"
text = f"⚠️ [{title}] 파이프라인 #{p['id']} '{step}' 실패\n사유: {reason}"
kb = {"inline_keyboard": [[{"text": "🔄 재시도", "callback_data": f"ytpub_retry_{p['id']}"}]]}
await send_raw(text=text, reply_markup=kb)
add_log(self.agent_id, f"pipeline {p['id']} 실패 알림", "warning")
```
`send_raw``reply_markup`을 받도록 `agent-office/app/telegram/messaging.py``send_raw` 시그니처 확인/확장(이미 지원하면 그대로).
- [ ] **Step 4: 통과 확인**`cd agent-office && PYTHONPATH=.. python -m pytest tests/test_youtube_publisher_retry.py -v` → PASS + 전체 회귀
- [ ] **Step 5: 커밋**
```bash
git add agent-office/app/agents/youtube_publisher.py agent-office/app/telegram/messaging.py agent-office/tests/test_youtube_publisher_retry.py
git commit -m "feat(agent-office): youtube_publisher 파이프라인 실패 텔레그램 알림+재시도 버튼
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 6: webhook ytpub_retry 디스패치
**Files:** Modify `agent-office/app/telegram/webhook.py`; Test `agent-office/tests/test_youtube_publisher_retry.py`
> **먼저 확인**: `_handle_callback`의 prefix 분기 구조 + 기존 핸들러(`_handle_insta_issue` 등)가 service_proxy를 호출/회신하는 패턴.
- [ ] **Step 1: 실패 테스트 추가**
```python
@pytest.mark.asyncio
async def test_handle_ytpub_retry_calls_proxy(monkeypatch):
from app.telegram import webhook
retry = AsyncMock(return_value={"status_code": 202, "ok": True, "retrying_step": "video"})
monkeypatch.setattr("app.telegram.webhook.service_proxy.pipeline_retry", retry, raising=False)
monkeypatch.setattr("app.telegram.webhook.send_raw", AsyncMock(), raising=False)
res = await webhook._handle_ytpub_retry({"id": 1}, "ytpub_retry_7")
retry.assert_awaited_once_with(7)
```
(import 경로/`send_raw` 위치는 webhook.py 실제에 맞춤.)
- [ ] **Step 2: 실패 확인** → FAIL (`_handle_ytpub_retry` 미존재)
- [ ] **Step 3: 구현**`_handle_callback`에 분기:
```python
if callback_id.startswith("ytpub_retry_"):
return await _handle_ytpub_retry(callback_query, callback_id)
```
핸들러:
```python
async def _handle_ytpub_retry(callback_query: dict, callback_id: str) -> dict:
try:
pid = int(callback_id.removeprefix("ytpub_retry_"))
except (ValueError, AttributeError):
return {"ok": False, "error": "invalid_callback_data"}
res = await service_proxy.pipeline_retry(pid)
sc = res.get("status_code")
if sc in (200, 202):
await send_raw(text=f"🔄 파이프라인 #{pid} 재개: {res.get('retrying_step','?')}")
else:
await send_raw(text=f"⚠️ 재개 불가 (#{pid}): {res.get('detail', sc)}")
return {"ok": True}
```
(`service_proxy`/`send_raw` import는 webhook.py 기존 방식 따름.)
- [ ] **Step 4: 통과 확인** + 전체 agent-office 회귀
- [ ] **Step 5: 커밋**
```bash
git add agent-office/app/telegram/webhook.py agent-office/tests/test_youtube_publisher_retry.py
git commit -m "feat(agent-office): ytpub_retry 텔레그램 콜백 → music-lab retry 프록시
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 7: 문서 + 배포 + 메모리
**Files:** Modify `web-backend/CLAUDE.md`, `memory/service_music.md`
- [ ] **Step 1: CLAUDE.md music API 표에 추가**
```
| POST | `/api/music/pipeline/{id}/retry` | 실패 파이프라인 실패 step부터 재개 (publish+업로드완료 시 409) |
```
- [ ] **Step 2: 전체 회귀**
```bash
cd music-lab && PYTHONPATH=.. python -m pytest tests/ -q
cd ../agent-office && PYTHONPATH=.. python -m pytest tests/ -q
```
Expected: 모두 PASS (사전존재 stale 제외).
- [ ] **Step 3: 커밋 + push (NAS 배포)**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git add CLAUDE.md docs/superpowers/plans/2026-06-12-music-pipeline-reliability.md
git commit -m "docs(music): 파이프라인 retry API 문서 + 구현 계획
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
git push origin main
```
- [ ] **Step 4: 메모리 갱신**`service_music.md`에 신뢰성/복구(자동 재시도 publish 제외 + 수동 retry 엔드포인트 + youtube_publisher 실패 알림) 추가.
- [ ] **Step 5: 프로덕션 확인(경량)** — 배포 후 `POST /api/music/pipeline/<없는id>/retry` → 404, 실제 failed 파이프라인 있으면 retry 동작. (없으면 단위 테스트로 갈음.)
---
## Self-Review
**Spec coverage:**
- 자동 재시도(publish 제외, _resolve_input 제외) → Task 2 ✓
- 수동 재개(실패 step, publish+video_id 가드) → Task 1(step 판별)+Task 3 ✓
- 실패 알림 + [🔄재시도] → Task 5 ✓
- 재시도 콜백 → Task 4(proxy)+Task 6(dispatch) ✓
- stuck 감지 제외(YAGNI) → 계획에 없음 ✓
**Placeholder scan:** 코드 스텝 모두 구체. "conftest 관례 확인"·"list_active가 failed 포함하는지 확인"은 기존 코드 소유를 존중하는 의도적 검증 지시(placeholder 아님).
**Type consistency:** `get_last_failed_step(pid)` Task1↔Task3 일치. `_dispatch_step(step,p,ctx,feedback)` Task2 정의↔테스트 mock 일치. `run_step(pid, step)` 시그니처 기존 일치. callback `ytpub_retry_{pid}` Task5 생성↔Task6 파싱 일치. `pipeline_retry(pid)` Task4↔Task6 일치. retry 응답 `retrying_step`/`status_code` Task3↔Task4↔Task6 일치.

View File

@@ -0,0 +1,368 @@
# Lotto Evolver UI + 에이전트 활동 가시화 설계 (v2.1)
- **상태**: Draft (사용자 리뷰 대기)
- **작성일**: 2026-05-23
- **대상 저장소**:
- `web-ui` (프론트엔드) — `/lotto/evolver` 페이지 신설 + 공용 활동 컴포넌트
- `web-backend` agent-office — LottoAgent task_id 도입 + sync_evolver_activity cron
- **선행 작업**: v2 Lotto Weight Evolver (2026-05-22 배포, 운영 중)
- **목표**: 토요일 22:15 텔레그램 리포트의 "[웹에서 차트 보기]" 링크가 가리키는 페이지 구축 + 로또 에이전트의 모든 활동(시그널·digest·큐레이션·evolver)을 한 곳에서 추적 가능하게.
---
## 1. 문제 정의
v2 텔레그램 메시지가 `https://gahusb.synology.me/lotto/evolver` 링크를 포함하지만 web-ui repo에 해당 라우트가 없음 → React Router catch-all 404. spec section 13에서 "프론트 UI는 별도 PR"로 명시했지만 링크는 미리 박혀있음 → UX 깨짐.
또한 LottoAgent의 활동(signals / digest / weekly_evolution_report / curate)이 agent_office.db의 `agent_logs`에는 기록되지만 `agent_tasks` 테이블에는 **`curate_weekly`만** 들어감 → agent-office UI에서 "Tasks" 섹션 봤을 때 활동 이력이 누락. lotto-lab의 weight_evolver cron(매일 apply / 월 generate / 토 evaluate)은 lotto.db에만 기록 → agent_office에서 완전히 안 보임.
사용자 의도: "로또 에이전트가 무엇을 했는지" 한 곳에서 확인 가능하게.
## 2. 의사결정 요약
| 결정 사항 | 선택 | 비고 |
|---|---|---|
| 라우트 위치 | 별도 `/lotto/evolver` (텔레그램 링크와 일치) | `/stock/trade`, `/stock/screener` 패턴 따름 |
| 사용 시나리오 | 토 22:15 텔레그램 직후 주간 요약 대시보드 | 평일 운영·장기 분석은 부차 |
| 페이지 구조 | 단일 스크롤, 5개 카드 (Header / Winner / TrialsGrid / BaseDiff / BaseHistory / Actions) | sub-tab 불필요 |
| 차트 | Recharts (이미 dep) — Radar / Bar / Line + 인라인 metric-card | small multiples 대신 텍스트 강조 |
| 활동 노출 위치 | `/lotto/evolver` + `/agent-office` 양쪽 (공용 컴포넌트) | DRY |
| 백엔드 보강 | 기존 add_log만 있던 LottoAgent 메서드에 task_id 도입 + 신규 sync_evolver_activity cron | 멱등 guard 포함 |
## 3. 아키텍처
### 3.1 컴포넌트 다이어그램
```
┌─────────────────────────────────────────────────────────────┐
│ web-ui (신규 컴포넌트) │
│ │
│ src/pages/lotto/ │
│ Evolver.jsx ← /lotto/evolver 진입점 │
│ Evolver.css │
│ evolver/ │
│ WinnerCard.jsx ← Radar (5축) + 메타 │
│ TrialsGrid.jsx ← 6일 Bar 비교 + 펼치기 │
│ BaseDiff.jsx ← 5 metric-card (텍스트+arrow)│
│ BaseHistory.jsx ← LineChart 12주 시계열 │
│ EvolverActions.jsx ← 수동 트리거 (dev) │
│ useEvolverApi.js ← status+history+activity hook│
│ │
│ src/components/lotto/ │
│ LottoActivityTimeline.jsx ← 공용 활동 timeline │
│ /lotto/evolver + /agent-office│
└─────────────────────────────────────────────────────────────┘
↓ (HTTP)
┌─────────────────────────────────────────────────────────────┐
│ web-backend (보강) │
│ │
│ agent-office/app/agents/lotto.py │
│ • run_signal_check → task_id 도입 (신규) │
│ • run_daily_digest → task_id 도입 (신규) │
│ • run_weekly_evolution_report → task_id 도입 (신규) │
│ • sync_evolver_activity → 신규 메서드 │
│ │
│ agent-office/app/scheduler.py │
│ • lotto_evolver_activity_sync — 매일 09:30 cron 신규 │
│ │
│ agent-office/app/db.py │
│ • get_tasks_by_agent_date_kind — 멱등 guard helper 신규 │
│ │
│ agent-office/app/main.py │
│ • GET /agents/{id}/tasks에 task_type 필터 추가 (확장) │
│ │
│ lotto-lab: 변경 없음 (web-ui가 evolver API 직접 소비) │
└─────────────────────────────────────────────────────────────┘
```
### 3.2 책임 경계
- **web-ui Evolver 페이지**: 데이터 시각화 전담. 비즈니스 로직 없음. fetch는 useEvolverApi에 집중.
- **LottoActivityTimeline**: 시간순 timeline 표현만. logs/tasks/evolverEvents 3종 입력 받아 merge sort + 렌더.
- **LottoAgent**: 모든 자율 작업 시 task row 생성 (다른 에이전트와 동일 패턴).
- **sync_evolver_activity**: lotto-lab의 결과를 agent_office.db에 거울 비추기. 백엔드 polling 패턴. 멱등.
- **lotto-lab**: 변경 없음. 모든 evolver API는 web-ui가 직접 호출.
## 4. 페이지 정보 layout
```
┌─────────────────────────────────────────────────────────────┐
│ HEADER │
│ Lotto · Weight Evolver │
│ "스스로 가중치를 조절하는 자율 학습 루프" │
│ 마지막 회고: 1225회 (2026-05-21 22:00) │
├─────────────────────────────────────────────────────────────┤
│ ① WinnerCard (대형, 메인) │
│ 🏆 목요일 · W_4 · max=4개 일치 │
│ ┌─ Radar Chart (5축) ──┐ │
│ │ freq, finger, gap, │ │
│ │ cooccur, divers │ │
│ └──────────────────────┘ │
│ avg_score · n_picks graded · update reason │
├─────────────────────────────────────────────────────────────┤
│ ② TrialsGrid │
│ 월 화 수 목⭐ 금 토 (가로 6개 Bar) │
│ ░░ ▓▓ ░░ ██ ▒▒ ░░ │
│ max=2 1 3 4 2 1 │
│ 클릭 → 그날 5세트 numbers + scores 펼침 │
├─────────────────────────────────────────────────────────────┤
│ ③ BaseDiff │
│ 5개 metric-card 가로 정렬 │
│ freq 0.20 → 0.18 ↓ -10% │
│ finger 0.20 → 0.32 ↑↑ +60% │
│ gap 0.20 → 0.20 = (변화 없음) │
│ cooccur 0.20 → 0.22 ↑ +10% │
│ divers 0.20 → 0.08 ↓↓ -60% │
│ → reason: winner_4plus │
├─────────────────────────────────────────────────────────────┤
│ ④ BaseHistory (12주) │
│ LineChart 5 라인 (freq/finger/gap/cooccur/divers) │
│ X축: effective_from, Y축: weight 0~1 │
│ dot click → reason tooltip + 회차 표시 │
├─────────────────────────────────────────────────────────────┤
│ ⑤ LottoActivityTimeline (compact=false) │
│ 최근 7일 — task + log + lotto-lab evolver 이벤트 merge │
│ 2026-05-23 22:15 🧬 weekly_evolution_report succeeded │
│ 2026-05-23 22:00 ⚖️ weight_evolver_eval (lotto-lab) │
│ 2026-05-23 21:15 🔍 deep_check succeeded │
│ ... │
├─────────────────────────────────────────────────────────────┤
│ ⑥ EvolverActions (개발자 모드) │
│ [수동 generate-now] [수동 evaluate-now] │
│ 응답 JSON 콘솔에 표시 │
└─────────────────────────────────────────────────────────────┘
```
### 4.1 모바일 반응형
- ≤640px: 1 컬럼, 차트는 가로폭 100%
- 641-1024px: WinnerCard·TrialsGrid 가로 분할 (50/50)
- ≥1025px: 위 layout 그대로
## 5. 데이터 흐름
### 5.1 useEvolverApi hook
```js
function useEvolverApi({ days = 7, weeks = 12 } = {}) {
// 4개 fetch 동시 — Promise.all
// 1. GET /api/lotto/evolver/status → status
// 2. GET /api/lotto/evolver/history?weeks=12 → history
// 3. GET /api/agent-office/agents/lotto/logs?days=7 → logs
// 4. GET /api/agent-office/agents/lotto/tasks?days=7 → tasks
//
// activity = merge(logs, tasks, evolverEventsFromHistory) sorted by timestamp DESC
return { status, history, activity, loading, error, refetch };
}
```
`activity` 합성 규칙:
- agent_logs의 created_at + level + message + task_id
- agent_tasks의 created_at + task_type + status + result_data
- history.items의 created_at + update_reason + weight (evolver eval 자체 이벤트로 별도 표시)
- 클라이언트에서 timestamp DESC sort → React에서 렌더링
### 5.2 Recharts 매핑
| 컴포넌트 | 차트 | data prop |
|---|---|---|
| WinnerCard | `RadarChart` | `[{metric, value, previous}]` 5점 (overlay: previous_base) |
| TrialsGrid | `BarChart` 수평 6개 | `[{day_name, avg_score, max_correct, is_winner}]` |
| BaseHistory | `LineChart` | `[{effective_from, freq, finger, gap, cooccur, divers}, ...]` |
### 5.3 LottoActivityTimeline
```jsx
<LottoActivityTimeline
logs={agentLogs}
tasks={agentTasks}
evolverEvents={evolverEventsFromHistory}
days={7}
compact={false}
/>
```
merge & sort:
```js
const stream = [
...logs.map(l => ({ ts: l.created_at, kind: 'log', payload: l })),
...tasks.map(t => ({ ts: t.created_at, kind: 'task', payload: t })),
...evolverEvents.map(e => ({ ts: e.created_at, kind: 'evolver', payload: e })),
].sort((a, b) => b.ts.localeCompare(a.ts));
```
각 stream item:
- kind='task': 아이콘 + task_type label + status badge + (completed_at - created_at) 소요시간
- kind='log': 아이콘(level) + message
- kind='evolver': ⚖️ + update_reason + winner_score
icon · color mapping (task_type 기준):
```
curate_weekly 📋 blue
signal_check 🔍 green / fired면 amber
daily_digest 📊 cyan
weekly_evolution_report 🧬 purple
evolver_generate 🌱 teal
evolver_apply 🎲 gray
```
### 5.4 cold start / empty state
- `weight_base_history` empty → 큰 빈 카드: "아직 학습 시작 전. 다음 월요일 09:00 자동 시작" + `[수동 generate-now 트리거]` 버튼
- `trials` empty (월 09:00 전) → 안내 카드
- `activity` empty → 회색 "최근 활동 없음"
## 6. 백엔드 보강
### 6.1 LottoAgent 메서드 — task_id 도입
3개 메서드에 `_run` 패턴(`create_task` + try/except + `update_task_status` + `add_log(..., task_id=...)`) 적용:
| 메서드 | 새 task_type | result_data 핵심 |
|---|---|---|
| `run_signal_check(source)` | `signal_check` | source, overall_fire, n_results, fired_metrics |
| `run_daily_digest()` | `daily_digest` | evaluated, fired, signals_count |
| `run_weekly_evolution_report()` | `weekly_evolution_report` | draw_no, update_reason, winner_day |
기존 `_run`(`curate_weekly`)은 그대로.
### 6.2 sync_evolver_activity — 신규 메서드
매일 09:30 cron. lotto-lab의 today_trial 가져와 agent_office.db에 task+log 기록. 멱등 guard.
```python
async def sync_evolver_activity(self):
"""lotto-lab evolver 상태 polling → agent_office.db에 거울. 멱등."""
today_iso = _today_kst_iso()
dow = _today_dow()
status = await service_proxy.lotto_evolver_status()
# 오늘 trial + picks → evolver_apply task
today_trial = next((t for t in status["trials"] if t["day_of_week"] == dow), None)
if today_trial and today_trial.get("picks") and not db.get_tasks_by_agent_date_kind("lotto", today_iso, "evolver_apply"):
tid = db.create_task("lotto", "evolver_apply", {
"date": today_iso, "trial_id": today_trial["id"],
"day_of_week": dow, "weight": today_trial["weight"],
})
db.update_task_status(tid, "succeeded", result_data={
"n_picks": len(today_trial["picks"]),
"meta_scores": [p["meta_score"] for p in today_trial["picks"]],
})
db.add_log("lotto", f"evolver_apply: 오늘 W로 {len(today_trial['picks'])}세트 추출", task_id=tid)
# 월요일 + 6 trials 완성 → evolver_generate task
if dow == 0 and len(status["trials"]) == 6 and not db.get_tasks_by_agent_date_kind("lotto", today_iso, "evolver_generate"):
tid = db.create_task("lotto", "evolver_generate", {"week_start": status["week_start"]})
db.update_task_status(tid, "succeeded", result_data={"trials_count": 6})
db.add_log("lotto", f"evolver_generate: {status['week_start']} 주의 6 trials 생성", task_id=tid)
```
토요일 22:15 evaluate는 `run_weekly_evolution_report`가 이미 task 기록 → sync 불필요.
### 6.3 db.py — 신규 helper
```python
def get_tasks_by_agent_date_kind(agent_id: str, date_iso: str, task_type: str) -> List[Dict[str, Any]]:
"""같은 (agent, date, task_type)으로 이미 생성된 task 조회 — 멱등 guard."""
with _conn() as conn:
rows = conn.execute(
"""
SELECT * FROM agent_tasks
WHERE agent_id = ? AND task_type = ?
AND substr(created_at, 1, 10) = ?
ORDER BY created_at DESC
""",
(agent_id, task_type, date_iso),
).fetchall()
return [dict(r) for r in rows]
```
### 6.4 scheduler.py — cron 추가
```python
async def _run_lotto_sync_evolver_activity():
agent = AGENT_REGISTRY.get("lotto")
if agent:
await agent.sync_evolver_activity()
scheduler.add_job(
_run_lotto_sync_evolver_activity,
"cron", hour=9, minute=30,
id="lotto_evolver_activity_sync",
)
```
### 6.5 main.py — API 확장
`GET /api/agent-office/agents/{id}/tasks`에 query param 추가:
```python
@app.get("/api/agent-office/agents/{agent_id}/tasks")
async def get_agent_tasks(agent_id: str, days: int = 7, task_type: Optional[str] = None):
return {"items": db.get_agent_tasks(agent_id, days=days, task_type=task_type)}
```
`db.get_agent_tasks`도 task_type 필터 추가 (기존 함수 보강).
### 6.6 task_type 명세 (참조)
| task_type | 트리거 | 어디서 생성 |
|---|---|---|
| `curate_weekly` | 월 09:05 또는 deep_check | LottoAgent._run (기존) |
| `signal_check` | light / sim / deep cron | LottoAgent.run_signal_check (신규 wrap) |
| `daily_digest` | 매일 09:25 | LottoAgent.run_daily_digest (신규 wrap) |
| `weekly_evolution_report` | 토 22:15 | LottoAgent.run_weekly_evolution_report (신규 wrap) |
| `evolver_generate` | 월 09:30 sync | LottoAgent.sync_evolver_activity (신규) |
| `evolver_apply` | 매일 09:30 sync | LottoAgent.sync_evolver_activity (신규) |
## 7. 라우터 등록
`web-ui/src/routes.jsx`에 추가:
```jsx
const Evolver = lazy(() => import('./pages/lotto/Evolver'));
// appRoutes 배열에 추가:
{
path: 'lotto/evolver',
element: <Evolver />,
},
```
## 8. 구현 Phase
| Phase | 범위 | 검증 |
|---|---|---|
| 1 | agent-office 백엔드 보강 (LottoAgent task_id wrap + sync cron + db helper) + 단위 테스트 | task row 생성 확인, 멱등 가드 동작 |
| 2 | agent-office API 확장 (task_type 필터) | curl로 필터링 동작 확인 |
| 3 | web-ui Evolver 페이지 — useEvolverApi + WinnerCard + TrialsGrid + BaseDiff + BaseHistory + EvolverActions | 로컬 dev 브라우저에서 모든 카드 정상 렌더, 모바일 반응형 |
| 4 | LottoActivityTimeline 공용 컴포넌트 — /lotto/evolver에 통합 + /agent-office LottoAgent 카드에 compact 모드 통합 | 두 페이지에서 동일 데이터 보임 |
| 5 | 라우터 등록 + 텔레그램 링크 404 해결 확인 | `release:nas` → 텔레그램 [차트 보기] 클릭 → 정상 페이지 |
Phase 1-2: web-backend repo, Phase 3-5: web-ui repo. 각 repo는 별도 git, 별도 배포 (web-backend git push → Gitea webhook auto, web-ui `npm run release:nas`).
## 9. 비기능 요구
- **백워드 호환**: 기존 LottoAgent 호출자 (cron 등) 시그니처 변경 없음. 내부 task_id wrap만 추가.
- **장애 격리**: sync_evolver_activity 실패해도 lotto-lab 영향 없음. task_id wrap 실패 시 try/except로 메서드 자체는 계속 동작.
- **멱등성**: sync_evolver_activity는 멱등 guard로 cron 재실행·재시작 안전.
- **테스트**:
- LottoAgent task_id wrap — mock task_id 받아 update 호출 확인
- sync_evolver_activity 멱등 — 같은 날 2번 호출 시 1 row만
- LottoActivityTimeline merge sort — unit test로 stream 순서·아이콘 매핑
- **관측**: 모든 LottoAgent 메서드의 result_data 표준화 (Section 6.1 표 참조)
## 10. 비목표 (Out of scope)
- TrialsGrid에서 과거 주 deep dive 조회 (`GET /trials/{week_start}` 사용) — v2.2 후속, 별도 UI
- 차트 export / CSV 다운로드
- 가중치 수동 편집 UI — v3에서 사용자 개입 모드 도입 검토
- 다른 에이전트(stock / music / realestate)의 활동 통합 timeline — 현재 spec은 lotto만
- 실시간 WebSocket 푸시 (agent-office에 ws 있지만 evolver 활동은 polling으로 충분)
## 11. v3 후속 검토
- 다른 에이전트 활동도 같은 패턴(LottoActivityTimeline 제너릭화 → AgentActivityTimeline)으로 노출
- /lotto/evolver 페이지에 사용자 의견 입력 (이번 winner가 마음에 듦/싫음) → 학습 시그널로 활용
- BaseHistory에 brush 도입 (긴 history 시계열 zoom)
- TrialsGrid에 picks 채점 결과 통계 (몇 개 trial에서 4개 일치 났는지 등)

View File

@@ -0,0 +1,559 @@
# Tarot Lab v1 — Design Spec
**작성일:** 2026-05-23
**상태:** 디자인 승인 완료, 구현 계획 작성 대기
**관련 자산:**
- `source/images/tarot_page/tarot_main_landing_page.png` (랜딩 시안)
- `source/images/tarot_page/tarot_card_select_page.png` (카드 선택 시안)
- `source/images/tarot_page/tarot_background.png` (정적 배경 폴백)
- `source/images/tarot_page/tarot_cards.png` (카드 콜라주 참고)
- `source/videos/tarot_main_background.mp4` (히어로 영상)
---
## 1. 목표와 배경
개인 웹 플랫폼에 라이더-웨이트(RWS) 기반 타로 리딩 기능을 추가한다. v1은 **오늘의 카드 / 3장 스프레드 / 리딩 히스토리·마이페이지** 3개 핵심 흐름을 한 번에 배포하고, AI 해석은 Claude Sonnet 4.6을 통해 **근거 기반(evidence)** 으로 생성한다. 켈틱 크로스 10장 스프레드와 카드 78장 정식 이미지 자산은 v2 분리.
### 비목표 (v2 이후)
- 켈틱 크로스 10장 스프레드
- 사용자가 제공할 카드 78장 정식 이미지 자산의 정식 매핑 (v1은 placeholder/CSS)
- 78장 의미 텍스트 완성본 (v1은 메이저 22 + 마이너 키워드만)
- 텔레그램 자동 push ("매일 오늘의 카드")
- 카드 78장 도감 화면
- 즐겨찾기 메모 편집 UI (백엔드 endpoint는 v1에 포함, UI는 v2)
- **카드 시각 효과 보강** — 카드 이미지 자산 도착 이후 보강:
- 카드 hover·focus 시 보더 주변 황금 글로우·sparkle particles
- 카드 뒤집기 애니메이션 (3D rotateY transform, 0.6~0.8s ease-out, 뒷면→앞면 전환)
- 우주 입자 floating · 별 깜빡임 등 분위기 효과
- v1은 hover lift + 단순 fade-in 정도의 미니멀 모션만
---
## 2. 아키텍처
```
web-ui (React + Vite)
/tarot 랜딩 (히어로 영상 + 3-tier)
/tarot/today 오늘의 카드 (원카드)
/tarot/reading 3장 스프레드 (메인 인터랙션)
/tarot/history 마이페이지 (리딩 이력)
│ /api/agent-office/tarot/*
agent-office (FastAPI 확장)
app/routes/tarot.py 4 endpoint
app/agents/tarot.py TarotAgent (Claude Sonnet 호출 + 응답 검증)
app/db.py tarot_readings 테이블 추가
▼ Anthropic API
Claude Sonnet 4.6
```
### 경계 결정 이유
- **카드 78장 메타데이터는 프론트 정적 JSON** — 자주 안 변하고 셔플·선택에 백엔드 호출 불필요. 라운드트립 절약.
- **AI 해석만 백엔드** — API key 보호 + 호출 로깅·검증·reroll 가능.
- **히스토리도 백엔드** — localStorage는 기기 의존, 사용자가 영속화 요구.
- **신규 컨테이너 없음** — agent-office 확장. nginx·docker-compose 변경 0건.
### Why agent-office인가
1. `ANTHROPIC_API_KEY` 이미 환경변수로 연결됨
2. Claude SDK + httpx 클라이언트 set up 완료
3. Agent FSM 패턴(idle→working→reporting)에 자연스럽게 맞음 — TarotAgent도 "리딩 수행" 작업으로 모델링
4. 텔레그램 봇 연결되어 있어 v2에서 "매일 오늘의 카드" push 확장 여지
---
## 3. 프론트 데이터 모델
### 정적 카드 데이터 (`web-ui/src/pages/tarot/data/cards.js`)
```js
export const TAROT_DECK = [
// Major Arcana 22장
{
id: 0,
slug: "the-fool",
name: "바보",
nameEn: "The Fool",
arcana: "major",
element: "air",
keywords: ["새로운 시작", "도약", "순수", "자유"],
reversedKeywords: ["무모함", "경솔함", "위험", "방향 상실"],
meaningUpright: "미지의 세계로 내딛는 첫걸음. 계산보다 직관과 신뢰로 시작하는 시기.",
meaningReversed: "준비 없이 뛰어들어 위험을 자초하거나, 두려움으로 첫걸음을 미루는 상태.",
image: null, // 사용자가 /images/tarot/cards/the-fool.png 추가 시 자동 매핑
},
// ... Major 21장 더
// Minor Arcana 56장
{
id: 22,
slug: "ace-of-wands",
name: "지팡이 에이스",
arcana: "minor",
suit: "wands",
rank: 1,
element: "fire",
keywords: ["창조의 불씨", "영감", "새로운 시작"],
reversedKeywords: ["지연", "동기 부족", "방향 상실"],
meaningUpright: "...",
meaningReversed: "...",
image: null,
},
// ... Minor 55장 더
];
export const SPREADS = {
one_card: {
id: "one_card",
name: "오늘의 카드",
positions: [{ idx: 0, label: "오늘" }],
},
three_card: {
id: "three_card",
name: "3장 스프레드",
positions: [
{ idx: 0, label: "과거" },
{ idx: 1, label: "현재" },
{ idx: 2, label: "미래" },
],
},
};
export const CATEGORIES = ["연애", "일·커리어", "관계", "재물", "건강", "일반"];
```
**v1 시드 데이터 작업량:**
- 메이저 22장: 정·역 키워드 + 정·역 의미 텍스트 완성 (필수)
- 마이너 56장: 정·역 키워드만 (필수) + 의미 텍스트는 짧은 요약 1문장씩 (v2에서 보강)
### 카드 이미지 자동 매핑 규칙
- 사용자가 `web-ui/public/images/tarot/cards/<slug>.png` 추가 시 자동 표시
- `cards.js`에서 `image: \`/images/tarot/cards/${slug}.png\`` 일관 패턴
- `onError` → CSS 카드 디자인 폴백 (그라데이션 보더 + 카드명 + 심볼)
---
## 4. 백엔드 데이터 모델
### tarot_readings 테이블 (`agent_office.db`)
```sql
CREATE TABLE IF NOT EXISTS tarot_readings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL, -- UTC ISO8601
spread_type TEXT NOT NULL, -- 'one_card' | 'three_card'
category TEXT, -- '연애' | '일·커리어' | …
question TEXT, -- 사용자 입력 (NULL 가능)
cards TEXT NOT NULL, -- JSON: [{position, card_id, reversed}]
interpretation_json TEXT, -- Claude 응답 파싱 결과 전체
summary TEXT, -- interpretation_json.summary 빠른 조회용
model TEXT, -- 'claude-sonnet-4-6'
tokens_in INTEGER,
tokens_out INTEGER,
cost_usd REAL,
confidence TEXT, -- 'high' | 'medium' | 'low'
favorite INTEGER DEFAULT 0,
note TEXT
);
CREATE INDEX idx_tarot_created ON tarot_readings(created_at DESC);
CREATE INDEX idx_tarot_favorite ON tarot_readings(favorite, created_at DESC);
```
**저장 정책:**
- 모든 리딩은 자동 저장 (사용자가 "저장" 누르지 않아도). 사용자가 별도 액션 없이도 히스토리에서 확인 가능.
- `favorite` 토글 + `note` 편집은 별도 PATCH 호출
- 카드는 `card_id`(slug)만 저장 — 실제 이름·의미는 항상 프론트 데이터에서 조회 → 카드 데이터 수정이 과거 이력에 자동 반영
### interpretation_json 구조
```json
{
"summary": "전체 흐름 한 단락 (3~4문장)",
"cards": [
{
"position": "과거",
"card": "the-fool",
"reversed": false,
"interpretation": "이 위치에서 이 카드가 의미하는 바 (3~4문장)",
"evidence": {
"card_meaning_used": "참고 카드 정보에서 인용한 키워드·상징",
"position_logic": "왜 이 의미가 이 위치에 그렇게 적용되는지 (1~2문장)",
"category_lens": "카테고리 관점에서 부각되는 면 (1문장)"
},
"advice": "이 카드가 주는 짧고 구체적인 조언 (1문장)"
}
],
"interactions": [
{
"type": "synergy" | "conflict" | "transition",
"between": ["the-fool", "the-lovers"],
"explanation": "두 카드의 슈트·원소·정역방향 흐름 근거 (1~2문장)"
}
],
"advice": "3장(또는 1장) 종합 조언 (2문장)",
"warning": null,
"confidence": "high" | "medium" | "low"
}
```
---
## 5. API 명세
### 5.1 `POST /api/agent-office/tarot/interpret`
AI 해석만 수행 (저장과 분리). 응답 받은 후 사용자가 별도 액션 없으면 자동 저장 호출.
**Request:**
```json
{
"spread_type": "three_card",
"category": "연애",
"question": "다음 달 그 사람과의 관계는?",
"cards": [
{ "position": "과거", "card_id": "the-fool", "reversed": false },
{ "position": "현재", "card_id": "the-lovers", "reversed": true },
{ "position": "미래", "card_id": "ten-of-cups", "reversed": false }
],
"cards_reference": "## 1. 위치: 과거 | 카드: The Fool ...",
"context_meta": {
"major_minor_ratio": "2:1",
"element_distribution": { "air": 2, "water": 1, "fire": 0, "earth": 0 },
"orientation_flow": "upright→reversed→upright"
}
}
```
`cards_reference``context_meta`는 프론트가 `cards.js`를 기반으로 빌드해서 전송. 백엔드가 카드 데이터를 따로 가지고 있을 필요 없음 (DRY).
**Response:** `interpretation_json` 구조 + 호출 메타.
```json
{
"interpretation_json": { /* 4 */ },
"model": "claude-sonnet-4-6",
"tokens_in": 712,
"tokens_out": 942,
"cost_usd": 0.0163,
"latency_ms": 5240,
"reroll_count": 0
}
```
**에러:**
- 400 — spread_type 미지원 / cards 길이 불일치 / cards_reference 빈 문자열
- 429 — Anthropic API rate limit
- 500 — Claude 호출 실패 (Retry-After 헤더 포함) 또는 reroll 2회 모두 실패
### 5.2 `POST /api/agent-office/tarot/readings`
리딩 저장. interpret 결과를 그대로 + 사용자 컨텍스트.
**Request:**
```json
{
"spread_type": "three_card",
"category": "연애",
"question": "...",
"cards": [...],
"interpretation_json": { ... },
"model": "claude-sonnet-4-6",
"tokens_in": 712, "tokens_out": 942, "cost_usd": 0.0163,
"confidence": "medium"
}
```
**Response:** `{ "id": 123, "created_at": "2026-05-23T07:42:11Z" }`
### 5.3 `GET /api/agent-office/tarot/readings`
페이지네이션 + 필터.
**Query:** `?page=1&size=20&favorite=true&spread_type=three_card&category=연애`
**Response:**
```json
{
"items": [
{ "id": 123, "created_at": "...", "spread_type": "three_card",
"category": "연애", "question": "...", "cards": [...],
"summary": "한 줄 요약", "confidence": "medium", "favorite": 1 }
],
"page": 1, "size": 20, "total": 47
}
```
### 5.4 `PATCH /api/agent-office/tarot/readings/{id}`
즐겨찾기 토글·메모.
**Request:** `{ "favorite": true }` 또는 `{ "note": "메모" }`
### 5.5 `DELETE /api/agent-office/tarot/readings/{id}`
이력 삭제.
### Nginx 라우팅
변경 없음. 기존 `/api/agent-office/` 매칭에 흡수됨.
---
## 6. AI 프롬프트 설계
### SYSTEM_PROMPT
```text
당신은 라이더-웨이트(RWS) 타로 덱의 전통 상징체계에 정통한 타로 리더입니다.
사용자의 질문, 카테고리, 뽑힌 카드 각각의 정·역방향과 위치를 받아 근거 기반으로 해석합니다.
# 해석 원칙
1. 데이터 우선: "참고 카드 정보" 블록의 키워드·기본의미·상징만을 1차 근거로 사용.
외부 변형 의미·다른 덱 해석은 사용하지 않음.
2. 위치 의미 결합: 카드의 의미와 위치(과거/현재/미래 또는 오늘)를 명시적으로 결합해서 해석. evidence에 근거 기록.
3. 카드 간 상호작용 분석 (3장 스프레드):
- 시너지: 같은 슈트, 같은 원소, 메이저 비율, 정·역 흐름
- 충돌·전환: 슈트 충돌(컵-소드, 완드-펜타클), 정→역 전환, 메이저↔마이너 전환
4. 자기 성찰 톤: 운명론 단정 금지. "…할 가능성이 있어 보입니다" 같은 표현.
5. 카테고리 컨텍스트: 동일 카드라도 카테고리에 따라 강조점이 달라야 함.
6. 질문 직접 응답: 사용자 질문을 evidence·advice에서 인용·반영.
# 응답 형식 (strict JSON only — 코드블록 없이 raw JSON)
{
"summary": "전체 흐름 한 단락 (3~4문장)",
"cards": [
{
"position": "<위치 라벨>",
"card": "<card_id>",
"reversed": <bool>,
"interpretation": "3~4문장",
"evidence": {
"card_meaning_used": "참고 카드 정보에서 인용한 키워드·상징",
"position_logic": "왜 이 위치에 이렇게 적용되는지 (1~2문장)",
"category_lens": "카테고리 관점에서 부각되는 면 (1문장)"
},
"advice": "1문장"
}
],
"interactions": [
{ "type": "synergy"|"conflict"|"transition",
"between": ["<card_id>", "<card_id>"],
"explanation": "1~2문장" }
],
"advice": "2문장. interactions를 1개 이상 참조할 것.",
"warning": "역방향·충돌 경계 (없으면 null)",
"confidence": "high"|"medium"|"low"
}
# confidence 판정 기준
- high: 3장 모두 한 방향 서사 또는 명확한 전환
- medium: 2장 일관, 1장 별도 신호
- low: 카드 간 의미 충돌이 커서 명확한 흐름 잡기 어려움
# 금지사항
- 참고 카드 정보에 없는 상징 도입 금지
- 역방향 카드를 정방향처럼 다루지 말 것
- "신비롭게 들리는" 문구로 채우지 말 것 — evidence에 인용·근거 명시
- JSON 외 텍스트 금지
```
### USER_PROMPT_TEMPLATE
```text
# 질문
{question}
# 카테고리
{category}
# 스프레드
{spread_name} ({spread_count}장)
# 뽑힌 카드와 참고 카드 정보
{cards_with_reference_block}
# 작업
위 정보만을 근거로 사용해, 시스템 지침의 JSON 형식으로 응답하세요.
- 각 카드의 evidence.card_meaning_used에는 위 "참고 카드 정보"에서 발췌한 키워드·의미를 그대로 인용.
- interactions는 3장 간 슈트·원소·정역방향 패턴을 분석해 최소 1개 이상 도출.
- confidence는 카드 흐름의 일관성에 따라 정직하게 판정.
```
### cards_with_reference_block 예시
```
## 1. 위치: 과거 | 카드: The Fool (정방향)
- 아르카나: Major (0)
- 원소: 공기 (Air)
- 정방향 키워드: 새로운 시작, 도약, 순수, 자유
- 정방향 의미: 미지의 세계로 내딛는 첫걸음. 계산보다 직관과 신뢰로 시작하는 시기.
## 2. 위치: 현재 | 카드: The Lovers (역방향)
- 아르카나: Major (6)
- 원소: 공기 (Air)
- 역방향 키워드: 관계 갈등, 선택의 어려움
- 역방향 의미: 두 길 사이에서 머뭇거리거나, 이미 내린 선택의 의구심이 커지는 시기.
## 3. 위치: 미래 | 카드: Ten of Cups (정방향)
- 아르카나: Minor (Cups, 10)
- 원소: 물 (Water)
- 정방향 키워드: 정서적 충만, 가족·공동체의 행복
- 정방향 의미: 컵 슈트의 완성 단계. 감정적 만족이 안정된 형태로 자리잡는 시기.
## 추가 컨텍스트
- 메이저:마이너 비율: 2:1 (메이저 우세 → 큰 인생 주제)
- 원소 분포: 공기 2, 물 1
- 정역 흐름: 정→역→정 (일시적 정체 후 회복 가능성)
```
### 응답 검증 (백엔드)
- `cards[].evidence.card_meaning_used`가 비어있으면 → reroll 1회 (max 1 retry, 총 2회 호출)
- `interactions`가 비어있고 spread_type == "three_card"이면 → reroll 1회
- reroll 2회 모두 실패 → 받은 응답 그대로 저장 + log warning + 500 응답
- JSON 파싱 실패 → codeblock 추출 시도 → raw 추출 시도 → 텍스트 그대로 summary에 박고 cards=[]
### 비용
- Sonnet 4.6 입력 $3/1M, 출력 $15/1M
- 회당 입력 ~700, 출력 ~900 토큰
- 회당 비용 ~$0.015~0.022
- 환경변수로 가격 오버라이드: `TAROT_COST_INPUT_PER_M`, `TAROT_COST_OUTPUT_PER_M`
---
## 7. UI 흐름
### 7.1 Route 구조
| Path | 화면 | 컴포넌트 |
|---|---|---|
| `/tarot` | 랜딩 | `Tarot.jsx` |
| `/tarot/today` | 오늘의 카드 | `TodayCard.jsx` |
| `/tarot/reading` | 3장 스프레드 메인 | `Reading.jsx` |
| `/tarot/history` | 마이페이지 | `History.jsx` |
### 7.2 랜딩 (`/tarot`)
- 영상 배경 (`tarot_main_background.mp4` autoplay muted loop, `prefers-reduced-motion` 시 정지 이미지)
- Overlay: `linear-gradient(rgba(15,4,40,.5) → rgba(15,4,40,.85))`
- 헤더 sticky nav: 오늘의 카드 / 타로 리딩 / 가이드 / 히스토리
- Hero: h1 "당신의 오늘을 비추는 타로" + sub + 2 CTA (지금 시작하기 / 오늘의 카드)
- 3-tier 카드: 🌙 오늘의 운세 / 🃏 3장 스프레드 / ✨ AI 해석 (hover lift)
### 7.3 3장 스프레드 (`/tarot/reading`)
3-step 진행, 한 화면 안에서 step 전환.
**Step 1 — 질문 입력 (좌측 panel)**
- 질문 textarea
- 카테고리 chip 선택 (`CATEGORIES` 중 1개)
- 스프레드 라디오 (3장 / 1장)
- [⊃ 카드 셔플하기] 버튼
**Step 2 — 카드 선택 (중앙)**
- 셔플된 카드 16장 그리드 (4×4, 카드 뒷면)
- 카드 hover 시 lift + glow
- 카드 click 시 자리(과거→현재→미래)로 날아가며 flip + 위치 라벨 표시
- 3장 모두 채워지면 [AI 해석 시작] 버튼 활성
**Step 3 — AI 해석 (우측 panel)**
- 좌측: 3장 카드 자리 (카드 click으로 우측 panel 전환)
- 우측 panel: 선택된 카드명 + 키워드 chip + 기본 의미 + AI interpretation + AI evidence(접을 수 있음) + advice
- 하단: 종합 summary + advice + warning(있을 때) + confidence 배지
- 액션: [⭐ 즐겨찾기 토글] / [다시 뽑기]
### 7.4 오늘의 카드 (`/tarot/today`)
- 단일 큰 카드 슬롯 + "운명을 묻다" 버튼
- 카테고리·질문 옵션 (default = "일반 / 없음")
- 클릭 → 1장 추출 + flip 애니메이션 + Claude 호출 → 우측 텍스트로 해석 표시
- 하루 1회 제한은 v1에 없음 (소비 자유)
### 7.5 히스토리 (`/tarot/history`)
- 카드 리스트형: 날짜 · 스프레드 종류 · 질문 · 카드 미니 · 요약 한 줄 · confidence 배지 · ⭐ 토글
- 클릭 → 디테일 모달 (원본 해석 전체)
- 필터: 즐겨찾기만 / 스프레드 종류 / 카테고리
- 페이지네이션 20개씩
### 7.6 공용 컴포넌트
- `TarotCard.jsx` — 단일 카드 (앞·뒷면 토글, props: cardId / reversed / size / clickable)
- `CardGrid.jsx` — 셔플 16장 그리드 (props: deckSlice / onPick)
- `SpreadSlots.jsx` — 위치별 슬롯 (props: spread / cards)
- `InterpretationPanel.jsx` — 우측 패널 (카드 의미 + AI 텍스트 + evidence 접기)
- `useTarotShuffle.js` — FisherYates + 16장 슬라이스 hook
- `useTarotReading.js` — 카드 선택 상태 + reference 블록 빌더 + AI 호출 + 저장 hook
### 7.7 디자인 토큰
- 배경 그라데이션: `#0a0420 → #1a0d2e → #2a1648`
- 금색 액센트: `#d4af37`
- 카드 보더 글로우: `0 0 24px rgba(212, 175, 55, .35)`
- 폰트: 본문 기존 / 타이틀 세리프 (Cormorant Garamond + Noto Serif KR 폴백)
- 네임스페이스: `.tarot-*`
### 7.8 navLinks 추가
- id: `tarot`, label: `Tarot`, path: `/tarot`, subtitle: `ARCANA`,
description: "라이더-웨이트 카드로 오늘과 내일을 비추는 리딩 랩",
icon: sparkle 아이콘, accent: `#a78bfa`
---
## 8. 미디어 자산
### 히어로 영상
- 원본: `source/videos/tarot_main_background.mp4`
- 배포 위치: `web-ui/public/videos/tarot_hero.mp4` (Vite public/ 직접 서빙)
- 권장 압축: 1920×1080 H.264 ≤4Mbps, ≤15초 loop
- 폴백: `prefers-reduced-motion` 또는 `navigator.connection.saveData``tarot_background.png` 정지 이미지
### 배경 이미지
- 원본: `source/images/tarot_page/tarot_background.png`
- 배포 위치: `web-ui/public/images/tarot_background.png`
- 사용: 영상 fallback + 카드 선택 페이지 배경 layer
### 카드 자산
- v1: `web-ui/public/images/tarot/card_back.svg` — 단일 카드 뒷면 SVG (보라+금 + ARCANA TAROT 모노그램)
- v1 카드 앞면: 78장 모두 CSS 카드 디자인 (그라데이션 보더 + 카드명 세리프 + 심볼 이모지)
- 사용자 자산 추가 시: `web-ui/public/images/tarot/cards/<slug>.png` 자동 매핑, 누락 시 `onError` → CSS 폴백
- 정적 파일이므로 이미지 추가 후 별도 빌드 불필요. NAS의 `frontend/images/tarot/cards/`에 robocopy 또는 직접 업로드 → 페이지 reload만으로 즉시 반영
- 사용자가 78장을 한 번에 추가하지 않아도 됨 — 매핑된 것은 이미지로, 안 된 것은 CSS 폴백으로 자연스럽게 혼용
---
## 9. 테스트 전략
### 프론트 (Vitest)
- `data/cards.js` 검증: 78장 총수, slug 중복 없음, 메이저 22 + 마이너 56, 모든 카드 keywords·meaningUpright·meaningReversed 존재
- `useTarotShuffle.js`: FisherYates 정확성 (중복 없음, 분포)
- `useTarotReading.js`: 카드 선택 상태 전환, reference 블록 빌더 단위 테스트
- `TarotCard.jsx`: 정·역 토글, flip 상태, 이미지 onError 폴백
- `Reading.jsx`: step 1→2→3 전환
### 백엔드 (pytest)
- `tarot.py::interpret`: 응답 파싱 (raw JSON / codeblock 감싸진 JSON / 깨진 JSON 폴백)
- `tarot.py::interpret`: evidence·interactions 누락 시 reroll 1회 → 실패 시 그대로 저장
- `db.py`: tarot_readings CRUD 정확성, favorite 필터, 페이지네이션
- Anthropic 호출은 mock — 실제 호출은 통합 테스트 1건만
### 제외
- AI 응답 품질 자체는 자동 테스트 불가 — manual QA로 검수
---
## 10. 배포
1. **백엔드 (agent-office 수정만)**: `git push` → Gitea Webhook → agent-office 재빌드 + 자동 마이그레이션 (`CREATE TABLE IF NOT EXISTS`)
2. **프론트**: 로컬 빌드 → `npm run release:nas` → robocopy (영상·이미지 포함)
3. **docker-compose 변경 없음**
4. **nginx 변경 없음**
5. **`scripts/deploy*.sh` 변경 없음** — 컨테이너 리스트 그대로
---
## 11. 위험·완화
| 위험 | 완화 |
|---|---|
| Claude 응답 JSON 깨짐 | 파싱 폴백 3단(codeblock→raw→텍스트) + reroll 1회 |
| 영상 파일 NAS 트래픽↑ | 압축 후 사이즈 체크 — 5MB 초과 시 사용자 노티 |
| 카드 이미지 미준비로 임팩트↓ | CSS 카드 디자인을 시안 톤(보라+금)에 맞춰 정교화 |
| AI 비용 폭주 | 회당 ~$0.02, 일 50회 가정 시 월 ~$30 — 개인 사용 OK |
| 78장 의미 텍스트 작성 부담 | v1 plan에 별도 "데이터 시드 task" 분리, 메이저 22 우선 + 마이너 키워드만 |
| reference 블록을 프론트가 빌드 → 백엔드 검증 누락 | reference 블록 빈 문자열·길이 단순 검증만 추가 (carot 검증은 v2) |
---
## 12. v1 작업량 추산
- 백엔드: agent-office 추가 ~300 LOC (`agents/tarot.py` + `routes/tarot.py` + `db.py` 마이그레이션 + 테스트)
- 프론트: ~1500~2000 LOC (4 페이지 + 5~7 컴포넌트 + 데이터 + CSS)
- 카드 시드 데이터: 메이저 22장 완성 + 마이너 56장 키워드만 + 짧은 의미 1문장
- 예상 plan task: 15~18개

View File

@@ -0,0 +1,670 @@
# saju-lab 신설 + tarot-lab 분리 — 마이그레이션 설계
**작성일**: 2026-05-25
**상태**: Spec (구현 plan 작성 전)
---
## 1. 목표
1. **saju-lab 신설**: 별도 디렉토리에 있던 `saju-web` (Next.js + Supabase + OpenAI) 프로젝트를 web-backend 모노레포의 한 lab 서비스로 마이그레이션. Python FastAPI + Claude + SQLite 패턴으로 단순화.
2. **tarot-lab 분리**: 현재 `agent-office` 컨테이너 내부 모듈로 들어 있는 tarot 기능을 독립 컨테이너로 분리. agent-office가 가벼워지고 tarot은 자체 라이프사이클을 가짐.
두 작업이 같은 패턴(독립 lab 컨테이너 신설)을 공유하므로 하나의 spec에 담아 순차 구현.
---
## 2. 배경
### 2-1. saju-web 현황
- 위치: `C:\Users\jaeoh\Desktop\workspace\saju-web`
- 스택: Next.js 16, TypeScript, Supabase(OAuth+DB), OpenAI gpt-4o, PortOne 결제, Kakao 공유
- 기능 4종: 사주분석(10토큰), 궁합(15토큰), 토정비결(5토큰), 오늘의 운세
- 핵심 자산: `lib/saju-calculator.ts`, `lib/ai-interpretation.ts`, `lib/daeun-calculator.ts`, `lib/solar-terms.ts` (계산 엔진 ~1500줄)
- 현재 사용 중이 아님. 자산 보존 + 패턴 일치화를 위한 마이그레이션
### 2-2. tarot-lab 현황
- 위치: `agent-office/app/tarot/` (모듈), `agent-office/app/routers/tarot.py`
- DB: `agent_office.db``tarot_readings` 테이블
- API: `/api/agent-office/tarot/*` 6개 endpoint (interpret, save, list, get, patch, delete)
- 21개 단위 테스트 존재
- 문제: agent-office가 점점 비대해짐 (텔레그램·로또·주식·청약·유튜브·타로 모두 한 컨테이너에). tarot은 독립 도메인이라 분리가 자연스러움
### 2-3. 다른 lab 패턴 (참조 기준)
`insta-lab`, `music-lab`, `realestate-lab`은 모두 동일 패턴:
```
<lab>/
├── Dockerfile (python:3.12-slim)
├── requirements.txt
├── pytest.ini
├── tests/
└── app/
├── main.py (FastAPI)
├── config.py
├── db.py (SQLite)
└── <도메인 모듈들>
```
- 인증 없음 (개인 NAS 서비스)
- nginx가 `/api/<name>/`로 라우팅
- docker-compose의 한 항목으로 등록
- Gitea Webhook → deployer가 rsync + docker compose up -d --build
---
## 3. 핵심 결정 사항
| 항목 | 결정 |
|------|------|
| 백엔드 언어 | Python FastAPI (saju 계산 엔진은 TypeScript → Python 포팅) |
| AI 모델 | Claude Sonnet 4.6 (`claude-sonnet-4-6`) + prompt-caching beta. tarot과 일관 |
| DB | SQLite 로컬 (saju-lab은 `saju.db`, tarot-lab은 `tarot.db`) |
| 인증 | 없음 (다른 lab 패턴 일치). saju-web의 Supabase/PortOne/Kakao 제거 |
| saju-lab v1 기능 | 사주 분석 + 궁합 + 사주 결과 내 세운(歲運) (오늘의 운세는 세운으로 통합). 토정비결은 v2 |
| tarot DB 마이그레이션 | 1회성 복사 스크립트 (agent_office.db → tarot.db), cutover 후 agent-office tarot 모듈 완전 제거 |
| saju-lab UI | 시안 기반 신규 (시안 추후 제공, Phase 2 마지막 단계) |
| API prefix | `/api/saju/*`, `/api/tarot/*` (완전 이전) — `/api/agent-office/tarot/*`는 제거 |
| 포트 (내부) | tarot-lab 18250, saju-lab 18300 |
| 진행 순서 | Phase 1 tarot 분리 → Phase 2 saju 신설 |
---
## 4. 디렉토리 구조
```
web-backend/
├── tarot-lab/ # [신설]
│ ├── Dockerfile
│ ├── requirements.txt
│ ├── pytest.ini
│ ├── tests/
│ │ ├── test_db.py # agent-office/tests/test_tarot_db.py 이관
│ │ ├── test_schema.py
│ │ ├── test_pipeline.py
│ │ └── test_routes.py # 6 endpoint (interpret + readings CRUD 5)
│ └── app/
│ ├── __init__.py
│ ├── main.py # FastAPI app + /api/tarot/* 라우터 6개
│ ├── config.py # TAROT_MODEL, TAROT_COST_*, ANTHROPIC_API_KEY, TAROT_TIMEOUT_SEC
│ ├── db.py # tarot.db: 5 CRUD + _tarot_row_to_dict
│ ├── models.py # Pydantic 모델 5개 (TarotCardDraw, TarotInterpretRequest, TarotInterpretResponse, TarotSaveRequest, TarotPatchRequest)
│ ├── pipeline.py # Claude 호출 + reroll 1회
│ ├── prompt.py # SYSTEM_PROMPT + build_user_message
│ └── schema.py # validate_interpretation
├── saju-lab/ # [신설]
│ ├── Dockerfile
│ ├── requirements.txt # fastapi, httpx, anthropic, pydantic, sxtwl(절기/음력)
│ ├── pytest.ini
│ ├── tests/
│ │ ├── fixtures/
│ │ │ └── reference_saju.json # Node.js 원본에서 추출한 입력→출력 쌍
│ │ ├── test_core.py # 천간/지지/십성/십이운성/calculate_saju
│ │ ├── test_solar_terms.py # 24절기
│ │ ├── test_lunar.py # 음력 변환
│ │ ├── test_analysis.py # 오행/신강신약/용신/세운
│ │ ├── test_daeun.py
│ │ ├── test_shinsal.py # 신살/공망/지장간
│ │ ├── test_compatibility.py # 궁합 점수
│ │ ├── test_pipeline.py # Claude mock + reroll
│ │ ├── test_compat_pipeline.py
│ │ ├── test_schema.py
│ │ ├── test_db.py
│ │ └── test_routes.py
│ └── app/
│ ├── __init__.py
│ ├── main.py # FastAPI app
│ ├── config.py # SAJU_MODEL, SAJU_COST_*, ANTHROPIC_API_KEY, SAJU_TIMEOUT_SEC
│ ├── db.py # saju.db: saju_records, compat_records 테이블 + CRUD
│ ├── models.py # SajuRequest, CompatRequest, etc.
│ ├── calculator/
│ │ ├── __init__.py
│ │ ├── constants.py # HEAVENLY_STEMS, EARTHLY_BRANCHES, FIVE_ELEMENTS, HIDDEN_STEMS, TEN_GODS, TWELVE_FORTUNES
│ │ ├── core.py # get_year_ganzi, get_month_ganzi, get_day_ganzi, get_hour_ganzi, get_ten_god, get_twelve_fortune, calculate_saju
│ │ ├── solar_terms.py # get_solar_term_date, get_current_solar_term, get_solar_term_month_branch, get_days_to_next_solar_term — sxtwl 사용
│ │ ├── lunar.py # solar_to_lunar, lunar_to_solar
│ │ ├── shinsal.py # get_hidden_stems, get_all_hidden_stems, analyze_branch_interactions, calculate_shinsal, calculate_gongmang
│ │ ├── analysis.py # calculate_detailed_element_balance, calculate_element_score, analyze_day_master_strength, estimate_yongshin, calculate_seun, perform_full_analysis
│ │ ├── daeun.py # calculate_daeun, get_current_daeun, get_daeun_description
│ │ └── compatibility.py # calculate_compatibility (오행 상생/상극 + 지지 합/충 점수화)
│ ├── interpret/
│ │ ├── __init__.py
│ │ ├── pipeline.py # Claude 호출 + reroll (tarot 패턴)
│ │ ├── compat_pipeline.py
│ │ ├── prompt.py # 사주 12항목 SYSTEM_PROMPT (Claude용 재작성, evidence-based)
│ │ ├── compat_prompt.py # 궁합 SYSTEM_PROMPT
│ │ └── schema.py # validate_saju_interpretation, validate_compat_interpretation
│ └── routers/
│ ├── __init__.py
│ ├── saju.py # POST /api/saju/interpret, /readings CRUD, /current-fortune
│ └── compat.py # POST /api/saju/compat/interpret, /readings CRUD
├── agent-office/ # [수정]
│ ├── app/
│ │ ├── tarot/ # [제거]
│ │ ├── routers/tarot.py # [제거]
│ │ ├── models.py # Tarot* 5개 제거
│ │ ├── db.py # tarot_readings 관련 CRUD 5개 + _tarot_row_to_dict + CREATE TABLE 제거
│ │ └── main.py # include_router(tarot_router.router) 줄 제거
│ ├── tests/ # test_tarot_*.py 4개 제거
│ └── scripts/
│ └── migrate_tarot_to_lab.py # [신설] 1회성 마이그레이션
├── docker-compose.yml # [수정] tarot-lab, saju-lab 추가
├── nginx/default.conf # [수정] /api/tarot/ → tarot-lab, /api/saju/ → saju-lab, /api/agent-office/tarot/ 제거
├── scripts/
│ ├── deploy-nas.sh # [수정] CONTAINERS 배열에 saju-lab, tarot-lab 추가
│ └── deploy.sh # [수정] 5위치 (CLAUDE.md memory의 "배포 스크립트 동기화" 항목 참조)
└── docs/superpowers/specs/
└── 2026-05-25-saju-tarot-lab-migration-design.md # 본 문서
```
**프론트엔드 (`web-ui/`)** — Phase 1·2 양쪽 변경:
```
web-ui/
├── src/
│ ├── api.js # [Phase 1 수정] tarot helpers 6개 URL prefix 변경 + [Phase 2 추가] saju/compat helpers
│ ├── routes.jsx # [Phase 2 수정] /saju, /saju/result, /saju/compatibility, /saju/compatibility/result 라우트
│ ├── components/Icons.jsx # [Phase 2 수정] IconSaju 추가
│ └── pages/
│ ├── tarot/ # [Phase 1] URL prefix만 변경, 그 외 변경 없음
│ └── saju/ # [Phase 2 신설, 시안 받은 후]
│ ├── Saju.jsx
│ ├── SajuForm.jsx
│ ├── SajuResult.jsx
│ ├── Compatibility.jsx
│ ├── CompatibilityForm.jsx
│ ├── CompatibilityResult.jsx
│ ├── data/
│ │ ├── constants.js # 천간/지지/오행 상수 (UI 표시용)
│ │ └── interpretations.js
│ ├── hooks/
│ │ ├── useSajuForm.js
│ │ └── useSajuInterpretation.js
│ └── components/
│ ├── SajuBoard.jsx # 4기둥 시각화
│ ├── ElementChart.jsx# 오행 차트
│ ├── DaeunTimeline.jsx
│ └── InterpretationPanel.jsx
```
---
## 5. Phase 1: tarot-lab 분리
### 5-1. 신규 tarot-lab 컨테이너 생성
**파일 단순 복사 + 모듈 평탄화:**
- `agent-office/app/tarot/__init__.py``tarot-lab/app/__init__.py` (간단화)
- `agent-office/app/tarot/prompt.py``tarot-lab/app/prompt.py`
- `agent-office/app/tarot/pipeline.py``tarot-lab/app/pipeline.py` (import 경로 수정: `..config``.config`, `..models``.models`)
- `agent-office/app/tarot/schema.py``tarot-lab/app/schema.py`
**추출 파일:**
- `tarot-lab/app/config.py`: agent-office의 config.py에서 TAROT_* 환경변수만 추출
```python
import os
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
TAROT_MODEL = os.getenv("TAROT_MODEL", "claude-sonnet-4-6")
TAROT_COST_INPUT_PER_M = float(os.getenv("TAROT_COST_INPUT_PER_M", "3.0"))
TAROT_COST_OUTPUT_PER_M = float(os.getenv("TAROT_COST_OUTPUT_PER_M", "15.0"))
TAROT_TIMEOUT_SEC = int(os.getenv("TAROT_TIMEOUT_SEC", "180"))
TAROT_DATA_PATH = os.getenv("TAROT_DATA_PATH", "/app/data")
TAROT_DB_PATH = os.path.join(TAROT_DATA_PATH, "tarot.db")
```
- `tarot-lab/app/models.py`: agent-office models.py에서 Tarot* 5개만 추출
- `tarot-lab/app/db.py`:
- tarot_readings CREATE TABLE + WAL 활성화
- 5 CRUD (save/get/list/update/delete) + `_tarot_row_to_dict`
- DB 경로는 `TAROT_DB_PATH` (volume mount된 `/app/data/tarot.db`)
- `tarot-lab/app/main.py`:
```python
from fastapi import FastAPI, HTTPException
from .models import (...)
from . import pipeline, db as db_module
app = FastAPI(title="tarot-lab")
@app.on_event("startup")
def _init_db():
db_module.init_db()
# /api/tarot/* 5 endpoints (routers/tarot.py 코드 그대로)
```
**Dockerfile (insta-lab 패턴):**
```dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
```
**requirements.txt:**
```
fastapi==0.115.0
uvicorn[standard]==0.32.0
httpx==0.27.2
pydantic==2.9.2
```
### 5-2. 테스트 이관
- `agent-office/tests/test_tarot_*.py` 4개 파일 → `tarot-lab/tests/test_*.py`
- import 경로 수정 (`from app.tarot.pipeline` → `from app.pipeline`)
- pytest.ini 추가 (`testpaths = tests`, `pythonpath = .`)
- 모두 통과 확인 (21 tests)
### 5-3. DB 마이그레이션 스크립트
`agent-office/scripts/migrate_tarot_to_lab.py`:
```python
"""1회성 — agent_office.db의 tarot_readings를 tarot.db로 복사.
멱등성: 이미 존재하는 id는 SKIP.
실행: docker exec agent-office python /app/scripts/migrate_tarot_to_lab.py
"""
import sqlite3
import os
SRC = os.getenv("AGENT_OFFICE_DB", "/app/data/agent_office.db")
DST = os.getenv("TAROT_DB", "/app/data/tarot.db")
def migrate():
src = sqlite3.connect(SRC)
dst = sqlite3.connect(DST)
dst.execute("""
CREATE TABLE IF NOT EXISTS tarot_readings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
... (agent-office 스키마 그대로)
)
""")
rows = src.execute("SELECT * FROM tarot_readings").fetchall()
cols = [c[0] for c in src.execute("SELECT * FROM tarot_readings LIMIT 1").description]
placeholders = ",".join("?" * len(cols))
cols_str = ",".join(cols)
moved = 0
for r in rows:
cur = dst.execute(f"SELECT 1 FROM tarot_readings WHERE id = ?", (r[0],))
if cur.fetchone() is None:
dst.execute(f"INSERT INTO tarot_readings ({cols_str}) VALUES ({placeholders})", r)
moved += 1
dst.commit()
print(f"migrated {moved} / {len(rows)} rows")
if __name__ == "__main__":
migrate()
```
**볼륨 공유 전략**: tarot-lab의 `/app/data`를 agent-office의 `/app/data`와 같은 NAS 호스트 디렉토리에 마운트. tarot.db는 신규 파일이라 별도 마운트 가능.
### 5-4. docker-compose / nginx / deploy 갱신
**docker-compose.yml**에 추가:
```yaml
tarot-lab:
build: ./tarot-lab
container_name: tarot-lab
restart: unless-stopped
ports:
- "18250:8000"
environment:
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- TAROT_MODEL=${TAROT_MODEL:-claude-sonnet-4-6}
- TAROT_DATA_PATH=/app/data
volumes:
- ${RUNTIME_PATH:-.}/data:/app/data
```
**nginx/default.conf**에 추가, 기존 `/api/agent-office/tarot/`은 제거:
```nginx
location /api/tarot/ {
proxy_pass http://tarot-lab:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_connect_timeout 60s;
}
```
**deploy 스크립트 5위치** (memory의 "배포 스크립트 동기화" 참조):
- `scripts/deploy-nas.sh`의 CONTAINERS 배열
- `scripts/deploy.sh`의 SERVICES, DIRS 배열
- 컨테이너 목록 하드코딩된 모든 위치에 `tarot-lab` 추가 (Phase 1) / `saju-lab` 추가 (Phase 2)
### 5-5. agent-office cutover
마이그레이션 + 데이터 검증 후:
- `agent-office/app/tarot/` 디렉토리 통째로 제거
- `agent-office/app/routers/tarot.py` 제거
- `agent-office/app/main.py`에서 tarot router import + include_router 줄 제거
- `agent-office/app/models.py`에서 `TarotCardDraw`, `TarotInterpretRequest`, `TarotInterpretResponse`, `TarotSaveRequest`, `TarotPatchRequest` 제거
- `agent-office/app/db.py`에서 `save_tarot_reading`, `get_tarot_reading`, `list_tarot_readings`, `update_tarot_reading`, `delete_tarot_reading`, `_tarot_row_to_dict` 제거
- `agent-office/app/db.py`의 CREATE TABLE에서 `tarot_readings` 줄 제거 (또는 idempotent 유지: 기존 DB 호환 위해 CREATE IF NOT EXISTS는 유지하되 코드 경로 제거)
- `agent-office/tests/test_tarot_*.py` 4개 제거
- agent-office pytest 통과 확인
### 5-6. web-ui api.js URL 변경
`web-ui/src/api.js`의 tarot helpers 6개:
- `tarotInterpret`: `/api/agent-office/tarot/interpret` → `/api/tarot/interpret`
- `tarotSaveReading`: `/api/agent-office/tarot/readings` → `/api/tarot/readings`
- `tarotListReadings`: 동일 변환
- `tarotGetReading`: 동일 변환
- `tarotPatchReading`: 동일 변환
- `tarotDeleteReading`: 동일 변환
Phase 1 검증: `npm run dev` → http://127.0.0.1:3007/tarot → 3장 리딩 1회 e2e 동작 확인.
---
## 6. Phase 2: saju-lab 신설
### 6-1. 계산 엔진 포팅 (TypeScript → Python)
**핵심 위험**: 계산 엔진은 ~1500줄 TypeScript로 매년 검증된 코드. Python으로 옮기면서 미세한 버그가 들어가면 모든 사주 해석이 잘못됨.
**대응 전략 — Reference Output 비교 테스트**:
1. saju-web의 `lib/saju-calculator.ts` 코드를 Node.js로 직접 실행 (`node -e "..."`)
2. 알려진 입력 30~50쌍에 대해 `calculateSaju(year, month, day, hour, gender)` + `performFullAnalysis(saju, currentYear)` + `calculateDaeun(...)` 호출 결과를 JSON 파일로 저장
3. `tests/fixtures/reference_saju.json` 형식:
```json
[
{
"input": {"year": 1990, "month": 5, "day": 15, "hour": 14, "gender": "male"},
"expected": {
"saju": {...},
"analysis": {...},
"daeun": [...]
}
},
... (50개)
]
```
4. Python 포팅 후 pytest로 매 입력 → expected와 1:1 비교 (`assert deep_equal(actual, expected)`)
**포팅 순서** (의존성 그래프):
1. `calculator/constants.py` — 모든 상수 (천간 10·지지 12·오행 5·십성·십이운성·지장간·신살)
2. `calculator/solar_terms.py` — `sxtwl` Python 라이브러리 사용 (24절기 + 음력)
3. `calculator/lunar.py` — `sxtwl` 음력↔양력 변환
4. `calculator/core.py` — `get_year_ganzi`, `get_month_ganzi` (절기 기반), `get_day_ganzi`, `get_hour_ganzi`, `get_ten_god`, `get_twelve_fortune`, `calculate_saju`
5. `calculator/shinsal.py` — 지장간(`get_hidden_stems`, `get_all_hidden_stems`), 지지 상호작용(`analyze_branch_interactions`), 신살(`calculate_shinsal`), 공망(`calculate_gongmang`)
6. `calculator/analysis.py` — 오행 점수(`calculate_detailed_element_balance`, `calculate_element_score`), 신강신약(`analyze_day_master_strength`), 용신(`estimate_yongshin`), 세운(`calculate_seun`), 종합(`perform_full_analysis`)
7. `calculator/daeun.py` — `calculate_daeun`, `get_current_daeun`, `get_daeun_description`
8. `calculator/compatibility.py` — 두 사주의 오행 매칭 + 지지 합/충 점수화 → 0~100 점수
각 단계마다 reference test 통과를 게이트로.
### 6-2. Claude 프롬프트 (tarot 패턴 재활용)
**`interpret/prompt.py`** — 사주 12항목 해석:
- 시스템 프롬프트: "당신은 한국 전통 사주명리학 전문가다. 다음 사주 + 분석 결과를 보고, JSON 스키마로 12항목 해석을 작성하라. 각 항목은 evidence 필드를 포함해 어떤 사주 요소에서 결론을 도출했는지 명시하라."
- 12항목: 타고난 기질 / 오행 밸런스 / 지지 상호작용 / 신살 영향 / 재물운 / 직업 적성 / 애정운 / 건강운 / 현재 대운 / 올해 세운 / 인생 황금기 / 종합 조언
- JSON 응답 스키마:
```json
{
"items": [
{ "key": "기질", "title": "...", "content": "...", "evidence": {"saju_element": "...", "reasoning": "..."} },
...
],
"summary": "...",
"advice": "...",
"warning": "...",
"confidence": "high|medium|low"
}
```
- `cache_control: ephemeral`을 system 블록에 적용
**`interpret/compat_prompt.py`** — 궁합 해석:
- 두 사주 + 궁합 점수 + 오행 상생/상극 분석 → JSON 응답
- evidence: 어떤 지지 합/충에서 점수가 나왔는지 명시
**`interpret/schema.py`** — validate 함수:
- `validate_saju_interpretation(parsed)`: items 12개 존재 / 각 evidence 채워졌는지 / confidence 값 검증
- `validate_compat_interpretation(parsed)`: 마찬가지
**`interpret/pipeline.py`** — Claude 호출 (tarot pipeline.py 거의 그대로 복사 + 사주용 prompt/schema 사용):
- max_tokens 2400 (12항목 + 종합이라 더 길음)
- reroll 1회
- latency_ms / tokens 로깅
### 6-3. DB 스키마
`saju-lab/app/db.py`:
```python
SAJU_DB_SCHEMA = """
CREATE TABLE IF NOT EXISTS saju_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
birth_year INTEGER NOT NULL,
birth_month INTEGER NOT NULL,
birth_day INTEGER NOT NULL,
birth_hour INTEGER,
gender TEXT NOT NULL,
calendar_type TEXT DEFAULT 'solar',
saju_data JSON NOT NULL,
analysis_data JSON NOT NULL,
daeun_data JSON NOT NULL,
interpretation_json JSON,
model TEXT,
tokens_in INTEGER DEFAULT 0,
tokens_out INTEGER DEFAULT 0,
cost_usd REAL DEFAULT 0,
latency_ms INTEGER DEFAULT 0,
reroll_count INTEGER DEFAULT 0,
favorite INTEGER DEFAULT 0,
memo TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS compat_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
person_a JSON NOT NULL,
person_b JSON NOT NULL,
score INTEGER NOT NULL,
breakdown JSON NOT NULL,
interpretation_json JSON,
model TEXT,
tokens_in INTEGER DEFAULT 0,
tokens_out INTEGER DEFAULT 0,
cost_usd REAL DEFAULT 0,
favorite INTEGER DEFAULT 0,
memo TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
"""
```
CRUD 함수: `save_saju_record`, `get_saju_record`, `list_saju_records`, `update_saju_record`, `delete_saju_record` + compat 5개.
### 6-4. API 엔드포인트
**`routers/saju.py`**:
| 메서드 | 경로 | 설명 |
|--------|------|------|
| POST | `/api/saju/interpret` | 입력 → 계산 + AI 해석 + DB 저장. 응답에 saju/analysis/daeun/interpretation/reading_id 포함 |
| GET | `/api/saju/readings` | 페이지네이션 목록 (page, size, favorite) |
| GET | `/api/saju/readings/{id}` | 상세 조회 |
| PATCH | `/api/saju/readings/{id}` | favorite, memo 수정 |
| DELETE | `/api/saju/readings/{id}` | 삭제 |
| GET | `/api/saju/current-fortune?reading_id={id}` | 저장된 사주 기반 오늘의 세운 (실시간 계산, AI 호출 없음) |
**`routers/compat.py`**:
| 메서드 | 경로 | 설명 |
|--------|------|------|
| POST | `/api/saju/compat/interpret` | 두 사람 입력 → 두 사주 계산 + 궁합 점수 + AI 해석 + DB 저장 |
| GET | `/api/saju/compat/readings` | 목록 |
| GET | `/api/saju/compat/readings/{id}` | 상세 |
| PATCH | `/api/saju/compat/readings/{id}` | favorite, memo |
| DELETE | `/api/saju/compat/readings/{id}` | 삭제 |
### 6-5. docker-compose / nginx 등록
**docker-compose.yml**에 saju-lab 항목 추가 (tarot-lab과 동일 패턴, 포트 18300).
**nginx/default.conf**에 추가:
```nginx
location /api/saju/ {
proxy_pass http://saju-lab:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_connect_timeout 60s;
}
```
deploy 스크립트 5위치에 `saju-lab` 추가.
### 6-6. web-ui /saju 페이지
**시안 추후 제공** (사용자 확인). 시안 받은 후 tarot 페이지 패턴 따라 구현:
- 입력 폼: 생년월일 + 시간 + 성별 + 양력/음력 (양력 default)
- 결과 페이지: 사주판 시각화 + 오행 차트 + 대운 타임라인 + AI 12항목 아코디언
- 궁합: 두 사람 입력 폼 + 결과 카드
- 인사이트 패널 (tarot의 InterpretationPanel.jsx 패턴 차용)
`api.js`에 helpers 추가:
- `sajuInterpret`, `sajuListReadings`, `sajuGetReading`, `sajuPatchReading`, `sajuDeleteReading`, `sajuCurrentFortune`
- `compatInterpret`, `compatListReadings`, `compatGetReading`, `compatPatchReading`, `compatDeleteReading`
`routes.jsx`에 라우트 추가:
- `/saju` (입력), `/saju/result` (사주 결과), `/saju/compatibility` (입력), `/saju/compatibility/result` (궁합 결과)
`components/Icons.jsx`에 `IconSaju` 추가.
---
## 7. 데이터 흐름
### tarot-lab (Phase 1)
```
[web-ui /tarot/reading]
↓ POST /api/tarot/interpret { cards, question, category, spread_type }
[nginx /api/tarot/ → tarot-lab:8000]
↓ pipeline.interpret() → Claude API
↓ validate + reroll
[tarot-lab]
↓ POST /api/tarot/readings { ... save body }
↓ db.save_tarot_reading() → tarot.db INSERT
← { id, created_at }
```
### saju-lab (Phase 2)
```
[web-ui /saju/result]
↓ POST /api/saju/interpret { year, month, day, hour, gender, calendarType }
[nginx /api/saju/ → saju-lab:8000]
↓ calculator.calculate_saju() → SajuData
↓ calculator.perform_full_analysis() → SajuAnalysis
↓ calculator.calculate_daeun() → DaeunPillar[]
↓ interpret.pipeline.interpret() → Claude API
↓ validate + reroll
↓ db.save_saju_record() → saju.db INSERT
← { saju, analysis, daeun, interpretation, reading_id, cost_usd, latency_ms }
[web-ui]
```
### saju-lab 궁합
```
[web-ui /saju/compatibility/result]
↓ POST /api/saju/compat/interpret { person_a: {...}, person_b: {...} }
[saju-lab]
↓ calculate_saju(person_a) + calculate_saju(person_b)
↓ compatibility.calculate_compatibility(saju_a, saju_b) → { score, breakdown }
↓ interpret.compat_pipeline.interpret() → Claude API
↓ db.save_compat_record()
← { saju_a, saju_b, score, breakdown, interpretation, reading_id }
```
---
## 8. 에러 처리
| 시나리오 | 처리 |
|---------|------|
| Claude API HTTP error | `TarotError` / `SajuError` raise → FastAPI 500 |
| Claude JSON 파싱 실패 | `_extract_json` codeblock 스트립 + 첫 `{` / 마지막 `}` 추출. 실패 시 reroll |
| validate 실패 (필수 필드 누락) | reroll 1회. 그래도 실패 시 `_Error("검증 실패")` raise → 500 |
| 계산 엔진 입력 오류 (잘못된 날짜 등) | Pydantic validation → 422 |
| DB 락 | sqlite WAL 모드. 짧은 retry 없이 raise (드물게 발생) |
| 마이그레이션 스크립트 중복 실행 | `INSERT OR IGNORE` 패턴 / 멱등 |
---
## 9. 테스트 전략
### tarot-lab
- 기존 21 tests 이관 + import 경로 수정 후 100% 통과
### saju-lab — 계산 엔진
- **Reference output 비교가 핵심**. 30~50개 입력 → JSON 저장 → Python 결과와 deep_equal 비교
- 각 모듈 단위 테스트 (constants, solar_terms, lunar, core, shinsal, analysis, daeun, compatibility)
- 회귀 방지: 추가 입력 케이스 발견 시 fixtures에 추가
### saju-lab — Claude 파이프라인
- httpx mock (respx 또는 monkeypatch) 사용 (tarot 패턴 그대로)
- validate / reroll / JSON 파싱 폴백 / cost 계산 검증
### saju-lab — 라우터
- TestClient 기반 e2e (FastAPI 표준)
- DB tmp_path fixture
### 통합 검증 (Phase 1, Phase 2 끝)
- `npm run dev` + http://127.0.0.1:3007/tarot에서 리딩 1회 (Phase 1)
- 같은 곳에서 /saju에서 사주 + 궁합 1회씩 (Phase 2, 시안 적용 후)
---
## 10. 환경변수 정리
**tarot-lab 신규 환경변수** (docker-compose env):
- `ANTHROPIC_API_KEY` (필수)
- `TAROT_MODEL` (기본 `claude-sonnet-4-6`)
- `TAROT_COST_INPUT_PER_M` (기본 3.0)
- `TAROT_COST_OUTPUT_PER_M` (기본 15.0)
- `TAROT_TIMEOUT_SEC` (기본 180)
- `TAROT_DATA_PATH` (기본 `/app/data`)
**saju-lab 신규 환경변수**:
- `ANTHROPIC_API_KEY` (필수)
- `SAJU_MODEL` (기본 `claude-sonnet-4-6`)
- `SAJU_COST_INPUT_PER_M`, `SAJU_COST_OUTPUT_PER_M`
- `SAJU_TIMEOUT_SEC`
- `SAJU_DATA_PATH`
---
## 11. 마이그레이션 위험 + 완화
| 위험 | 영향 | 완화 |
|------|------|------|
| TS→Python 포팅 미세 차이 (예: 절기 일자 1일 차이) | 모든 사주 결과 변형 | Reference output 비교 테스트 50건 + sxtwl로 절기 동일 알고리즘 사용 |
| tarot.db 마이그레이션 중 데이터 손실 | 사용자 리딩 이력 손실 | 멱등 스크립트 + 검증 후 cutover. agent-office의 원본 데이터는 cutover 후에도 30일 유지 (테이블만 DROP 안 함) |
| 두 컨테이너 추가로 NAS 메모리 압박 | 다른 서비스 OOM | python:3.12-slim 기반 ~150MB. 18GB RAM 여유 충분 |
| API prefix 변경 missed 위치 (web-ui에서 일부 호출만 변경) | 일부 페이지 404 | grep 검색 (`/api/agent-office/tarot`) 후 일괄 변경 |
| nginx restart 누락 | 라우팅 안 됨 | docker compose up -d --build → nginx 컨테이너 재시작 자동 (deployer 패턴) |
| saju-web 코드 사라짐 (참조 못 하게 됨) | 검증 어려움 | saju-web 디렉토리는 그대로 유지 (포팅 끝나도 archive로 보존) |
---
## 12. 향후 (v2, 본 spec 밖)
- 토정비결 (12개월 운세) — saju-lab v2에서 추가
- 정밀 음력 + 윤달 처리 검증
- 자동 마이그레이션 스크립트의 ON DELETE CASCADE 검토 (이력 정합성)
- agent-office의 tarot 관련 텔레그램 명령이 있다면 그것도 saju-lab에 추가할지 검토
- saju-lab UI 디자인 시안 확정 후 별도 짧은 plan으로 진행
---
## 13. 참고 자료
- saju-web/PROJECT_OVERVIEW.md — 마이그레이션 원본 명세
- web-backend/CLAUDE.md — lab 서비스 패턴 참조
- agent-office/app/tarot/, agent-office/app/routers/tarot.py — Phase 1 이관 원본
- web-backend/insta-lab/, music-lab/, realestate-lab/ — Dockerfile + 디렉토리 구조 참조 패턴
- sxtwl (Python 만세력 라이브러리) — solarlunar 대체
- docs/superpowers/specs/2026-05-23-tarot-lab-design.md — 본 작업의 직전 spec (tarot-lab 원본 설계)

View File

@@ -0,0 +1,387 @@
# saju-lab UI v1 — 호령 사주 페이지 설계
**작성일**: 2026-05-26
**상태**: Spec (구현 plan 작성 전)
**전제**: saju-lab 백엔드 완성 (474 tests, SHA 8123f75) + web-ui Task 28 (api helpers + placeholder pages)
---
## 1. 목표
사용자 시안 4종(`source/images/saju_page/horyung_saju_main.png`, `_today.png`, `_gunghab.png`, `_saju.png`) + 캐릭터 시트(`source/characters/horyung.png`) + 컬러시트(`saju_color_sheet.png`) 기반으로 web-ui `/saju/*` 페이지를 호령 마스코트와 함께 구축한다.
v1 범위: **메인 / 오늘의 운세 / 사주풀이** 3개 페이지. 궁합은 v2 placeholder.
---
## 2. 결정된 핵심 사항
| 항목 | 결정 |
|------|------|
| 캐릭터 자산 | horyung.png + saju_color_sheet.png에서 PNG 6개 추출 |
| 백엔드 확장 | saju-lab에 fortune_scores + lucky + monthly_flow 산출 추가 |
| 입력 흐름 | 메인에서 사주 1회 입력 → reading_id를 다른 페이지 URL query로 공유 |
| v1 페이지 | 메인 + 사주풀이 + 오늘운세 (궁합은 v2) |
| 반응형 | 데스크탑(1280+) 우선 + 태블릿 그라데이션 |
| 컬러 | 시안 추출 — 크림 베이스 + 다크 네이비 + 골드 + 살구 + 청록 |
| 폰트 | Pretendard (본문) + Noto Serif KR (큰 제목, Google Fonts) |
| CSS 격리 | `.saju-page` scope (다른 페이지에 새지 않음) |
---
## 3. 백엔드 확장 (saju-lab)
### 3-1. 신설 모듈
**`saju-lab/app/calculator/fortune_scores.py`** — 4 카테고리 점수:
```
calculate_fortune_scores(saju, analysis, current_year) → {
wealth: 0-100 (재물운)
romance: 0-100 (연애운)
social: 0-100 (인간관계)
career: 0-100 (직장운)
overall: 0-100 (가중평균: wealth*0.3 + career*0.3 + romance*0.2 + social*0.2)
}
```
알고리즘 (각 base 60에서 가산/감산, clamp 0-100):
- **wealth**: +정재 강도 / +편재 강도 / +식상→재 통로 / -비겁 강도 / +세운재성
- **romance**: +일지 합 / +정관·정재 균형 / -일지 충 / +세운 도화살
- **social**: +인성 / +비겁 적정 / +식상 / +격국 균형 / +천을귀인
- **career**: +정관 강도 / +편관 제어 / +일간 신강 / +세운 관성
**`saju-lab/app/calculator/lucky.py`** — 럭키 데이터:
```
calculate_lucky(saju, analysis, target_date) → {
color: [str, str] # 용신 오행 컬러 1~2개 (예: ["청록", "녹색"])
number: int 1-9 # (일진 천간 idx + 시진 천간 idx) % 9 + 1
direction: str # 용신 오행 방향 (동/남/중앙/서/북)
good_signs: [str] # 세운 천간이 일간 재성 → "재물 기회" 등
warnings: [str] # 세운 지지가 일지 충 → "대인 갈등 주의"
}
```
오행→컬러/방향 매핑은 정적 dict. 럭키 숫자는 일진+시진(시간 미상 시 일진만)으로 산출.
**`saju-lab/app/calculator/monthly_flow.py`** — 12개월 운세 흐름:
```
calculate_monthly_flow(saju, year) → [
{month: 1, stem: "壬", branch: "寅", score: 65, label: "변동"},
{month: 2, stem: "癸", branch: "卯", score: 70, label: "성장"},
... 12 entries
]
```
각 월: 해당 월의 60갑자(寅월부터 12월 사이클) → 일간 관계(상생/상극/충/합) → score 0-100 + label(`변동`/`성장`/`안정`/`도전`/`정체` 등).
### 3-2. `routers/saju.py` 응답 확장
`SajuInterpretResponse`에 3 필드 추가:
```python
fortune_scores: dict # {wealth, romance, social, career, overall}
lucky: dict # {color, number, direction, good_signs, warnings}
monthly_flow: list[dict] # 12 entries
```
`interpret_saju_endpoint`에서 계산 + DB 저장 + 응답 포함.
### 3-3. `db.py` 스키마 마이그레이션
`saju_records` 테이블에 ALTER TABLE로 3 컬럼 추가 (idempotent):
- `fortune_scores_json TEXT`
- `lucky_json TEXT`
- `monthly_flow_json TEXT`
`init_db()`에 try/except OperationalError 패턴 (이미 존재하면 skip).
`_saju_row_to_dict`에서 3 컬럼 JSON 파싱하여 응답에 포함.
### 3-4. 테스트
- `test_fortune_scores.py` — 5-8 case (정재 강함 → wealth 80+, 일지 충 → romance 50-, clamp 검증)
- `test_lucky.py` — 5 case (오행→컬러/방향 매핑, 럭키 숫자 1-9 범위)
- `test_monthly_flow.py` — 3 case (12 entries 정확, 일간 충 월 score 낮음)
기존 30 reference fixture 비교는 영향 없음 (응답에 새 필드만 추가).
---
## 4. 프론트엔드 구조 (web-ui)
### 4-1. 디렉토리
```
web-ui/
├── public/images/saju/
│ ├── horyung/
│ │ ├── horyung-front.png # 시안 main hero용 (정면, 큰 사이즈)
│ │ ├── horyung-bust.png # 작은 카드용 (가슴샷)
│ │ ├── horyung-greeting.png # 인사 표정 (메인 좌상단)
│ │ ├── horyung-thinking.png # 생각하는 표정 (사주풀이)
│ │ ├── horyung-pointing.png # 가르치는 표정 (오늘운세)
│ │ └── horyung-happy.png # 기쁜 표정 (점수 높을 때)
│ ├── frame-cloud.png # 시안의 한국화 산 배경 (hero용)
│ ├── pattern-cloud.svg # 한국 전통 구름 패턴
│ └── icons/
│ ├── icon-today.svg
│ ├── icon-heart.svg
│ └── icon-book.svg
└── src/pages/saju/
├── Saju.css # 모든 saju 페이지 공통 스타일 (격리)
├── data/
│ └── constants.js # 4 카테고리 메타, 컬러 토큰
├── hooks/
│ ├── useSajuForm.js
│ └── useSajuReading.js # reading_id → fetched data + 캐시
├── components/
│ ├── HoryungMascot.jsx
│ ├── SajuNav.jsx # 시안 상단 네비게이션 (호령사주 로고 + nav)
│ ├── SajuInputForm.jsx
│ ├── ActionCard.jsx # 3 카드 (오늘운세/궁합/사주풀이)
│ ├── ScoreCard.jsx # 카테고리 점수 카드
│ ├── FortuneRing.jsx # 종합점 ring SVG
│ ├── LuckyBox.jsx # 럭키 컬러/숫자/방향
│ ├── ElementBarChart.jsx # 오행 5색 가로 바
│ ├── SajuPillars.jsx # 4기둥 8자 표시
│ ├── MonthlyFlow.jsx # 12개월 운세 흐름 차트
│ ├── InterpretAccordion.jsx # AI 12항목 아코디언
│ └── HoryungQuote.jsx # 호령 말풍선
├── Saju.jsx # 메인 페이지
├── SajuResult.jsx # 사주풀이 결과
├── Today.jsx # 오늘의 운세
└── Compatibility.jsx # v2 placeholder
```
### 4-2. 라우팅 (변경 없음, Task 28에서 등록됨)
| 경로 | 컴포넌트 | reading_id 필요 |
|------|---------|----------------|
| `/saju` | Saju.jsx (메인) | 아니오 |
| `/saju/result?rid=N` | SajuResult.jsx | 예 |
| `/saju/today?rid=N` | Today.jsx | 예 |
| `/saju/compatibility` | Compatibility.jsx (placeholder) | — |
기존 `/saju/result` 등은 Task 28에서 placeholder로 등록 — 본 task에서 실제 컴포넌트로 교체.
### 4-3. 데이터 흐름
```
[사용자] → /saju (메인)
↓ 사주 입력
↓ sajuInterpret(form)
↓ POST /api/saju/interpret
[saju-lab] 계산 + Claude AI + fortune_scores + lucky + monthly_flow
↓ 응답: { reading_id, ... 풍부한 데이터 }
[프론트] navigate(`/saju/result?rid=${reading_id}`)
[사주풀이 페이지] /saju/result?rid=N
↓ useSajuReading(N) → sajuGetReading(N)
↓ GET /api/saju/readings/N
↓ saju_data + analysis_data + daeun_data + interpretation_json + fortune_scores + lucky + monthly_flow
↓ 렌더
[오늘운세] /saju/today?rid=N — 사용자가 메인 또는 사주풀이에서 클릭
↓ useSajuReading(N) + sajuCurrentFortune(N)
↓ 렌더: ring + 4 score + lucky + 오늘 세운
```
### 4-4. 호령 마스코트
`HoryungMascot.jsx``pose` prop으로 6개 PNG 중 선택.
```jsx
<HoryungMascot pose="greeting" size="lg" /> // 메인 좌상단
<HoryungMascot pose="thinking" size="md" /> // 사주풀이
<HoryungMascot pose="pointing" size="md" /> // 오늘운세
<HoryungMascot pose="happy" size="sm" /> // 점수 높을 때 (옵션)
```
`onError` 핸들러로 PNG 누락 시 silent (디자인 깨짐 방지).
### 4-5. CSS 격리 + 컬러 시스템
`Saju.css`:
```css
.saju-page {
/* 베이스 */
--saju-cream: #FAF6EE;
--saju-paper: #F2EAD8;
--saju-ink: #2E2D45; /* 다크 네이비 (헤더, 본문) */
--saju-ink-deep: #1F1D38;
/* 액센트 */
--saju-gold: #D4A574;
--saju-gold-deep: #B5874E;
--saju-apricot: #C58F76;
--saju-rose: #D9A2A6;
--saju-jade: #4B7065;
--saju-violet: #6A5285;
/* 카테고리 (3 ActionCard) */
--saju-today-bg: #4B7065; /* 청록 (오늘운세) */
--saju-gunghab-bg: #A8736E; /* 살구 (궁합) */
--saju-saju-bg: #4F4A78; /* 보라 (사주풀이) */
/* 점수 카테고리 (4 ScoreCard) */
--saju-wealth: #D4A574; /* 골드 (재물) */
--saju-romance: #D9A2A6; /* 로즈 (연애) */
--saju-social: #4B7065; /* 청록 (인간관계) */
--saju-career: #6A5285; /* 보라 (직장) */
min-height: 100vh;
background: var(--saju-cream);
color: var(--saju-ink);
font-family: 'Pretendard', sans-serif;
}
.saju-page .saju-h1,
.saju-page .saju-h2 {
font-family: 'Noto Serif KR', serif;
font-weight: 700;
letter-spacing: -0.02em;
}
```
모든 saju 컴포넌트의 클래스는 `saju-` prefix로 시작 (다른 페이지와 격리).
### 4-6. 반응형
- 기준: `1280px+` 데스크탑 (시안 그대로)
- `768~1280px` 태블릿: hero 컬럼 → 세로 스택, action card 3 → 2x2 grid
- `~768px` 모바일: 호령 작게 (size="sm"), action card 1열, 입력 폼 세로
`@media` 쿼리로 `Saju.css` 안에서 처리.
### 4-7. 폰트
`index.html`에 Google Fonts preconnect + Noto Serif KR 추가:
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+KR:wght@500;700&display=swap" rel="stylesheet">
```
큰 제목(h1/h2)만 Noto Serif KR, 본문은 기존 Pretendard.
---
## 5. 컴포넌트별 세부
### 5-1. Saju.jsx (메인)
레이아웃 (시안 horyung_saju_main.png):
- 상단: SajuNav (호령사주 로고 + 4 nav + "사주풀이 시작하기" 버튼)
- Hero: 좌측 호령(front + greeting 박스) / 우측 큰 h1 + 3 ActionCard
- Bottom: 좌측 통계 미리보기 / 우측 SajuInputForm
폴백: reading_id가 query에 있으면 (`/saju?rid=N`) 통계 영역에 미리보기 점수 + 마지막 분석 결과로.
### 5-2. SajuResult.jsx (사주풀이)
레이아웃 (시안 horyung_saju_saju.png):
- 상단: SajuNav + "사주풀이" 큰 타이틀 + 기본 정보 (이름, 생년월일) + 호령(thinking)
- 중단 좌: 사주 4기둥 표 (SajuPillars) + 오행 바 차트 (ElementBarChart)
- 중단 우: 호령의 비전 박스 (HoryungQuote — interpretation의 summary 발췌)
- 하단: 성격강점 / 직업운 / 재물운 / 연애운 4 카드 (12항목 중 추출) + 12개월 운세 흐름 (MonthlyFlow)
- 우하단: 이번 달 핵심 결정 포인트 (interpretation_json.advice)
데이터: `useSajuReading(rid)` → saju + analysis + daeun + interpretation_json + monthly_flow
### 5-3. Today.jsx (오늘의 운세)
레이아웃 (시안 horyung_saju_today.png):
- 상단: SajuNav + "오늘의 운세" 큰 타이틀 + 호령(pointing) + 풍경 배경
- 중단: FortuneRing(overall) + 4 ScoreCard(wealth/romance/social/career) + LuckyBox
- 하단: 행운 알림 / 위험 알림 (lucky.good_signs, lucky.warnings)
- 최하단: 다음 페이지 (사주풀이 / 궁합보기) 버튼
데이터: `useSajuReading(rid)` → fortune_scores + lucky + `sajuCurrentFortune(rid)` → 오늘 세운
### 5-4. Compatibility.jsx (v2 placeholder)
```jsx
export default function Compatibility() {
return (
<div className="saju-page saju-page--compat-stub">
<SajuNav />
<div className="saju-stub">
<HoryungMascot pose="thinking" />
<h2>궁합보기는 만나요!</h2>
<p> 사람의 사주를 함께 풀어보는 기능을 준비 중입니다.</p>
<Link to="/saju">메인으로 돌아가기</Link>
</div>
</div>
);
}
```
백엔드 `/api/saju/compat/*`는 이미 동작하지만 UI는 v2에서 정식 구현.
---
## 6. 에러 처리
| 시나리오 | 처리 |
|---------|------|
| 메인 입력 폼 — 잘못된 날짜 | Pydantic 422 → 폼에서 "올바른 날짜를 입력해주세요" |
| Claude API 504/500 | "잠시 후 다시 시도해주세요" + 사용자 입력 보존 |
| reading_id 무효(404) | "사주 결과를 찾을 수 없습니다" + 메인으로 돌아가기 버튼 |
| 호령 PNG 누락 | onError로 silent hide (디자인은 살짝 빈 자리, 동작은 정상) |
| fortune_scores 산출 실패 (예외) | 기본값 60/60/60/60으로 fallback + 콘솔 warn |
---
## 7. 테스트 전략
### 백엔드
- fortune_scores: 5-8 unit test (각 카테고리 high/low 케이스 + clamp)
- lucky: 5 unit test (오행→컬러 매핑, 숫자 1-9 범위, 방향)
- monthly_flow: 3 unit test (12 entries, 점수 범위, 충/합 영향)
- 기존 30 reference fixture 비교: 영향 없음 (응답 추가 필드만)
### 프론트
- 컴포넌트 단위 테스트는 v1 범위 밖 (수동 e2e 검증)
- 로컬 e2e: `npm run dev` + 입력 → 사주풀이/오늘운세 1회 정상 동작
- 호령 6 PNG 모두 존재 확인 (수동)
- 반응형 — Chrome DevTools 1280/1024/768 3가지 확인
---
## 8. 위험 + 완화
| 위험 | 완화 |
|------|------|
| 호령 PNG crop 좌표가 부정확 | plan 단계에서 PIL로 trial-and-error + 사용자 검수. onError로 silent fallback |
| fortune_scores 점수 산식이 명리학적 부정확 | v1은 plausible default + base 60으로 보수적. 실사용 피드백으로 튜닝 |
| 시안 색상과 미세 차이 | 시안 PNG에서 color picker로 hex 추출 후 CSS variable로 명시 |
| Noto Serif KR Google Fonts 로드 지연 | display=swap로 폰트 fallback (Pretendard) → 깜빡임 최소화 |
| reading_id 만료(DB row 삭제) | 404 graceful fallback + 새 입력 유도 |
| Claude 응답 시간 초과 | nginx timeout 300s + 폼에서 progress 표시 |
---
## 9. 향후 (v2, 본 spec 밖)
- 궁합보기 페이지 정식 구현 (시안 horyung_saju_gunghab.png 기반)
- 상담안내 페이지 (nav에 있는 메뉴)
- 즐겨찾기/히스토리 페이지 (sajuListReadings 활용)
- 사주풀이 PDF 내보내기
- 호령 캐릭터 lottie 애니메이션 (정적 PNG → 동적)
---
## 10. 참고
- 시안: `source/images/saju_page/horyung_saju_{main,today,gunghab,saju}.png`
- 캐릭터: `source/characters/horyung.png`
- 컬러시트: `source/images/saju_page/saju_color_sheet.png`
- 백엔드: web-backend/saju-lab/ (SHA 8123f75)
- 직전 spec: `docs/superpowers/specs/2026-05-25-saju-tarot-lab-migration-design.md` (saju-lab 백엔드 설계)
- web-ui Task 28 commit: e634cde (api.js + routes + IconSaju + placeholder pages)

View File

@@ -0,0 +1,415 @@
# 호령 사주 UI v2 리디자인 — 디자인 문서
- **상태**: Spec 단계 (brainstorming 종료, plan 대기)
- **작성일**: 2026-05-26
- **대상 저장소**: `web-ui` (React + Vite, `/saju` 라우트 트리)
- **참조 디자인 소스**: `C:\Users\jaeoh\Desktop\workspace\source\images\saju_page\사주풀이\` (백호 사주도사 프로토타입: babel/standalone JSX 11 파일 + styles.css)
- **선행 시스템**: saju-lab UI v1 (`web-ui/src/pages/saju/`, 호령 캐릭터 7 PNG 자산 포함)
- **백엔드 변경 없음**: saju-lab `/api/saju/*` API는 그대로 사용
---
## 1. 목적 & 성공 기준
### 목적
v1의 임시 구조(컴포넌트 12개 직렬 배치, 단일 SajuNav)를 한국 전통 명리학 미학에 충실한 **풀 디자인 시스템**으로 교체. 4 라우트(`/saju`, `/saju/result`, `/saju/today`, `/saju/compatibility`)를 동시 리디자인하고 신규 `/saju/me` placeholder 추가.
### 성공 기준
1. 4 라우트가 새 디자인 토큰/컴포넌트/네비로 일관되게 동작
2. 1024px breakpoint에서 모바일(BottomNav) ↔ 데스크탑(헤더 nav) 자동 전환
3. `useSajuReading` hook + 기존 API 호출 0개 변경, 응답 매핑만 추가
4. 호령 PNG 7개 자산 100% 재사용 (variant API로 추상화)
5. v1 컴포넌트 12개 + SajuNav 제거 — 두 디자인 시스템 동시 유지 X
6. 시각 QA: 골든 패스(메인→입력→result→today→compatibility) + 1024px ± 경계 + me placeholder 모두 정상
---
## 2. 미학 방향 (Aesthetic Direction)
**컨셉**: *한국 전통 명리학 + 차분한 호령 캐릭터*. 디자인 프로토타입이 이미 강하게 commit한 방향을 충실히 옮긴다.
### 2.1 타이포
- **Display**: Nanum Myeongjo (weight 800, `letter-spacing: -0.02em`) — 페이지 타이틀, h1, 큰 한자
- **Body**: Nanum Gothic (weight 400/700, `letter-spacing: -0.01em`) — 본문, 버튼, 캡션
- **Fallback serif**: Gowun Batang
- Google Fonts CSS 로드는 `web-ui/index.html`에 link 추가 (페이지 import 대신 — preconnect로 LCP 개선)
- Inter/Roboto/system-ui 같은 generic AI sans는 사용 금지
### 2.2 컬러 시스템 (CSS 토큰)
디자인 프로토타입 `styles.css``:root` 변수를 그대로 도입:
| 토큰 | 값 | 용도 |
|---|---|---|
| `--navy` | `#1F2A44` | dominant body color, dark surface |
| `--navy-deep` | `#141B30` | night-bg gradient 하단 |
| `--navy-soft` | `#2E3B5A` | 보조 dark |
| `--ivory` | `#F7F2E8` | paper 배경, dark surface 위 텍스트 |
| `--ivory-soft` | `#FBF7EF` | 카드 배경 |
| `--ivory-warm` | `#F0E9D9` | 액센트 배경 |
| `--gold` | `#D4AF37` | sharp accent, 보더, ornament |
| `--gold-soft` | `#E8C76B` | 활성 상태 텍스트 |
| `--gold-dim` | `#B89530` | 비활성 골드 |
| `--green` / `--green-soft` / `--green-bg` | 한국 전통 녹색 | 궁합 화면 accent |
| `--purple` / `--purple-soft` / `--purple-bg` | `#6A4C7C` 계열 | 사주풀이 accent |
| `--pink` / `--pink-deep` / `--pink-bg` | `#F2C7CD` 계열 | 보조 |
| `--gray` / `--gray-soft` | `#6B6B6B` / `#9A968D` | 메타 텍스트 |
| `--gray-line` / `--gray-line-strong` | 보더 |
| `--shadow-card` / `--shadow-pop` / `--shadow-dark` | 그림자 단계 |
**화면별 accent 단일 색** (팔레트 골고루 분산 안티패턴 회피):
- 홈 (`/saju`) — navy
- 오늘 (`/saju/today`) — gold
- 궁합 (`/saju/compatibility`) — green
- 사주풀이 (`/saju/result`) — purple
- 마이 (`/saju/me`) — gray
### 2.3 배경 텍스처
- `.paper-bg` — radial gold/purple wash + 페이퍼 노이즈 (사주풀이, 오늘, 궁합, 마이)
- `.night-bg` — 밤하늘 gradient (홈 hero)
- `.mt-wash` — 데스크탑 헤더 산수화 SVG decoration (좌하단 + 우하단 산 outline, opacity 0.35)
- 단색 배경은 카드 내부에서만 (`--ivory-soft`)
### 2.4 차별화 요소 (UNFORGETTABLE)
1. **OrnateFrame** — 한국 전통 더블 보더 + 4 코너 꺽쇠 SVG (`<path d="M0 4 L0 0 L4 0" />`)
2. **MascotBubble** — 호령 발자국이 매 말풍선마다 `paw-bob` 2.4s ease infinite로 미세 bobbing
3. **OrnamentBloom** — 골드 꽃봉오리 SVG가 모든 섹션 타이틀 좌우 ornament
4. **TopRibbon** — 구름 SVG ribbon이 페이지 상단에 은은히
5. **CharBox** — 사주명식 천간/지지 한자 Nanum Myeongjo 800 + 원소별 색 (목=green, 화=red, 토=earth, 금=gold, 수=blue)
### 2.5 모션
- `screenIn` 0.3s `cubic-bezier(0.16,1,0.3,1)` translateY(6→0) — 라우트 진입 fade-up
- `paw-bob` 2.4s ease infinite — 호령 발자국
- BottomNav 활성 항목 배경 색 전환 0.2s
- 과한 마이크로 인터랙션 X — "페이지당 1 hero 모션" 원칙
---
## 3. 아키텍처 & 라우팅
### 3.1 라우트 매핑
| 라우트 | 디자인 화면 | 파일 | 상태 |
|---|---|---|---|
| `/saju` | HomeScreen | `Saju.jsx` | **교체** (v1 메인) |
| `/saju/result?rid=N` | SajuScreen (4탭) | `SajuResult.jsx` | **교체** (v1 결과) |
| `/saju/today?rid=N` | TodayScreen | `Today.jsx` | **교체** (v1 오늘) |
| `/saju/compatibility` | MatchScreen | `Compatibility.jsx` | **placeholder → 본격 구현** |
| `/saju/compatibility/result?cid=N` | (디자인에 없음) | `CompatibilityResult.jsx` | 디자인 토큰만 라이트 리스타일 |
| `/saju/me` | MeScreen placeholder | `Me.jsx` | **신규** |
라우트 수: 5 신규 진입점 + 1 sub. `routes.jsx``/saju/me` lazy import 추가.
### 3.2 디렉토리 구조
```
web-ui/src/pages/saju/
├── _shell/ # v2 디자인 시스템 + 네비
│ ├── tokens.css # CSS 변수 정의
│ ├── shell.css # paper-bg, night-bg, mt-wash, OrnateFrame, screenIn
│ ├── useViewportMode.js # 1024px breakpoint hook
│ ├── BottomNav.jsx # 모바일 5항목 (home/today/match/saju/me)
│ ├── DesktopHeader.jsx # 데스크탑 horizontal nav + 로고
│ ├── Mascot.jsx # variant API: full|head|upper|greeting|thinking|pointing|happy
│ ├── MascotBubble.jsx # tone: ivory|navy|purple|green
│ ├── OrnateFrame.jsx
│ ├── OrnamentBloom.jsx
│ ├── TopRibbon.jsx
│ ├── TitleBlock.jsx
│ ├── PrimaryButton.jsx # gold inset shadow
│ ├── GhostButton.jsx
│ ├── Icons.jsx # 5 nav icon + IconPaw/IconChevron/IconSparkle/IconYinYang
│ └── helpers/
│ ├── daeunLabel.js # age → 성장기/학습기/...
│ ├── deriveTraits.js # elements + sipsin → 6 성향
│ └── hexA.js # hex → rgba(x,x,x,a)
├── Saju.jsx # routes 진입, useViewportMode → 분기
├── SajuResult.jsx
├── Today.jsx
├── Compatibility.jsx
├── CompatibilityResult.jsx
├── Me.jsx
└── views/ # mobile/desktop 컴포넌트 분리
├── home.mobile.jsx
├── home.desktop.jsx
├── saju.mobile.jsx # 4탭 (basic/chart/flow/traits)
├── saju.desktop.jsx # 데스크탑은 4탭 그대로 vs 2-column 변형 — plan에서 결정
├── today.mobile.jsx
├── today.desktop.jsx
├── match.mobile.jsx
└── match.desktop.jsx
```
**Me 페이지는 mobile/desktop 분리 안 함** (placeholder라 단순 — `Me.jsx` 본문에 직접 구현).
```
기존 v1 파일들:
- `components/` 디렉토리 **전체 삭제** (SajuNav, HoryungMascot, SajuInputForm, ActionCard, SajuPillars, ElementBarChart, FortuneRing, ScoreCard, LuckyBox, InterpretAccordion, MonthlyFlow, HoryungQuote)
- `hooks/useSajuForm.js`, `hooks/useSajuReading.js` 유지 (데이터 흐름)
- `Saju.css` 신규 `_shell/tokens.css` + `_shell/shell.css`로 교체
---
## 4. 컴포넌트 명세
### 4.1 `useViewportMode()`
```js
function useViewportMode() {
const [mode, setMode] = useState(() =>
typeof window !== 'undefined' && window.innerWidth >= 1024 ? 'desktop' : 'mobile'
);
useEffect(() => {
const onResize = () => {
const next = window.innerWidth >= 1024 ? 'desktop' : 'mobile';
setMode(prev => (prev === next ? prev : next));
};
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return mode;
}
```
- 디자인 프로토타입의 동일 hook 그대로 포팅
- SSR 안전 (typeof window 체크) — Vite 기본 CSR이라 항상 window 존재하지만 방어
- debounce 없음 — resize 빈도가 낮고 setState가 동일 값일 때 reflow 없음 (Object.is 비교)
### 4.2 `<Mascot variant="...">`
| variant | 매핑 PNG (기존 v1 자산) |
|---|---|
| `full` | `/images/saju/horyung/horyung-main.png` |
| `head` | `/images/saju/horyung/horyung-bust.png` (얼굴 중심 crop) |
| `upper` | `/images/saju/horyung/horyung-front.png` |
| `greeting` | `/images/saju/horyung/horyung-greeting.png` |
| `thinking` | `/images/saju/horyung/horyung-thinking.png` |
| `pointing` | `/images/saju/horyung/horyung-pointing.png` |
| `happy` | `/images/saju/horyung/horyung-happy.png` |
props: `variant`, `size` (px), `style` (override). `<img loading="lazy">`.
### 4.3 `<BottomNav current onChange theme>`
- `position: fixed; bottom: 0` — iPhone frame이 아닌 실제 모바일 뷰포트의 하단
- 5 아이템: home/today/match/saju/me. NavLink 사용으로 라우트 매핑 (`useLocation`으로 current 결정)
- theme: `'ivory'` (paper 배경) / `'navy'` (night 배경) — backdrop-filter blur 적용
- 활성 항목: 화면별 accent 색 배경(opacity 0.10~0.18) + 라벨 weight 700
### 4.4 `<DesktopHeader>`
- `position: sticky; top: 0; z-index: 30` — 스크롤 시 상단 고정
- 좌측: 로고 (`壽` 한자 + "호령사주" Nanum Myeongjo)
- 중앙: nav 5 링크 (BottomNav와 동일 항목, horizontal 배치)
- 우측: 미사용 (향후 me 메뉴)
- 배경: `--ivory-soft` + 하단 `--gray-line` 1px
### 4.5 `<OrnateFrame children color bg radius padding double>`
- 디자인 프로토타입 `common.jsx`의 OrnateFrame 그대로 포팅
- `double=true`면 inset 4px 위치에 추가 보더
- 4 코너 꺽쇠 SVG (rotate 0/90/180/270)
### 4.6 `<MascotBubble text align tone tail paw>`
- tone 팔레트 (`ivory`/`navy`/`green`/`purple`) → bg/border/text 색
- `paw=true`면 우하단 IconPaw + `paw-bob` 애니메이션
- `tail=true`면 풍선 꼬리 (rotate 45deg 사각형)
### 4.7 Buttons
- `PrimaryButton`: gold inset shadow (`inset 0 1px 0 rgba(212,175,55,0.4)`) + 풀 너비 옵션
- `GhostButton`: 투명 배경 + 보더만, 동일 폰트/spacing
### 4.8 `Me.jsx` (placeholder, mobile/desktop 공통)
- `paper-bg` + `<TopRibbon>` + `<Mascot variant="thinking">` + `<MascotBubble tone="purple">` "곧 만나요" + 비활성 카드 4개 (이력/북마크/설정/문의 — disabled)
- 뷰포트 분리 없이 단일 컴포넌트 (placeholder라 단순)
### 4.9 입력 폼 컴포넌트 (Home에서 사용)
- `<InputRow label name type ...>` — 디자인 토큰 적용된 단일 행 (label 좌측 64px + input 우측)
- `<DateSelect>`, `<TimeSelect>`, `<GenderToggle>`, `<CalendarToggle>` (양/음력) — `useSajuForm` state와 연결
- Phase 2에서 신설. 기존 v1 `SajuInputForm.jsx`의 검증 로직만 이식, 시각 표현은 새 디자인
---
## 5. 데이터 흐름
### 5.1 hook 재사용
- `useSajuReading(rid)` — 그대로 유지. `api.js``sajuGetReading(id)` 호출 → `reading` 객체 반환
- `useSajuForm()` — 그대로 유지. 입력 검증 + `sajuInterpret(body)` 호출 + navigate
### 5.2 매핑 헬퍼 (`_shell/helpers/`)
#### `daeunLabel(age)` → string
- `age < 10` → "성장기"
- `age < 20` → "학습기"
- `age < 30` → "도전기"
- `age < 40` → "성장기"
- `age < 50` → "전성기"
- `age < 60` → "안정기"
- `age < 70` → "정리기"
- `age >= 70` → "여유기"
#### `deriveTraits(elements, sipsin)` → `[{id, ko, icon, color}]` (최대 6개)
- 강한 원소 1~2개 → 매칭 성향:
- `fire >= 30``{id:'challenge', ko:'도전정신', color:'#C04A4A'}`
- `metal >= 30``{id:'lead', ko:'리더십', color:'#D4AF37'}`
- `wood >= 30``{id:'adapt', ko:'적응력', color:'#4E6B5C'}`
- `water >= 30``{id:'wisdom', ko:'지혜', color:'#3A5A8C'}`
- `earth >= 30``{id:'wealth', ko:'풍부함', color:'#A67B3F'}`
- 일간 강도 (신강/신약) → `will` (의지)
- 결과 6개 미만이면 다음으로 강한 원소 추가
- 순서: 강한 원소 점수 내림차순
#### `hexA(hex, alpha)` → `rgba(...)` 문자열
- 디자인 프로토타입 동일 헬퍼
### 5.3 SAJU_DATA mock → 실제 API 매핑 표
| 디자인 mock 필드 (screen-saju.jsx) | API 응답 경로 (saju-lab) | 비고 |
|---|---|---|
| `name`, `birth`, `gender`, `birthTime`, `birthPlace` | `reading.input.*` | 직접 매핑 |
| `sajuLabel` | `reading.label` | "경오년 신사월 갑자일 OO시" |
| `ilgan` | `reading.ilgan` | `{ko, ch, element, sound}` |
| `pillars[]` | `reading.pillars` | year/month/day/hour 4기둥 |
| `pillars[].cheongan.color` | 원소→색 매핑 (`elementColor()`) | wood=green, fire=red, earth=earth, metal=gold, water=blue |
| `pillars[].sipsin`, `jijang` | `reading.pillars[i].sipsin`, `jijang` | |
| `ohaeng[]` | `reading.analysis.elements` | `{wood, fire, earth, metal, water}``[{id, ko, ch, value, color}]` 변환 |
| `daeun[]` | `reading.daeun` (8개) | `label``daeunLabel(age)` 헬퍼, `current`는 현재 나이 기반 derive |
| `traits[]` | `deriveTraits(elements, sipsin)` | 헬퍼로 derive (API 응답에 직접 없음) |
| TraitsTab `title`, `desc` | 상위 3 성향 → 정적 desc 사전 매핑 | YAGNI: 백엔드에 trait description 추가는 향후 작업 |
| Today: `fortune_scores`, `lucky`, `monthly_flow` | API 응답에 이미 존재 | 그대로 사용 |
### 5.4 BottomNav active state
```jsx
const { pathname } = useLocation();
const current =
pathname === '/saju' ? 'home'
: pathname.startsWith('/saju/today') ? 'today'
: pathname.startsWith('/saju/compatibility') ? 'match'
: pathname.startsWith('/saju/result') ? 'saju'
: pathname.startsWith('/saju/me') ? 'me'
: 'home';
```
---
## 6. 반응형 & 네비게이션 전략
### 6.1 1024px breakpoint
- `< 1024px` → 모바일: 페이지 컴포넌트가 `<MobileXxx>` 렌더, `<BottomNav>` 표시
- `>= 1024px` → 데스크탑: `<DesktopXxx>` 렌더, `<DesktopHeader>` 표시
- 페이지 진입 시 `useViewportMode()`가 결정. resize 시 동적 전환
### 6.2 iPhone frame 제거
- 디자인 프로토타입은 모바일 미리보기용으로 iPhone 외곽선을 그렸으나 실제 모바일 디바이스는 OS frame이 있으므로 frame DOM 제거
- StatusBar(`BrandStatusBar`)도 미사용 — 실제 디바이스 status bar 자연스럽게 사용
### 6.3 컨테이너 max-width
- 모바일: `100%` (BottomNav만 fixed)
- 데스크탑: 콘텐츠 max-width 1200px, `margin: 0 auto`. mt-wash 배경은 viewport 풀
### 6.4 transition between modes
- 1024px 경계에서 mode 변경 시 컴포넌트가 unmount → 새 컴포넌트 mount → screenIn 0.3s 재생
- 폼 입력 중 transition 발생 시: useSajuForm 상태는 hook이 보관하므로 데이터 유실 X
---
## 7. 점진적 구현 단계 (Phase Plan)
각 Phase 끝에 `npm run dev``http://localhost:3007/saju` 시각 확인 + git commit. PR은 Phase 1~3, 4~5, 6 (fixup) 3개로 분할 권장.
| Phase | 산출물 | 검증 |
|---|---|---|
| **1. Shell + 토큰** | `_shell/` 전체 + `Me.jsx` + 라우트 `/saju/me` 추가 + Google Fonts link | `/saju/me` 진입 시 placeholder + BottomNav/Header 모두 정상. 기존 4 페이지 무손상 |
| **2. Home** | `Saju.jsx` + `views/home.{mobile,desktop}.jsx` + 입력 폼 + 호령 hero | 모바일/데스크탑 모두 입력 → submit → `/saju/result?rid=N` 이동 |
| **3. SajuResult** | `SajuResult.jsx` + `views/saju.{mobile,desktop}.jsx` 4탭 + 매핑 헬퍼 | 실제 reading 데이터로 4탭 모두 정상 표시. 일간 표시·오행 막대·대운 흐름·성향 derive 검증 |
| **4. Today** | `Today.jsx` + `views/today.{mobile,desktop}.jsx` | fortune_scores·lucky·monthly_flow 표시. PrimaryButton "다른 운세 보기" → SajuResult 이동 |
| **5. Compatibility** | `Compatibility.jsx` + `views/match.{mobile,desktop}.jsx` 본격 구현. `CompatibilityResult.jsx` 라이트 리스타일 | 두 사람 입력 폼 + compat API 호출 + 결과 화면 |
| **6. QA + cleanup** | v1 `components/` 삭제, `Saju.css` 제거, 시각 QA, 1024px 경계 chrome devtools | 골든 패스 통과, dead code 없음 |
---
## 8. 에러 / 빈 상태
| 상황 | UI |
|---|---|
| API 실패 (네트워크/500) | `<OrnateFrame color="--purple">` + `<MascotBubble tone="purple">` "아이고, 다시 시도해주세요" + `<GhostButton>` 새로고침 |
| `?rid=` 없이 `/saju/result` 직접 진입 | `<MascotBubble tone="ivory">` "사주를 먼저 입력해주세요" + `<PrimaryButton color="--purple">` "사주 입력하러 가기" → `/saju` |
| `?rid=` 없이 `/saju/today` 직접 진입 | 동일 패턴, accent gold |
| `?cid=` 없이 `/saju/compatibility/result` 진입 | 동일 패턴, accent green |
| `/saju/me` | `<MascotBubble tone="purple">` "곧 만나요" + 비활성 placeholder 카드 4개 |
| 백엔드 timeout (사주 해석 30~60초) | 로딩 화면: `<Mascot variant="thinking">` + `<MascotBubble>` "호령이 풀이 중이에요..." + spinner |
---
## 9. 검증 전략
### 9.1 자동 테스트
- `useViewportMode.test.js``vi.mock` window.innerWidth + resize 이벤트 dispatch, 1023/1024 경계 변환 확인
- `daeunLabel.test.js` — 8 구간 모두 정답 매핑
- `deriveTraits.test.js` — 강한 원소 1~5개 입력에 대한 정렬·중복 제거 확인
- `Mascot.test.jsx` — 7 variant 모두 올바른 src prop
### 9.2 시각 검증 (Phase 마다 dev server)
1. `npm run dev``http://localhost:3007/saju` 진입
2. 모바일 chrome devtools (375×667 iPhone SE, 390×844 iPhone 12)
3. 데스크탑 (1280×720 이상)
4. 1024px 경계 ± 1px (1023↔1024)에서 mode 전환 확인
5. 5 라우트 모두 BottomNav active 상태 + DesktopHeader active 상태 일치
6. 호령 PNG 7 variant 모두 로드 확인 (Network 탭)
7. 폰트 로드 (Nanum Myeongjo, Nanum Gothic, Gowun Batang)
### 9.3 회귀
- 기존 reading_id URL 호환 (`/saju/result?rid=N` 패턴 유지)
- `useSajuReading` hook 응답 매핑이 v1과 동일 데이터 표시
- saju-lab API 호출 0개 변경 (네트워크 탭 비교)
---
## 10. YAGNI 명시 제외
다음은 이번 v2에서 의도적으로 **제외**:
- i18n / 다국어
- 다크모드 토글 (디자인 자체가 화면별 light/dark scope 고정)
- 호령 마스코트 드래그·물리 모션 (paw-bob bobbing만)
- BottomNav 햅틱·진동
- 인증/로그인 (Me는 placeholder, 향후 별도 spec)
- PWA / 오프라인 캐시
- 백엔드 trait description API (`deriveTraits` 프론트 헬퍼로 충분)
- 디자인 프로토타입의 desktop-shell.jsx full conversion — DesktopHeader만 차용, shell 전체는 v2 컨테이너에 흡수
---
## 11. 마이그레이션 노트
### 11.1 삭제 대상 (Phase 6에서 일괄 정리)
- `web-ui/src/pages/saju/components/` 전체 12 파일
- `web-ui/src/pages/saju/Saju.css`
- v1 `Compatibility.jsx`의 placeholder 본문 (본격 구현으로 교체)
### 11.2 보존 대상
- `web-ui/src/pages/saju/hooks/useSajuForm.js`, `useSajuReading.js` (데이터 흐름)
- `web-ui/public/images/saju/horyung/` 7 PNG 자산 (Mascot variant API가 매핑)
- `web-ui/src/api.js` saju 헬퍼 함수들
### 11.3 routes.jsx 변경
기존 import 라인 유지 + Me lazy import 추가:
```diff
+ const SajuMe = lazy(() => import('./pages/saju/Me'));
```
`children` 배열에 me 라우트 추가:
```diff
path: '/saju',
children: [
{ index: true, element: <Saju /> },
{ path: 'result', element: <SajuResult /> },
{ path: 'today', element: <SajuToday /> },
{ path: 'compatibility', element: <Compatibility /> },
{ path: 'compatibility/result', element: <CompatibilityResult /> },
+ { path: 'me', element: <SajuMe /> },
],
```
---
## 12. Plan 단계로 넘길 결정 사항
다음은 plan 작성 시 구체화:
- 각 view 파일별 line budget (현실적 500~800 라인 예상, 더 크면 sub 컴포넌트 분할)
- 색→원소 매핑 함수 (`elementColor(elementId)`) 위치 — `_shell/helpers/` vs view 안 인라인
- 데스크탑 `saju.desktop.jsx`의 4탭 유지 vs 2-column 변형 (디자인 프로토타입의 `desktop-saju.jsx` 상세 검토 후 결정)
- 데스크탑 헤더의 me 메뉴 (향후 인증 위치 — 현재는 nav 5번째 링크)
- 시각 QA 시 사용자 직접 확인 단계 (Claude가 puppeteer로 자동화하지 않음 — 시각 판단은 사람)
- `<InputRow>` 등 입력 컴포넌트의 상세 props 시그니처

View File

@@ -0,0 +1,362 @@
# Agent Office — Docker 로그 기반 통합 타임라인 설계
> 작성일: 2026-05-28
> 대상: web-backend (5개 lab + agent-office) + web-ui (LogTab)
## 배경
`/agent-office` 의 각 에이전트 상세 패널에 노출되는 **로그 탭** 이 현재는 의미가 빈약하다.
- 노출 소스는 `agent-office` 의 자체 SQLite `agent_logs` 테이블 한 곳뿐.
- `base.py BaseAgent.transition()` 가 매번 `State: idle -> working ({detail})` 형식 자동 로그를 기록 — 사용자가 실제로 무슨 일이 일어났는지 파악하기 어려운 노이즈가 다수.
- 각 에이전트가 실제로 호출하는 외부 서비스 컨테이너 (lotto / stock / music-lab / insta-lab / realestate-lab) 의 docker stdout 은 LogTab 에 한 줄도 흐르지 않는다.
따라서 LogTab 에서는 “이 에이전트가 어떤 API 를 불러서 어떤 응답을 받았는지” “외부 서비스에서 어떤 비즈니스 이벤트가 발생했는지” 가 보이지 않는다.
## 목표
1. 각 에이전트 LogTab 에 **해당 서비스 컨테이너의 의미 있는 docker 로그** 를 흘려보낸다.
2. healthcheck / static / OPTIONS 같은 노이즈 로그는 **서버 측에서 미리 차단** 한다.
3. API 호출 한 줄 (`POST /api/lotto/recommend → 200 142ms`) 과 비즈니스 이벤트 (`수집 완료: new=12, total=340`) 양쪽 모두 표시한다.
4. 에이전트 내부 동작 로그 (`agent_logs` DB) 와 서비스 로그를 **한 화면에 시간순으로 통합** 한다.
5. `State: idle -> working` 형식 자동 transition 로그는 제거한다.
## 비목표
- 실시간 WebSocket push (지금은 5초 폴링이면 충분).
- 컨테이너 외부 (NAS 호스트, Windows AI 서버) 로그 수집.
- 로그 검색 / 필터 UI (당장은 단순 시간순 표시).
- 다른 lab (image-lab / tarot-lab / saju-lab / packs-lab / video-lab) 은 1차 범위에서 제외 — 5개 활성 에이전트가 가리키는 5개 컨테이너만 다룬다.
## 결정사항 요약
| 항목 | 결정 |
|---|---|
| 수집 방식 | 각 서비스가 `/logs/recent` 엔드포인트 노출 + agent-office 가 polling |
| 표시 방식 | 통합 타임라인 (agent 로그 + service 로그 시간순 merge) |
| 로그 범위 | 액세스 로그 (healthcheck 제외) + 비즈니스 이벤트 (logger.info/warning/error) |
| ring buffer 크기 | 컨테이너당 500개, in-memory deque |
| docker logs retention | `max-size 10m × max-file 3` = 서비스당 30MB |
| agent_logs DB retention | **90일** (매일 03:00 cleanup) |
| state 자동 로그 | 제거 (`base.py BaseAgent.transition()``add_log("State: ...")`) |
| 자동 수집 메커니즘 | Python `logging.Handler` 를 BufferLogHandler 로 등록 — 기존 logger.info/warning/error 호출이 자동으로 ring buffer 에 흐름 |
## 아키텍처
```
┌─────────────────────────────────────────────────────────────┐
│ web-ui (LogTab) │
│ ─ GET /api/agent-office/agents/{id}/logs?limit=N │
│ ─ 5초 폴링 (기존 refreshTrigger 흐름 재활용) │
│ ─ source 뱃지 표시 (access | log | agent) │
└─────────────────────────────────────────────────────────────┘
│ 통합 타임라인 (시간순 merge)
┌─────────────────────────────────────────────────────────────┐
│ agent-office │
│ - get_merged_logs(agent_id, limit) = │
│ agent_logs (state 로그 제외) │
│ + service_proxy.fetch_logs(container, path_prefix) │
│ → ts 기준 정렬 → 최근 N개 │
│ - 매핑: AGENT_CONTAINER_MAP │
│ stock → ("stock", "/api/(stock|trade|portfolio)") │
│ music → ("music-lab", "/api/music") │
│ insta → ("insta-lab", "/api/insta") │
│ realestate → ("realestate-lab", "/api/realestate") │
│ lotto → ("lotto-backend", "/api/lotto") │
└─────────────────────────────────────────────────────────────┘
│ GET http://{container}:{port}/logs/recent
│ ?since=ISO&limit=N&path_prefix=...
│ (내부 docker 네트워크 only, nginx public 라우팅 X)
┌─────────────────────────────────────────────────────────────┐
│ 각 서비스 컨테이너 (5개) │
│ 공용 모듈 _shared/access_log.py: │
│ - LogBuffer: collections.deque(maxlen=500) │
│ - AccessLogMiddleware: 모든 요청 후 한 줄 기록 │
│ 제외: /health /healthz /ping /favicon /docs /redoc │
│ /openapi.json /logs/recent OPTIONS HEAD │
│ - BufferLogHandler: logger.info/warning/error 자동 캡처 │
│ - /logs/recent 라우터 │
└─────────────────────────────────────────────────────────────┘
```
## 공용 모듈 — `web-backend/_shared/access_log.py`
```python
from collections import deque
from datetime import datetime
from fastapi import APIRouter, Request
from starlette.middleware.base import BaseHTTPMiddleware
import logging
import time
_BUFFER = deque(maxlen=500)
EXCLUDED_PATHS = {"/health", "/healthz", "/ping", "/favicon.ico",
"/docs", "/redoc", "/openapi.json", "/logs/recent"}
EXCLUDED_PREFIXES = ("/static/",)
EXCLUDED_METHODS = {"OPTIONS", "HEAD"}
def _should_log(request: Request) -> bool:
if request.method in EXCLUDED_METHODS:
return False
path = request.url.path
if path in EXCLUDED_PATHS:
return False
if any(path.startswith(p) for p in EXCLUDED_PREFIXES):
return False
return True
class AccessLogMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
start = time.time()
response = await call_next(request)
if not _should_log(request):
return response
elapsed_ms = int((time.time() - start) * 1000)
status = response.status_code
_BUFFER.append({
"ts": datetime.utcnow().isoformat() + "Z",
"level": "info" if status < 400 else
"warning" if status < 500 else "error",
"source": "access",
"method": request.method,
"path": request.url.path,
"status": status,
"ms": elapsed_ms,
"message": f"{request.method} {request.url.path}{status} ({elapsed_ms}ms)",
})
return response
class BufferLogHandler(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
try:
_BUFFER.append({
"ts": datetime.utcfromtimestamp(record.created).isoformat() + "Z",
"level": record.levelname.lower(),
"source": "log",
"logger": record.name,
"message": record.getMessage(),
})
except Exception:
pass
router = APIRouter()
@router.get("/logs/recent")
def logs_recent(limit: int = 200, since: str | None = None,
path_prefix: str | None = None):
items = list(_BUFFER)
if since:
items = [x for x in items if x["ts"] > since]
if path_prefix:
items = [x for x in items
if x["source"] == "log" or x.get("path", "").startswith(path_prefix)]
return {"logs": items[-limit:]}
def install(app, logger_root: str = ""):
"""서비스 main.py 가 호출하는 단일 설치 함수."""
app.add_middleware(AccessLogMiddleware)
app.include_router(router)
logging.getLogger(logger_root).addHandler(BufferLogHandler())
```
### 각 서비스 main.py 적용
```python
from _shared.access_log import install as install_access_log
install_access_log(app)
```
## docker-compose 변경
5개 서비스 (`lotto-backend`, `stock`, `music-lab`, `insta-lab`, `realestate-lab`) 에 동일 패턴 추가:
```yaml
environment:
- PYTHONPATH=/app:/shared
volumes:
- ../_shared:/shared:ro
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
```
`/logs/recent`**nginx default.conf 의 public location 블록에 추가하지 않는다**. 내부 docker 네트워크에서 `http://{container_name}:{port}/logs/recent` 로만 접근.
## agent-office 측 변경
### `app/constants.py`
```python
AGENT_CONTAINER_MAP = {
"stock": ("stock", 8000, r"^/api/(stock|trade|portfolio)"),
"music": ("music-lab", 8000, r"^/api/music"),
"insta": ("insta-lab", 8000, r"^/api/insta"),
"realestate": ("realestate-lab", 8000, r"^/api/realestate"),
"lotto": ("lotto-backend", 8000, r"^/api/lotto"),
}
```
### `app/service_proxy.py`
```python
async def fetch_service_logs(agent_id: str, since: str | None = None,
limit: int = 200) -> list[dict]:
mapping = AGENT_CONTAINER_MAP.get(agent_id)
if not mapping:
return []
host, port, path_re = mapping
url = f"http://{host}:{port}/logs/recent"
params = {"limit": limit}
if since:
params["since"] = since
try:
async with httpx.AsyncClient(timeout=3.0) as client:
resp = await client.get(url, params=params)
data = resp.json().get("logs", [])
except Exception as e:
logger.warning("fetch_service_logs(%s) 실패: %s", agent_id, e)
return []
# path_prefix 필터: access 로그만 path_re 검증
return [x for x in data if x["source"] == "log"
or re.match(path_re, x.get("path", ""))]
```
### `app/db.py`
```python
def get_logs(agent_id: str, limit: int = 50) -> list[dict]:
# 'State: ...' 자동 로그 제외 (사용자 요청)
rows = conn.execute("""
SELECT * FROM agent_logs
WHERE agent_id=?
AND message NOT LIKE 'State: %'
ORDER BY created_at DESC LIMIT ?
""", (agent_id, limit)).fetchall()
return [...]
def delete_old_logs(days: int = 90) -> int:
cutoff = (datetime.utcnow() - timedelta(days=days)).isoformat()
with _conn() as conn:
c = conn.execute("DELETE FROM agent_logs WHERE created_at < ?", (cutoff,))
return c.rowcount
```
### `app/main.py`
```python
@app.get("/api/agent-office/agents/{agent_id}/logs")
async def agent_logs(agent_id: str, limit: int = 50):
agent_items = get_logs(agent_id, limit=limit)
service_items = await fetch_service_logs(agent_id, limit=limit)
merged = sorted(agent_items + service_items,
key=lambda x: x.get("ts") or x.get("created_at"),
reverse=True)[:limit]
return {"logs": merged}
```
### `app/agents/base.py`
```python
async def transition(self, new_state, detail="", task_id=None):
# add_log(... "State: ...") 호출 삭제 — 사용자 요청
...
# ws_manager 알림은 유지
```
### `app/scheduler.py`
```python
scheduler.add_job(
lambda: delete_old_logs(days=90),
CronTrigger(hour=3, minute=0),
id="cleanup_old_logs",
)
```
## web-ui 측 변경
### `src/pages/agent-office/components/LogTab.jsx`
- log row schema 가 두 가지로 늘어남: agent_logs `{level, message, created_at}` vs service `{ts, level, source, method, path, status, ms, message}`.
- source 뱃지를 추가로 표시: `[ACCESS]` / `[LOG]` / `[AGENT]`.
- access 로그는 method + path + status + ms 를 보조 라인으로 표시.
색상 가이드:
- `source=access` 청록 (#5eead4)
- `source=log` 파랑 (#60a5fa)
- `level=warning` 노랑 (#fbbf24)
- `level=error` 빨강 (#ef4444)
- `source=agent` (agent_logs) 회색 (#9ca3af)
## Phase 분리
대규모 변경이라 단일 PR 위험. 3단계로 나눠 진행.
### Phase 1 — PoC (가장 우선)
1. `web-backend/_shared/access_log.py` 신설.
2. `web-backend/lotto/app/main.py` 한 곳에만 `install_access_log(app)` 추가.
3. `web-backend/docker-compose.yml``lotto-backend` 서비스에 PYTHONPATH + volume + logging 추가.
4. `agent-office``service_proxy.fetch_service_logs()` + `AGENT_CONTAINER_MAP` (lotto 만) + `get_logs(agent_id)` merge.
5. `LogTab.jsx` 가 source 뱃지를 표시하도록 확장.
6. base.py `State: ...` 자동 로그 제거 + `db.get_logs()` NOT LIKE 필터 추가.
검증: `/agent-office` 에서 lotto 에이전트 선택 → LogTab 에 `POST /api/lotto/...` 한 줄과 기존 logger.info 출력이 같이 보이는지.
### Phase 2 — 4개 서비스 확장
1. stock / music-lab / insta-lab / realestate-lab 의 `main.py``install_access_log(app)` 추가.
2. docker-compose 4개 서비스 동일 패턴 적용.
3. `AGENT_CONTAINER_MAP` 에 4개 매핑 추가.
4. `delete_old_logs` cleanup job 등록.
검증: 5개 에이전트 모두 LogTab 에서 의미 있는 로그 노출.
### Phase 3 — 비즈니스 이벤트 보강
디자인 4/5 의 "추가 권장" 표 항목들을 `logger.info(...)` 한 줄씩 추가. 약 1015줄.
- stock: Order 응답, AI Coach 호출, 스크리너 결과
- music-lab: 생성 시작/완료
- insta-lab: 키워드 추출 완료, 슬레이트 생성 완료, 발행 결과
- lotto-backend: AI 큐레이터 호출/응답, 점수 계산 완료
## 알려진 위험과 완화
| 위험 | 완화 |
|---|---|
| `/logs/recent` 가 외부로 노출되면 access pattern + 내부 동작 노출 | nginx public location 에 등재하지 않음 + 내부 docker 네트워크만 |
| 각 서비스의 logger 가 propagate 설정이 달라 BufferLogHandler 에 안 흐를 가능성 | `install()` 에서 `logging.getLogger("")` (root) 에 핸들러 등록 — 모든 child logger 가 자동 전파 |
| BufferLogHandler 의 `emit()` 가 다른 핸들러의 포맷팅에 영향 | `Handler.emit` 만 override, formatter 사용 안 함 |
| ring buffer 가 0.5초당 수십 건 트래픽으로 가득 차서 30초 분량밖에 안 남음 | 500개는 평소 트래픽 기준 1시간 이상 보관. 모니터링하다 부족하면 1000 으로 상향 |
| `lotto-backend` 컨테이너의 personal/blog/todo API 가 lotto 에이전트 로그에 섞임 | `AGENT_CONTAINER_MAP` 의 path_prefix 정규식으로 `/api/lotto` 만 매칭 — 다른 prefix 는 자연스럽게 필터 |
| docker-compose volume `../_shared:/shared:ro` 가 NAS 운영 환경에서 경로 차이로 깨질 가능성 | repo 의 상대경로 (`../_shared`) 는 NAS 의 `/volume1/docker/webpage/backend/_shared` 와 동일 구조로 git pull 됨. Gitea webhook 으로 push 되는 경로에 `_shared/` 디렉토리도 함께 포함됨을 deployer rsync 시 검증 |
## 변경 파일 요약
```
■ 신설
web-backend/_shared/__init__.py
web-backend/_shared/access_log.py
■ web-backend
lotto/app/main.py + install_access_log + 추가 logger.info 34개 (Phase 3)
stock/app/main.py + install_access_log + 추가 logger.info 3개 (Phase 3)
music-lab/app/main.py + install_access_log + 추가 logger.info 2개 (Phase 3)
insta-lab/app/main.py + install_access_log + 추가 logger.info 3개 (Phase 3)
realestate-lab/app/main.py + install_access_log (Phase 3 추가 없음)
docker-compose.yml 5개 서비스 PYTHONPATH/volume/logging 추가
■ web-backend/agent-office
app/service_proxy.py + fetch_service_logs(agent_id, ...)
app/main.py agent_logs 엔드포인트가 merge 사용
app/db.py + delete_old_logs + get_logs NOT LIKE 'State: %'
app/scheduler.py + 매일 03:00 cleanup job
app/agents/base.py transition() 의 add_log('State: ...') 제거
app/constants.py + AGENT_CONTAINER_MAP
■ web-ui
src/pages/agent-office/components/LogTab.jsx
source 뱃지 + access 로그 method/status/ms 표시
```

View File

@@ -0,0 +1,191 @@
# 로또 자가학습 백테스트 & 캘리브레이션 — 설계 Spec
- **작성일**: 2026-05-31
- **상태**: 설계 승인 (구현 plan 대기)
- **대상 서비스**: `lotto` (lotto-lab) + `agent-office` (LottoAgent) + `web-ui` (/lotto 자율학습 탭)
- **사이클**: 스마트 에이전트 고도화 3종(로또/주식/인스타) 중 **1번 로또**. 주식·인스타는 후속 사이클.
---
## 1. 배경 & 목표
사용자(CEO)는 로또 에이전트를 "분석 번호를 계속 가상구매해 시도횟수를 늘리고, 실제 당첨조합을 역분석해 스스로 학습·디벨롭하며 일요일에 회고 브리핑하는 스마트 에이전트"로 고도화하길 원한다. 명시 목표는 "로또 1등".
### ⚠️ 정직성 전제 (설계의 토대)
로또는 매 회차 균등·독립 추첨이다. C(45,6)=8,145,060 조합이 전부 동일 확률이며 회차 간 독립이다. 따라서:
- **과거 데이터(빈도·갭·공동출현)의 미래 예측력은 수학적으로 0.** 통계 분석으로 1등 확률을 올릴 수 없다.
- 고정 예산 N장으로 1등 확률을 최대화하는 유일한 방법은 **서로 다른(distinct) 조합 N개**를 사는 것이다.
이 사실을 부정하지 않고 **시스템에 내장**한다. 본 프로젝트의 가치는 "예측"이 아니라:
1. **정직한 측정** — "내 분석 엔진이 무작위를 이기는가?"를 null-model 대조군으로 매번 엄밀히 검정.
2. **자가학습 엔진 인프라** — 측정→학습→회고 루프 자체의 엔지니어링.
3. **커버리지 최적화** — 1등이 목표라면 distinct 조합 커버리지 최대화가 수학적 최적.
→ 사용자 결정(2026-05-31): **"정직한 측정 + 커버리지 최적"** 프레이밍 채택. 패턴 학습은 계속하되 모든 백테스트에 null-model 베이스라인을 내장한다.
### 기존 자산 (100% 재활용, 신규 ML 없음)
- `analyzer.build_analysis_cache(draws)` / `score_combination(numbers, cache, weights)` — 임의 조합의 5개 sub-score + 종합점수(0~1) = **"분석치"**.
- `analyzer.build_number_weights` + `utils.weighted_sample_6` — 가중 후보 생성.
- `generator.run_simulation` — 20k 후보를 `score_combination(·, active_weights)`로 랭킹→best_picks. **W가 선택을 바꾸는 경로가 이미 존재.**
- `weight_evolver` — 토 22:00 주간 6 가중치 후보 채점→base 갱신.
### 발견된 잠재 결함 (본 작업으로 수정)
`weight_evolver.apply_today_and_pick``recommend_numbers(draws)`(W 미사용)로 픽을 뽑은 뒤 W로 점수만 매긴다. 즉 **현재 daily 픽은 W와 무관**하고, evolver가 평가하는 매칭 결과도 W-독립이라 가중치 진화가 픽 품질에 연결돼 있지 않다. → forward 가상구매를 **시뮬레이션 선택 경로(풀 생성→W 랭킹→상위 K 구매)**로 구현하면 W가 결과를 실제로 바꿔 가중치 학습이 비로소 의미를 갖고 이 결함도 해소된다.
---
## 2. 핵심 개념 — Self-Learning Backtest Loop
세 축으로 구성:
### 축 A — Forward 가상구매 (매주, 회차당 수천 장)
매 회차 추첨 후, 각 전략별로 대량 후보를 생성·랭킹해 상위 K장을 "구매"로 간주 → 실제 당첨번호로 채점 → **회차별 집계 1행만 영구 저장**. 개별 티켓 미저장.
- 전략: `engine_w`(6개 trial 가중치 각각) / `random_null`(무작위 대조군) / `coverage`(distinct 최대화).
- 이 매칭 결과가 evolver의 학습 신호가 된다.
### 축 B — Winner 캘리브레이션 (역대 전체 백필 + 매주 증분)
각 회차의 **실제 당첨조합을 그 시점 이전 데이터로 만든 캐시(point-in-time)에 넣어** 5개 분석치 + 종합점수 + percentile을 기록.
- percentile = 당첨조합 score_total이 그 시점 무작위 M개 표본 분포에서 차지하는 위치.
- "내 엔진이 실제 당첨번호에 높은 점수를 주는가?"의 가장 정직한 신호. 당첨조합이 일관되게 낮은 percentile이면 엔진은 헛다리.
### 축 C — 일요일 회고 브리핑
토 추첨(20:45)→동기화(21:10)→기존 evolver 리포트(토 22:15) 이후, **일 09:00**에 차분히 회고. 이번 회차 forward 성적 + 당첨조합 역분석 + 내 추천과 비교 + 캘리브레이션 추세 + 가중치 진화를 텔레그램 1통 + UI.
---
## 3. 데이터 모델 (lotto.db 신규)
집계 전용 — row 수 ≈ 회차 × 전략 (수천 규모, 무시 가능).
### `backtest_runs` — forward 가상구매 집계
```
id INTEGER PK
draw_no INTEGER NOT NULL -- 채점 대상(당첨 확정된) 회차
strategy TEXT NOT NULL -- 'engine_w' | 'random_null' | 'coverage'
weight_label TEXT NOT NULL -- engine_w는 trial day_of_week('w0'..'w5'), 그 외 '-'
weight_json TEXT -- 사용한 W (random/coverage는 NULL)
trial_id INTEGER -- FK weight_trials (engine_w만, nullable)
n_tickets INTEGER NOT NULL -- 구매(채점) 장수
m3 INTEGER NOT NULL DEFAULT 0 -- 3개 일치 장수
m4 INTEGER NOT NULL DEFAULT 0
m5 INTEGER NOT NULL DEFAULT 0
m6 INTEGER NOT NULL DEFAULT 0
bonus_hits INTEGER NOT NULL DEFAULT 0 -- 5+보너스(2등) 장수
best_match INTEGER NOT NULL DEFAULT 0
avg_meta_score REAL -- 구매 티켓 평균 분석치
created_at TEXT NOT NULL
UNIQUE(draw_no, strategy, weight_label) -- 멱등
```
- 등수 매핑: 1등=m6, 2등=bonus_hits, 3등=m5bonus_hits, 4등=m4, 5등=m3.
### `winner_calibration` — 회차별 당첨조합 역분석
```
draw_no INTEGER PK -- 멱등
winning_json TEXT NOT NULL -- [n1..n6] (보너스 별도 보관 안 함)
score_total REAL NOT NULL
score_frequency REAL NOT NULL
score_fingerprint REAL NOT NULL
score_gap REAL NOT NULL
score_cooccur REAL NOT NULL
score_diversity REAL NOT NULL
percentile REAL -- 0~1, 무작위 M표본 대비 당첨조합 점수 위치
my_pick_avg REAL -- 그 회차 engine 추천 평균 분석치(있으면)
cache_draws INTEGER NOT NULL -- point-in-time 캐시에 쓰인 회차 수
created_at TEXT NOT NULL
```
> 누적 성적표(track record)는 `backtest_runs` SUM 집계로 on-the-fly 계산 — 별도 테이블 불필요.
---
## 4. 컴포넌트
### 4.1 lotto-lab `app/backtest.py` (순수 연산 — FastAPI 의존성 0, Windows 이전 대비)
- `generate_pool(cache, number_weights, n) -> list[tuple]``weighted_sample_6` 반복으로 distinct 후보 풀.
- `purchase_tickets(pool, cache, W, k) -> list[dict]` — 풀을 `score_combination(·, W)`로 랭킹→상위 k장 distinct.
- `coverage_select(pool, k) -> list` — distinct 보장 상위 커버리지(초기엔 단순 distinct, 휠링은 향후).
- `grade_tickets(tickets, winning6, bonus) -> dict` — 매칭 히스토그램 + 등수 카운트 + best_match + avg_meta. `bonus`는 draws 레코드에서 가져옴(2등=5일치+보너스 판정용).
- `run_forward_purchase(draw_no, k=5000, pool_n=20000) -> dict` — engine(6 W)+random_null+coverage 각각 **전략당 k=5000장(수천 장)** 구매·채점·`backtest_runs` 저장(멱등). 풀 pool_n=20000에서 랭킹.
- `calibrate_winner(draw_no, sample_m=2000) -> dict``draws[:idx]`(대상 회차 제외) 캐시로 당첨조합 채점 + 무작위 sample_m 표본 percentile → `winner_calibration` 저장(멱등).
- `backfill_calibration(batch=50) -> dict` — 미처리 회차만 청크 처리, 재개 가능.
- `build_review_payload(draw_no) -> dict` — 회고 브리핑용 조립(당첨조합 분해 + 내 추천 비교 + forward 성적 + 캘리브레이션 추세 + 진화 결과).
### 4.2 lotto-lab `app/routers/backtest.py`
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/lotto/backtest/track-record` | 누적 성적표(전략별 등수 카운트, engine vs random) |
| GET | `/api/lotto/backtest/calibration?weeks=N` | 캘리브레이션 이력 + 추세 |
| GET | `/api/lotto/backtest/review/{draw_no}` | 회고 payload |
| POST | `/api/lotto/backtest/run-forward?draw_no=` | forward 수동 트리거 |
| POST | `/api/lotto/backtest/backfill` | 캘리브레이션 백필(백그라운드) |
### 4.3 weight_evolver 업그레이드
- `evaluate_weekly`: 학습 신호를 N=5(W-무관)에서 **forward 가상구매(engine_w 6전략) + null-model 대비 lift**로 승격.
- lift = engine_w 등수 점수 random_null 등수 점수(동일 회차).
- 승자 = lift 최대 trial. **모든 W의 lift가 노이즈 범위(±ε) 내면 base `unchanged`** → 노이즈 과적합 방지.
- `decide_base_update` 규칙은 유지하되 입력(winner)을 backtest 기반으로 교체.
- 기존 `auto_picks` 경로는 하위호환·일일 활동표시용으로 유지(evolver 결정에는 미사용).
---
## 5. 플로우
1. **캘리브레이션 백필 (1회)**: `POST /backtest/backfill` → 백그라운드 청크(50회차/배치, 멱등 재개). 이후 회차마다 증분.
2. **주간 forward**: 당첨번호 동기화 직후 `run_forward_purchase(latest)`. 참고: 6 W × 20k 풀은 기존 시뮬이 **하루 6회** 돌리는 부하보다 가벼움 → NAS 부담 작음.
3. **일 09:00 회고 (agent-office 신규 cron)**: `LottoAgent.run_sunday_review()` → forward+calibration 보장 → `GET /backtest/review/{latest}` → 텔레그램 1통.
4. **evolver (토 22:00, 기존 cron)**: backtest 집계를 학습 신호로 소비.
### Windows 이전 경로 (NAS 부하 측정 후 필요시)
`backtest.py`가 순수 함수라, lotto-lab은 system-of-record 유지 + 무거운 연산만 Windows WSL docker 워커에 위임(`/api/internal/lotto/*` webhook, 기존 music/video/image 워커 패턴 재활용) + agent 폴링. 코드 경계가 깨끗해 마이그레이션 비용 최소. **초기 구현은 NAS-first**, 측정 후 결정.
---
## 6. 출력
### 6.1 텔레그램 (일 09:00, `notifiers/telegram_lotto.py` 신규 섹션)
이번 당첨조합 5분석치 분해 + 내 추천 평균과 비교 + 이번주 forward 성적(등수 카운트, **무작위 대비 lift**) + 캘리브레이션 percentile 추세 + 가중치 진화 결과.
### 6.2 web-ui `/lotto` "자율 학습" 탭 확장 (`.lotto-evolver-*` 다크 네임스페이스 재활용)
- **TrackRecordCard**: 누적 "매주 전략당 5,000장 샀다면" 등수 — engine vs random_null 나란히 + 총지출 대비 당첨금(정직하게 적자 표시).
- **CalibrationChart**: 당첨조합 score_total 추세 + 내 추천 평균 오버레이 + percentile 밴드 → "우위 없음"을 시각화.
- **WinnerAnalysisCard**: 이번 회차 당첨조합 5분석치 레이더 + 내 추천 비교.
---
## 7. 에러·성능·멱등
- **멱등성**: `winner_calibration` UNIQUE(draw_no), `backtest_runs` UNIQUE(draw_no,strategy,weight_label) → 재실행 skip.
- **NAS 성능**: 주간 forward는 기존 시뮬보다 가벼움. 백필만 1회 무거움(≈1100 point-in-time 캐시 재구성) → 청크+백그라운드+멱등 재개. 야간/유휴 트리거 권장.
- **텔레그램 실패**: 로그만 남기고 job은 성공 처리(기존 패턴). 회고 데이터는 이미 DB에 있어 UI는 영향 없음.
## 8. 테스트 전략
- 등수 매핑(m3~m6/bonus → 1~5등) 단위 테스트.
- null-model 기대값 + lift 계산.
- percentile 계산 정확성.
- **point-in-time 캐시가 대상 회차를 제외하는지** (calibrate_winner 정직성 핵심).
- 멱등 백필(재실행 시 중복 row 없음, 중단 후 재개).
- evolver의 lift-over-random 승자 선택 + ε-게이팅(노이즈 시 unchanged).
- 기존 `count_match`/`calc_pick_score` 테스트 유지.
## 9. 리스크 & 완화
| 리스크 | 완화 |
|--------|------|
| 무작위성 → 실제 우위 없음 | null-model 정직 프레이밍, 우위 없음을 데이터로 보고하는 게 목표 |
| Celeron 백필 부하 | 청크+1회성+멱등 재개, 필요시 Windows 이전 |
| evolver 노이즈 추종 | lift-over-random + ε-게이팅으로 unchanged 처리 |
| DB 증가 | 집계 전용, row 수 무시 가능 |
| forward 풀 중복으로 커버리지 손실 | distinct 강제 + coverage 전략 별도 측정 |
## 10. 결정 로그 (2026-05-31 brainstorming)
1. 3종 중 **로또 먼저**, 주식·인스타는 후속 사이클.
2. 회고 브리핑 = **토 추첨 직후 일 09:00**.
3. 시도 규모 = **수천 장/회차 + 집계만 저장**.
4. 자율성 = **가중치 자동튜닝 강화**(산식 구조 고정).
5. 백테스트 범위 = **캘리브레이션 전체 백필 + 가상구매 forward**.
6. 출력 = **텔레그램 + 기존 자율학습 탭 확장**.
7. 프레이밍 = **정직한 측정(null-model) + 커버리지 최적**.
8. 연산 위치 = **NAS-first, 필요시 Windows WSL 이전**.
## 11. 스코프 밖 / 향후
- 주식 에이전트(보유종목 집중 분석+차트 매수/매도 시그널), 인스타 에이전트(자율 카드 발급) — 별도 사이클.
- 휠링/커버링 디자인(하위 등수 최소 보장) — coverage 전략 고도화로 향후.
- Windows WSL 워커 분리 — NAS 부하 측정 후.

View File

@@ -0,0 +1,122 @@
# 주식 보유종목 인텔리전스 — 설계 Spec
- **작성일**: 2026-05-31
- **상태**: 설계 승인 (구현 plan 대기)
- **대상 서비스**: `stock` + `agent-office`(StockAgent) + `web-ui`(stock/포트폴리오 페이지)
- **사이클**: 스마트 에이전트 고도화 3종 중 **2번 주식**. (1번 로또 완료, 3번 인스타 후속)
---
## 1. 배경 & 목표
현재 StockAgent는 아침 뉴스 요약(07:30) · KRX 강세주 스크리너(16:30) · AI 뉴스 sentiment(08:00)를 브리핑한다. CEO는 여기서 더 나아가 **내 보유종목을 집중 분석**해 ①종목별 매수/매도 자세 ②이슈 정리 ③포트폴리오 건강을 매일 advisory로 브리핑받길 원한다.
### 핵심 결정 (2026-05-31 brainstorming)
1. **실행 수준 = 브리핑 전용(advisory)**. `/api/trade/order`(KIS 실주문) 미사용. 매수/매도는 "제안"만, 실제 주문은 사용자 수동. (로또와 동일한 정직·관찰 철학)
2. **분석 주기 = 일봉 EOD + 장중 경량 가드**. 장마감 후 일봉으로 기술분석 → 다음날 아침 브리핑. 장중엔 현재가로 손절·급변(±N%)만 경도 알림. 인트라데이 분봉 파이프라인 신설 안 함.
3. **브리핑 범위 = 보유종목 + 포트 레벨**. 종목별 액션 + 포트폴리오 건강(집중도·비중·현금·손익).
4. **이슈 소스 = 기존 뉴스+감성+LLM 요약 + 급변·거래량·외인수급 이벤트**. 신규 스크래핑 0 (DART·실적 일정 제외).
### 기존 자산 (100% 재활용, 신규 ML/데이터소스 없음)
- `stock/app/screener/snapshot.py``krx_daily_prices`(일봉 OHLCV) + `krx_master`(listing) + naver 외인 flow. 스크리너 잡(평일 16:30)이 갱신.
- `stock/app/screener/engine.py` + `nodes/`(ma_alignment·momentum·rs_rating·vcp_lite·volume_surge·foreign_buy·high52w·hygiene). **`ScreenContext.restrict(tickers)`** + `latest_close()`/`latest_high()`로 보유종목 한정 분석 가능.
- `portfolio` 테이블(broker·ticker·name·quantity·avg_price·purchase_price) + `/api/portfolio`(현재가·손익 계산) + `broker_cash`(예수금).
- `price_fetcher`(현재가 3분 TTL) · `news_sentiment` 테이블(종목별 감성) · `ai_summarizer`(Claude Haiku).
### 알려진 제약 (설계 반영)
- **섹터 필드 없음**: `portfolio`·`krx_master`에 sector 없음 → 섹터 편중은 best-effort(FDR `StockListing`의 Sector/Industry가 있으면 사용, 없으면 생략)이고, **시장(KOSPI/KOSDAQ)·종목 비중 집중도**를 기본 지표로 사용.
- **KRX 외 종목**(미국주 등): krx_daily_prices 밖 → 기술분석 불가, **뉴스·현재가·손익만** graceful 처리.
- **snapshot 히스토리 의존**: MA200·52주 고점 노드는 ~1년 일봉 필요. 스크리너가 이미 이 노드들을 쓰므로 윈도우는 충족 가정(plan에서 lookback 확인 단계 포함).
---
## 2. 데이터 모델 & 컴포넌트
### 신규 테이블 `holdings_signals` (stock.db, 일별 종목 시그널 이력)
```
date TEXT NOT NULL -- KST 거래일
ticker TEXT NOT NULL
name TEXT
action TEXT NOT NULL -- 'add' | 'hold' | 'trim' | 'sell'
tech_score REAL -- 매수강도(score 노드 가중합, 0~1 정규화)
exit_flags TEXT NOT NULL DEFAULT '{}' -- JSON {stop_loss,ma50_break,ma200_break,momentum_loss,take_profit,climax}
issues TEXT NOT NULL DEFAULT '[]' -- JSON [{type, severity, summary}]
close INTEGER
pnl_rate REAL -- 평단 대비 % (스냅샷 시점)
reasons TEXT -- 액션 근거 텍스트
created_at TEXT NOT NULL DEFAULT (datetime('now'))
PRIMARY KEY(date, ticker) -- 멱등 upsert
```
> 추세/이력은 이 테이블에서 조회. 포트 레벨 요약은 on-the-fly 계산(별도 테이블 불필요).
### 신규 `stock/app/holdings_intel.py` (순수연산 중심, FastAPI 의존성 최소)
- `get_holdings() -> list[dict]``portfolio` 행 + 현재가(price_fetcher) + pnl_rate. KRX 여부 플래그(`is_krx`).
- `technical_posture(ctx_restricted, tickers) -> dict[ticker, score]``ScreenContext.restrict(tickers)`에 score 노드 실행 → 매수강도.
- `exit_rules(holding, prices_df, params) -> dict`**신규**: 손절·MA이탈·모멘텀소멸·익절·클라이맥스 flag 산출 (§3).
- `decide_action(tech_score, exit_flags, pnl) -> (action, reasons)`**신규**: 매수강도+exit 조합 → add/hold/trim/sell + 근거.
- `market_events(prices_df, flow, params) -> dict[ticker, list]` — 급변(±N%)·거래량 Z-score·외인 순매도.
- `news_issues(tickers) -> dict[ticker, list]` — news+news_sentiment 필터 → Claude Haiku 악재·심각도 요약(악재 있는 종목만).
- `portfolio_health(holdings, cash) -> dict` — 종목 비중 집중도(HHI/최대비중)·시장 mix·현금 비중·총 손익.
- `compute_and_store(asof) -> dict` — 위를 조합해 holdings_signals upsert (멱등).
- `build_holdings_brief(asof) -> dict` — 브리핑/UI payload 조립(종목별 action+issues + portfolio_health + 추세).
### API (stock)
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/stock/holdings/intel` | 최신 브리핑 payload |
| GET | `/api/stock/holdings/intel/history?ticker=&days=` | 종목 시그널 추세 |
| POST | `/api/stock/holdings/intel/run` | 수동 계산 트리거(BackgroundTask) |
---
## 3. 매도/리스크 룰 & 이슈 (설정 가능 임계값 — 기본값 제시)
### exit_flags (각 boolean + 값)
- **stop_loss**: `current < avg_price × (1 STOP_PCT)` (기본 STOP_PCT=0.08, Minervini식)
- **ma50_break / ma200_break**: 종가 < MA50 / MA200
- **momentum_loss**: momentum/RS 노드 점수가 직전 대비 임계 하락 (or 음전환)
- **take_profit**: `pnl_rate ≥ TAKE_PCT` (기본 25%) — 부분 익절 후보
- **climax**: 거래량 급증(vol > avg×CLIMAX_VOL_X) + 종가 상단 꼬리 (분산 의심)
### decide_action 매트릭스
- tech_score 高 + exit_flags 無 → **add**(추가매수 후보)
- exit_flags 無 (강건) → **hold**
- ma50_break 또는 momentum_loss 또는 take_profit → **trim**(일부 축소)
- stop_loss 또는 ma200_break → **sell**(청산 후보)
- 각 결정에 trigger된 flag를 근거 텍스트로 동봉. (advisory — "제안")
### issues
- **시장이벤트** (기존 데이터): 일봉 ±EVENT_PCT% 급변 / 거래량 Z-score>임계 / naver flow 외인 순매도 N일 연속.
- **뉴스이슈**: 보유종목 최근 뉴스 + news_sentiment 음수 → Claude Haiku로 `{type, severity(low/med/high), summary}` 요약. 악재 있는 종목만 호출(비용 bounded).
---
## 4. 플로우 · 에이전트 · UI
1. **EOD 계산 (평일 16:40)**: 기존 스크리너/뉴스 잡과 동일하게 **agent-office cron이 orchestrate**`_run_stock_holdings_eod()``StockAgent.run_holdings_eod()` → stock `POST /api/stock/holdings/intel/run``holdings_intel.compute_and_store(today)` → holdings_signals upsert. 스크리너 snapshot 갱신(16:30) 직후라 일봉 준비됨.
2. **아침 브리핑 (평일 08:30, agent-office StockAgent.run_holdings_brief)**: 저장된 최신 시그널 + 야간 갭(현재가) → 텔레그램 1통(종목별 액션 + 포트 건강 + 상위 이슈). AI 뉴스(08:00) 다음 슬롯.
3. **장중 경량 가드 (평일 09:00~15:30, 30분 간격)**: 현재가로 손절선 이탈·급변(±N%)만 점검 → 발생 시 텔레그램 alert. throttle(종목·유형별 재발화 억제) + daily cap (로또 시그널 패턴 재활용).
4. **agent-office**: `service_proxy`에 holdings intel 호출 추가 + StockAgent 메서드(run_holdings_brief / intraday_guard) + scheduler cron.
5. **UI (web-ui)**: stock/포트폴리오 페이지에 **"보유종목 인텔리전스" 탭/섹션 통합** — 종목별 액션 카드(자세·exit flags·근거) + 포트 건강 위젯 + 이슈 피드 + 종목 시그널 추세(history).
---
## 5. 에러·성능·테스트·리스크
- **멱등성**: holdings_signals PRIMARY KEY(date,ticker) upsert → 재계산 안전.
- **성능 (NAS Celeron)**: 보유종목만 restrict(소수 종목)이라 전체 스크리너 대비 매우 가벼움. LLM 이슈 요약은 악재 종목만(bounded). EOD 1회 + 장중 가드는 현재가만(경량).
- **graceful degrade**: price_fetcher/KIS/news 실패 시 부분 데이터로 진행 + 경고 로그. KRX 외 종목은 기술분석 skip(뉴스·손익만). 텔레그램 실패는 로그만(job 성공 유지).
- **테스트**: exit_rules 각 flag, decide_action 매트릭스 전 분기, market_events 검출, portfolio_health 계산, holdings_signals 멱등, KRX 외 종목 graceful, 뉴스 0건 경로.
- **리스크**: ①기술적 시그널은 휴리스틱이지 보장 아님 → advisory 프레이밍·자동매매 없음 ②섹터 데이터 갭 → 시장·비중 집중도로 대체 ③snapshot 히스토리 의존 → plan에 lookback 확인 ④보유종목 출처는 portfolio 테이블(사용자/KIS 동기화) — 누락 시 빈 브리핑 graceful.
---
## 6. 결정 로그 (2026-05-31)
1. 실행 수준 = **advisory 전용** (KIS 실주문 미사용)
2. 주기 = **일봉 EOD + 장중 경량 가드**
3. 범위 = **보유종목 + 포트 레벨**
4. 이슈 소스 = **기존 뉴스+감성+LLM + 급변·거래량·외인 이벤트**
## 7. 스코프 밖 / 향후
- 자동매매(승인후/완전자동), 인트라데이 분봉, DART 공시·실적 일정, 신규 매수후보 발굴(기존 16:30 스크리너가 담당), 교체(rotation) 제안 — 향후 사이클.
- 인스타 에이전트(자율 카드 발급) — 다음 사이클.

View File

@@ -0,0 +1,97 @@
# 인스타 카드뉴스 품질 고도화 + 업로드 친화 패키지 — 설계 Spec
- **작성일**: 2026-06-02
- **상태**: 설계 승인 (구현 plan 대기)
- **대상**: `insta-lab`(템플릿·카피·zip·web-ui) + `web-ai/services/insta-render`(렌더 워커, **별도 repo**)
- **사이클**: 스마트 에이전트 고도화 3종 중 **3번 인스타**. (1 로또·2 주식 배포 완료)
---
## 1. 배경 & 목표
현재 insta-lab은 뉴스→키워드→Claude 카피(cover+본문8+cta+caption+hashtags)→Redis push→**Windows insta-render 워커**가 Jinja→HTML→Playwright 스크린샷(1080×1350)→텔레그램 전달 흐름이다. 그러나 카드가 "진짜 카드뉴스" 품질에 못 미치고(메모리상 렌더 known-issue), 현재 default 템플릿은 55줄짜리 기본형(accent+headline/body/footer)이다.
CEO 목표: **진짜 카드뉴스 형식**으로 카드 품질을 끌어올리고, 완성 패키지를 **인스타에 업로드하기 쉽게** 만든다.
### 핵심 결정 (2026-06-02 brainstorming)
1. **업로드 방식 = 반자동(현행 개선)**. Instagram Graph API/Meta 앱/IG 비즈니스 계정 미사용. 완성 카드+캡션을 사용자가 인스타 앱에서 직접 업로드하되, **마찰 없는 패키지 전달**(텔레그램 + zip 다운로드)로 개선.
2. **카드 품질 = 디자인 시스템 템플릿 고도화**. 폴리시한 HTML/CSS 디자인 시스템 + Playwright 렌더, known-issue 해결. (AI 생성 비주얼·Vision import 수리 아님)
3. **비주얼 = 모던 미니멀**. 넉넉한 여백·강한 산세리프 타이포·1~2 accent·깔끔한 그리드. 단일 강한 default 테마(멀티테마 X), accent만 카테고리별.
### 기존 자산 (재사용)
- `insta-lab/app/card_writer.py` — Claude 카피 생성(cover_copy{headline,body,accent_color}, body_copies[8]{headline,body}, cta_copy{headline,body,cta}, suggested_caption, hashtags[]).
- `insta-lab/app/templates/default/card.html.j2` — 격상 대상(현 55줄 기본형).
- `web-ai/services/insta-render/`: `worker.py`(BLPOP `queue:insta-render``GET /api/insta/slates/{id}``render_slate` → webhook `/api/internal/insta/update`), `card_renderer.py`(`_build_pages`로 10페이지 spec 구성 cover/body8/cta, Jinja→HTML→`page.goto(file://, networkidle)``screenshot(full_page=False)` @viewport 1080×1350, `CARD_TEMPLATE_DIR`에서 템플릿 로드).
- nginx `/media/insta/``/data/insta_cards/`(카드 PNG 공개 서빙) — 패키지 다운로드에 활용.
### known-issue 근원 (이번 작업으로 해결)
- 웹폰트(@import Google Fonts) 로딩 전 스크린샷 → fallback 폰트 렌더.
- `full_page=False` + 콘텐츠가 1350px 초과 → 하단 잘림.
- (기존 minimal 테마) Vision-import 마스킹 좌표·background-image 경로 문제 → **신규 깨끗한 디자인 시스템 템플릿으로 경로 자체를 제거(우회)**.
---
## 2. 디자인 시스템 (모던 미니멀)
`insta-lab/app/templates/default/card.html.j2`를 페이지 타입별 레이아웃을 가진 디자인 시스템으로 재작성.
### 페이지 타입별 레이아웃 (`_build_pages`의 page_type 사용)
- **cover** (page 1): 카테고리 배지 + 대형 헤드라인(96px급) + 서브카피 + 브랜드 핸들. 시선 집중.
- **body** ×8 (page 2~9): 좌상단 번호 인덱스(02~09) + 포인트 헤드라인(72px급) + 본문(40px급, 2~4문장) + 하단 진행 인디케이터(점/바). 일관 그리드.
- **cta** (page 10): 요약 헤드라인 + 마무리 본문 + 행동유도(팔로우/저장) + 핸들.
### 디자인 토큰
- 타이포: Pretendard(우선) 또는 Noto Sans KR, weight 900/700/400, letter-spacing 음수, line-height 1.15~1.55.
- 레이아웃: 1080×1350 고정, safe-margin(예: 좌우/상하 ~80px), 그리드 정렬.
- 컬러: 라이트 배경(#F7F7FA 계열) + `accent_color`(카테고리별, 데이터 기존: economy #0F62FE / psychology #A66CFF / celebrity #FF5C8A 등) 포인트.
- 푸터: `{page_no} / {total_pages}` + 브랜드 핸들. body는 진행 인디케이터.
### 제약
- 각 페이지 = 정확히 1080×1350 고정 박스, `overflow:hidden`. 긴 본문 대비 본문 컨테이너 `max-height` + 줄수 clamp(말줄임 또는 폰트 축소).
- 단일 default 테마. accent만 카테고리 차등(추가 테마 디렉토리 안 만듦).
---
## 3. 렌더 견고화 (web-ai 워커, known-issue 해결)
`web-ai/services/insta-render/card_renderer.py` 보강:
- **폰트 보장**: `page.goto` 후 screenshot 전에 `await page.evaluate('document.fonts.ready')` 대기 추가. (가능하면 Pretendard를 워커에 self-host/번들해 네트워크 의존 제거 — 폴백으로 fonts.ready 대기.)
- **정확한 1080×1350**: 템플릿이 `.card{width:1080px;height:1350px;overflow:hidden}`을 보장. `full_page=False` + viewport 1080×1350 유지. 콘텐츠 오버플로우는 템플릿 CSS(clamp/max-height)로 차단.
- **PNG 검증**: 렌더 후 각 PNG가 1080×1350인지 + 0바이트/빈 페이지 아닌지 확인. 실패 시 webhook `failed`.
- **템플릿 sync (open item)**: 워커의 `CARD_TEMPLATE_DIR`가 신규 디자인 템플릿을 받는 경로 확인·정립. (insta-lab 템플릿 → 워커로 어떻게 전달되는지 plan에서 확인: web-ai repo 복사본인지 별도 sync인지. 신규 템플릿이 워커에 반영돼야 효과 발생.)
---
## 4. 카피 정합 + 업로드 친화 패키지
- **카피 글자수 가이드**: `card_writer.py`의 프롬프트에 헤드라인/본문 글자수 상한 명시(디자인 박스에 맞게) → 오버플로우 예방. 시작 기준값(템플릿 박스 확정 시 ±조정): cover headline ≤ 22자 / body headline ≤ 26자 / body ≤ 120자 / cta headline ≤ 22자. CSS clamp가 2차 방어이므로 가이드는 근사치여도 안전.
- **업로드 친화 패키지 (신규)**: 기존 텔레그램 미디어그룹(10장)+캡션/해시태그 유지 + **zip 다운로드** 추가:
- 신규 API `GET /api/insta/slates/{id}/package` → 10 PNG + `caption.txt`(suggested_caption + hashtags) 묶은 zip 반환.
- web-ui 슬레이트 상세에 "패키지 다운로드" 버튼.
- 사용자가 zip 받아 인스타 앱에 캐러셀 업로드 + caption 붙여넣기.
- **승인 게이트 유지**: 키워드 후보 푸시 → 사용자 선택 → 렌더 → 전달. 자동 게시 없음(반자동).
---
## 5. 에러·테스트·리스크·스코프
- **2 repo 배포 경로**: insta-lab = git push → Gitea webhook 자동배포. web-ai 워커 = Windows 머신에서 별도 갱신(repo: ai-trade.git). 템플릿·렌더 변경이 양쪽에 반영돼야 함.
- **테스트**:
- insta-lab: card_writer 글자수 제약, zip 패키지 구성(10 PNG + caption.txt), package API.
- web-ai: 페이지 타입별 템플릿 렌더 HTML 스냅샷, PNG 1080×1350 크기 검증, fonts.ready 대기, 오버플로우 clamp (web-ai `tests/test_worker` 확장).
- **리스크**:
- 템플릿 sync 누락 → 워커가 구 템플릿 렌더(효과 없음). plan에서 sync 경로 확정.
- 긴 카피 오버플로우 → 글자수 가이드 + CSS clamp 이중 방어.
- 폰트 로딩 타이밍 → fonts.ready 대기(+self-host).
- known-issue는 깨끗한 디자인 시스템 + 렌더 견고화로 **근본 해결**(Vision-import 경로 제거).
---
## 6. 결정 로그 (2026-06-02)
1. 업로드 = 반자동(현행 개선, Graph API 미사용)
2. 카드 품질 = 디자인 시스템 템플릿 고도화
3. 비주얼 = 모던 미니멀, 단일 default 테마
## 7. 스코프 밖 / 향후
- Instagram Graph API 자동 게시, 멀티 테마, AI 생성 비주얼, Vision design_importer 수리, 카테고리별 차별 테마 — 향후.
- 9:30 자동 슬레이트(auto_select) 흐름 자체는 변경 안 함(품질·패키지만 개선).

View File

@@ -0,0 +1,120 @@
# insta 자율 카드 발급 (스마트 에이전트 3번) — 설계
> 작성 2026-06-11. InstaAgent를 "후보 푸시/단순 auto_select"에서 **선별 지능 + 승인 게이트 + 카덴스/추적**을 갖춘 자율 발급 파이프라인으로 확장.
## 1. 목표
매일 09:30, InstaAgent가 **발행할 가치 있는 주제만 자율 선별**해 카드를 생성·렌더하고, **카드별 승인 게이트**로 사람이 최종 결정(브랜드 안전)한 뒤 업로드용 카드를 발급한다. 발행 상태·이력을 추적해 중복 회피·카덴스 판단에 환류한다.
Instagram Graph API는 사용하지 않는다(수동 업로드). "발행(published)" = 승인되어 업로드 준비가 끝난 카드 상태 + 텔레그램으로 전달.
## 2. 현재 상태 (배경)
- insta-lab: 뉴스수집→키워드추출→슬레이트 생성(`POST /slates`)→Redis push→Windows 워커 렌더→webhook이 `card_assets` 등록. (2026-06-11 렌더 갭 복구 완료, slate 상태 `draft→rendered`.)
- agent-office `InstaAgent`: 09:30 cron에서 collect+extract 후 (기본) 텔레그램 후보 버튼 푸시 / (`auto_select=True`) 카테고리 1위 키워드 자동 렌더+미디어그룹 발송. 버튼 탭 → `render_{kid}` 콜백 → 슬레이트 생성·렌더·발송.
- `account_preferences`(카테고리 가중치) 존재. 발행 성과 추적은 없음.
즉 "생성→렌더→전달"은 동작한다. 본 설계는 그 앞단의 **자율 선별**과 뒷단의 **승인·추적**을 추가한다.
## 3. 요구사항 (확정)
- **선별 신호 4종**: ① 중복 회피(최근 발행/반려 주제 제외) ② 신선도(뉴스 최신성) ③ 계정 컨셉 적합도(카테고리 가중치) ④ Claude 판단(카드가치·흥미·리스크). 가중합 → threshold 게이트.
- **카덴스**: 에이전트 결정 — 매일 09:30, threshold 이상인 픽만 `max_per_day`까지(0~N 가변). 가치 없으면 발행 안 함.
- **승인**: 카드별 게이트. 자동 생성 후 텔레그램 프리뷰 `[✅승인][❌반려][🔄재생성]`. 승인만 published.
- **추적**: slate 상태 ∈ `{draft, rendered, rejected, published}` + 발행 이력. decision=approved→`published`, decision=rejected→`rejected`("approved"는 별도 저장 상태가 아니라 decision 액션). 성과 지표(좋아요·도달)는 범위 외(YAGNI — IG API 없어 수동).
## 4. 아키텍처 (접근법 A: 데이터 있는 곳에서 선별, 에이전트는 오케스트레이션)
```
[09:30 cron] InstaAgent.on_schedule (autonomous_issue=True)
1. collect + extract (기존 재사용)
2. GET /api/insta/keywords/ranked?threshold&limit ← insta-lab: 4신호 점수
3. eligible 픽마다(max_per_day): create_slate → wait render (기존 재사용)
4. 텔레그램 프리뷰(커버1장+요약) + [✅][❌][🔄] + agent_task(requires_approval) → waiting
[telegram webhook] → InstaAgent.on_callback
issue_approve_{id} → POST /slates/{id}/decision{approved} → published + 10장 미디어그룹 + /package zip
issue_reject_{id} → POST /slates/{id}/decision{rejected}
issue_regen_{id} → 같은 키워드로 슬레이트 재생성(새 카피) → 새 프리뷰 (이전 슬레이트 폐기)
```
경계: **insta-lab = 선별 점수 + 상태머신(DB 소유)**, **agent-office = cron 오케스트레이션 + 텔레그램 승인**.
## 5. insta-lab 상세
### 5.1 `app/selection.py` (순수 함수)
입력: 후보 키워드 리스트, 발행/반려 이력, 카테고리 선호 가중치, (선택) Claude 판단 점수.
출력: 후보별 `{keyword_id, final_score, breakdown:{dedup,freshness,account_fit,claude}, eligible}`.
신호별 정의:
- **dedup** (0 또는 1, exclude 게이트): 최근 `dedup_window_days`(기본 14) 내 `published`/`rejected` 슬레이트와 동일 키워드(정규화 후 exact/substring) + 동일 카테고리면 `eligible=False`로 제외.
- **freshness** (0~1): 키워드 `suggested_at`이 최근일수록 높음(예: 24h=1.0, 선형 감쇠, 7일+=0).
- **account_fit** (0~1): `account_preferences[category].weight`(정규화) × 키워드 자체 score.
- **claude** (0~1): Claude Haiku가 후보 일괄 평가(아래 5.3). 실패 시 이 항 제외하고 나머지로 정규화(graceful).
- **final_score** = 가중합 `w_fresh*freshness + w_fit*account_fit + w_claude*claude` (dedup 제외 통과한 것만). 기본 가중치 `{fresh:0.3, fit:0.3, claude:0.4}`. `eligible = (dedup 통과) and (final_score >= threshold)`.
### 5.2 엔드포인트
- `GET /api/insta/keywords/ranked?limit=N&threshold=T`
- 내부에서: 미사용 키워드 조회 + 발행/반려 이력 조회 + 선호 조회 + Claude 일괄 호출 → `selection.py` → 정렬된 후보 + breakdown + `eligible` 반환.
- `POST /api/insta/slates/{id}/decision` body `{"decision": "approved"|"rejected"}`
- approved → `status='published'`, `published_at=now`, `decision_at=now` (멱등: 이미 published면 no-op).
- rejected → `status='rejected'`, `decision_at=now`.
### 5.3 Claude 판단 프롬프트 (insta-lab, 기존 ANTHROPIC 클라이언트 재사용)
- 1회 호출로 후보 N개 일괄 평가. 입력: 각 후보 `{keyword, category}`. 출력: JSON `[{keyword_id, score(0~1), reason}]`.
- 기준: 카드뉴스로 만들 가치(흥미·시의성·정보성) 및 리스크(민감·논란). 모델 `ANTHROPIC_MODEL_HAIKU`.
- 실패/파싱오류 → 빈 결과 반환 → selection이 claude 항 제외.
### 5.4 스키마 (idempotent ALTER)
- `card_slates``published_at TEXT NULL`, `decision_at TEXT NULL` 추가.
- 상태값: `draft → rendered → approved/rejected → published`. (approved는 과도기 상태 없이 decision=approved 시 바로 published로 둔다 — 단순화. rejected는 종결.)
- 발행 이력 = `SELECT keyword, category, published_at FROM card_slates WHERE status IN ('published','rejected') AND COALESCE(published_at,decision_at) >= datetime('now', '-D days')`.
## 6. agent-office 상세
### 6.1 `InstaAgent.on_schedule`
- `custom_config.autonomous_issue` 분기. False면 **기존 동작 유지**(candidate-push / auto_select) — 하위호환.
- True면: collect+extract(기존) → `service_proxy.insta_ranked(threshold, limit=max_per_day)``eligible` 픽 순회(최대 `max_per_day`):
- 슬레이트 생성·렌더 대기(기존 `_render_and_push`의 생성·대기 부분 재사용/분리) → **프리뷰 발송**(6.3) → `create_task(requires_approval=True)``waiting` 상태.
- eligible 0개 → "오늘 발행할 가치 있는 주제 없음" 1통.
### 6.2 콜백 (telegram webhook → `on_callback`)
- `issue_approve_{slate_id}`: `insta_decision(slate_id, "approved")` → 전체 10장 미디어그룹 + `/package` zip 전달 + "✅ 발행 완료" → 해당 task succeeded.
- `issue_reject_{slate_id}`: `insta_decision(slate_id, "rejected")` → "❌ 반려됨" → task 종료.
- `issue_regen_{slate_id}`: 해당 슬레이트의 키워드로 새 슬레이트 생성(새 Claude 카피)·렌더 → 새 프리뷰. 이전 슬레이트는 rejected 처리.
### 6.3 텔레그램 프리뷰 (미디어그룹은 인라인 키보드 불가)
- 커버(01.png) 단장 사진 + 캡션: 키워드·카테고리·`final_score`·breakdown 요약 + inline `[✅승인][❌반려][🔄재생성]` (`callback_data=issue_*_{slate_id}`).
### 6.4 설정 (`agent_config.custom_config`)
- `autonomous_issue` (bool, 기본 false), `select_threshold` (기본 0.6), `max_per_day` (기본 2), `dedup_window_days` (기본 14).
### 6.5 service_proxy 추가
- `insta_ranked(threshold, limit)``GET /keywords/ranked`
- `insta_decision(slate_id, decision)``POST /slates/{id}/decision`
## 7. 에러 처리 / 엣지
- ranked의 Claude 실패 → 룰 점수만으로 진행(graceful), 경고 로그.
- eligible 0개 → 안내 1통(또는 무음 옵션, 기본 안내).
- 렌더 실패 → task failed 통지, 프리뷰 미발송.
- 승인 미응답 → 슬레이트 pending(rendered) 유지, 자동 발행 안 함(안전). 만료 없음.
- 멱등: 중복 승인/반려 no-op. cron 재실행 시 이미 발행/반려 주제는 dedup으로 회피.
- regen 무한루프 방지: regen은 사용자 트리거(버튼)라 자동 반복 없음.
## 8. 테스트
- **insta-lab**: `selection.py` 순수 단위테스트(dedup 최근 제외 / freshness 정렬 / account_fit 가중 / 가중합·threshold 게이트 / claude 실패 시 정규화). ranked 엔드포인트(Claude mock). decision 엔드포인트(approved→published+published_at, rejected, 멱등).
- **agent-office**: 자율 `on_schedule`(proxy mock: ranked eligible→슬레이트 생성→프리뷰 발송 + task requires_approval). 콜백 approve/reject/regen(proxy·messaging mock).
## 9. 범위 외 (YAGNI)
- 발행 성과 지표(좋아요·도달) 수집/학습 — IG API 미사용, 수동 입력 부담으로 제외.
- 신뢰도 하이브리드 자동발행(승인 생략) — 승인 게이트로 통일.
- 임베딩 기반 유사도 dedup — 정규화 exact/substring + 카테고리로 충분(추후 필요 시 확장).
## 10. 영향받는 파일
- insta-lab: `app/selection.py`(신규), `app/main.py`(ranked·decision 라우트), `app/db.py`(컬럼 ALTER + 발행이력/상태 헬퍼), `tests/`.
- agent-office: `app/agents/insta.py`(자율 경로·콜백), `app/service_proxy.py`(2 헬퍼), `app/webhook.py`(issue_* 콜백 디스패치), `tests/`.
- web-backend/CLAUDE.md insta API 목록 + `service_insta.md` 메모리 갱신.

View File

@@ -0,0 +1,127 @@
# co-gahusb — 세션 간 협업 팀 버스 설계
작성일: 2026-06-12
대상 repo: `web-backend` (서버) + `web-ui`/`web-ai` (클라이언트 배선)
목적: 독립 실행되는 4개 Claude Code 세션(FE/BE/AI/Producer)이 역할을 갖고 비동기로 소통·협업하되, 공유 DB/리소스는 동시 쓰기를 방지한다.
## 배경
web-ui / web-backend / web-ai 세션은 각각 독립 프로세스라 서로의 컨텍스트를 못 본다. 협업하려면 세 곳(서로 다른 머신 포함)에서 닿는 공유 메시지 버스가 필요하다. 사용자가 방식 B(독립 MCP 서버)를 선택했고, 민감한 공유 영역의 동시 쓰기 분리를 핵심 요구로 명시했다.
## 결정 사항 (브레인스토밍 확정)
- 호스팅: 신규 독립 컨테이너 **`co-gahusb`**, NAS, 포트 **18920**(18900 agent-office 옆, 미사용 확인).
- 전송/인증: **HTTP streamable MCP** + 정적 **Bearer 키**([[reference_webai_auth_pattern]] 재사용). nginx `/api/co/``co-gahusb:18920`, `Authorization` forward.
- 백엔드: **Redis**(기존 공유 컨테이너 `redis://redis:6379`). 전 연산 원자적 → SQLite multi-writer 함정([[reference_sqlite_concurrency]]) 회피.
- 동시쓰기 분리: **소유권 파티션 + 어드바이저리 락**.
- 역할: web-ui=FE, web-backend=BE, web-ai=AI, 이 세션=Producer.
- 수신: 각 세션 **/loop 폴링**(`read_inbox` + `list_tasks`).
## 아키텍처
```
[FE 세션 web-ui] [BE 세션 web-backend] [AI 세션 web-ai(다른 머신)] [Producer 세션]
\ | / /
\ | / /
──────── .mcp.json HTTP + Bearer ───────────────────────────────
nginx /api/co/ (Authorization forward)
co-gahusb:18920 (FastMCP streamable-http)
Redis (원자적 연산)
```
서버 구현: **Python `mcp` SDK(FastMCP) + streamable-http transport**(모든 lab이 FastAPI/Python 스택과 일관). 단일 책임 모듈로 분리:
- `app/server.py` — FastMCP 인스턴스 + 툴 등록 + ASGI 앱(streamable-http) + Bearer 인증 미들웨어
- `app/store.py` — Redis 데이터 액세스 레이어(메시지/작업/락), 전 함수 원자적
- `app/locks.py` — 락 Lua 스크립트(소유자 확인 후 release/heartbeat)
- `app/models.py` — 입출력 dataclass/스키마
- `app/config.py` — env(REDIS_URL, CO_BUS_KEY, 포트)
## MCP 툴 표면 (MVP — YAGNI)
| 분류 | 툴 | 시그니처 → 반환 |
|------|-----|------|
| 메시지 | `post_message` | `(from_role, to_role, body, thread_id?)``{message_id}` |
| 메시지 | `read_inbox` | `(role, after_id?, mark_read?=false)``{messages:[{id, from_role, body, thread_id, ts}], cursor}` |
| 작업 | `create_task` | `(title, assignee_role, detail?, created_by)``{task_id}` |
| 작업 | `claim_task` | `(task_id, role)``{ok, task}` (이미 claim 시 `{ok:false, held_by}`) |
| 작업 | `update_task` | `(task_id, status, role, note?)``{ok, task}` (status ∈ open/in_progress/blocked/done) |
| 작업 | `list_tasks` | `(status?, assignee_role?)``{tasks:[...]}` |
| 락 | `acquire_lock` | `(resource, role, ttl_sec=300)``{acquired, held_by?, ttl_remaining?}` |
| 락 | `release_lock` | `(resource, role)``{released}` (소유자 아니면 `{released:false}`) |
| 락 | `heartbeat_lock` | `(resource, role, ttl_sec=300)``{renewed}` (소유자만) |
| 락 | `list_locks` | `()``{locks:[{resource, held_by, ttl_remaining}]}` |
| 가시성 | `team_log` | `(after_id?)``{events:[...], cursor}` (최근 활동 피드) |
## Redis 데이터 모델 (전부 원자적)
- **메시지**: `co:inbox:{role}` = Redis **Stream**. `post_message`=XADD, `read_inbox`=XREAD(`after_id` 커서, 비파괴). `mark_read``co:read:{role}` 키에 마지막 id 저장.
- **작업**: `co:task:{id}` Hash(title/assignee/status/detail/created_by/ts), `co:tasks` Set(id 목록), `INCR co:taskseq`로 id. `claim_task`/`update_task`는 **Lua 스크립트**로 read-modify-write 원자화(중복 claim/경합 방지).
- **락**: 획득 = `SET co:lock:{resource} {role} NX EX {ttl}`(원자적). `release_lock`/`heartbeat_lock` = **Lua**로 `GET` 소유자 일치 확인 후 `DEL`/`EXPIRE`(check-and-act 원자화 → 남의 락 조작 불가).
- **활동로그**: `co:log` = 캡트 Stream(`XADD ... MAXLEN ~ 500`). 메시지·작업·락 이벤트 기록 → Producer 오버사이트.
## 동시 쓰기 분리 (핵심 요구)
**1차 — 정적 소유권 파티션** (락 불필요한 자연 분리):
- `web-ui` → FE만, `web-backend` → BE만, `web-ai` → AI만 쓰기. 각 세션은 자기 repo만 편집 → git 충돌 원천 차단.
**2차 — 교차 리소스 어드바이저리 락** (여러 역할이 건드릴 수 있는 민감 영역만):
- 예약 resource 명: `nas-deploy`, `stock-db-schema`, `lotto-db-schema`, `memory-mirror`(web-ui↔web-ai 미러), `nginx-conf`, `compose`.
- 규약: 위 리소스 변경 전 `acquire_lock` 필수. 점유 중이면 `{acquired:false, held_by, ttl_remaining}` → 대기. **TTL 자동 해제로 세션 사망 시 데드락 방지**, 긴 작업은 `heartbeat_lock` 갱신.
- 어드바이저리(협조적): 버스는 FS를 강제 잠그지 않음 → 각 세션 CLAUDE.md에 "공유 리소스 = 락 먼저" 규약 명문화로 강제.
## 클라이언트 배선
- 각 repo `.mcp.json`:
```json
{ "mcpServers": { "co-gahusb": {
"type": "http",
"url": "https://gahusb.synology.me/api/co/mcp",
"headers": { "Authorization": "Bearer ${CO_BUS_KEY}" } } } }
```
(키는 커밋 금지 — 각 머신 env/로컬에서 주입. `.mcp.json`엔 placeholder, 실제 키는 `.env`/환경변수.)
- 각 repo CLAUDE.md에 역할 블록 추가: "너는 역할 X / 모든 co-gahusb 툴에 role=X / 공유 리소스 변경 전 acquire_lock / `/loop`로 inbox·tasks 폴링".
- web-ai는 다른 머신 → 해당 머신에서 `.mcp.json` 적용(스펙에 절차 명시).
## 인프라 등재 (신규 컨테이너 추가 의무 위치 — [[reference_nas_url_routing]], [[reference_deploy_nas_services_whitelist]])
1. `docker-compose.yml` — `co-gahusb` 서비스(build, `REDIS_URL`, `depends_on: redis`, `CO_BUS_KEY` env, `${RUNTIME_PATH}` 볼륨 불요(상태는 Redis)).
2. nginx `default.conf` — **public `location /api/co/`** 추가(7번째 등재 규칙; `/api/internal/` 불필요).
3. deploy 스크립트 SERVICES 화이트리스트에 `co-gahusb` 등재.
4. `${RUNTIME_PATH}` 절대경로 — 본 서비스는 영속 볼륨 없음(Redis 백엔드)이라 코드 디렉토리만.
5. frontend `depends_on` — 불필요(백엔드 전용 서비스).
6. `.env` — `CO_BUS_KEY` 추가(커밋 금지).
## 에러 / 엣지 처리
- 인증 실패 → 401, 1회만 ERROR 로그 후 조용([[reference_webai_auth_pattern]]).
- 락 획득 실패 → 예외 아닌 `{acquired:false, held_by, ttl_remaining}` 정상 반환.
- 만료 락 → Redis TTL 자동 소멸(별도 GC 불필요).
- 알 수 없는 role/resource → 명시적 에러 메시지.
- Redis 연결 실패 → 503 + 명확한 메시지.
## 테스트 (TDD, pytest + fakeredis)
- **락**: 두 역할 같은 resource 획득 → 2번째 거부 / TTL 만료 후 획득 / 소유자 아닌 release·heartbeat 거부 / heartbeat 갱신 후 ttl 증가.
- **메시지**: XADD 순서대로 `after_id` 커서 읽기 / mark_read 후 재읽기 시 제외 / 다른 role 우편함 격리.
- **작업**: create→claim(중복 claim 거부)→update status 전이 / list 필터.
- **인증**: 키 일치 통과 / 불일치 401.
- **team_log**: 이벤트 기록 + MAXLEN 캡.
## 구현 순서 (phase)
1. 스캐폴드: 디렉토리/Dockerfile/requirements/config (기존 lab 구조 미러)
2. `store.py` + `locks.py` (TDD, fakeredis) — 락 → 메시지 → 작업 → team_log
3. `server.py` — FastMCP 툴 등록 + Bearer 인증 + ASGI
4. 인프라 등재 6위치 (compose/nginx/deploy/env)
5. 클라이언트 배선: web-ui·web-backend `.mcp.json` + CLAUDE.md 역할 블록 (web-ai는 절차 문서화)
6. 배포(Gitea push → webhook) + 스모크 테스트(헬스/인증/락 경합)
## 비범위 (YAGNI)
- 실시간 push(텔레그램) — 후속. 우선 /loop 폴링.
- SQLite 감사로그 — Redis 캡트 스트림으로 충분.
- 웹 대시보드 — agent-office 오버사이트와 추후 통합 여지.
- 락의 FS 레벨 강제 — 어드바이저리로 충분(세션은 협조적).

View File

@@ -0,0 +1,105 @@
# music/YouTube 파이프라인 신뢰성·복구 — 설계
> 작성 2026-06-12. YouTube 자동화 파이프라인의 step 실패를 자동 재시도(일시적)하고, 영구 실패는 실패 step부터 수동 재개(텔레그램 [🔄재시도])할 수 있게 한다. "music/YouTube 파이프라인 고도화" 중 **신뢰성/복구** 슬라이스.
## 1. 목표
파이프라인 step(`cover→video→thumb→meta→review→publish`) 실패가 ① 일시적이면 자동 재시도로 흡수하고, ② 영구적이면 terminal `failed`로 둔 뒤 **이전 산출물을 보존한 채 실패 step부터 재개**할 수 있게 한다. 현재는 step 한 번 실패하면 전체 파이프라인이 terminal `failed`가 되고 복구 경로가 없어 처음부터 다시 만들어야 한다.
## 2. 배경 (현재 동작)
- `orchestrator.run_step(pipeline_id, step, feedback)`: `pipeline_jobs` row 생성 → step 실행 → 성공 시 `update_pipeline_state(next_state)`, 예외 시 `pipeline_jobs.status='failed'` + 파이프라인 `state='failed'` + `failed_reason="{step}: {e}"`. **재시도/재개 없음.**
- 항상 `bg.add_task(orchestrator.run_step, pid, step, ...)`로 BackgroundTask 호출(start_pipeline→cover, feedback→next_step, publish_pipeline→publish).
- 이전 step 산출물(`cover_url`/`video_url`/`thumbnail_url`/`metadata_json`/`review_json`)은 파이프라인 row에 **보존**됨 → 실패 step만 재실행하면 이어갈 수 있는 구조.
- `state_machine`: STEPS, `_APPROVE_NEXT`, TERMINAL_STATES={published, cancelled, **failed**, awaiting_manual}.
- `agent-office youtube_publisher.poll_state_changes`: `*_pending` 신규 진입만 텔레그램 알림. **`failed`는 무알림(silent)** — 사용자가 실패를 모름.
## 3. 요구사항 (확정)
- **자동 재시도**: step 실행 실패 시 `STEP_MAX_RETRIES`(기본 2 → 총 3회)까지 backoff 재시도. 소진 후 terminal `failed`.
- `_resolve_input` 에러(입력/설정)는 재시도 안 함(재시도해도 안 고쳐짐).
- **`publish` step은 자동 재시도 제외** — youtube 업로드는 비멱등(중복 업로드 위험). 1회 시도 후 실패면 즉시 terminal.
- 재시도 대상 = `cover/video/thumb/meta/review`.
- **수동 재개**: terminal `failed` 파이프라인을 실패 step부터 재실행. 이전 산출물 보존.
- publish 재개 가드: `youtube_video_id`가 이미 있으면 재개 거부(원 업로드 성공 가능성 → 중복 방지).
- **실패 알림**: 영구 실패 시 텔레그램 알림 + 인라인 `[🔄재시도]` 버튼(현재 silent 갭 해소).
- **범위 밖(YAGNI)**: stuck 감지(*_running hang / *_pending 방치). 수동 재시도로 복구 가능하므로 이번 슬라이스 제외.
## 4. 아키텍처
3 컴포넌트:
```
[music-lab orchestrator] run_step: step 실행을 재시도 루프로 (publish 제외) → 소진 시 failed
[music-lab API] POST /api/music/pipeline/{id}/retry → 실패 step부터 run_step 재트리거
[agent-office] youtube_publisher: failed 감지 → 텔레그램 알림+[🔄재시도]
webhook: ytpub_retry_{pid} → service_proxy.pipeline_retry → music-lab retry
```
## 5. music-lab 상세
### 5.1 자동 재시도 (`pipeline/orchestrator.py`)
- 상수: `STEP_MAX_RETRIES = 2`, `STEP_RETRY_BACKOFF_SEC = [5, 15]`(시도 간 대기), `NON_RETRY_STEPS = {"publish"}`.
- `run_step`의 step 실행부(현재 try lines 31-47)를 루프로:
```
attempts = 1 if step in NON_RETRY_STEPS else (STEP_MAX_RETRIES + 1)
for i in range(attempts):
try:
result = await _dispatch_step(step, p, ctx, feedback)
update_pipeline_job(job_id, status="succeeded")
update_pipeline_state(pipeline_id, result["next_state"], **fields)
return
except Exception as e:
last = e
if i < attempts - 1:
add_log/pipeline_job note "retry {i+1}"
await asyncio.sleep(STEP_RETRY_BACKOFF_SEC[min(i, len-1)])
# 소진
update_pipeline_job(job_id, status="failed", error=str(last))
update_pipeline_state(pipeline_id, "failed", failed_reason=f"{step}: {last}")
```
- `_resolve_input` 실패는 루프 진입 전 early-return(현행 유지, 재시도 X).
- 재시도 시도 가시화: `pipeline_jobs`에 attempt별 기록(또는 error 메시지에 "attempt n/N").
### 5.2 resume 엔드포인트 (`main.py`)
- `POST /api/music/pipeline/{id}/retry`:
- 파이프라인 조회 없으면 404.
- `state != "failed"` → 409 "재개 불가 (state=...)".
- 실패 step 판별: `db.get_last_failed_step(pipeline_id)` (pipeline_jobs에서 status='failed' 최신 step). 없으면 `failed_reason.split(":")[0].strip()` 폴백.
- 실패 step이 `publish`이고 `youtube_video_id`가 이미 있으면 → 409 "이미 업로드됨 (중복 방지)".
- `bg.add_task(orchestrator.run_step, pid, failed_step)` 재트리거. 반환 `{ok: true, retrying_step}`.
- `db.get_last_failed_step(pipeline_id) -> str | None` 헬퍼 신규.
## 6. agent-office 상세
### 6.1 실패 알림 (`agents/youtube_publisher.py`)
- `poll_state_changes`: `_STEP_TITLES`(*_pending) 처리 후, `state == "failed"` 인 파이프라인도 검사.
- 신규 failed(중복 방지: `self._notified_failed: set[int]`, 또는 기존 dict에 ('failed', reason_hash))면 텔레그램 발송:
`⚠️ [{track_title}] 파이프라인 #{id} '{step}' 실패\n사유: {failed_reason}` + 인라인 `[🔄 재시도]` (callback_data `ytpub_retry_{id}`).
- 발송 후 notified 기록.
- `service_proxy.list_active_pipelines()`가 failed를 포함하는지 확인 — 미포함이면 failed도 반환하도록 보강(또는 별도 조회). (plan에서 확인.)
### 6.2 재시도 콜백 (`telegram/webhook.py`)
- `_handle_callback`에 `callback_id.startswith("ytpub_retry_")` 분기 → `_handle_ytpub_retry`.
- `_handle_ytpub_retry`: `pid = int(callback_id.removeprefix("ytpub_retry_"))` → `service_proxy.pipeline_retry(pid)` → 결과 텔레그램 회신("재개: {step}" / 거부 사유).
- `service_proxy.pipeline_retry(pid)` 신규: `POST {MUSIC_LAB_URL}/api/music/pipeline/{pid}/retry`.
## 7. 에러 처리 / 엣지
- 재시도 backoff 중 컨테이너 재시작 → 해당 step 작업 유실, 파이프라인 비-terminal stuck. 범위 밖이나 수동 [🔄재시도]로 복구 가능(안전망).
- resume 시 state≠failed → 409(중복 재개·동시성 방지). 텔레그램 [🔄재시도] 중복 탭도 멱등 거부.
- pipeline_jobs에 failed row 없고 state만 failed → `failed_reason` prefix 폴백.
- publish 재개 + `youtube_video_id` 존재 → 409(중복 업로드 방지).
- 알림 중복: notified 기록으로 같은 failed 1회만 발송.
## 8. 테스트
- **orchestrator (재시도)**: step 2회 실패 후 성공 → next_state 도달(3시도). 끝까지 실패 → failed. publish는 1시도 후 즉시 failed(재시도 X). `_resolve_input` 실패 → 재시도 없이 failed.
- **API retry**: failed→run_step 재트리거(mock 확인) + retrying_step 반환. 비-failed→409. publish+youtube_video_id→409.
- **db**: `get_last_failed_step` — 최신 failed job step 반환, 없으면 None.
- **agent-office**: poll 신규 failed→텔레그램 발송(중복 방지). `_handle_ytpub_retry`→service_proxy.pipeline_retry 호출 + pid 파싱.
## 9. 영향받는 파일
- music-lab: `app/pipeline/orchestrator.py`(재시도 루프 + `_dispatch_step` 추출), `app/main.py`(retry 엔드포인트), `app/db.py`(`get_last_failed_step`), `tests/`.
- agent-office: `app/agents/youtube_publisher.py`(failed 알림), `app/telegram/webhook.py`(ytpub_retry 디스패치), `app/service_proxy.py`(`pipeline_retry`, 필요 시 `list_active_pipelines` failed 포함), `tests/`.
- web-backend/CLAUDE.md music API 표 + `service_music.md` 메모리 갱신.

7
image-lab/Dockerfile Normal file
View File

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

View File

13
image-lab/app/auth.py Normal file
View File

@@ -0,0 +1,13 @@
"""Windows image-render worker → NAS image-lab internal webhook 인증."""
from __future__ import annotations
import os
from fastapi import Header, HTTPException
def verify_internal_key(x_internal_key: str = Header(...)):
expected = os.getenv("INTERNAL_API_KEY")
if not expected:
raise HTTPException(401, "INTERNAL_API_KEY not configured on server")
if x_internal_key != expected:
raise HTTPException(401, "Invalid X-Internal-Key")

83
image-lab/app/db.py Normal file
View File

@@ -0,0 +1,83 @@
"""SQLite persistence for image_tasks. Single table — task 단위 추적만."""
from __future__ import annotations
import json
import os
import sqlite3
from contextlib import contextmanager
from typing import Any, Dict, Optional
DB_PATH = os.path.join(os.getenv("IMAGE_DATA_DIR", "/app/data"), "image.db")
@contextmanager
def _conn():
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")
conn.execute("PRAGMA busy_timeout=5000")
try:
yield conn
conn.commit()
finally:
conn.close()
def init_db() -> None:
with _conn() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS image_tasks (
id TEXT PRIMARY KEY,
provider TEXT NOT NULL,
params TEXT NOT NULL,
status TEXT DEFAULT 'queued',
progress INTEGER DEFAULT 0,
message TEXT DEFAULT '',
image_url TEXT,
error TEXT,
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)
"""
)
def _row_to_dict(row) -> Dict[str, Any]:
return {
"id": row["id"], "provider": row["provider"], "params": row["params"],
"status": row["status"], "progress": row["progress"], "message": row["message"],
"image_url": row["image_url"], "error": row["error"],
"created_at": row["created_at"], "updated_at": row["updated_at"],
}
def create_task(task_id: str, provider: str, params: Dict[str, Any]) -> Dict[str, Any]:
with _conn() as conn:
conn.execute(
"INSERT INTO image_tasks (id, provider, params) VALUES (?, ?, ?)",
(task_id, provider, json.dumps(params)),
)
row = conn.execute("SELECT * FROM image_tasks WHERE id = ?", (task_id,)).fetchone()
return _row_to_dict(row)
def update_task(task_id: str, status: str, progress: int, message: str = "",
image_url: Optional[str] = None, error: Optional[str] = None) -> None:
with _conn() as conn:
conn.execute(
"""
UPDATE image_tasks
SET status = ?, progress = ?, message = ?, image_url = ?, error = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
WHERE id = ?
""",
(status, progress, message, image_url, error, task_id),
)
def get_task(task_id: str) -> Optional[Dict[str, Any]]:
with _conn() as conn:
row = conn.execute("SELECT * FROM image_tasks WHERE id = ?", (task_id,)).fetchone()
return _row_to_dict(row) if row else None

View File

@@ -0,0 +1,52 @@
"""Windows image-render → NAS image-lab internal webhook.
POST /api/internal/image/update
- X-Internal-Key 인증 필수
- image_tasks row update (status, progress, message, image_url, error)
"""
from __future__ import annotations
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from . import db
from .auth import verify_internal_key
logger = logging.getLogger(__name__)
router = APIRouter()
class UpdatePayload(BaseModel):
task_id: str
status: str = Field(..., description="processing|succeeded|failed")
progress: int = Field(..., ge=0, le=100)
message: str = ""
image_url: Optional[str] = None
error: Optional[str] = None
@router.post(
"/api/internal/image/update",
dependencies=[Depends(verify_internal_key)],
)
def image_update(payload: UpdatePayload):
task = db.get_task(payload.task_id)
if task is None:
raise HTTPException(404, f"task not found: {payload.task_id}")
db.update_task(
payload.task_id,
payload.status,
payload.progress,
message=payload.message,
image_url=payload.image_url,
error=payload.error,
)
logger.info(
"internal/image/update task=%s status=%s progress=%d",
payload.task_id, payload.status, payload.progress,
)
return {"ok": True}

113
image-lab/app/main.py Normal file
View File

@@ -0,0 +1,113 @@
"""FastAPI entrypoint for image-lab.
POST /api/image/generate — provider + prompt → Redis push → task_id
GET /api/image/tasks/{id} — DB 조회
GET /api/image/providers — 3 provider 메타
"""
from __future__ import annotations
import json
import logging
import os
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional
import redis.asyncio as aioredis
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
from . import db
from .internal_router import router as internal_router
logger = logging.getLogger(__name__)
CORS_ALLOW_ORIGINS = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080")
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379")
redis_client = aioredis.from_url(REDIS_URL, decode_responses=False)
SUPPORTED_PROVIDERS = {"gpt_image", "nano_banana", "flux"}
app = FastAPI()
app.include_router(internal_router)
app.add_middleware(
CORSMiddleware,
allow_origins=[o.strip() for o in CORS_ALLOW_ORIGINS.split(",")],
allow_credentials=False,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
allow_headers=["Content-Type"],
)
@app.on_event("startup")
def on_startup():
db.init_db()
@app.get("/health")
def health():
return {"ok": True, "service": "image-lab"}
@app.get("/api/image/providers")
def list_providers():
"""3 provider 항상 노출 (key 누락은 worker가 failed 보고)."""
return {"providers": [
{"id": "gpt_image", "name": "GPT Image 2.0", "models": ["gpt-image-1"],
"sizes": ["1024x1024", "1024x1536", "1536x1024"]},
{"id": "nano_banana", "name": "Nano Banana (Gemini)", "models": ["gemini-2.5-flash-image"],
"sizes": ["1024x1024"]},
{"id": "flux", "name": "FLUX (local)", "models": ["flux-schnell", "flux-dev"],
"sizes": ["1024x1024", "832x1216", "1216x832"]},
]}
class GenerateRequest(BaseModel):
provider: str = Field(..., description="gpt_image|nano_banana|flux")
model: Optional[str] = None
prompt: str
size: Optional[str] = None
negative_prompt: Optional[str] = None
# Provider 별 추가 키는 extra 허용
extra: Optional[Dict[str, Any]] = None
class Config:
extra = "allow"
async def _push_render_job(task_id: str, job_type: str, params: dict) -> None:
"""Redis queue:image-render에 push."""
kst = timezone(timedelta(hours=9))
payload = {
"task_id": task_id,
"kind": "image",
"job_type": job_type,
"params": params,
"submitted_at": datetime.now(kst).isoformat(),
}
await redis_client.rpush("queue:image-render", json.dumps(payload))
@app.post("/api/image/generate")
async def generate_image(req: GenerateRequest):
"""이미지 생성 — Redis 큐로 Windows image-render에 위임."""
if req.provider not in SUPPORTED_PROVIDERS:
raise HTTPException(400, f"지원하지 않는 provider: {req.provider} (supported: {sorted(SUPPORTED_PROVIDERS)})")
task_id = str(uuid.uuid4())
params = req.model_dump(exclude_none=True)
db.create_task(task_id, req.provider, params)
job_type = f"{req.provider}_generation" # gpt_image_generation, nano_banana_generation, flux_generation
await _push_render_job(task_id, job_type, params)
return {"task_id": task_id, "provider": req.provider}
@app.get("/api/image/tasks/{task_id}")
def get_task_status(task_id: str):
t = db.get_task(task_id)
if not t:
raise HTTPException(404, "task not found")
return t

4
image-lab/env.example Normal file
View File

@@ -0,0 +1,4 @@
INTERNAL_API_KEY=replace-me
IMAGE_DATA_DIR=/app/data
CORS_ALLOW_ORIGINS=http://localhost:3007,http://localhost:8080
REDIS_URL=redis://redis:6379

View File

@@ -0,0 +1,5 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
pydantic==2.9.2
redis==5.0.8
httpx==0.27.2

View File

View File

@@ -0,0 +1,19 @@
import pytest
from fastapi import HTTPException
from app.auth import verify_internal_key
def test_no_server_key_rejects(monkeypatch):
monkeypatch.delenv("INTERNAL_API_KEY", raising=False)
with pytest.raises(HTTPException) as e:
verify_internal_key("anything")
assert e.value.status_code == 401
def test_wrong_key_rejects(monkeypatch):
monkeypatch.setenv("INTERNAL_API_KEY", "secret")
with pytest.raises(HTTPException) as e:
verify_internal_key("wrong")
assert e.value.status_code == 401
def test_correct_key_passes(monkeypatch):
monkeypatch.setenv("INTERNAL_API_KEY", "secret")
assert verify_internal_key("secret") is None

View File

@@ -0,0 +1,29 @@
import os, tempfile, importlib
def _fresh_db(monkeypatch, tmp):
monkeypatch.setenv("IMAGE_DATA_DIR", tmp)
import app.db as db
importlib.reload(db)
db.init_db()
return db
def test_create_and_get_task(monkeypatch):
with tempfile.TemporaryDirectory() as tmp:
db = _fresh_db(monkeypatch, tmp)
row = db.create_task("t1", "gpt_image", {"prompt": "a cat"})
assert row["id"] == "t1"
assert row["provider"] == "gpt_image"
assert row["status"] == "queued"
got = db.get_task("t1")
assert got["id"] == "t1"
assert db.get_task("nope") is None
def test_update_task_sets_image_url(monkeypatch):
with tempfile.TemporaryDirectory() as tmp:
db = _fresh_db(monkeypatch, tmp)
db.create_task("t2", "nano_banana", {"prompt": "x"})
db.update_task("t2", "succeeded", 100, message="done", image_url="/media/image/t2.png")
got = db.get_task("t2")
assert got["status"] == "succeeded"
assert got["image_url"] == "/media/image/t2.png"
assert got["progress"] == 100

View File

@@ -0,0 +1,38 @@
import os, tempfile, importlib
from fastapi import FastAPI
from fastapi.testclient import TestClient
def _client(monkeypatch, tmp):
monkeypatch.setenv("IMAGE_DATA_DIR", tmp)
monkeypatch.setenv("INTERNAL_API_KEY", "secret")
import app.db as db; importlib.reload(db); db.init_db()
import app.internal_router as ir; importlib.reload(ir)
app = FastAPI(); app.include_router(ir.router)
return TestClient(app), db
def test_update_requires_key(monkeypatch):
with tempfile.TemporaryDirectory() as tmp:
client, db = _client(monkeypatch, tmp)
db.create_task("t1", "gpt_image", {"prompt": "x"})
r = client.post("/api/internal/image/update",
json={"task_id": "t1", "status": "succeeded", "progress": 100})
assert r.status_code == 422 or r.status_code == 401 # header 누락
def test_update_succeeds_with_key(monkeypatch):
with tempfile.TemporaryDirectory() as tmp:
client, db = _client(monkeypatch, tmp)
db.create_task("t1", "gpt_image", {"prompt": "x"})
r = client.post("/api/internal/image/update",
headers={"X-Internal-Key": "secret"},
json={"task_id": "t1", "status": "succeeded", "progress": 100,
"image_url": "/media/image/t1.png"})
assert r.status_code == 200
assert db.get_task("t1")["image_url"] == "/media/image/t1.png"
def test_update_unknown_task_404(monkeypatch):
with tempfile.TemporaryDirectory() as tmp:
client, db = _client(monkeypatch, tmp)
r = client.post("/api/internal/image/update",
headers={"X-Internal-Key": "secret"},
json={"task_id": "nope", "status": "failed", "progress": 0})
assert r.status_code == 404

View File

@@ -0,0 +1,43 @@
import os, tempfile, importlib
from fastapi.testclient import TestClient
def _client(monkeypatch, tmp):
monkeypatch.setenv("IMAGE_DATA_DIR", tmp)
import app.db as db
importlib.reload(db)
db.init_db()
import app.main as main
importlib.reload(main)
pushed = []
async def fake_push(task_id, job_type, params):
pushed.append((task_id, job_type, params))
monkeypatch.setattr(main, "_push_render_job", fake_push)
return TestClient(main.app), db, pushed
def test_providers_lists_three(monkeypatch):
with tempfile.TemporaryDirectory() as tmp:
client, _, _ = _client(monkeypatch, tmp)
r = client.get("/api/image/providers")
ids = {p["id"] for p in r.json()["providers"]}
assert ids == {"gpt_image", "nano_banana", "flux"}
def test_generate_rejects_unknown_provider(monkeypatch):
with tempfile.TemporaryDirectory() as tmp:
client, _, _ = _client(monkeypatch, tmp)
r = client.post("/api/image/generate", json={"provider": "midjourney", "prompt": "x"})
assert r.status_code == 400
def test_generate_creates_task_and_pushes(monkeypatch):
with tempfile.TemporaryDirectory() as tmp:
client, db, pushed = _client(monkeypatch, tmp)
r = client.post("/api/image/generate", json={"provider": "gpt_image", "prompt": "a cat"})
assert r.status_code == 200
task_id = r.json()["task_id"]
assert db.get_task(task_id)["status"] == "queued"
assert pushed[0][1] == "gpt_image_generation"

View File

@@ -35,6 +35,13 @@ DEFAULT_PROMPT = """너는 인스타그램 카드 뉴스 카피라이터다.
"suggested_caption": "<인스타 캡션 본문>",
"hashtags": ["#태그1", "#태그2", ...]
}}
[글자수 제약 — 카드 디자인 박스에 맞게 반드시 준수]
- cover_copy.headline: 22자 이내
- body_copies[].headline: 26자 이내
- body_copies[].body: 120자 이내 (2~4문장)
- cta_copy.headline: 22자 이내
초과하면 잘리므로 간결하고 임팩트 있게 작성한다.
"""

View File

@@ -124,6 +124,13 @@ def init_db() -> None:
(cat, 1.0),
)
# 발행 상태 컬럼 (idempotent ALTER) — 자율 발급 파이프라인
cs_cols = [r[1] for r in conn.execute("PRAGMA table_info(card_slates)").fetchall()]
if "published_at" not in cs_cols:
conn.execute("ALTER TABLE card_slates ADD COLUMN published_at TEXT")
if "decision_at" not in cs_cols:
conn.execute("ALTER TABLE card_slates ADD COLUMN decision_at TEXT")
# ── news_articles ────────────────────────────────────────────────
def add_news_article(row: Dict[str, Any]) -> int:
@@ -217,6 +224,39 @@ def update_slate_status(slate_id: int, status: str) -> None:
)
def set_slate_decision(slate_id: int, decision: str) -> None:
"""승인/반려 결정 기록. approved→published(+published_at), rejected→rejected.
멱등: 이미 published면 published_at 유지."""
now = "strftime('%Y-%m-%dT%H:%M:%fZ','now')"
with _conn() as conn:
if decision == "approved":
conn.execute(
f"UPDATE card_slates SET status='published', "
f"published_at=COALESCE(published_at, {now}), decision_at={now} "
f"WHERE id=?",
(slate_id,),
)
elif decision == "rejected":
conn.execute(
f"UPDATE card_slates SET status='rejected', decision_at={now} WHERE id=?",
(slate_id,),
)
else:
raise ValueError(f"invalid decision: {decision}")
def list_recent_issued_topics(window_days: int = 14) -> List[Dict[str, Any]]:
"""최근 window_days 내 published/rejected 슬레이트의 (keyword, category). dedup용."""
with _conn() as conn:
rows = conn.execute(
"SELECT keyword, category FROM card_slates "
"WHERE status IN ('published','rejected') "
"AND COALESCE(published_at, decision_at) >= datetime('now', ?)",
(f"-{int(window_days)} days",),
).fetchall()
return [dict(r) for r in rows]
def get_card_slate(slate_id: int) -> Optional[Dict[str, Any]]:
with _conn() as conn:
row = conn.execute("SELECT * FROM card_slates WHERE id=?", (slate_id,)).fetchone()

View File

@@ -10,18 +10,50 @@ from __future__ import annotations
import json
import logging
import os
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from . import db
from . import config, db
from .auth import verify_internal_key
logger = logging.getLogger(__name__)
router = APIRouter()
def _register_rendered_assets(slate_id: int) -> int:
"""워커가 저장한 10장 PNG를 card_assets로 등록.
cutover(2026-05-19) 후 렌더는 Windows insta-render 워커가 NAS SMB 볼륨에
직접 쓰지만, NAS DB에 card_assets를 등록하는 단계가 누락됐었다. 이 함수가
그 갭을 메운다. 워커 출력 경로 후보를 순서대로 스캔해 실제 파일만 등록한다
(경로 정합 가드: CARDS_DIR 하위 / INSTA_DATA_PATH 직하 둘 다 수용).
저장하는 file_path는 insta-lab 컨테이너 내부 절대경로 →
get_asset(FileResponse) / package(zip)가 그대로 읽는다.
"""
candidates = [
os.path.join(config.CARDS_DIR, str(slate_id)), # /app/data/insta_cards/{id}
os.path.join(config.INSTA_DATA_PATH, str(slate_id)), # /app/data/{id}
]
for base in candidates:
if not os.path.isdir(base):
continue
count = 0
for page in range(1, 11):
fp = os.path.join(base, f"{page:02d}.png")
if os.path.exists(fp) and os.path.getsize(fp) > 0:
db.add_card_asset(slate_id, page, fp)
count += 1
if count:
logger.info("card_assets 등록: slate=%s pages=%d dir=%s", slate_id, count, base)
return count
logger.warning("렌더 PNG를 찾지 못함: slate=%s (후보=%s)", slate_id, candidates)
return 0
class UpdatePayload(BaseModel):
task_id: str
status: str = Field(..., description="processing|succeeded|failed")
@@ -56,12 +88,16 @@ def insta_update(payload: UpdatePayload):
result_id=result_id,
error=payload.error,
)
# succeeded 시 slate_status도 'rendered'로 갱신 (cutover 후 NAS가 처리)
# succeeded 시 slate_status도 'rendered'로 갱신 + card_assets 등록 (cutover 후 NAS가 처리)
if payload.status == "succeeded" and result_id is not None:
try:
db.update_slate_status(result_id, "rendered")
except Exception:
logger.exception("update_slate_status %s 실패 (무시)", result_id)
try:
_register_rendered_assets(result_id)
except Exception:
logger.exception("card_assets 등록 %s 실패 (무시)", result_id)
logger.info(
"internal/insta/update task=%s status=%s progress=%d",
payload.task_id, payload.status, payload.progress,

View File

@@ -80,6 +80,7 @@ def extract_for_category(category: str, limit: int = KEYWORDS_PER_CATEGORY) -> L
"articles_count": sum(1 for a in articles if kw["keyword"] in a["title"]),
})
saved.append({"id": kid, **kw, "category": category})
logger.info(f"키워드 추출 완료: category={category!r}, count={len(saved)}")
return saved

View File

@@ -1,15 +1,19 @@
"""FastAPI entrypoint for insta-lab."""
import asyncio
import io
import json
import logging
import os
import zipfile
from datetime import datetime, timezone
from typing import Optional
from fastapi import FastAPI, HTTPException, BackgroundTasks, Body, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, StreamingResponse
from pydantic import BaseModel
from _shared.access_log import install as install_access_log
from .config import (
CORS_ALLOW_ORIGINS, NAVER_CLIENT_ID, ANTHROPIC_API_KEY,
@@ -18,7 +22,7 @@ from .config import (
)
import redis.asyncio as aioredis
from . import db, news_collector, keyword_extractor, card_writer, trend_collector
from . import db, news_collector, keyword_extractor, card_writer, trend_collector, selection, selection_judge
from .internal_router import router as internal_router
logger = logging.getLogger(__name__)
@@ -27,6 +31,7 @@ REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379")
redis_client = aioredis.from_url(REDIS_URL, decode_responses=False)
app = FastAPI()
install_access_log(app)
app.include_router(internal_router)
app.add_middleware(
@@ -148,6 +153,35 @@ def list_keywords(
return {"items": db.list_trending_keywords(category=category, used=used)}
# judge(Claude)에 보낼 최대 후보 수 — 미사용 키워드 대량 누적 시 응답 truncation으로
# claude 점수가 전부 null로 degrade되는 것을 방지 (base score 상위 N개만 평가).
JUDGE_CANDIDATE_CAP = 30
@app.get("/api/insta/keywords/ranked")
def ranked_keywords(
limit: int = Query(20, ge=1, le=100),
threshold: float = Query(0.6, ge=0.0, le=1.0),
dedup_window_days: int = Query(14, ge=1, le=90),
):
candidates = db.list_trending_keywords(used=False)
if not candidates:
return {"items": []}
# base score 상위 JUDGE_CANDIDATE_CAP개로 제한 → judge·선별 동일 집합에 적용(claude 신호 일관)
candidates = sorted(
candidates, key=lambda c: float(c.get("score", 0.0)), reverse=True
)[:JUDGE_CANDIDATE_CAP]
issued = db.list_recent_issued_topics(window_days=dedup_window_days)
prefs = {p["category"]: p["weight"] for p in db.get_preferences()}
claude_scores = selection_judge.judge_candidates(candidates)
now_iso = datetime.now(timezone.utc).isoformat()
scored = selection.score_candidates(
candidates, issued, prefs, claude_scores=claude_scores,
threshold=threshold, now_iso=now_iso,
)
return {"items": scored[:limit]}
# ── Slates ───────────────────────────────────────────────────────
class SlateRequest(BaseModel):
keyword: str
@@ -171,6 +205,7 @@ async def _bg_create_slate(task_id: str, keyword: str, category: str, keyword_id
"submitted_at": datetime.now(kst).isoformat(),
}
await redis_client.rpush("queue:insta-render", json.dumps(payload))
logger.info(f"슬레이트 생성 완료: slate_id={sid}, keyword={keyword!r}, category={category!r}")
# 사용자는 GET /api/insta/tasks/{task_id}로 폴링 — worker가 webhook으로 status update
db.update_task(task_id, "processing", 70, "Redis 큐 푸시 → Windows worker 대기 중", result_id=sid)
except Exception as e:
@@ -217,6 +252,7 @@ async def _bg_render(task_id: str, slate_id: int):
"submitted_at": datetime.now(kst).isoformat(),
}
await redis_client.rpush("queue:insta-render", json.dumps(payload))
logger.info(f"렌더 큐 푸시 완료: slate_id={slate_id}, task_id={task_id}")
db.update_task(task_id, "processing", 30, "Redis 큐 푸시 → Windows worker 대기 중")
except Exception as e:
logger.exception("queue push failed")
@@ -243,6 +279,53 @@ def get_asset(slate_id: int, page: int):
return FileResponse(match["file_path"], media_type="image/png")
@app.get("/api/insta/slates/{slate_id}/package")
def download_package(slate_id: int):
slate = db.get_card_slate(slate_id)
if not slate:
raise HTTPException(404, "slate not found")
assets = sorted(db.list_card_assets(slate_id), key=lambda a: a["page_index"])
if not assets:
raise HTTPException(409, "아직 렌더된 카드가 없습니다")
buf = io.BytesIO()
written = 0
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
for a in assets:
fp = a["file_path"]
if os.path.exists(fp):
z.write(fp, arcname=f"{a['page_index']:02d}.png")
written += 1
caption = (slate.get("suggested_caption") or "").strip()
tags = slate.get("hashtags") or []
if isinstance(tags, str):
try:
tags = json.loads(tags)
except Exception:
tags = []
caption_full = caption + ("\n\n" + " ".join(tags) if tags else "")
z.writestr("caption.txt", caption_full)
if written == 0:
raise HTTPException(409, "렌더된 카드 파일이 없습니다")
buf.seek(0)
return StreamingResponse(buf, media_type="application/zip", headers={
"Content-Disposition": f'attachment; filename="insta_slate_{slate_id}.zip"'
})
class DecisionBody(BaseModel):
decision: str # "approved" | "rejected"
@app.post("/api/insta/slates/{slate_id}/decision")
def slate_decision(slate_id: int, body: DecisionBody):
if not db.get_card_slate(slate_id):
raise HTTPException(404, "slate not found")
if body.decision not in ("approved", "rejected"):
raise HTTPException(400, "decision must be approved|rejected")
db.set_slate_decision(slate_id, body.decision)
return db.get_card_slate(slate_id)
@app.delete("/api/insta/slates/{slate_id}")
def delete_slate(slate_id: int):
if not db.get_card_slate(slate_id):
@@ -271,12 +354,40 @@ class TemplateBody(BaseModel):
description: str = ""
def _default_prompt_templates() -> dict:
"""DB에 저장된 override가 없을 때 노출할 코드 기본값.
생성 파이프라인이 실제로 폴백하는 값과 동일한 단일 소스를 사용."""
return {
"slate_writer": {
"template": card_writer.DEFAULT_PROMPT,
"description": "카드 10페이지 카피 생성 마스터 프롬프트 (Claude Sonnet). "
"{category}/{keyword}/{articles} 치환자 필수.",
},
"category_seeds": {
"template": json.dumps(DEFAULT_CATEGORY_SEEDS, ensure_ascii=False, indent=2),
"description": "트렌드 수집·분류용 카테고리별 시드 키워드 (JSON). "
"최상위 키가 분류 라벨로도 쓰임.",
},
}
@app.get("/api/insta/templates/prompts/{name}")
def get_prompt(name: str):
pt = db.get_prompt_template(name)
if not pt:
raise HTTPException(404)
return pt
if pt:
return pt
# DB override 없음 → 코드 기본값 노출 (편집 UI가 마스터 프롬프트를 보고 수정 가능)
defaults = _default_prompt_templates()
if name in defaults:
d = defaults[name]
return {
"name": name,
"template": d["template"],
"description": d["description"],
"updated_at": None,
"is_default": True,
}
raise HTTPException(404)
@app.put("/api/insta/templates/prompts/{name}")

View File

@@ -0,0 +1,83 @@
"""발행 가치 자율 선별 — 순수 점수 함수 (외부 IO 없음, 단위테스트 대상).
신호: dedup(게이트), freshness, account_fit, claude(선택).
final = 가중합(존재하는 신호만 정규화). eligible = dedup통과 and final>=threshold.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
DEFAULT_WEIGHTS = {"freshness": 0.3, "account_fit": 0.3, "claude": 0.4}
FRESH_WINDOW_HOURS = 168.0 # 7일 → 0
def _parse_iso(s: str) -> datetime:
return datetime.fromisoformat(s.replace("Z", "+00:00")).astimezone(timezone.utc)
def _norm(kw: str) -> str:
return (kw or "").strip().lower()
def _is_duplicate(keyword: str, category: str, issued: List[Dict[str, Any]]) -> bool:
n = _norm(keyword)
if not n:
return False
for it in issued:
if it.get("category") != category:
continue
m = _norm(it.get("keyword", ""))
if not m:
continue
if n == m or n in m or m in n:
return True
return False
def _freshness(suggested_at: str, now: datetime) -> float:
try:
hours = (now - _parse_iso(suggested_at)).total_seconds() / 3600.0
except Exception:
return 0.0
return max(0.0, min(1.0, 1.0 - hours / FRESH_WINDOW_HOURS))
def score_candidates(
candidates: List[Dict[str, Any]],
issued_topics: List[Dict[str, Any]],
prefs: Dict[str, float],
claude_scores: Optional[Dict[int, float]] = None,
weights: Optional[Dict[str, float]] = None,
threshold: float = 0.6,
now_iso: Optional[str] = None,
) -> List[Dict[str, Any]]:
w = weights or DEFAULT_WEIGHTS
now = _parse_iso(now_iso) if now_iso else datetime.now(timezone.utc)
max_w = max(prefs.values()) if prefs else 1.0
if max_w <= 0:
max_w = 1.0
out: List[Dict[str, Any]] = []
for c in candidates:
cat = c.get("category", "")
dup = _is_duplicate(c.get("keyword", ""), cat, issued_topics)
freshness = _freshness(c.get("suggested_at", ""), now)
weight = prefs.get(cat, 1.0)
account_fit = max(0.0, min(1.0, (weight / max_w) * float(c.get("score", 0.0))))
claude = None
if claude_scores is not None and c["id"] in claude_scores:
claude = max(0.0, min(1.0, float(claude_scores[c["id"]])))
parts = [("freshness", freshness), ("account_fit", account_fit)]
if claude is not None:
parts.append(("claude", claude))
total_w = sum(w[name] for name, _ in parts)
final = sum(w[name] * val for name, val in parts) / total_w if total_w else 0.0
eligible = (not dup) and (final >= threshold)
out.append({
"id": c["id"], "keyword": c.get("keyword"), "category": cat,
"final_score": round(final, 4), "eligible": eligible,
"breakdown": {"dedup_excluded": dup, "freshness": round(freshness, 4),
"account_fit": round(account_fit, 4), "claude": claude},
})
out.sort(key=lambda x: (-x["eligible"], -x["final_score"]))
return out

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