60 Commits

Author SHA1 Message Date
1e4c1b42b7 fix(insta-lab): 프롬프트 템플릿 GET이 미저장 시 코드 기본값 반환
slate_writer/category_seeds가 DB에 없으면 404 대신 생성 파이프라인이
실제 폴백하는 코드 기본값(card_writer.DEFAULT_PROMPT,
DEFAULT_CATEGORY_SEEDS)을 is_default=true로 반환. 편집 UI가 마스터
프롬프트를 표시·수정 가능. 미지정 이름은 여전히 404. 테스트 4건.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:22:20 +09:00
56 changed files with 12820 additions and 51 deletions

View File

@@ -164,10 +164,16 @@ docker compose up -d
| `lotto_briefings` | AI 큐레이터 주간 브리핑 (5세트 + 내러티브 + 토큰·비용 집계) |
| `todos` | 투두리스트 (UUID PK) — personal 서비스로 이전됨, 레거시 테이블 유지 |
| `blog_posts` | 블로그 글 (tags: JSON 배열) — personal 서비스로 이전됨, 레거시 테이블 유지 |
| `weight_trials` | 주별 6일치 후보 가중치 (4 perturb + 2 dirichlet) |
| `auto_picks` | 매일 N=5 시도 번호 + 채점 결과 |
| `weight_base_history` | base 갱신 이력 (winner_4plus / ema_blend / unchanged / cold_start) |
**스케줄러 job**
- 09:10 / 21:10 매일 — 당첨번호 동기화 + 채점 (`sync_latest``check_results_for_draw`)
- 00:05, 04:05, 08:05, 12:05, 16:05, 20:05 — 몬테카를로 시뮬레이션 (20,000후보 → 상위100 → best_picks 20개 교체)
- 월요일 09:00 — weight_evolver_weekly (6개 후보 생성 + 그날 N=5 추출)
- 매일 09:00 — weight_evolver_daily (월요일 제외, 오늘 W로 N=5 추출)
- 토요일 22:00 — weight_evolver_eval (회고 + 다음주 base 갱신)
**lotto-lab API 목록**
@@ -204,6 +210,11 @@ docker compose up -d
| GET | `/api/lotto/briefing/latest` | 최신 브리핑 |
| GET | `/api/lotto/briefing/{draw_no}` | 특정 회차 브리핑 |
| GET | `/api/lotto/briefing` | 브리핑 이력 |
| GET | `/api/lotto/evolver/status` | weight_evolver 이번주 trials + current_base + 진행 상황 |
| GET | `/api/lotto/evolver/history?weeks=12` | base 변경 이력 |
| GET | `/api/lotto/evolver/trials/{week_start}` | 특정 주 6 trials + 채점 결과 |
| POST | `/api/lotto/evolver/generate-now` | 수동 트리거 — 이번주 후보 생성 |
| POST | `/api/lotto/evolver/evaluate-now` | 수동 회고 + 다음주 base 갱신 |
### stock (stock/)
- Windows AI 서버 연동: `WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000`
@@ -553,6 +564,11 @@ docker compose up -d
- `LOTTO_BACKEND_URL`: 기본 `http://lotto:8000`
- `LOTTO_CURATOR_MODEL`: 기본 `claude-sonnet-4-5`
- `YOUTUBE_DATA_API_KEY`: YouTube Data API v3 키 (미설정 시 YouTube trending 수집 skip)
- `LOTTO_SIGNAL_WINDOW`: baseline 윈도우 크기 (기본 8)
- `LOTTO_Z_NORMAL`: normal fire 임계치 (기본 1.5)
- `LOTTO_Z_URGENT`: urgent fire 임계치 (기본 2.5)
- `LOTTO_THROTTLE_HOURS`: 같은 메트릭 재발화 throttle (기본 6시간)
- `LOTTO_URGENT_DAILY_MAX`: urgent 하루 cap (기본 3통)
**YouTubeResearchAgent (`agents/youtube.py`)**
- `agent_id = "youtube"` — AGENT_REGISTRY에 등록
@@ -577,6 +593,11 @@ docker compose up -d
- ~~09:15 매일 — 청약 매칭 데일리 리포트~~ (Task 2026-04-28에서 폐기. realestate-lab의 push 트리거로 전환)
- 09:00 매일 — YouTube 트렌드 수집 (`youtube_research`) → music-lab `/api/music/market/ingest` push
- 매주 월요일 08:00 — YouTube 주간 리포트 텔레그램 발송 (`youtube_weekly_report`)
- 09:15 매일 — 로또 light_check (시뮬·전략 가중치 평가)
- 매 4시간 :15 — 로또 sim_check (00/04/08/12/16/20시)
- 일/수 21:15 — 로또 deep_check (큐레이션 후 confidence 포함 평가)
- 09:25 매일 — 로또 daily_digest (지난 24h 발화 텔레그램 1통)
- 토요일 22:15 — 로또 weight_evolver 주간 텔레그램 리포트
**RealestateAgent (`agents/realestate.py`)**
- 진입점: `on_new_matches(matches: list[dict]) -> {sent, sent_ids, message_id}`
@@ -608,6 +629,9 @@ docker compose up -d
| GET | `/api/agent-office/conversation/stats` | 텔레그램 자연어 대화 토큰·캐시 통계 (`days` 필터) |
| POST | `/api/agent-office/youtube/research` | YouTube 트렌드 수집 수동 트리거 (body: `{countries: []}`) |
| GET | `/api/agent-office/youtube/research/status` | 마지막 수집 작업 상태 |
| GET | `/api/agent-office/lotto/signals?days=7` | 로또 능동 시그널 이력 (모든 fire_level) |
| GET | `/api/agent-office/lotto/baselines` | 로또 메트릭별 baseline μ/σ + 윈도우 상태 |
| POST | `/api/agent-office/lotto/signal-check?source=light` | 로또 시그널 평가 수동 트리거 (light/sim/deep) |
### personal (personal/)
- 개인 서비스 (포트폴리오 + 블로그 + 투두 통합)

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.
@@ -89,14 +109,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]:

View File

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

View File

@@ -29,3 +29,12 @@ CORS_ALLOW_ORIGINS = os.getenv(
# Lotto Curator
LOTTO_BACKEND_URL = os.getenv("LOTTO_BACKEND_URL", "http://lotto:8000")
LOTTO_CURATOR_MODEL = os.getenv("LOTTO_CURATOR_MODEL", "claude-sonnet-4-5")
# Lotto Active Signals
LOTTO_SIGNAL_WINDOW = int(os.getenv("LOTTO_SIGNAL_WINDOW", "8"))
LOTTO_Z_NORMAL = float(os.getenv("LOTTO_Z_NORMAL", "1.5"))
LOTTO_Z_URGENT = float(os.getenv("LOTTO_Z_URGENT", "2.5"))
LOTTO_DIGEST_HOUR = int(os.getenv("LOTTO_DIGEST_HOUR", "9"))
LOTTO_DIGEST_MIN = int(os.getenv("LOTTO_DIGEST_MIN", "25"))
LOTTO_THROTTLE_HOURS = int(os.getenv("LOTTO_THROTTLE_HOURS", "6"))
LOTTO_URGENT_DAILY_MAX = int(os.getenv("LOTTO_URGENT_DAILY_MAX", "3"))

View File

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

View File

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

View File

@@ -98,6 +98,39 @@ def init_db() -> None:
completed_at TEXT
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS lotto_signals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
triggered_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
source TEXT NOT NULL,
metric TEXT NOT NULL,
value REAL NOT NULL,
baseline_mu REAL,
baseline_sigma REAL,
z_score REAL,
fire_level TEXT NOT NULL,
notified_at TEXT,
payload TEXT
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_ls_triggered
ON lotto_signals(triggered_at DESC)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_ls_fire
ON lotto_signals(fire_level, notified_at)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS lotto_baselines (
metric TEXT PRIMARY KEY,
window_values TEXT NOT NULL DEFAULT '[]',
mu REAL NOT NULL DEFAULT 0.0,
sigma REAL NOT NULL DEFAULT 0.0,
last_pushed_draw_no INTEGER,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)
""")
# Seed default agent configs
for agent_id, name in [
("stock", "주식 트레이더"),
@@ -203,12 +236,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]
@@ -556,3 +601,168 @@ def get_latest_youtube_research_job() -> Optional[Dict[str, Any]]:
"started_at": row["started_at"],
"completed_at": row["completed_at"],
}
# --- lotto_signals / lotto_baselines CRUD ---
def insert_lotto_signal(
source: str,
metric: str,
value: float,
baseline_mu: Optional[float],
baseline_sigma: Optional[float],
z_score: Optional[float],
fire_level: str,
payload: Optional[Dict[str, Any]] = None,
) -> int:
with _conn() as conn:
cur = conn.execute(
"""
INSERT INTO lotto_signals
(source, metric, value, baseline_mu, baseline_sigma, z_score, fire_level, payload)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
source, metric, value,
baseline_mu, baseline_sigma, z_score, fire_level,
json.dumps(payload or {}, ensure_ascii=False),
),
)
return cur.lastrowid
def mark_signal_notified(signal_id: int) -> None:
with _conn() as conn:
conn.execute(
"UPDATE lotto_signals SET notified_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
(signal_id,),
)
def get_recent_lotto_signals(hours: int = 24, min_fire: str = "normal") -> List[Dict[str, Any]]:
"""지난 N시간 발화 시그널. min_fire='normal'이면 normal+urgent."""
levels = ("urgent",) if min_fire == "urgent" else ("normal", "urgent")
placeholders = ",".join("?" * len(levels))
with _conn() as conn:
rows = conn.execute(
f"""
SELECT * FROM lotto_signals
WHERE triggered_at >= datetime('now', ?)
AND fire_level IN ({placeholders})
ORDER BY triggered_at DESC
""",
(f"-{int(hours)} hours", *levels),
).fetchall()
return [dict(r) for r in rows]
def get_signals_history(days: int = 7) -> List[Dict[str, Any]]:
"""차트/이력 페이지용 — 모든 fire_level 포함."""
with _conn() as conn:
rows = conn.execute(
"""
SELECT * FROM lotto_signals
WHERE triggered_at >= datetime('now', ?)
ORDER BY triggered_at DESC
""",
(f"-{int(days)} days",),
).fetchall()
return [dict(r) for r in rows]
def get_recent_urgent_count(hours: int = 24) -> int:
with _conn() as conn:
row = conn.execute(
"""
SELECT COUNT(*) AS c FROM lotto_signals
WHERE triggered_at >= datetime('now', ?)
AND fire_level = 'urgent'
AND notified_at IS NOT NULL
""",
(f"-{int(hours)} hours",),
).fetchone()
return int(row["c"]) if row else 0
def get_last_signal_notification(metric: str, fire_level: str, hours: int) -> Optional[str]:
"""같은 metric+fire_level이 hours 내에 알림 발송된 마지막 시각. throttle용."""
with _conn() as conn:
row = conn.execute(
"""
SELECT notified_at FROM lotto_signals
WHERE metric = ?
AND fire_level = ?
AND notified_at IS NOT NULL
AND notified_at >= datetime('now', ?)
ORDER BY notified_at DESC LIMIT 1
""",
(metric, fire_level, f"-{int(hours)} hours"),
).fetchone()
return row["notified_at"] if row else None
def get_baseline(metric: str) -> Optional[Dict[str, Any]]:
with _conn() as conn:
row = conn.execute(
"SELECT * FROM lotto_baselines WHERE metric = ?",
(metric,),
).fetchone()
if not row:
return None
d = dict(row)
d["window_values"] = json.loads(d["window_values"])
return d
def upsert_baseline(
metric: str,
window_values: List[float],
mu: float,
sigma: float,
last_pushed_draw_no: Optional[int],
) -> None:
with _conn() as conn:
conn.execute(
"""
INSERT INTO lotto_baselines
(metric, window_values, mu, sigma, last_pushed_draw_no, updated_at)
VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))
ON CONFLICT(metric) DO UPDATE SET
window_values = excluded.window_values,
mu = excluded.mu,
sigma = excluded.sigma,
last_pushed_draw_no = excluded.last_pushed_draw_no,
updated_at = excluded.updated_at
""",
(
metric,
json.dumps(window_values),
mu, sigma, last_pushed_draw_no,
),
)
def get_all_baselines() -> List[Dict[str, Any]]:
with _conn() as conn:
rows = conn.execute("SELECT * FROM lotto_baselines ORDER BY metric").fetchall()
out = []
for r in rows:
d = dict(r)
d["window_values"] = json.loads(d["window_values"])
out.append(d)
return out
def get_tasks_by_agent_date_kind(agent_id: str, date_iso: str, task_type: str) -> List[Dict[str, Any]]:
"""같은 (agent, date, task_type)으로 이미 생성된 task 조회. 멱등 guard."""
with _conn() as conn:
rows = conn.execute(
"""
SELECT * FROM agent_tasks
WHERE agent_id = ? AND task_type = ?
AND substr(created_at, 1, 10) = ?
ORDER BY created_at DESC
""",
(agent_id, task_type, date_iso),
).fetchall()
return [_task_to_dict(r) for r in rows]

View File

@@ -1,5 +1,6 @@
import os
import json
from typing import Optional
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
@@ -104,8 +105,15 @@ 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):
@@ -227,3 +235,30 @@ def youtube_research_status():
if not job:
return {"status": "never_run"}
return job
# --- Lotto Signal Endpoints ---
@app.get("/api/agent-office/lotto/signals")
async def list_lotto_signals(days: int = 7):
"""시그널 이력 (모든 fire_level)."""
from .db import get_signals_history
return {"items": get_signals_history(days=days)}
@app.get("/api/agent-office/lotto/baselines")
async def list_lotto_baselines():
"""현재 baseline μ/σ + window 상태."""
from .db import get_all_baselines
return {"items": get_all_baselines()}
@app.post("/api/agent-office/lotto/signal-check")
async def trigger_signal_check(source: str = "light"):
"""수동 트리거 (디버그·테스트용). source ∈ {light, sim, deep}."""
if source not in ("light", "sim", "deep"):
raise HTTPException(status_code=400, detail="source must be light/sim/deep")
agent = AGENT_REGISTRY.get("lotto")
if not agent:
raise HTTPException(status_code=503, detail="lotto agent not registered")
return await agent.run_signal_check(source=source)

View File

@@ -1,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로 자동 발송.
@@ -59,3 +59,169 @@ async def send_prize_alert(event: Dict[str, Any]) -> None:
await send_raw(text)
except Exception as e:
logger.warning(f"[telegram_lotto] prize alert send failed: {e}")
# ---------- 능동 시그널 알림 (urgent + digest) ----------
_METRIC_LABEL = {
"sim_signal": "Sim Consensus",
"drift": "Strategy Drift",
"confidence": "Confidence",
}
def _format_urgent_signal(event: Dict[str, Any]) -> str:
"""긴급 시그널 텔레그램 메시지 포맷."""
triggered = event.get("triggered_at", "")[:19].replace("T", " ")
results = event.get("results", [])
fired = [r for r in results if r.get("fire_level") in ("normal", "urgent")]
lines = [
"🚨 로또 능동 신호",
"",
f"[{triggered}]",
f"강한 시그널 {len(fired)}종 발화:",
]
for r in fired:
label = _METRIC_LABEL.get(r["metric"], r["metric"])
v = r.get("value")
mu = r.get("baseline_mu")
sigma = r.get("baseline_sigma")
z = r.get("z_score")
v_text = f"{v:.2f}" if v is not None else "N/A"
if mu is not None and sigma is not None and z is not None:
lines.append(f"{label} {v_text} (μ={mu:.2f}, σ={sigma:.2f}) z={z:.1f}")
else:
lines.append(f"{label} {v_text}")
# drift 페이로드 — 어떤 전략이 변동했는지 한 줄
for r in fired:
if r["metric"] == "drift":
wn = (r.get("payload") or {}).get("weights_now") or {}
wp = (r.get("payload") or {}).get("weights_prev") or {}
if wn and wp:
diffs = {k: wn.get(k, 0) - wp.get(k, 0) for k in (set(wn) | set(wp))}
top = sorted(diffs.items(), key=lambda kv: abs(kv[1]), reverse=True)[:2]
detail = ", ".join(f"{k} {'+' if d>=0 else ''}{d*100:.0f}%p" for k, d in top)
lines.append("")
lines.append(f"요인: {detail}")
break
lines.append("")
lines.append(f"[자세히 보기] ({LOTTO_URL}/agent)")
return "\n".join(lines)
def _format_signal_digest(digest: Dict[str, Any]) -> str:
"""일일 요약 메시지. 발화 0건이면 빈 문자열 (발송 skip 신호)."""
fired = int(digest.get("fired", 0))
if fired == 0:
return ""
signals_list = digest.get("signals", [])
evaluated = digest.get("evaluated", 0)
lines = [
"📊 로또 일일 요약 (지난 24h)",
"",
f"평가 {evaluated}회 / 발화 {fired}",
]
for s in signals_list:
label = _METRIC_LABEL.get(s["metric"], s["metric"])
z = s.get("z_score")
when = (s.get("triggered_at") or "")[11:16] # HH:MM
z_text = f"z={z:.1f}" if z is not None else "z=-"
lines.append(f"{label:14s} {s['fire_level']:6s} {z_text} ({when})")
weights_trend = digest.get("weights_trend") or {}
if weights_trend:
lines += ["", "전략 가중치 추세 (최근 8회 baseline):"]
for strategy, delta in sorted(weights_trend.items(), key=lambda kv: -abs(kv[1])):
arrow = "" if delta > 0.01 else ("" if delta < -0.01 else "")
lines.append(f" {strategy:12s} {arrow} {delta*100:+.0f}%")
return "\n".join(lines)
async def send_urgent_signal(event: Dict[str, Any]) -> None:
text = _format_urgent_signal(event)
try:
await send_raw(text)
except Exception as e:
logger.warning(f"[telegram_lotto] urgent signal send failed: {e}")
async def send_signal_summary(digest: Dict[str, Any]) -> None:
text = _format_signal_digest(digest)
if not text:
return # 발화 0건이면 발송 skip
try:
await send_raw(text)
except Exception as e:
logger.warning(f"[telegram_lotto] digest send failed: {e}")
# ---------- Weight Evolver 주간 리포트 ----------
_DAY_NAMES = ["", "", "", "", "", ""]
_METRIC_NAMES = ["freq", "finger", "gap", "cooccur", "divers"]
_REASON_LABEL = {
"winner_4plus": "4개 이상 일치 → base 교체",
"ema_blend": "3개 일치 → EMA blend (0.3)",
"unchanged": "유효 성과 없음 → base 유지",
"cold_start": "초기 균등 적용",
}
def _format_evolution_report(eval_result: Dict[str, Any], current_base: List[float]) -> str:
"""주간 weight evolution 텔레그램 메시지. ok=False 또는 winner 없으면 빈 문자열."""
if not eval_result or "winner" not in eval_result:
return ""
draw_no = eval_result.get("draw_no", "?")
winner = eval_result["winner"]
new_base = eval_result.get("new_base") or [0.0] * 5
reason = eval_result.get("update_reason", "")
dow = winner.get("day_of_week", 0)
day_name = _DAY_NAMES[dow] if 0 <= dow < len(_DAY_NAMES) else "?"
lines = [
f"🧬 로또 학습 주간 리포트 ({draw_no}회차)",
"",
f"이번주 시도: 6일 × {winner.get('n_picks', 5)}세트",
"",
f"🏆 Winner: {day_name}요일",
f" W = [" + ", ".join(
f"{name} {w:.2f}" for name, w in zip(_METRIC_NAMES, winner["weight"])
) + "]",
f" 최고 적중: {winner.get('max_correct', 0)}개 일치 (max={winner.get('max_correct', 0)})",
f" 평균 점수: {winner.get('avg_score', 0):.2f}",
"",
f"📊 다음주 base 변경 ({reason}):",
]
# 우선순위: eval_result.previous_base > current_base (eval 직후 stale) > 균등 fallback
base_now = eval_result.get("previous_base") or current_base or [0.2] * 5
for i, (cur, new) in enumerate(zip(base_now, new_base)):
diff = new - cur
if abs(diff) < 0.005:
marker = "="
elif diff > 0:
marker = "+" if diff < 0.05 else "++"
else:
marker = "-" if diff > -0.05 else "--"
lines.append(f" {_METRIC_NAMES[i]:8s} {cur:.2f}{new:.2f} ({marker})")
lines.append("")
lines.append(f"{_REASON_LABEL.get(reason, reason)}")
lines.append("")
lines.append(f"[웹에서 차트 보기] ({LOTTO_URL}/evolver)")
return "\n".join(lines)
async def send_evolution_report(eval_result: Dict[str, Any], current_base: List[float]) -> None:
text = _format_evolution_report(eval_result, current_base)
if not text:
return
try:
await send_raw(text)
except Exception as e:
logger.warning(f"[telegram_lotto] evolution report send failed: {e}")

View File

@@ -36,6 +36,36 @@ async def _run_lotto_schedule():
if agent:
await agent.on_schedule()
async def _run_lotto_light_check():
agent = AGENT_REGISTRY.get("lotto")
if agent:
await agent.run_signal_check(source="light")
async def _run_lotto_sim_check():
agent = AGENT_REGISTRY.get("lotto")
if agent:
await agent.run_signal_check(source="sim")
async def _run_lotto_deep_check():
agent = AGENT_REGISTRY.get("lotto")
if agent:
await agent.run_signal_check(source="deep")
async def _run_lotto_daily_digest():
agent = AGENT_REGISTRY.get("lotto")
if agent:
await agent.run_daily_digest()
async def _run_lotto_weekly_evolution_report():
agent = AGENT_REGISTRY.get("lotto")
if agent:
await agent.run_weekly_evolution_report()
async def _run_lotto_sync_evolver_activity():
agent = AGENT_REGISTRY.get("lotto")
if agent:
await agent.sync_evolver_activity()
async def _run_youtube_research():
agent = AGENT_REGISTRY.get("youtube")
if agent:
@@ -70,9 +100,20 @@ def init_scheduler():
id="stock_ai_news_sentiment",
)
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_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")

View File

@@ -338,3 +338,59 @@ async def lookup_pipeline_by_msg(msg_id: int) -> Optional[dict]:
if resp.status_code == 200:
return resp.json()
return None
async def lotto_best() -> List[Dict[str, Any]]:
"""GET /api/lotto/best — best_picks 20개 (numbers + scores 5종)."""
from .config import LOTTO_BACKEND_URL
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/best")
resp.raise_for_status()
data = resp.json()
items = data.get("items") if isinstance(data, dict) else data
return items or []
async def lotto_strategy_weights() -> Dict[str, float]:
"""GET /api/lotto/strategy/weights — 전략별 가중치 dict."""
from .config import LOTTO_BACKEND_URL
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/strategy/weights")
resp.raise_for_status()
data = resp.json()
weights = data.get("weights") if isinstance(data, dict) else data
if isinstance(weights, list):
return {item["strategy"]: float(item["weight"]) for item in weights}
return {k: float(v) for k, v in (weights or {}).items()}
async def lotto_latest_draw() -> Optional[int]:
"""GET /api/lotto/latest — 최신 회차 번호만 반환."""
from .config import LOTTO_BACKEND_URL
try:
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/latest")
resp.raise_for_status()
data = resp.json()
# /api/lotto/latest 응답 키: {"drawNo": N, ...}
# 하위 호환을 위해 drawNo, draw_no, drwNo, draw 순서로 시도
for key in ("drawNo", "draw_no", "drwNo", "draw"):
if isinstance(data, dict) and data.get(key):
return int(data[key])
return None
except Exception:
return None
async def lotto_evolver_status() -> Dict[str, Any]:
"""GET /api/lotto/evolver/status — 이번주 trials + 다음주 base 정보."""
from .config import LOTTO_BACKEND_URL
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/evolver/status")
resp.raise_for_status()
return resp.json()
async def lotto_evolver_evaluate() -> Dict[str, Any]:
"""POST /api/lotto/evolver/evaluate-now — 회고 트리거 (텔레그램 리포트용)."""
from .config import LOTTO_BACKEND_URL
async with httpx.AsyncClient(timeout=60.0) as client:
resp = await client.post(f"{LOTTO_BACKEND_URL}/api/lotto/evolver/evaluate-now")
resp.raise_for_status()
return resp.json()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -90,6 +90,29 @@ services:
timeout: 5s
retries: 3
video-lab:
build:
context: ./video-lab
container_name: video-lab
restart: unless-stopped
ports:
- "18801:8000"
environment:
- TZ=${TZ:-Asia/Seoul}
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
- VIDEO_DATA_DIR=/app/data
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
volumes:
- ${RUNTIME_PATH}/data/video:/app/data
depends_on:
- redis
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 60s
timeout: 5s
retries: 3
insta-lab:
build:
context: ./insta-lab
@@ -265,6 +288,7 @@ services:
- personal
- packs-lab
- travel-proxy
- video-lab
ports:
- "8080:80"
volumes:
@@ -274,6 +298,7 @@ services:
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:ro
- ${RUNTIME_PATH}/data/music:/data/music:ro
- ${RUNTIME_PATH}/data/videos:/data/videos:ro
- ${RUNTIME_PATH}/data/video:/data/video:ro
- ${RUNTIME_PATH}/data/insta/insta_cards:/data/insta_cards:ro
extra_hosts:
- "host.docker.internal:host-gateway"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,301 @@
# LottoAgent 능동성 확장 설계
- **상태**: Draft (사용자 리뷰 대기)
- **작성일**: 2026-05-20
- **대상 컨테이너**: agent-office
- **영향 외부 도메인**: lotto-lab (read-only API 소비만)
---
## 1. 문제 정의
현재 LottoAgent는 매주 월요일 09:05 cron으로 무조건 큐레이션을 1회 실행하고 헤드라인을 텔레그램으로 푸시한다. "결과가 좋지 않은 회차"도 동일하게 발화되며, **정량적 시그널이 평소보다 강할 때 별도로 알리는 능동성**이 없다.
사용자 의도: 통계·시뮬레이션·전략 가중치를 에이전트가 스스로 모니터링하다가 "좋은 수치"가 나오면 능동적으로 보고하는 패턴.
## 2. 의사결정 요약
| 결정 사항 | 선택 | 비고 |
|---|---|---|
| 분석 주기 | 다중 트리거 혼합 | 매일 정기 + 시뮬레이션 후 + 회차 후 |
| 시그널 종류 | 3종 — Sim Consensus / Strategy Drift / Confidence | Hot/Cold 변화는 제외 (노이즈) |
| 알림 정책 | 일일 요약 + 긴급 즉시 | 2개 동시 발화 OR 단일 z≥2.5 → 긴급 |
| 임계치 전략 | 적응형 (최근 8회 μ + σ) | warmup·보수적 단계 포함 |
| 시뮬 강도 조절 (Layer B) | v1 미포함 | 운영 검증 후 v2에서 도입 검토 |
## 3. 아키텍처
### 3.1 컴포넌트 다이어그램
```
┌─────────────────────────────────────────────────────────────┐
│ agent-office │
│ │
│ cron (scheduler.py) │
│ ├─ lotto_light_check 매일 09:15 │
│ ├─ lotto_sim_check 4시간마다 :15 │
│ ├─ lotto_deep_check 일/수 21:15 │
│ ├─ lotto_daily_digest 매일 09:25 │
│ └─ lotto_curate 월요일 09:05 (기존 유지) │
│ ↓ │
│ curator/signals.py (신규) │
│ ├─ evaluate_sim_consensus() ← lotto_best API │
│ ├─ evaluate_strategy_drift() ← strategy/weights API │
│ ├─ evaluate_confidence() ← deep_check 시 큐레이션 결과 │
│ └─ adaptive_baseline() ← μ, σ 갱신 │
│ ↓ │
│ agent_office.db │
│ ├─ lotto_signals (이벤트 이력) │
│ └─ lotto_baselines (롤링 8회 윈도우) │
│ ↓ │
│ notifiers/telegram_lotto.py │
│ ├─ send_urgent_signal() ← 긴급 │
│ └─ send_signal_summary() ← 일일 요약 │
└─────────────────────────────────────────────────────────────┘
↑ (HTTP GET, 기존 lotto-lab API 재사용, 변경 없음)
lotto:8000
├─ /api/lotto/best
├─ /api/lotto/strategy/weights
└─ /api/lotto/curator/*
```
### 3.2 책임 경계
- **lotto-lab**: 변경 없음. 기존 GET API만 소비.
- **agent-office**: 능동 모니터링 layer 전부 담당. DB도 `agent_office.db` 안에 분리해서 lotto.db와 결합 없음.
- **프론트엔드**: Phase 4 별도 (web-ui repo). 본 spec 범위 밖.
## 4. 시그널 평가 로직
### 4.1 Sim Consensus Score
```
best_picks 20개의 점수 5종 (s1..s5) 사용
normalize(s_k) = (s_k - min_k) / (max_k - min_k) per metric across 20 picks
consensus_i = geomean( normalize(s1_i), ..., normalize(s5_i) )
sim_signal = mean( sorted(consensus_i, desc)[:10] )
```
- 기하평균: 5종 점수가 **동시에** 높을 때만 강한 시그널. 단일 폭주는 감쇠.
- top-10 평균: 전체 20개 분포에서 강한 후보군의 농도 측정.
### 4.2 Strategy Drift Score
```
drift_t = Σ | w_strategy_t - w_strategy_{t-1} | for each strategy in strategy_weights
```
- 회차 단위로 비교. 한 전략이 EMA로 큰 폭 이동했을 때 누적값이 큼.
- 시스템이 "지난 회차에서 의미 있게 학습한" 시그널.
### 4.3 Confidence Score
`curator.pipeline.curate_weekly()` 반환의 `validated.confidence` (0~1) 그대로.
- light_check / sim_check: N/A (LLM 호출 없음)
- deep_check: 직전 큐레이션 confidence를 baseline 윈도우에 push
### 4.4 Adaptive Baseline
```
lotto_baselines.window_values = [v_{t-7}, v_{t-6}, ..., v_t] (FIFO 8)
mu = mean(window_values)
sigma = stddev(window_values, ddof=1)
z_now = (v_now - mu) / sigma
```
- **Cold start**: window 크기 < 4 → fire_level='warmup', 발화 X
- **준비 단계**: window 4~7 → 임계치 z=2.0 (false positive 줄임)
- **정상 운영**: window 8 풀 → z_normal=1.5, z_urgent=2.5
### 4.5 Trigger × Metric 매트릭스
| Trigger | Sim Consensus | Strategy Drift | Confidence |
|---|---|---|---|
| `light_check` (매일 09:15) | ✓ 평가 | ✓ 회차 변경 시만 | — |
| `sim_check` (4h마다) | ✓ 평가 | ✓ 회차 변경 시만 | — |
| `deep_check` (일/수 21:15) | ✓ 평가 | ✓ 회차 변경 시만 | ✓ (큐레이션 후) |
| `lotto_curate` (월 09:05) | — | — | ✓ 큐레이션 결과 직접 push |
**회차 변경 가드**: Strategy Drift / Confidence는 **회차 단위 메트릭**. baseline 윈도우에 push할 때 `last_pushed_draw_no`를 비교, 동일 회차면 skip. 같은 회차 내에서 값 비교는 가능하지만 baseline 갱신은 회차당 1회만.
```
if metric in ('drift', 'confidence'):
if current_draw_no == baselines[metric].last_pushed_draw_no:
# baseline 윈도우는 그대로, z-score만 현재값으로 비교
skip_window_update = True
```
Sim Consensus는 회차 무관 (4시간마다 시뮬 자체가 갱신) → 매 평가 시 window push.
### 4.6 Fire 결정
```
fires = [m for m in [sim, drift, conf] if m.z >= LOTTO_Z_NORMAL]
if len(fires) >= 2 or any(m.z >= LOTTO_Z_URGENT for m in fires):
fire_level = 'urgent'
elif len(fires) == 1:
fire_level = 'normal'
else:
fire_level = 'noop'
```
## 5. 알림 흐름
### 5.1 트리거→발송 다이어그램
```
cron / signal_check
signals.evaluate_all()
lotto_signals INSERT (all results)
fire_level == 'urgent' → send_urgent_signal() → 텔레그램 즉시
fire_level == 'normal' → 09:25 digest 합류
fire_level == 'noop' → 기록만
```
### 5.2 텔레그램 메시지 폼
**Urgent**:
```
🚨 로또 능동 신호
[2026-05-20 16:18]
강한 시그널 2종 동시 발화:
• Sim Consensus 1.84 (μ=1.02, σ=0.21) z=3.9
• Strategy Drift 0.18 (μ=0.06, σ=0.04) z=3.0
요인: gap_focus 전략이 지난 3회차 EMA +22%p
다음 시뮬: 20:05
[자세히 보기] (→ /lotto/agent)
```
**Daily digest** (09:25):
```
📊 로또 일일 요약 (지난 24h)
평가 6회 / 발화 2회
• Sim Consensus normal z=1.7 (16:18)
• Confidence normal z=1.6 (월 09:05)
전략 가중치 추세 (최근 8회 baseline):
gap_focus ↑ +12%
hot_focus → -2%
pair_bias ↓ -8%
```
- 24h 내 발화 0건이면 digest 자체 skip (조용한 날 강제 알림 없음).
### 5.3 Throttle 규칙
| 규칙 | 동작 |
|---|---|
| 같은 metric + 같은 fire_level이 6시간 이내 재발화 | 두 번째는 DB 기록만, 텔레그램 skip |
| urgent 누적 ≥ 3통/day | 4번째부터 normal로 강등 → digest 합류 |
| digest 24h 발화 0건 | digest skip |
| Anthropic / 텔레그램 실패 | 평가는 success로 기록, 메시지만 60초 후 1회 retry |
## 6. 데이터 모델
### 6.1 lotto_signals
```sql
CREATE TABLE IF NOT EXISTS lotto_signals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
triggered_at TEXT NOT NULL, -- ISO8601 UTC
source TEXT NOT NULL, -- 'light' | 'sim' | 'deep'
metric TEXT NOT NULL, -- 'sim_signal' | 'drift' | 'confidence'
value REAL NOT NULL,
baseline_mu REAL,
baseline_sigma REAL,
z_score REAL,
fire_level TEXT NOT NULL, -- 'noop' | 'warmup' | 'normal' | 'urgent'
notified_at TEXT, -- 텔레그램 발송 시각 (NULL=미발송)
payload TEXT -- JSON 부가 정보
);
CREATE INDEX idx_ls_triggered ON lotto_signals(triggered_at DESC);
CREATE INDEX idx_ls_fire ON lotto_signals(fire_level, notified_at);
```
### 6.2 lotto_baselines
```sql
CREATE TABLE IF NOT EXISTS lotto_baselines (
metric TEXT PRIMARY KEY,
window_values TEXT NOT NULL, -- JSON: [v1..v8]
mu REAL NOT NULL,
sigma REAL NOT NULL,
last_pushed_draw_no INTEGER, -- 회차 단위 메트릭의 중복 push 방지 (drift, confidence)
updated_at TEXT NOT NULL
);
```
마이그레이션: `agent-office/app/db.py``init_db()``CREATE TABLE IF NOT EXISTS` 추가만으로 idempotent. 기존 테이블 영향 없음.
## 7. API 추가
| 메서드 | 경로 | 설명 |
|---|---|---|
| GET | `/api/agent-office/lotto/signals?days=7` | 시그널 이력 (timeline, 차트용) |
| GET | `/api/agent-office/lotto/baselines` | 현재 baseline μ/σ 조회 |
| POST | `/api/agent-office/lotto/signal-check` | 수동 트리거 (디버깅·테스트용) |
## 8. 환경 변수
```bash
LOTTO_SIGNAL_WINDOW=8 # baseline 윈도우 크기
LOTTO_Z_NORMAL=1.5 # normal fire 임계치
LOTTO_Z_URGENT=2.5 # urgent fire 임계치
LOTTO_DIGEST_HOUR=9 # digest cron hour (KST)
LOTTO_DIGEST_MIN=25
LOTTO_THROTTLE_HOURS=6 # 같은 메트릭 재발화 throttle
LOTTO_URGENT_DAILY_MAX=3 # urgent 하루 cap
```
모두 default 있음. `.env` 미설정 시 default로 동작.
## 9. 스케줄러 cron
```python
scheduler.add_job(lotto_light_check, "cron", hour=9, minute=15, id="lotto_light_check")
scheduler.add_job(lotto_sim_check, "cron", minute=15, hour="0,4,8,12,16,20", id="lotto_sim_check")
scheduler.add_job(lotto_deep_check, "cron", day_of_week="sun,wed", hour=21, minute=15, id="lotto_deep_check")
scheduler.add_job(lotto_daily_digest, "cron", hour=9, minute=25, id="lotto_digest")
# 기존: lotto_curate (월 09:05) 유지
```
## 10. 구현 Phase
| Phase | 범위 | 검증 |
|---|---|---|
| 1 | DB 마이그레이션 + `signals.py` (순수 함수, LLM X) | `POST /lotto/signal-check`로 수동 호출 → z-score 계산 확인 |
| 2 | cron 4개 + `lotto_signals` INSERT (텔레그램 X) | 24h 가동 → DB에 시그널 누적 |
| 3 | 텔레그램 urgent / digest + throttle | dry-run env로 메시지 검증 후 실제 발송 |
| 4 | 프론트 (web-ui) — `/lotto/agent` 시그널 timeline UI | 별도 PR (본 spec 범위 외) |
Phase 1~3이 백엔드 능동성 완성. 각 Phase 끝에 commit + 자동 배포.
## 11. 비기능 요구
- **백워드 호환**: 기존 월요일 큐레이션 cron 변경 없음
- **장애 격리**: signals 평가 실패해도 LottoAgent.state는 idle 유지 (try/except + add_log warning)
- **테스트**: `signals.py`의 메트릭 함수는 input/output 순수형 → 단위 테스트 작성 가능
- **관측**: `agent_logs` 테이블 그대로 활용 (별도 로깅 추가 없음)
## 12. 비목표 (Out of scope)
- 자동 구매·자동 픽 갱신 (보고만)
- 시뮬레이션 강도 자동 조절 (Layer B는 v2)
- 텔레그램 인라인 키보드 (v2에서 자동 액션 도입 시 함께)
- 핫넘버/콜드넘버 시그널 (노이즈 위험, v1 제외)
- 프론트 UI (별도 PR)
## 13. v2 후속 검토
- Layer B 시뮬 강도 조절 (모호 시그널 시 deep_simulate)
- 사용자 피드백 루프 (텔레그램 [좋아요/별로] 버튼 → 임계치 가중 조정)
- 회차 retrospective 자동 분석 (당첨번호 vs 추천번호 패턴 학습)

View File

@@ -0,0 +1,419 @@
# Lotto Weight Evolver — 자율 학습 루프 설계 (v2)
- **상태**: Draft (사용자 리뷰 대기)
- **작성일**: 2026-05-22
- **대상 컨테이너**: lotto (lotto-lab) + agent-office (텔레그램 보고)
- **선행 작업**: v1 LottoAgent 능동성 확장 (2026-05-20 배포)
- **목표**: 5종 시뮬 점수 가중치를 매주 6가지로 변형 시도 → 토요일 회고 → winner 가중치를 다음 주 base로 적용 → 무한 반복 자가 학습 루프
---
## 1. 문제 정의
현재 `analyzer.score_combination()`은 5종 점수(`score_frequency`, `score_fingerprint`, `score_gap`, `score_cooccur`, `score_diversity`)를 **균등 합산**으로 `score_total`을 계산한다. 어떤 메트릭이 실제 추첨 결과와 더 잘 상관되는지에 대한 학습 없이 가중치가 고정.
또한 `purchase_history` 기반 `strategy_evolver`**사용자가 실제 구매한 번호만** 학습 시그널로 사용. 사람이 안 사면 학습 안 됨.
사용자 요구: 에이전트가 사람 없이도 **매일 다른 가중치로 시뮬레이션 → 번호 시도 → 토요일 추첨 후 best 가중치 식별 → 다음주 base 갱신**의 무한 학습 루프.
## 2. 의사결정 요약
| 결정 사항 | 선택 | 비고 |
|---|---|---|
| 학습 대상 | 시뮬 점수 5종 가중치 (`W = [w_freq, w_finger, w_gap, w_cooccur, w_diversity]`) | 메타 전략 가중치는 strategy_evolver가 별도 학습 (v2에서 손대지 않음) |
| 탐험 전략 | 현재 base 주변 4개 perturbation + Dirichlet 무작위 2개 | 매주 월요일 6개 후보 |
| 일일 시도량 | N = 5 세트/일 × 6일 = 30 세트/주 | 통계적 의미 + 비용 균형 |
| 평가 시그널 | strategy_evolver의 `RANK_BONUS` + `correct/6` | 기존 패턴 재사용으로 일관성 |
| Base 적용 강도 | Hybrid — winner_max_correct ≥ 4면 교체, =3이면 EMA blend (0.3), ≤2면 유지 | 노이즈에 base가 헤매지 않도록 보호 |
| v1과의 결합 | W가 `analyzer.score_combination`에 반영 → best_picks 점수 자동 영향 → v1 시그널 자동 cascade | 별도 통합 코드 없음 |
| strategy_evolver와의 상호작용 | strategy_evolver는 `score_total`을 그대로 입력으로 사용 → W 변경 시 입력 분포가 함께 변함. **의도된 간접 영향** | v3에서 메타 가중치도 함께 학습할 때 명시적으로 분리 검토 |
| 자동 구매 | v2 비포함 | 사람 결정 영역 — purchase_history는 사람이 등록 |
## 3. 아키텍처
### 3.1 컴포넌트 다이어그램
```
┌─────────────────────────────────────────────────────────────┐
│ lotto-lab (자율 학습 루프 추가) │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ weight_evolver.py (신규) │ │
│ │ • generate_weekly_candidates() ← 월 09:00 │ │
│ │ • apply_today_weight() ← 매일 09:00 │ │
│ │ • evaluate_weekly() ← 토 22:00 │ │
│ │ • update_base() ← evaluate 안에서 │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ analyzer.score_combination(numbers, cache, │ │
│ │ weights=None) 확장 │ │
│ │ • weights=None → 균등 합산 (기존 호환) │ │
│ │ • weights=[..] → 가중 합산 │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ │
│ lotto.db 신규 테이블 3개 │
│ • weight_trials (주별 6일치 후보 가중치) │
│ • auto_picks (매일 N=5 시도 번호 + 채점 결과) │
│ • weight_base_history (base 변경 이력) │
│ │
│ 기존 시뮬 cron (00/04/08/12/16/20:05) — 변경 없음. │
│ 단 best_picks 재계산 시 활성 W를 읽어 적용. │
└─────────────────────────────────────────────────────────────┘
↓ (HTTP)
┌─────────────────────────────────────────────────────────────┐
│ agent-office │
│ │
│ cron 신규 1종: lotto_evolution_weekly (토 22:15) │
│ LottoAgent.run_weekly_evolution_report() (신규) │
│ notifiers/telegram_lotto.send_evolution_report() (신규) │
└─────────────────────────────────────────────────────────────┘
```
### 3.2 책임 경계
- **lotto-lab**: 가중치 생성·적용·평가·base 갱신 + DB CRUD + API. 시그널/알림 책임 없음.
- **agent-office**: 토요일 22:15 lotto-lab API 폴링 → 텔레그램 보고 1통.
- **v1 (signals layer)**: 변경 없음. W 변경의 효과는 best_picks 분포 변화로 자동 흡수.
- **strategy_evolver (메타 가중치 5종)**: 그대로 둠.
## 4. 가중치 진화 알고리즘
### 4.1 Weight Vector
```
W = [w_freq, w_finger, w_gap, w_cooccur, w_diversity]
제약: w_i ≥ 0.05, sum(W) = 1.0
```
(MIN_WEIGHT=0.05는 한 메트릭이 죽지 않도록 보호. strategy_evolver의 MIN_WEIGHT 패턴.)
### 4.2 주간 6개 후보 생성
`generate_weekly_candidates()` — 매주 월요일 09:00 KST.
```python
W_base = get_current_base() # weight_base_history 최신 row, 없으면 [0.2]*5
# 4개 Local Perturbation
for i in range(4):
noise = np.random.normal(0, 0.05, size=5)
W_i = W_base + noise
W_i = clamp(W_i, min=0.05)
W_i = W_i / W_i.sum()
save_trial(week_start, day=i, W_i, source='perturb', base=W_base)
# 2개 Dirichlet 탐험
for i in range(4, 6):
W_i = np.random.dirichlet([2.0]*5)
W_i = clamp(W_i, min=0.05)
W_i = W_i / W_i.sum()
save_trial(week_start, day=i, W_i, source='dirichlet', base=W_base)
```
- `σ=0.05` 정규분포: 각 메트릭 ±10%p 안쪽 변동
- `α=2.0` Dirichlet: 균등 분포에 약간 치우치게, 극단 가중치도 포함
### 4.3 일일 W 적용
`apply_today_weight()` — 매일 09:00 KST.
```python
W_today = get_trial(week_start, day_of_week=today)
set_active_weight(W_today) # 메모리 캐시 or DB row (W_active 테이블 또는 file)
generate_n_picks(N=5, weight=W_today) # auto_picks에 5세트 저장
```
같은 W로 그날 기존 시뮬 cron (4시간마다 6회) best_picks 재계산.
### 4.4 토요일 회고
`evaluate_weekly()` — 매주 토요일 22:00 KST (추첨 20:35 KST + sync 21:10 → 22:00 안전).
```python
winning_numbers = get_latest_draw().numbers # 1224, 1225, ...
trials = get_trials(week_start) # 6 trials
scores_per_day = []
for trial in trials:
picks = get_auto_picks(trial.id) # N=5
day_score = mean(
calc_pick_score(p.numbers, winning_numbers) for p in picks
)
max_correct = max(
count_match(p.numbers, winning_numbers) for p in picks
)
update_pick_grades(picks, winning_numbers) # auto_picks 채점 결과 저장
scores_per_day.append({
"trial_id": trial.id,
"day": trial.day_of_week,
"weight": trial.weight,
"score": day_score,
"max_correct": max_correct,
})
winner = max(scores_per_day, key=lambda s: s.score)
update_base(winner)
```
**점수 함수** (strategy_evolver `calc_draw_score` 패턴, 단순화):
v2에서는 보너스 번호를 평가에 포함하지 않음 → 5개 일치를 2등/3등으로 구분 불가. 따라서 보너스 무시한 단순 매핑:
```python
# correct → rank 매핑 (보너스 제외)
RANK_BY_CORRECT = {
6: 1, # 1등
5: 3, # 3등 (보너스 평가 안 함 → 2등 표시 X)
4: 4,
3: 5,
}
RANK_BONUS = {1: 1.0, 2: 0.8, 3: 0.6, 4: 0.3, 5: 0.1}
def calc_pick_score(pick_numbers, winning_numbers):
correct = count_match(pick_numbers, winning_numbers[:6])
base = correct / 6.0
rank = RANK_BY_CORRECT.get(correct)
bonus = RANK_BONUS.get(rank, 0)
return base + bonus
```
(rank=2의 보너스 0.8은 매핑되지 않으므로 v2 점수에 등장하지 않음. v3에서 보너스 번호 평가 도입 시 활성화.)
### 4.5 Base 갱신 규칙 (Hybrid)
```python
if winner.max_correct >= 4:
W_base_next = winner.weight
reason = "winner_4plus"
elif winner.max_correct == 3:
W_base_next = 0.3 * winner.weight + 0.7 * W_base_current
reason = "ema_blend"
else:
W_base_next = W_base_current
reason = "unchanged"
save_to_weight_base_history(W_base_next, reason, winner)
```
성과가 약할 때 base를 그대로 두는 게 핵심 — base가 노이즈에 따라 헤매지 않음.
### 4.6 Cold start (첫 주)
`weight_base_history`가 비어있으면 `W_base = [0.2]*5` (균등) 가정. 첫 주는 4 perturbation이 모두 균등 주변, 2 Dirichlet 탐험.
## 5. 데이터 모델
### 5.1 weight_trials
```sql
CREATE TABLE IF NOT EXISTS weight_trials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
week_start TEXT NOT NULL, -- 'YYYY-MM-DD' (해당 주 월요일)
day_of_week INTEGER NOT NULL, -- 0=월 .. 5=토
weight_json TEXT NOT NULL, -- '[0.18, 0.22, ...]'
source TEXT NOT NULL, -- 'perturb' | 'dirichlet'
base_at_gen TEXT, -- 생성 시점 W_base (참조용)
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
UNIQUE(week_start, day_of_week)
);
CREATE INDEX idx_wt_week ON weight_trials(week_start, day_of_week);
```
### 5.2 auto_picks
```sql
CREATE TABLE IF NOT EXISTS auto_picks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trial_id INTEGER NOT NULL REFERENCES weight_trials(id) ON DELETE CASCADE,
pick_no INTEGER NOT NULL, -- 1..5
numbers TEXT NOT NULL, -- JSON 정렬 6개
meta_score REAL, -- 활성 W로 계산한 score_total
correct INTEGER, -- 채점 후 채워짐
rank INTEGER, -- 1..5 또는 NULL
graded_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
UNIQUE(trial_id, pick_no)
);
CREATE INDEX idx_ap_trial ON auto_picks(trial_id);
CREATE INDEX idx_ap_graded ON auto_picks(graded_at);
```
### 5.3 weight_base_history
```sql
CREATE TABLE IF NOT EXISTS weight_base_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
effective_from TEXT NOT NULL, -- 'YYYY-MM-DD' (적용 시작 월요일)
weight_json TEXT NOT NULL,
source_trial_id INTEGER REFERENCES weight_trials(id), -- NULL=cold start
update_reason TEXT, -- 'winner_4plus' | 'ema_blend' | 'unchanged' | 'cold_start'
winner_score REAL,
winner_max_correct INTEGER,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
```
마이그레이션: `lotto/app/db.py``init_db()``CREATE TABLE IF NOT EXISTS` 추가만으로 idempotent. 기존 테이블 영향 없음.
## 6. analyzer.score_combination 시그니처 확장
```python
# 기존
def score_combination(numbers, cache) -> Dict[str, float]:
...
return {
"score_frequency": ...,
"score_fingerprint": ...,
"score_gap": ...,
"score_cooccur": ...,
"score_diversity": ...,
"score_total": sum(5 scores) # 균등 합산
}
# 변경
def score_combination(numbers, cache, weights: Optional[List[float]] = None) -> Dict[str, float]:
...
scores = [s_freq, s_finger, s_gap, s_cooccur, s_diversity]
if weights is None:
total = sum(scores)
else:
total = sum(s * w for s, w in zip(scores, weights))
return {
"score_frequency": ...,
...
"score_total": total
}
```
- 기본값 None → 기존 호출 호환 (변경 없는 효과)
- 시뮬 cron / smart_recommendation 등은 `get_active_weight()` 결과 전달
- 활성 W가 없으면 (cold start 이전) None 그대로 → 균등 합산 폴백
### 6.1 활성 W 조회 (`get_active_weight()`)
별도 캐시 테이블 없이 `weight_trials`에서 오늘 요일 row 직접 조회:
```python
def get_active_weight() -> Optional[List[float]]:
today = datetime.now(KST).date()
week_start = today - timedelta(days=today.weekday()) # 이번주 월요일
day_of_week = today.weekday() # 0=월, 6=일
if day_of_week == 6: # 일요일은 trial 없음 → 직전 토요일 W 유지
day_of_week = 5
row = db.get_weight_trial(week_start.isoformat(), day_of_week)
return json.loads(row["weight_json"]) if row else None
```
- 컨테이너 재시작·timezone 변화에 영향 없음 (DB 진실 기준)
- 일요일(6)은 토요일 W를 그대로 사용 (회고 cron 22:00 전까지)
- 첫 주 월요일 generate가 안 끝났을 때만 None 반환 → 균등 폴백
## 7. API 추가 (lotto-lab)
| 메서드 | 경로 | 설명 |
|---|---|---|
| GET | `/api/lotto/evolver/status` | 현재 base + 이번주 6 trials + 진행 상황 |
| GET | `/api/lotto/evolver/history?weeks=12` | 주별 winner + base 변경 이력 |
| GET | `/api/lotto/evolver/trials/{week_start}` | 특정 주 trials + 채점 결과 |
| POST | `/api/lotto/evolver/generate-now` | 수동 트리거 (다음 월요일 후보 생성) |
| POST | `/api/lotto/evolver/evaluate-now` | 수동 채점 (디버그) |
## 8. 스케줄러 cron (lotto-lab)
```python
scheduler.add_job(generate_weekly_candidates, "cron", day_of_week="mon", hour=9, minute=0, id="weight_evolver_weekly")
scheduler.add_job(apply_today_weight, "cron", hour=9, minute=0, id="weight_evolver_daily")
scheduler.add_job(evaluate_weekly, "cron", day_of_week="sat", hour=22, minute=0, id="weight_evolver_eval")
```
순서 보장: 월요일 09:00에 generate가 먼저 row 저장 후 commit, 그 다음 같은 시각 apply가 그 row 읽음. APScheduler가 동일 시간 job 직렬 실행 보장하지 않으므로 **월요일에 generate 함수 마지막에 inline으로 apply_today_weight 호출** — race 제거.
## 9. agent-office 통합 (텔레그램 주간 보고)
### 9.1 cron 추가
```python
scheduler.add_job(_run_lotto_weekly_evolution_report, "cron", day_of_week="sat", hour=22, minute=15, id="lotto_evolution_weekly")
```
### 9.2 LottoAgent.run_weekly_evolution_report (신규)
```python
async def run_weekly_evolution_report(self) -> dict:
from ..service_proxy import lotto_evolver_status
from ..notifiers.telegram_lotto import send_evolution_report
status = await lotto_evolver_status()
await send_evolution_report(status)
return {"ok": True, **status}
```
### 9.3 텔레그램 메시지 폼
```
🧬 로또 학습 주간 리포트 (1225회차)
이번주 시도: 6일 × 5세트 = 30번
🏆 Winner: 목요일 (W_4)
W = [freq 0.18, finger 0.32, gap 0.20, cooccur 0.22, divers 0.08]
최고 적중: 4개 일치 (1세트)
평균 점수: 0.42 (vs 다른 요일 0.18~0.30)
📊 다음주 base 변경:
freq 0.20 → 0.18 (-)
finger 0.20 → 0.32 (+)
gap 0.20 → 0.20 (=)
cooccur 0.20 → 0.22 (+)
divers 0.20 → 0.08 (--)
reason: winner_4plus (4개 이상 일치 → base 교체)
[웹에서 차트 보기] (/lotto/evolver)
```
## 10. v1 시그널과의 연동 (자동 cascade)
별도 코드 추가 없음. 활성 W가 `analyzer.score_combination`에 반영되면:
1. 매 4시간 시뮬 cron이 새 W로 best_picks 갱신
2. score 분포 자체가 변하므로 v1의 `sim_consensus_score`가 자동으로 새 분포 평가
3. W 변경 직후 outlier 패턴이 나오면 자연스럽게 sim_signal urgent fire
→ 사용자는 두 종류 텔레그램 받음:
- **🧬 토 22:15 weekly evolution report** (정해진 리듬)
- **🚨 평시 v1 urgent / 📊 v1 digest** (시그널 기반)
## 11. 구현 Phase
| Phase | 범위 | 검증 |
|---|---|---|
| 1 | DB 마이그레이션 + `weight_evolver.py` (순수 함수: generate/evaluate + 점수 함수) + 단위 테스트 | pytest로 perturbation·Dirichlet·점수·base 갱신 룰 검증 |
| 2 | analyzer.score_combination 시그니처 확장 + active weight 캐시 | 기존 시뮬 cron이 새 시그니처로 정상 동작 (regression X) |
| 3 | cron 3종 등록 + API 5종 | 수동 트리거로 generate→apply→evaluate 전체 흐름 확인 |
| 4 | agent-office 통합 (cron + 텔레그램 폼 + 테스트) | 토요일 22:15 자동 발송 확인 |
각 Phase 끝 commit + 자동 배포.
## 12. 비기능 요구
- **백워드 호환**: `analyzer.score_combination` 기본값 None → 기존 호출 그대로 작동
- **장애 격리**: 가중치 적용 실패 시 균등 합산 폴백, evaluate 실패해도 다음 주 base는 직전 값 유지
- **테스트**:
- `weight_evolver` 순수 함수 (clamp, normalize, perturbation, base update rule) — 단위 테스트
- `analyzer.score_combination(weights=...)` — 가중 합산 정확성 테스트
- `evaluate_weekly` mock 추첨번호 시나리오 — base 갱신 분기 3가지 (winner_4plus / ema_blend / unchanged)
- **관측**: `weight_base_history` 테이블로 모든 base 변경 추적 가능 (rollback도 가능)
## 13. 비목표 (Out of scope)
- 메타 전략(combined/simulation/heatmap/manual/custom) 가중치 학습 — strategy_evolver 영역, v3 후속
- 6일 trials의 day-transition에서 이전 W로 계산된 best_picks를 새 W로 재계산하는 처리 — 다음 시뮬 cron에서 자동 덮어씀
- Multi-objective 학습 (적중 + 분포 균등 등 복합 점수)
- 자동 구매 (purchase_history 자동 채움)
- 프론트 `/lotto/evolver` UI — v2 백엔드 완성 후 별도 PR (web-ui repo)
## 14. v3 후속 검토
- Multi-armed bandit (UCB1) — 탐험·활용 균형 더 정교
- 메타 전략 가중치도 함께 학습 (2-layer Bayesian Optimization)
- 가중치 공간을 RL agent로 학습 (policy gradient)
- 자동 구매 후보 픽 (winner W로 1주 N장 자동 발주, 사람 승인 후)

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

@@ -271,12 +271,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,63 @@
import os
import gc
import json
import tempfile
import pytest
from fastapi.testclient import TestClient
from app import db as db_module
@pytest.fixture
def client(monkeypatch):
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
monkeypatch.setattr(db_module, "DB_PATH", path)
db_module.init_db()
from app import main
monkeypatch.setattr(main, "DB_PATH", path)
with TestClient(main.app) as c:
yield c
gc.collect()
for ext in ("", "-wal", "-shm"):
try:
os.remove(path + ext)
except OSError:
pass
def test_get_slate_writer_returns_default_when_unset(client):
"""DB에 없으면 코드 기본 마스터 프롬프트를 200으로 반환 (404 아님)."""
resp = client.get("/api/insta/templates/prompts/slate_writer")
assert resp.status_code == 200
body = resp.json()
assert body["is_default"] is True
assert "{keyword}" in body["template"]
assert "{category}" in body["template"]
def test_get_category_seeds_returns_default_when_unset(client):
"""category_seeds 기본값은 유효한 JSON (카테고리→시드 배열)."""
resp = client.get("/api/insta/templates/prompts/category_seeds")
assert resp.status_code == 200
body = resp.json()
assert body["is_default"] is True
seeds = json.loads(body["template"])
assert "economy" in seeds and isinstance(seeds["economy"], list)
def test_get_unknown_prompt_still_404(client):
resp = client.get("/api/insta/templates/prompts/does_not_exist")
assert resp.status_code == 404
def test_saved_template_overrides_default(client):
"""PUT로 저장하면 이후 GET은 저장본(is_default 없음)을 반환."""
client.put("/api/insta/templates/prompts/slate_writer",
json={"template": "내 커스텀 프롬프트", "description": "custom"})
resp = client.get("/api/insta/templates/prompts/slate_writer")
assert resp.status_code == 200
body = resp.json()
assert body["template"] == "내 커스텀 프롬프트"
assert not body.get("is_default")

View File

@@ -170,7 +170,11 @@ def build_number_weights(cache: Dict[str, Any]) -> Dict[int, float]:
return weights
def score_combination(numbers: List[int], cache: Dict[str, Any]) -> Dict[str, float]:
def score_combination(
numbers: List[int],
cache: Dict[str, Any],
weights: Optional[List[float]] = None,
) -> Dict[str, float]:
"""
6개 번호 조합의 통계적 품질 점수 계산 (0~1 범위 정규화).
@@ -181,6 +185,13 @@ def score_combination(numbers: List[int], cache: Dict[str, Any]) -> Dict[str, fl
- score_cooccur (15%): 공동 출현 기댓값 대비
- score_diversity (10%): 연속번호, 범위, 구간 다양성
Args:
numbers: 6개 번호 리스트
cache: build_analysis_cache() 반환 딕셔너리
weights: 5가지 기법별 가중치 리스트 [frequency, fingerprint, gap, cooccur, diversity].
None이면 기본값 [0.25, 0.30, 0.20, 0.15, 0.10] 사용.
길이가 5가 아니면 ValueError 발생.
Returns:
{"score_total": ..., "score_frequency": ..., ...}
"""
@@ -282,12 +293,16 @@ def score_combination(numbers: List[int], cache: Dict[str, Any]) -> Dict[str, fl
)
# ── 최종 가중 합산 ────────────────────────────────────────────────────────
if weights is None:
weights = [0.25, 0.30, 0.20, 0.15, 0.10]
if len(weights) != 5:
raise ValueError("weights must have 5 elements")
score_total = (
score_frequency * 0.25
+ score_fingerprint * 0.30
+ score_gap * 0.20
+ score_cooccur * 0.15
+ score_diversity * 0.10
score_frequency * weights[0]
+ score_fingerprint * weights[1]
+ score_gap * weights[2]
+ score_cooccur * weights[3]
+ score_diversity * weights[4]
)
return {

View File

@@ -300,7 +300,51 @@ def init_db() -> None:
_ensure_column(conn, "lotto_briefings", "tier_rationale",
"ALTER TABLE lotto_briefings ADD COLUMN tier_rationale TEXT NOT NULL DEFAULT '{}'")
# ── weight_trials / auto_picks / weight_base_history 테이블 ──────────
conn.execute("""
CREATE TABLE IF NOT EXISTS weight_trials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
week_start TEXT NOT NULL,
day_of_week INTEGER NOT NULL,
weight_json TEXT NOT NULL,
source TEXT NOT NULL,
base_at_gen TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
UNIQUE(week_start, day_of_week)
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_wt_week
ON weight_trials(week_start, day_of_week)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS auto_picks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trial_id INTEGER NOT NULL REFERENCES weight_trials(id) ON DELETE CASCADE,
pick_no INTEGER NOT NULL,
numbers TEXT NOT NULL,
meta_score REAL,
correct INTEGER,
rank INTEGER,
graded_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
UNIQUE(trial_id, pick_no)
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_ap_trial ON auto_picks(trial_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_ap_graded ON auto_picks(graded_at)")
conn.execute("""
CREATE TABLE IF NOT EXISTS weight_base_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
effective_from TEXT NOT NULL,
weight_json TEXT NOT NULL,
source_trial_id INTEGER REFERENCES weight_trials(id),
update_reason TEXT,
winner_score REAL,
winner_max_correct INTEGER,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)
""")
def upsert_draw(row: Dict[str, Any]) -> None:
@@ -645,30 +689,49 @@ def replace_best_picks(
def get_best_picks(limit: int = 20) -> List[Dict[str, Any]]:
"""현재 활성화된 best_picks 조회 (점수 내림차순)"""
"""현재 활성화된 best_picks 조회 (점수 내림차순).
simulation_candidates와 LEFT JOIN하여 5종 점수 배열(scores)을 포함.
매칭 키: sc.run_id = bp.source_run_id AND sc.numbers = bp.numbers
LEFT JOIN 미매칭(NULL) 시 scores는 [0.0, 0.0, 0.0, 0.0, 0.0] 반환.
"""
with _conn() as conn:
rows = conn.execute(
"""
SELECT id, numbers, score_total, rank_in_run, source_run_id, based_on_draw, created_at
FROM best_picks
WHERE is_active = 1
ORDER BY score_total DESC
SELECT bp.id, bp.numbers, bp.score_total, bp.rank_in_run,
bp.source_run_id, bp.based_on_draw, bp.created_at,
sc.score_frequency, sc.score_fingerprint,
sc.score_gap, sc.score_cooccur, sc.score_diversity
FROM best_picks bp
LEFT JOIN simulation_candidates sc
ON sc.run_id = bp.source_run_id
AND sc.numbers = bp.numbers
WHERE bp.is_active = 1
ORDER BY bp.score_total DESC
LIMIT ?
""",
(limit,),
).fetchall()
return [
{
result = []
for r in rows:
scores = [
float(r["score_frequency"] or 0.0),
float(r["score_fingerprint"] or 0.0),
float(r["score_gap"] or 0.0),
float(r["score_cooccur"] or 0.0),
float(r["score_diversity"] or 0.0),
]
result.append({
"id": int(r["id"]),
"numbers": json.loads(r["numbers"]),
"score_total": r["score_total"],
"scores": scores,
"rank_in_run": r["rank_in_run"],
"source_run_id": r["source_run_id"],
"based_on_draw": r["based_on_draw"],
"created_at": r["created_at"],
}
for r in rows
]
})
return result
def get_simulation_runs(limit: int = 10) -> List[Dict[str, Any]]:
@@ -1228,3 +1291,155 @@ def list_reviews(limit: int = 10) -> List[Dict[str, Any]]:
).fetchall()
return [_review_row(r) for r in rows]
# --- weight_trials / auto_picks / weight_base_history CRUD ---
def save_weight_trial(
week_start: str,
day_of_week: int,
weight: List[float],
source: str,
base_at_gen: Optional[List[float]] = None,
) -> int:
with _conn() as conn:
cur = conn.execute(
"""
INSERT INTO weight_trials (week_start, day_of_week, weight_json, source, base_at_gen)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(week_start, day_of_week) DO UPDATE SET
weight_json = excluded.weight_json,
source = excluded.source,
base_at_gen = excluded.base_at_gen
""",
(week_start, day_of_week, json.dumps(weight),
source, json.dumps(base_at_gen) if base_at_gen else None),
)
if cur.lastrowid:
return cur.lastrowid
row = conn.execute(
"SELECT id FROM weight_trials WHERE week_start=? AND day_of_week=?",
(week_start, day_of_week),
).fetchone()
return int(row["id"])
def get_weight_trial(week_start: str, day_of_week: int) -> Optional[Dict[str, Any]]:
with _conn() as conn:
row = conn.execute(
"SELECT * FROM weight_trials WHERE week_start=? AND day_of_week=?",
(week_start, day_of_week),
).fetchone()
if not row:
return None
d = dict(row)
d["weight"] = json.loads(d.pop("weight_json"))
if d.get("base_at_gen"):
d["base_at_gen"] = json.loads(d["base_at_gen"])
return d
def get_weekly_trials(week_start: str) -> List[Dict[str, Any]]:
with _conn() as conn:
rows = conn.execute(
"SELECT * FROM weight_trials WHERE week_start=? ORDER BY day_of_week",
(week_start,),
).fetchall()
out = []
for r in rows:
d = dict(r)
d["weight"] = json.loads(d.pop("weight_json"))
if d.get("base_at_gen"):
d["base_at_gen"] = json.loads(d["base_at_gen"])
out.append(d)
return out
def save_auto_pick(
trial_id: int,
pick_no: int,
numbers: List[int],
meta_score: Optional[float] = None,
) -> int:
with _conn() as conn:
cur = conn.execute(
"""
INSERT OR REPLACE INTO auto_picks (trial_id, pick_no, numbers, meta_score)
VALUES (?, ?, ?, ?)
""",
(trial_id, pick_no, json.dumps(sorted(numbers)), meta_score),
)
return cur.lastrowid
def get_auto_picks(trial_id: int) -> List[Dict[str, Any]]:
with _conn() as conn:
rows = conn.execute(
"SELECT * FROM auto_picks WHERE trial_id=? ORDER BY pick_no",
(trial_id,),
).fetchall()
out = []
for r in rows:
d = dict(r)
d["numbers"] = json.loads(d["numbers"])
out.append(d)
return out
def update_auto_pick_grade(pick_id: int, correct: int, rank: Optional[int]) -> None:
with _conn() as conn:
conn.execute(
"""
UPDATE auto_picks
SET correct=?, rank=?, graded_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')
WHERE id=?
""",
(correct, rank, pick_id),
)
def get_current_base() -> Optional[List[float]]:
"""weight_base_history 최신 row의 weight. 없으면 None (cold start)."""
with _conn() as conn:
row = conn.execute(
"SELECT weight_json FROM weight_base_history ORDER BY id DESC LIMIT 1",
).fetchone()
if not row:
return None
return json.loads(row["weight_json"])
def save_base_history(
effective_from: str,
weight: List[float],
source_trial_id: Optional[int],
update_reason: str,
winner_score: Optional[float],
winner_max_correct: Optional[int],
) -> int:
with _conn() as conn:
cur = conn.execute(
"""
INSERT INTO weight_base_history
(effective_from, weight_json, source_trial_id, update_reason,
winner_score, winner_max_correct)
VALUES (?, ?, ?, ?, ?, ?)
""",
(effective_from, json.dumps(weight), source_trial_id,
update_reason, winner_score, winner_max_correct),
)
return cur.lastrowid
def get_base_history(limit: int = 12) -> List[Dict[str, Any]]:
with _conn() as conn:
rows = conn.execute(
"SELECT * FROM weight_base_history ORDER BY id DESC LIMIT ?",
(limit,),
).fetchall()
out = []
for r in rows:
d = dict(r)
d["weight"] = json.loads(d.pop("weight_json"))
out.append(d)
return out

View File

@@ -25,6 +25,7 @@ from .db import (
)
from .analyzer import build_analysis_cache, build_number_weights, score_combination
from .utils import weighted_sample_6
from .weight_evolver import get_active_weight
def run_simulation(
@@ -54,6 +55,7 @@ def run_simulation(
# ── 1. 통계 캐시 및 가중치 구성 (시뮬레이션 전체에서 재사용) ────────────
cache = build_analysis_cache(draws)
weights = build_number_weights(cache)
active_weights = get_active_weight() # None → analyzer uses fixed default
# ── 2. 후보 생성 및 스코어링 ──────────────────────────────────────────────
candidates: List[Dict[str, Any]] = []
@@ -69,7 +71,7 @@ def run_simulation(
continue
seen_keys.add(key)
scores = score_combination(nums, cache)
scores = score_combination(nums, cache, weights=active_weights)
candidates.append({
"numbers": sorted(nums),
**scores,

View File

@@ -38,6 +38,11 @@ from .strategy_evolver import (
get_weights_with_trend, recalculate_weights,
generate_smart_recommendation,
)
from .weight_evolver import (
generate_weekly_candidates_and_save,
apply_today_and_pick,
evaluate_weekly,
)
from .routers import curator as curator_router
from .routers import briefing as briefing_router
from .routers import review as review_router
@@ -111,9 +116,42 @@ def on_startup():
id="grade_weekly_review",
)
scheduler.add_job(_run_weight_evolver_weekly, "cron", day_of_week="mon", hour=9, minute=0, id="weight_evolver_weekly")
scheduler.add_job(_run_weight_evolver_daily, "cron", hour=9, minute=0, id="weight_evolver_daily")
scheduler.add_job(_run_weight_evolver_eval, "cron", day_of_week="sat", hour=22, minute=0, id="weight_evolver_eval")
scheduler.start()
async def _run_weight_evolver_weekly():
"""월 09:00 — 6개 후보 생성 후 inline으로 apply_today도 호출."""
try:
generate_weekly_candidates_and_save()
apply_today_and_pick(n=5)
except Exception as e:
logger.error(f"[weight_evolver_weekly] {e}")
async def _run_weight_evolver_daily():
"""매일 09:00 (월/일 제외 — 월=weekly inline, 일=토 trial 보호)."""
try:
from datetime import datetime, timezone, timedelta
KST = timezone(timedelta(hours=9))
if datetime.now(KST).weekday() in (0, 6):
return
apply_today_and_pick(n=5)
except Exception as e:
logger.error(f"[weight_evolver_daily] {e}")
async def _run_weight_evolver_eval():
"""토 22:00 — 회고 + 다음주 base 갱신."""
try:
evaluate_weekly()
except Exception as e:
logger.error(f"[weight_evolver_eval] {e}")
@app.get("/health")
def health():
return {"ok": True}
@@ -383,6 +421,62 @@ def api_strategy_evolve():
return {"ok": True, "weights": new_weights}
# ── weight-evolver API ───────────────────────────────────────────────────────
@app.get("/api/lotto/evolver/status")
async def evolver_status():
"""현재 base + 이번주 trials + auto_picks 진행 상황."""
from .weight_evolver import get_week_start
from .db import get_current_base, get_weekly_trials, get_auto_picks, get_latest_draw
ws = get_week_start()
trials = get_weekly_trials(ws)
trials_with_picks = []
for t in trials:
picks = get_auto_picks(t["id"])
trials_with_picks.append({**t, "picks": picks})
latest = get_latest_draw()
return {
"week_start": ws,
"current_base": get_current_base(),
"trials": trials_with_picks,
"latest_draw": latest["drw_no"] if latest else None,
}
@app.get("/api/lotto/evolver/history")
async def evolver_history(weeks: int = 12):
"""weight_base_history 최근 N개."""
from .db import get_base_history
return {"items": get_base_history(limit=weeks)}
@app.get("/api/lotto/evolver/trials/{week_start}")
async def evolver_trials(week_start: str):
"""특정 주 6 trials + 채점 결과."""
from .db import get_weekly_trials, get_auto_picks
trials = get_weekly_trials(week_start)
out = []
for t in trials:
picks = get_auto_picks(t["id"])
out.append({**t, "picks": picks})
return {"week_start": week_start, "trials": out}
@app.post("/api/lotto/evolver/generate-now")
async def evolver_generate_now():
"""수동 트리거 — 이번주 후보 생성."""
from .weight_evolver import generate_weekly_candidates_and_save
candidates = generate_weekly_candidates_and_save()
return {"ok": True, "candidates_count": len(candidates), "candidates": candidates}
@app.post("/api/lotto/evolver/evaluate-now")
async def evolver_evaluate_now():
"""수동 회고 + 다음주 base 갱신."""
from .weight_evolver import evaluate_weekly
return evaluate_weekly()
# ── 스마트 추천 API ────────────────────────────────────────────────────────
@app.get("/api/lotto/recommend/smart")
@@ -435,6 +529,7 @@ def api_best_picks(limit: int = 20):
"rank": p["rank_in_run"],
"numbers": nums,
"score_total": p["score_total"],
"scores": p["scores"],
"based_on_draw": p["based_on_draw"],
"simulation_run_id": p["source_run_id"],
"created_at": p["created_at"],

View File

@@ -4,3 +4,4 @@ requests==2.32.3
httpx==0.27.2
beautifulsoup4==4.12.3
APScheduler==3.10.4
numpy>=1.26

314
lotto/app/weight_evolver.py Normal file
View File

@@ -0,0 +1,314 @@
# lotto/app/weight_evolver.py
"""5종 시뮬 점수 가중치 자율 학습 루프.
순수 함수 (clamp/perturb/Dirichlet/score/base-rule) + DB 진입점은 별도 섹션.
"""
from __future__ import annotations
import math
import random
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
MIN_WEIGHT = 0.05
N_METRICS = 5
DEFAULT_UNIFORM = [0.2] * N_METRICS # cold start
RANK_BY_CORRECT = {6: 1, 5: 3, 4: 4, 3: 5}
RANK_BONUS = {1: 1.0, 2: 0.8, 3: 0.6, 4: 0.3, 5: 0.1}
def clamp_and_normalize(W: List[float], min_w: float = MIN_WEIGHT) -> List[float]:
"""각 값 ≥ min_w + 합=1.0. 보장 안 되면 raise."""
if len(W) != N_METRICS:
raise ValueError(f"W must have {N_METRICS} elements")
# Iteratively clamp then normalize until all values satisfy min_w floor.
# (Normalizing after clamping can reduce some already-floored values below
# min_w when the denominator is large — iterate to convergence.)
vals = [float(w) for w in W]
for _ in range(100): # converges in a few iterations in practice
clamped = [max(min_w, v) for v in vals]
total = sum(clamped)
vals = [v / total for v in clamped]
if all(v >= min_w - 1e-12 for v in vals):
break
return vals
def perturb_weights(
base: List[float],
sigma: float = 0.05,
seed: Optional[int] = None,
) -> List[float]:
"""base에 정규분포 noise(σ) 추가 → clamp+normalize."""
if seed is not None:
np.random.seed(seed)
noise = np.random.normal(0, sigma, size=N_METRICS)
perturbed = [b + n for b, n in zip(base, noise)]
return clamp_and_normalize(perturbed)
def dirichlet_weights(
alpha: float = 2.0,
seed: Optional[int] = None,
) -> List[float]:
"""Dirichlet(α, α, α, α, α) 샘플 → clamp+normalize."""
if seed is not None:
np.random.seed(seed)
sample = np.random.dirichlet([alpha] * N_METRICS).tolist()
return clamp_and_normalize(sample)
def generate_weekly_candidates(
base: Optional[List[float]] = None,
seed: Optional[int] = None,
) -> List[Dict[str, Any]]:
"""6개 후보 — 4 perturb + 2 dirichlet. day_of_week 0..5 매핑.
Returns:
[{"day_of_week": 0, "weight": [...], "source": "perturb"}, ...]
"""
if base is None:
base = DEFAULT_UNIFORM[:]
if seed is not None:
np.random.seed(seed)
trials = []
for i in range(4):
trials.append({
"day_of_week": i,
"weight": perturb_weights(base, sigma=0.05),
"source": "perturb",
})
for i in range(4, 6):
trials.append({
"day_of_week": i,
"weight": dirichlet_weights(alpha=2.0),
"source": "dirichlet",
})
return trials
def count_match(pick: List[int], winning: List[int]) -> int:
"""본번호 6개 일치 개수. 보너스 제외."""
return len(set(pick) & set(winning[:6]))
def calc_pick_score(pick_numbers: List[int], winning_numbers: List[int]) -> float:
"""correct/6 + RANK_BONUS. 보너스 번호 미고려."""
correct = count_match(pick_numbers, winning_numbers)
base = correct / 6.0
rank = RANK_BY_CORRECT.get(correct)
bonus = RANK_BONUS.get(rank, 0) if rank else 0
return base + bonus
def decide_base_update(
winner_max_correct: int,
winner_W: List[float],
current_base: Optional[List[float]],
) -> Tuple[List[float], str]:
"""Hybrid base update rule.
Returns:
(new_base, reason) — reason ∈ {'winner_4plus','ema_blend','unchanged','cold_start'}
"""
if winner_max_correct >= 4:
return list(winner_W), "winner_4plus"
if winner_max_correct == 3 and current_base is not None:
blended = [0.3 * w + 0.7 * c for w, c in zip(winner_W, current_base)]
return clamp_and_normalize(blended), "ema_blend"
if current_base is None:
return DEFAULT_UNIFORM[:], "cold_start"
return list(current_base), "unchanged"
# ---------- DB-touching entry points ----------
KST = timezone(timedelta(hours=9))
def _db():
from . import db as _db_mod
return _db_mod
def _today_kst():
return datetime.now(KST).date()
def get_week_start(d=None) -> str:
"""주어진 날짜의 월요일 ISO 'YYYY-MM-DD'."""
if d is None:
d = _today_kst()
ws = d - timedelta(days=d.weekday())
return ws.isoformat()
def get_active_weight() -> Optional[List[float]]:
"""오늘 적용 중인 W. 없으면 None (균등 폴백)."""
today = _today_kst()
week_start = get_week_start(today)
dow = today.weekday()
if dow == 6:
dow = 5 # 일요일은 토요일 W 유지
trial = _db().get_weight_trial(week_start, dow)
if trial:
return trial["weight"]
return None
def generate_weekly_candidates_and_save(seed: Optional[int] = None) -> List[Dict[str, Any]]:
"""월요일 09:00 cron 진입점. 6 trials 생성 후 DB 저장."""
db = _db()
base = db.get_current_base()
if base is None:
base = DEFAULT_UNIFORM[:]
db.save_base_history(
effective_from=get_week_start(),
weight=base,
source_trial_id=None,
update_reason="cold_start",
winner_score=None,
winner_max_correct=None,
)
candidates = generate_weekly_candidates(base, seed=seed)
week_start = get_week_start()
for c in candidates:
db.save_weight_trial(
week_start=week_start,
day_of_week=c["day_of_week"],
weight=c["weight"],
source=c["source"],
base_at_gen=base,
)
return candidates
def apply_today_and_pick(n: int = 5) -> Dict[str, Any]:
"""매일 09:00 cron 진입점. 오늘 W로 N=5 세트 추출 후 auto_picks 저장."""
db = _db()
from . import analyzer, recommender
today = _today_kst()
week_start = get_week_start(today)
dow = min(today.weekday(), 5)
trial = db.get_weight_trial(week_start, dow)
if trial is None:
return {"ok": False, "reason": "no_trial_for_today"}
W = trial["weight"]
draws = db.get_all_draw_numbers()
cache = analyzer.build_analysis_cache(draws)
picks_saved = []
for i in range(1, n + 1):
try:
r = recommender.recommend_numbers(draws)
nums = r["numbers"]
s = analyzer.score_combination(nums, cache, weights=W)
pid = db.save_auto_pick(trial["id"], i, nums, meta_score=s["score_total"])
picks_saved.append({"id": pid, "numbers": nums, "score": s["score_total"]})
except Exception:
continue
return {
"ok": True,
"trial_id": trial["id"],
"weight": W,
"picks": picks_saved,
}
def evaluate_weekly() -> Dict[str, Any]:
"""토 22:00 cron 진입점. 6일 trials × N picks 채점 + base 갱신."""
db = _db()
today = _today_kst()
week_start = get_week_start(today)
trials = db.get_weekly_trials(week_start)
if not trials:
return {"ok": False, "reason": "no_trials"}
latest = db.get_latest_draw()
if latest is None:
return {"ok": False, "reason": "no_latest_draw"}
winning = [
latest["n1"], latest["n2"], latest["n3"],
latest["n4"], latest["n5"], latest["n6"],
]
per_day = []
for trial in trials:
picks = db.get_auto_picks(trial["id"])
if not picks:
continue
day_scores = []
max_c = 0
for p in picks:
correct = count_match(p["numbers"], winning)
rank = RANK_BY_CORRECT.get(correct)
db.update_auto_pick_grade(p["id"], correct, rank)
day_scores.append(calc_pick_score(p["numbers"], winning))
if correct > max_c:
max_c = correct
avg_score = sum(day_scores) / len(day_scores)
per_day.append({
"trial_id": trial["id"],
"day_of_week": trial["day_of_week"],
"weight": trial["weight"],
"avg_score": avg_score,
"max_correct": max_c,
"n_picks": len(picks),
})
if not per_day:
return {"ok": False, "reason": "no_picks_graded"}
winner = max(per_day, key=lambda d: d["avg_score"])
current_base = db.get_current_base()
new_base, reason = decide_base_update(
winner_max_correct=winner["max_correct"],
winner_W=winner["weight"],
current_base=current_base,
)
next_monday = today + timedelta(days=(7 - today.weekday()) % 7 or 7)
next_monday_iso = next_monday.isoformat()
# Idempotent guard: 같은 effective_from으로 이미 저장된 row가 있으면 skip
existing = db.get_base_history(limit=1)
if existing and existing[0]["effective_from"] == next_monday_iso:
return {
"ok": True,
"draw_no": latest["drw_no"],
"week_start": week_start,
"previous_base": existing[0].get("weight"),
"winner": winner,
"new_base": existing[0]["weight"], # 이미 저장된 값
"update_reason": existing[0].get("update_reason", "idempotent_skip"),
"per_day": per_day,
}
db.save_base_history(
effective_from=next_monday_iso,
weight=new_base,
source_trial_id=winner["trial_id"],
update_reason=reason,
winner_score=winner["avg_score"],
winner_max_correct=winner["max_correct"],
)
return {
"ok": True,
"draw_no": latest["drw_no"],
"week_start": week_start,
"previous_base": current_base, # save 이전에 캡처한 값 — diff 계산용
"winner": winner,
"new_base": new_base,
"update_reason": reason,
"per_day": per_day,
}

View File

@@ -0,0 +1,45 @@
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app"))
import pytest
from analyzer import score_combination, build_analysis_cache
@pytest.fixture
def cache():
# build_analysis_cache expects [(drw_no, [n1,n2,n3,n4,n5,n6]), ...] tuples
fake_draws = [
(1, [1, 2, 3, 4, 5, 6]),
(2, [7, 8, 9, 10, 11, 12]),
]
return build_analysis_cache(fake_draws)
def test_score_default_uses_fixed_weights(cache):
"""weights=None은 기존 fixed [0.25, 0.30, 0.20, 0.15, 0.10]과 동등."""
s = score_combination([1, 2, 3, 4, 5, 6], cache)
assert "score_total" in s
assert 0.0 <= s["score_total"] <= 2.0
for k in ("score_frequency", "score_fingerprint", "score_gap",
"score_cooccur", "score_diversity"):
assert k in s
def test_score_with_custom_weights_sums_correctly(cache):
"""weights=[1,0,0,0,0]은 score_total == score_frequency."""
s = score_combination([1, 2, 3, 4, 5, 6], cache, weights=[1.0, 0.0, 0.0, 0.0, 0.0])
assert s["score_total"] == pytest.approx(s["score_frequency"], rel=1e-3)
def test_score_with_uniform_weights(cache):
"""weights=[0.2]*5는 단순 평균."""
s = score_combination([1, 2, 3, 4, 5, 6], cache, weights=[0.2] * 5)
expected = 0.2 * (s["score_frequency"] + s["score_fingerprint"]
+ s["score_gap"] + s["score_cooccur"] + s["score_diversity"])
assert s["score_total"] == pytest.approx(expected, rel=1e-3)
def test_score_weights_wrong_length_raises(cache):
with pytest.raises((ValueError, AssertionError)):
score_combination([1, 2, 3, 4, 5, 6], cache, weights=[0.5, 0.5])

View File

@@ -0,0 +1,122 @@
# lotto/tests/test_weight_evolver.py
import json
import math
import pytest
from app import weight_evolver as we
def test_clamp_and_normalize_min_floor():
"""모든 값이 0.05 이상이 되도록 보장 + 합=1.0."""
W = we.clamp_and_normalize([0.01, 0.6, 0.2, 0.1, 0.09])
assert all(w >= 0.05 - 1e-9 for w in W)
assert abs(sum(W) - 1.0) < 1e-9
def test_clamp_and_normalize_negative_becomes_floor():
W = we.clamp_and_normalize([-0.1, 0.5, 0.3, 0.2, 0.1])
assert W[0] >= 0.05 - 1e-9
assert abs(sum(W) - 1.0) < 1e-9
def test_perturbation_changes_around_base():
"""σ=0.05 정규분포 perturbation 후 정규화 — 각 값이 합리적 범위 안."""
base = [0.2, 0.2, 0.2, 0.2, 0.2]
W = we.perturb_weights(base, sigma=0.05, seed=42)
assert abs(sum(W) - 1.0) < 1e-9
assert all(w >= 0.05 - 1e-9 for w in W)
def test_dirichlet_random_distribution():
"""Dirichlet α=2 — 5종 비음수 합=1."""
W = we.dirichlet_weights(alpha=2.0, seed=42)
assert abs(sum(W) - 1.0) < 1e-9
assert all(0.05 - 1e-9 <= w <= 1.0 for w in W)
def test_generate_weekly_candidates_count():
"""6개 후보 생성 — 4 perturb + 2 dirichlet."""
base = [0.2, 0.2, 0.2, 0.2, 0.2]
trials = we.generate_weekly_candidates(base, seed=42)
assert len(trials) == 6
sources = [t["source"] for t in trials]
assert sources.count("perturb") == 4
assert sources.count("dirichlet") == 2
days = sorted(t["day_of_week"] for t in trials)
assert days == [0, 1, 2, 3, 4, 5]
def test_calc_pick_score_six_match():
"""6개 모두 일치 → 1등 → base=1.0 + bonus 1.0 = 2.0."""
score = we.calc_pick_score([1, 2, 3, 4, 5, 6], [1, 2, 3, 4, 5, 6])
assert score == pytest.approx(2.0)
def test_calc_pick_score_four_match():
"""4개 일치 → 4등 → base=4/6 + bonus 0.3."""
score = we.calc_pick_score([1, 2, 3, 4, 7, 8], [1, 2, 3, 4, 5, 6])
assert score == pytest.approx(4/6 + 0.3)
def test_calc_pick_score_three_match():
"""3개 일치 → 5등 → base=3/6 + bonus 0.1."""
score = we.calc_pick_score([1, 2, 3, 7, 8, 9], [1, 2, 3, 4, 5, 6])
assert score == pytest.approx(3/6 + 0.1)
def test_calc_pick_score_two_match_no_bonus():
"""2개 일치 → 미당첨 → base=2/6 + bonus 0."""
score = we.calc_pick_score([1, 2, 7, 8, 9, 10], [1, 2, 3, 4, 5, 6])
assert score == pytest.approx(2/6)
def test_decide_base_update_winner_4plus_replaces():
"""winner_max_correct ≥ 4 → 교체."""
current = [0.2, 0.2, 0.2, 0.2, 0.2]
winner_W = [0.1, 0.3, 0.2, 0.3, 0.1]
new_base, reason = we.decide_base_update(
winner_max_correct=4,
winner_W=winner_W,
current_base=current,
)
assert new_base == winner_W
assert reason == "winner_4plus"
def test_decide_base_update_winner_3_ema_blend():
"""winner_max_correct = 3 → 0.3*winner + 0.7*current."""
current = [0.2, 0.2, 0.2, 0.2, 0.2]
winner_W = [0.1, 0.3, 0.2, 0.3, 0.1]
new_base, reason = we.decide_base_update(
winner_max_correct=3,
winner_W=winner_W,
current_base=current,
)
expected = [0.3 * w + 0.7 * c for w, c in zip(winner_W, current)]
assert all(abs(a - b) < 1e-9 for a, b in zip(new_base, expected))
assert reason == "ema_blend"
def test_decide_base_update_winner_lt3_unchanged():
"""winner_max_correct ≤ 2 → 직전 base 유지."""
current = [0.2, 0.2, 0.2, 0.2, 0.2]
winner_W = [0.1, 0.3, 0.2, 0.3, 0.1]
new_base, reason = we.decide_base_update(
winner_max_correct=2,
winner_W=winner_W,
current_base=current,
)
assert new_base == current
assert reason == "unchanged"
def test_decide_base_update_cold_start_returns_default():
"""current_base=None (첫 회) → 균등 default 반환."""
winner_W = [0.1, 0.3, 0.2, 0.3, 0.1]
new_base, reason = we.decide_base_update(
winner_max_correct=4,
winner_W=winner_W,
current_base=None,
)
assert new_base == winner_W
assert reason == "winner_4plus"

View File

@@ -36,6 +36,17 @@ server {
autoindex off;
}
# video media — Nginx 직접 mp4 서빙
location ^~ /media/video/ {
alias /data/video/;
expires 1d;
add_header Cache-Control "public, max-age=86400" always;
add_header Accept-Ranges bytes always;
autoindex off;
}
# music videos — Nginx가 직접 비디오 파일 서빙
location ^~ /media/insta/ {
alias /data/insta_cards/;
@@ -68,6 +79,21 @@ server {
proxy_pass http://$music_backend$request_uri;
}
# video-lab — 영상 생성 gateway
location /api/video/ {
resolver 127.0.0.11 valid=10s;
set $video_backend video-lab:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://$video_backend$request_uri;
proxy_read_timeout 120s;
proxy_connect_timeout 10s;
}
# realestate API
location /api/realestate/ {
proxy_http_version 1.1;
@@ -230,6 +256,26 @@ server {
proxy_pass http://$music_internal_backend$request_uri;
}
# Plan-B-Video — Windows video-render → NAS video-lab internal webhook
# Layer 1·2: nginx IP 화이트리스트 (LAN + Tailscale)
# Layer 3: X-Internal-Key (FastAPI dependency)
location /api/internal/video/ {
allow 192.168.45.0/24; # LAN 화이트리스트
allow 100.64.0.0/10; # Tailscale CGNAT
allow 127.0.0.1; # NAS 내부
deny all;
resolver 127.0.0.11 valid=10s;
set $video_internal_backend video-lab:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Internal-Key $http_x_internal_key;
proxy_pass http://$video_internal_backend$request_uri;
}
# portfolio API (Stock) — trailing slash 유무 모두 매칭
location /api/portfolio {
proxy_http_version 1.1;

View File

@@ -2,7 +2,7 @@
set -euo pipefail
# ── 서비스 목록 (한 곳에서만 관리) ──
SERVICES="lotto travel-proxy deployer stock music-lab insta-lab realestate-lab agent-office personal packs-lab nginx scripts"
SERVICES="lotto travel-proxy deployer stock music-lab insta-lab realestate-lab agent-office personal packs-lab video-lab nginx scripts"
# 1. 자동 감지: Docker 컨테이너 내부인가?
if [ -d "/repo" ] && [ -d "/runtime" ]; then

View File

@@ -15,15 +15,15 @@ flock -n 200 || { echo "Deploy already running, skipping"; exit 0; }
# ── 서비스 목록 (한 곳에서만 관리) ──
# docker compose 서비스명 (deployer 제외 — 자기 자신을 재빌드하면 스크립트 중단)
BUILD_TARGETS="lotto travel-proxy stock music-lab insta-lab realestate-lab agent-office personal packs-lab frontend"
BUILD_TARGETS="lotto travel-proxy stock music-lab insta-lab realestate-lab agent-office personal packs-lab video-lab frontend"
# 컨테이너 이름 (고아 정리용 — blog-lab은 폐기 대상으로 정리 리스트에 유지)
CONTAINER_NAMES="lotto stock music-lab insta-lab blog-lab realestate-lab agent-office personal packs-lab travel-proxy frontend"
CONTAINER_NAMES="lotto stock music-lab insta-lab blog-lab realestate-lab agent-office personal packs-lab travel-proxy video-lab frontend"
# Infra 서비스 (image-based, 영속 데이터 보존을 위해 stop/rm 없이 up만)
INFRA_SERVICES="redis"
# 헬스체크 대상
HEALTH_ENDPOINTS="lotto stock travel-proxy music-lab insta-lab realestate-lab agent-office personal packs-lab redis"
HEALTH_ENDPOINTS="lotto stock travel-proxy music-lab insta-lab realestate-lab agent-office personal packs-lab video-lab redis"
# data 디렉토리 (packs-lab은 별도 media/packs 사용)
DATA_DIRS="music stock insta realestate agent-office personal"
DATA_DIRS="music stock insta realestate agent-office personal video"
# 1. 자동 감지: Docker 컨테이너 내부인가?
if [ -d "/repo" ] && [ -d "/runtime" ]; then

View File

@@ -162,6 +162,17 @@ def get_indices():
"""주요 지표(KOSPI 등) 실시간 크롤링 조회"""
return fetch_major_indices()
@app.get("/api/stock/holidays")
def get_holidays():
"""task-watcher가 조회하는 휴장일 목록. holidays.json(list) 노출 (인증 불필요)."""
try:
with open(_HOLIDAYS_PATH, encoding="utf-8") as f:
data = json.load(f)
holidays = data if isinstance(data, list) else []
except (OSError, ValueError):
holidays = []
return {"holidays": holidays}
@app.post("/api/stock/scrap")
def trigger_scrap():
"""수동 스크랩 트리거"""

View File

@@ -199,11 +199,21 @@ def fetch_major_indices() -> Dict[str, Any]:
value = usd_item.select_one(".value").get_text(strip=True)
change_val = usd_item.select_one(".change").get_text(strip=True)
# 방향 (blind 텍스트: 상승, 하락)
# 방향: .head_info의 point_up/point_dn 클래스로 판별 (해외 지수와 동일 패턴).
# .blind span이 "미국 USD"/"원"/"상승" 3개라 select_one(".blind")은 첫 번째 "미국 USD"를
# 잡아 방향 추출에 실패함 → head_info 클래스를 1순위로, 직속 .blind 텍스트를 fallback으로 사용.
direction = ""
blind_txt = usd_item.select_one(".blind").get_text(strip=True)
if "상승" in blind_txt: direction = "red"
elif "하락" in blind_txt: direction = "blue"
head_info = usd_item.select_one(".head_info")
hi_classes = head_info.get("class", []) if head_info else []
if "point_up" in hi_classes:
direction = "red"
elif "point_dn" in hi_classes:
direction = "blue"
else:
dir_blind = usd_item.select_one(".head_info > span.blind")
blind_txt = dir_blind.get_text(strip=True) if dir_blind else ""
if "상승" in blind_txt: direction = "red"
elif "하락" in blind_txt: direction = "blue"
# change_val은 네이버 HTML에서 부호 없이 숫자만 옴 → direction 기반으로 부호 붙여줌
# (프론트 getDirection()이 부호로 색/화살표를 판별하므로)

View File

@@ -15,9 +15,15 @@ PROMPT_TEMPLATE = """다음은 종목 {name}({ticker})에 대한 최근 뉴스 {
{news_block}
이 뉴스들이 종목에 호재인지 악재인지 평가하세요.
score: -10(매우 강한 악재) ~ +10(매우 강한 호재) 사이의 실수. 0은 중립.
reason: 30자 이내 한 줄 근거.
이 뉴스들이 종목 주가에 호재인지 악재인지 종합 평가하세요.
규칙:
- score: -10(매우 강한 악재) ~ +10(매우 강한 호재) 사이의 실수. 명확한 방향성이 없으면 0(중립).
- 뉴스가 호재·악재로 섞여 있으면 주가에 더 우세한 쪽을 기준으로 부호를 정하세요.
- reason은 반드시 score 부호와 같은 방향의 근거만 쓰세요.
· score가 양수(호재)면 호재 근거만, 음수(악재)면 악재 근거만 적습니다.
· 호재 평가에 악재 내용을, 악재 평가에 호재 내용을 섞지 마세요.
- reason: 30자 이내 한 줄.
JSON으로만 응답하세요. 다른 텍스트 금지:
{{"score": <float>, "reason": "<string>"}}"""

View File

@@ -124,8 +124,10 @@ async def refresh_daily(
if successes:
_upsert_news_sentiment(conn, asof, successes, source="articles")
top_pos = sorted(successes, key=lambda r: -r["score_raw"])[:5]
top_neg = sorted(successes, key=lambda r: r["score_raw"])[:5]
# 부호 게이트: 호재(score>0)·악재(score<0)만 분류. score 미만 종목이 5개 미만이어도
# 반대 부호 종목으로 채우지 않음 (양수 종목이 악재란에 섞이는 문제 방지). 중립(0)은 제외.
top_pos = sorted([r for r in successes if r["score_raw"] > 0], key=lambda r: -r["score_raw"])[:5]
top_neg = sorted([r for r in successes if r["score_raw"] < 0], key=lambda r: r["score_raw"])[:5]
return {
"asof": asof.isoformat(),

View File

@@ -0,0 +1,22 @@
"""GET /api/stock/holidays — task-watcher 휴장일 조회용."""
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_holidays_returns_list():
r = client.get("/api/stock/holidays")
assert r.status_code == 200
data = r.json()
assert "holidays" in data
assert isinstance(data["holidays"], list)
def test_holidays_entries_are_iso_dates():
r = client.get("/api/stock/holidays")
holidays = r.json()["holidays"]
if holidays:
import datetime as dt
for h in holidays[:5]:
dt.date.fromisoformat(h) # raise 안 하면 통과

View File

@@ -140,6 +140,71 @@ async def test_refresh_daily_no_match_ticker_skipped(conn):
assert {r["ticker"] for r in rows} == {"005930"}
@pytest.mark.asyncio
async def test_refresh_daily_sign_gate_no_positive_in_neg(conn):
"""전 종목 양수 점수면 top_neg는 비어야 함 (호재 종목이 악재란에 채워지면 안 됨)."""
asof = dt.date(2026, 5, 13)
fake_articles_by_ticker = {
"005930": [{"title": "h", "summary": "", "press": "", "pub_date": ""}],
"000660": [{"title": "h", "summary": "", "press": "", "pub_date": ""}],
"373220": [{"title": "h", "summary": "", "press": "", "pub_date": ""}],
}
fake_stats = {"total_articles": 3, "matched_pairs": 3, "hit_tickers": 3}
scores = {"005930": 6.0, "000660": 2.0, "373220": 0.5} # 모두 양수
async def fake_score(llm, ticker, news, *, name=None, model="m"):
return {
"ticker": ticker, "score_raw": scores[ticker], "reason": "r",
"news_count": 1, "tokens_input": 1, "tokens_output": 1, "model": model,
}
with patch.object(pipeline, "articles_source") as mas, \
patch.object(pipeline, "_analyzer") as ma, \
patch.object(pipeline, "_make_llm") as ml:
mas.gather_articles_for_tickers = MagicMock(return_value=(fake_articles_by_ticker, fake_stats))
ma.score_sentiment = fake_score
ml.return_value.__aenter__.return_value = AsyncMock()
ml.return_value.__aexit__.return_value = None
result = await pipeline.refresh_daily(conn, asof, concurrency=3)
assert len(result["top_pos"]) == 3
assert result["top_neg"] == [] # 양수 종목이 악재란에 들어가면 안 됨
@pytest.mark.asyncio
async def test_refresh_daily_sign_gate_excludes_neutral(conn):
"""score=0(중립)은 호재·악재 어디에도 포함되지 않음."""
asof = dt.date(2026, 5, 13)
fake_articles_by_ticker = {
"005930": [{"title": "h", "summary": "", "press": "", "pub_date": ""}],
"000660": [{"title": "h", "summary": "", "press": "", "pub_date": ""}],
"373220": [{"title": "h", "summary": "", "press": "", "pub_date": ""}],
}
fake_stats = {"total_articles": 3, "matched_pairs": 3, "hit_tickers": 3}
scores = {"005930": 3.0, "000660": 0.0, "373220": -3.0}
async def fake_score(llm, ticker, news, *, name=None, model="m"):
return {
"ticker": ticker, "score_raw": scores[ticker], "reason": "r",
"news_count": 1, "tokens_input": 1, "tokens_output": 1, "model": model,
}
with patch.object(pipeline, "articles_source") as mas, \
patch.object(pipeline, "_analyzer") as ma, \
patch.object(pipeline, "_make_llm") as ml:
mas.gather_articles_for_tickers = MagicMock(return_value=(fake_articles_by_ticker, fake_stats))
ma.score_sentiment = fake_score
ml.return_value.__aenter__.return_value = AsyncMock()
ml.return_value.__aexit__.return_value = None
result = await pipeline.refresh_daily(conn, asof, concurrency=3)
pos_tickers = {r["ticker"] for r in result["top_pos"]}
neg_tickers = {r["ticker"] for r in result["top_neg"]}
assert pos_tickers == {"005930"}
assert neg_tickers == {"373220"}
assert "000660" not in pos_tickers and "000660" not in neg_tickers
def test_top_market_cap_tickers(conn):
out = pipeline._top_market_cap_tickers(conn, n=2)
assert out == ["005930", "000660"]

10
video-lab/Dockerfile Normal file
View File

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

View File

17
video-lab/app/auth.py Normal file
View File

@@ -0,0 +1,17 @@
"""SP-8 — Windows worker → NAS video-lab internal webhook 인증.
X-Internal-Key 헤더를 .env의 INTERNAL_API_KEY와 비교.
서버 측 키 미설정 시 401 (안전한 기본값).
"""
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")

95
video-lab/app/db.py Normal file
View File

@@ -0,0 +1,95 @@
"""SQLite persistence for video_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("VIDEO_DATA_DIR", "/app/data"), "video.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 video_tasks (
id TEXT PRIMARY KEY,
provider TEXT NOT NULL,
params TEXT NOT NULL,
status TEXT DEFAULT 'queued',
progress INTEGER DEFAULT 0,
message TEXT DEFAULT '',
video_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"],
"video_url": row["video_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 video_tasks (id, provider, params) VALUES (?, ?, ?)",
(task_id, provider, json.dumps(params)),
)
row = conn.execute("SELECT * FROM video_tasks WHERE id = ?", (task_id,)).fetchone()
return _row_to_dict(row)
def update_task(
task_id: str,
status: str,
progress: int,
message: str = "",
video_url: Optional[str] = None,
error: Optional[str] = None,
) -> None:
with _conn() as conn:
conn.execute(
"""
UPDATE video_tasks
SET status = ?, progress = ?, message = ?, video_url = ?, error = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
WHERE id = ?
""",
(status, progress, message, video_url, error, task_id),
)
def get_task(task_id: str) -> Optional[Dict[str, Any]]:
with _conn() as conn:
row = conn.execute("SELECT * FROM video_tasks WHERE id = ?", (task_id,)).fetchone()
return _row_to_dict(row) if row else None

View File

@@ -0,0 +1,52 @@
"""SP-8 — Windows video-render → NAS video-lab internal webhook.
POST /api/internal/video/update
- X-Internal-Key 인증 필수
- video_tasks row update (status, progress, message, video_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 = ""
video_url: Optional[str] = None
error: Optional[str] = None
@router.post(
"/api/internal/video/update",
dependencies=[Depends(verify_internal_key)],
)
def video_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,
video_url=payload.video_url,
error=payload.error,
)
logger.info(
"internal/video/update task=%s status=%s progress=%d",
payload.task_id, payload.status, payload.progress,
)
return {"ok": True}

117
video-lab/app/main.py Normal file
View File

@@ -0,0 +1,117 @@
"""FastAPI entrypoint for video-lab.
POST /api/video/generate — provider + params → Redis push → task_id 반환
GET /api/video/tasks/{id} — DB 조회
"""
from __future__ import annotations
import json
import logging
import os
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, 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 = {"sora", "veo", "kling", "seedance"}
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": "video-lab"}
@app.get("/api/video/providers")
def list_providers():
"""4 provider 항상 노출 (key 누락은 worker가 failed 보고)."""
return {"providers": [
{"id": "sora", "name": "Sora 2", "models": ["sora-2", "sora-2-pro"],
"durations": [8, 16, 20], "sizes": ["1280x720", "1920x1080", "1080x1920", "848x480", "480x848"]},
{"id": "veo", "name": "Veo 3.1", "models": ["veo-3.1-generate-001", "veo-3.1-fast-generate-001"],
"durations": [4, 6, 8], "aspect_ratios": ["16:9", "9:16"]},
{"id": "kling", "name": "Kling", "models": ["1.5", "1.6", "2.1", "2.1-master", "2.5", "2.6"],
"durations": [5, 10], "aspect_ratios": ["16:9", "9:16", "1:1"]},
{"id": "seedance", "name": "Seedance 2.0", "models": ["seedance-2.0"],
"durations": [4, 5, 6, 8, 10, 12, 15], "aspect_ratios": ["16:9", "9:16", "1:1", "4:3"]},
]}
class GenerateRequest(BaseModel):
provider: str = Field(..., description="sora|veo|kling|seedance")
model: Optional[str] = None
prompt: str
# Optional 공통
duration: Optional[int] = None
aspect_ratio: Optional[str] = None
image_url: 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:video-render에 push."""
kst = timezone(timedelta(hours=9))
payload = {
"task_id": task_id,
"kind": "video",
"job_type": job_type,
"params": params,
"submitted_at": datetime.now(kst).isoformat(),
}
await redis_client.rpush("queue:video-render", json.dumps(payload))
@app.post("/api/video/generate")
async def generate_video(req: GenerateRequest):
"""영상 생성 — Redis 큐로 Windows video-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" # sora_generation, veo_generation, kling_generation, seedance_generation
await _push_render_job(task_id, job_type, params)
return {"task_id": task_id, "provider": req.provider}
@app.get("/api/video/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

View File

@@ -0,0 +1,6 @@
fastapi==0.115.6
uvicorn[standard]==0.30.6
redis>=5.0
pytest>=8.0.0
pytest-asyncio>=0.21
httpx>=0.27.0

View File

View File

@@ -0,0 +1,23 @@
"""verify_internal_key — Windows video-render webhook 인증."""
import pytest
from fastapi import HTTPException
from app.auth import verify_internal_key
def test_valid_key_passes(monkeypatch):
monkeypatch.setenv("INTERNAL_API_KEY", "secret123")
verify_internal_key(x_internal_key="secret123")
def test_invalid_key_raises_401(monkeypatch):
monkeypatch.setenv("INTERNAL_API_KEY", "secret123")
with pytest.raises(HTTPException) as exc:
verify_internal_key(x_internal_key="wrong")
assert exc.value.status_code == 401
def test_missing_env_key_raises_401(monkeypatch):
monkeypatch.delenv("INTERNAL_API_KEY", raising=False)
with pytest.raises(HTTPException) as exc:
verify_internal_key(x_internal_key="any")
assert exc.value.status_code == 401

View File

@@ -0,0 +1,89 @@
"""POST /api/internal/video/update — Windows video-render webhook."""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.internal_router import router
from app import db
@pytest.fixture(autouse=True)
def _set_key(monkeypatch):
monkeypatch.setenv("INTERNAL_API_KEY", "test-secret")
@pytest.fixture
def client(tmp_path, monkeypatch):
monkeypatch.setenv("VIDEO_DATA_DIR", str(tmp_path))
monkeypatch.setattr(db, "DB_PATH", str(tmp_path / "test_video.db"))
db.init_db()
app = FastAPI()
app.include_router(router)
return TestClient(app)
def _make_task():
tid = "video-task-1"
db.create_task(tid, "sora", {"prompt": "test"})
return tid
def test_update_with_valid_key_updates_db(client):
tid = _make_task()
r = client.post(
"/api/internal/video/update",
headers={"X-Internal-Key": "test-secret"},
json={"task_id": tid, "status": "processing", "progress": 30, "message": "downloading"},
)
assert r.status_code == 200
task = db.get_task(tid)
assert task["status"] == "processing"
assert task["progress"] == 30
assert task["message"] == "downloading"
def test_update_with_invalid_key_returns_401(client):
tid = _make_task()
r = client.post(
"/api/internal/video/update",
headers={"X-Internal-Key": "wrong"},
json={"task_id": tid, "status": "processing", "progress": 30},
)
assert r.status_code == 401
def test_update_succeeded_with_video_url(client):
tid = _make_task()
r = client.post(
"/api/internal/video/update",
headers={"X-Internal-Key": "test-secret"},
json={
"task_id": tid, "status": "succeeded", "progress": 100,
"message": "완료", "video_url": "/media/video/video-task-1.mp4",
},
)
assert r.status_code == 200
task = db.get_task(tid)
assert task["status"] == "succeeded"
assert task["video_url"] == "/media/video/video-task-1.mp4"
def test_update_failed_records_error(client):
tid = _make_task()
r = client.post(
"/api/internal/video/update",
headers={"X-Internal-Key": "test-secret"},
json={"task_id": tid, "status": "failed", "progress": 0, "error": "Sora API rate limit"},
)
assert r.status_code == 200
task = db.get_task(tid)
assert task["status"] == "failed"
assert "Sora" in (task.get("error") or "")
def test_update_unknown_task_returns_404(client):
r = client.post(
"/api/internal/video/update",
headers={"X-Internal-Key": "test-secret"},
json={"task_id": "nonexistent", "status": "processing", "progress": 10},
)
assert r.status_code == 404