Compare commits
69 Commits
4b28ef3afa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 20691b5057 | |||
| 3bf87a93fb | |||
| 4623c68d4e | |||
| f79dc87d75 | |||
| d4302acb6a | |||
| b7fd98c8c7 | |||
| 0b29283043 | |||
| 9dba1e74b0 | |||
| 4c9fe11fc9 | |||
| a356a5895f | |||
| 2e042e18c5 | |||
| 83e74ad1f4 | |||
| b70caddff1 | |||
| d6e34973a4 | |||
| 7007c90665 | |||
| ca7a502514 | |||
| dc471ecc60 | |||
| e91715bf2c | |||
| 1e4c1b42b7 | |||
| 0190a6c206 | |||
| 6ef4160da2 | |||
| 078c9f008a | |||
| 918151bda8 | |||
| 2ce6721c35 | |||
| c5303151c0 | |||
| ee61405ff1 | |||
| fef5f7a835 | |||
| e47ccdb762 | |||
| 4b6996b0f7 | |||
| 0f65aa53e4 | |||
| ea3485cde6 | |||
| d6366a38f3 | |||
| 0f8c71c552 | |||
| 1401c5703d | |||
| 92329f6fd5 | |||
| d0047c2b9d | |||
| 088944499c | |||
| a9fdbf8a93 | |||
| f46851d481 | |||
| 11b3700959 | |||
| 1db8a0063d | |||
| f017a61c79 | |||
| 1694823129 | |||
| a4614ebeae | |||
| 875e750f77 | |||
| 9cb40fb4e5 | |||
| 383f48c71e | |||
| 6be74737c2 | |||
| 3106716e70 | |||
| a126155948 | |||
| f509339cbb | |||
| e72a52a950 | |||
| eecaefc26d | |||
| b3c0683364 | |||
| 17321d948e | |||
| 8552cbc184 | |||
| b1c786e59d | |||
| b885d02ac4 | |||
| b35fab777e | |||
| 43081bea0e | |||
| bebe5797e7 | |||
| 9e1001b935 | |||
| e5465ad136 | |||
| 21d46d95dd | |||
| ac4a574ef2 | |||
| c985d2c605 | |||
| b4e873b5b0 | |||
| 6c5e93f64e | |||
| 6b7eb5a9c1 |
24
CLAUDE.md
24
CLAUDE.md
@@ -164,10 +164,16 @@ docker compose up -d
|
|||||||
| `lotto_briefings` | AI 큐레이터 주간 브리핑 (5세트 + 내러티브 + 토큰·비용 집계) |
|
| `lotto_briefings` | AI 큐레이터 주간 브리핑 (5세트 + 내러티브 + 토큰·비용 집계) |
|
||||||
| `todos` | 투두리스트 (UUID PK) — personal 서비스로 이전됨, 레거시 테이블 유지 |
|
| `todos` | 투두리스트 (UUID PK) — personal 서비스로 이전됨, 레거시 테이블 유지 |
|
||||||
| `blog_posts` | 블로그 글 (tags: JSON 배열) — 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**
|
**스케줄러 job**
|
||||||
- 09:10 / 21:10 매일 — 당첨번호 동기화 + 채점 (`sync_latest` → `check_results_for_draw`)
|
- 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개 교체)
|
- 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 목록**
|
**lotto-lab API 목록**
|
||||||
|
|
||||||
@@ -204,6 +210,11 @@ docker compose up -d
|
|||||||
| GET | `/api/lotto/briefing/latest` | 최신 브리핑 |
|
| GET | `/api/lotto/briefing/latest` | 최신 브리핑 |
|
||||||
| GET | `/api/lotto/briefing/{draw_no}` | 특정 회차 브리핑 |
|
| GET | `/api/lotto/briefing/{draw_no}` | 특정 회차 브리핑 |
|
||||||
| GET | `/api/lotto/briefing` | 브리핑 이력 |
|
| 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/)
|
### stock (stock/)
|
||||||
- Windows AI 서버 연동: `WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000`
|
- 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_BACKEND_URL`: 기본 `http://lotto:8000`
|
||||||
- `LOTTO_CURATOR_MODEL`: 기본 `claude-sonnet-4-5`
|
- `LOTTO_CURATOR_MODEL`: 기본 `claude-sonnet-4-5`
|
||||||
- `YOUTUBE_DATA_API_KEY`: YouTube Data API v3 키 (미설정 시 YouTube trending 수집 skip)
|
- `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`)**
|
**YouTubeResearchAgent (`agents/youtube.py`)**
|
||||||
- `agent_id = "youtube"` — AGENT_REGISTRY에 등록
|
- `agent_id = "youtube"` — AGENT_REGISTRY에 등록
|
||||||
@@ -577,6 +593,11 @@ docker compose up -d
|
|||||||
- ~~09:15 매일 — 청약 매칭 데일리 리포트~~ (Task 2026-04-28에서 폐기. realestate-lab의 push 트리거로 전환)
|
- ~~09:15 매일 — 청약 매칭 데일리 리포트~~ (Task 2026-04-28에서 폐기. realestate-lab의 push 트리거로 전환)
|
||||||
- 09:00 매일 — YouTube 트렌드 수집 (`youtube_research`) → music-lab `/api/music/market/ingest` push
|
- 09:00 매일 — YouTube 트렌드 수집 (`youtube_research`) → music-lab `/api/music/market/ingest` push
|
||||||
- 매주 월요일 08:00 — YouTube 주간 리포트 텔레그램 발송 (`youtube_weekly_report`)
|
- 매주 월요일 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`)**
|
**RealestateAgent (`agents/realestate.py`)**
|
||||||
- 진입점: `on_new_matches(matches: list[dict]) -> {sent, sent_ids, message_id}`
|
- 진입점: `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` 필터) |
|
| GET | `/api/agent-office/conversation/stats` | 텔레그램 자연어 대화 토큰·캐시 통계 (`days` 필터) |
|
||||||
| POST | `/api/agent-office/youtube/research` | YouTube 트렌드 수집 수동 트리거 (body: `{countries: []}`) |
|
| POST | `/api/agent-office/youtube/research` | YouTube 트렌드 수집 수동 트리거 (body: `{countries: []}`) |
|
||||||
| GET | `/api/agent-office/youtube/research/status` | 마지막 수집 작업 상태 |
|
| 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/)
|
### personal (personal/)
|
||||||
- 개인 서비스 (포트폴리오 + 블로그 + 투두 통합)
|
- 개인 서비스 (포트폴리오 + 블로그 + 투두 통합)
|
||||||
|
|||||||
@@ -18,6 +18,26 @@ from ..telegram import messaging
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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]:
|
async def _send_media_group(media: List[Dict[str, Any]], caption: str = "") -> Dict[str, Any]:
|
||||||
"""텔레그램 sendMediaGroup. media는 InputMediaPhoto dicts.
|
"""텔레그램 sendMediaGroup. media는 InputMediaPhoto dicts.
|
||||||
@@ -89,14 +109,18 @@ class InstaAgent(BaseAgent):
|
|||||||
raise TimeoutError(f"{step} timeout {timeout_sec}s")
|
raise TimeoutError(f"{step} timeout {timeout_sec}s")
|
||||||
|
|
||||||
async def _push_keyword_candidates(self, keywords: List[Dict[str, Any]]) -> None:
|
async def _push_keyword_candidates(self, keywords: List[Dict[str, Any]]) -> None:
|
||||||
by_cat: Dict[str, List[Dict[str, Any]]] = {}
|
# 중복 제거 + 신뢰도(score) 임계값 이상만 — "확실한 것만" 정리해서 전송
|
||||||
for k in keywords:
|
filtered = _dedup_and_filter_keywords(keywords)
|
||||||
by_cat.setdefault(k["category"], []).append(k)
|
if not filtered:
|
||||||
if not by_cat:
|
await messaging.send_raw(
|
||||||
await messaging.send_raw("📰 [인스타 큐레이터] 오늘은 추천할 키워드가 없습니다.")
|
f"📰 [인스타 큐레이터] 오늘은 확실한 추천 키워드가 없습니다 (신뢰도 {KEYWORD_MIN_SCORE:.1f}+ 기준)."
|
||||||
|
)
|
||||||
return
|
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]]] = []
|
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():
|
for cat, items in by_cat.items():
|
||||||
text_lines.append(f"\n<b>{cat}</b>")
|
text_lines.append(f"\n<b>{cat}</b>")
|
||||||
for k in items[:5]:
|
for k in items[:5]:
|
||||||
|
|||||||
@@ -17,11 +17,224 @@ class LottoAgent(BaseAgent):
|
|||||||
return await self._run(source="manual")
|
return await self._run(source="manual")
|
||||||
if action == "status":
|
if action == "status":
|
||||||
return {"ok": True, "message": f"{self.state}: {self.state_detail}"}
|
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}"}
|
return {"ok": False, "message": f"unknown action: {action}"}
|
||||||
|
|
||||||
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
|
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
|
||||||
pass
|
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:
|
async def _run(self, source: str) -> dict:
|
||||||
task_id = create_task(self.agent_id, "curate_weekly", {"source": source})
|
task_id = create_task(self.agent_id, "curate_weekly", {"source": source})
|
||||||
await self.transition("working", "후보 수집 및 AI 큐레이션 중...", task_id)
|
await self.transition("working", "후보 수집 및 AI 큐레이션 중...", task_id)
|
||||||
|
|||||||
@@ -29,3 +29,18 @@ CORS_ALLOW_ORIGINS = os.getenv(
|
|||||||
# Lotto Curator
|
# Lotto Curator
|
||||||
LOTTO_BACKEND_URL = os.getenv("LOTTO_BACKEND_URL", "http://lotto:8000")
|
LOTTO_BACKEND_URL = os.getenv("LOTTO_BACKEND_URL", "http://lotto:8000")
|
||||||
LOTTO_CURATOR_MODEL = os.getenv("LOTTO_CURATOR_MODEL", "claude-sonnet-4-5")
|
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"))
|
||||||
|
|
||||||
|
# Tarot Lab
|
||||||
|
TAROT_MODEL = os.getenv("TAROT_MODEL", "claude-sonnet-4-6")
|
||||||
|
TAROT_COST_INPUT_PER_M = float(os.getenv("TAROT_COST_INPUT_PER_M", "3.0"))
|
||||||
|
TAROT_COST_OUTPUT_PER_M = float(os.getenv("TAROT_COST_OUTPUT_PER_M", "15.0"))
|
||||||
|
TAROT_TIMEOUT_SEC = int(os.getenv("TAROT_TIMEOUT_SEC", "60"))
|
||||||
|
|||||||
185
agent-office/app/curator/signal_runner.py
Normal file
185
agent-office/app/curator/signal_runner.py
Normal 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}
|
||||||
150
agent-office/app/curator/signals.py
Normal file
150
agent-office/app/curator/signals.py
Normal 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"
|
||||||
@@ -98,6 +98,66 @@ def init_db() -> None:
|
|||||||
completed_at TEXT
|
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'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS tarot_readings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||||
|
spread_type TEXT NOT NULL,
|
||||||
|
category TEXT,
|
||||||
|
question TEXT,
|
||||||
|
cards TEXT NOT NULL,
|
||||||
|
interpretation_json TEXT,
|
||||||
|
summary TEXT,
|
||||||
|
model TEXT,
|
||||||
|
tokens_in INTEGER,
|
||||||
|
tokens_out INTEGER,
|
||||||
|
cost_usd REAL,
|
||||||
|
confidence TEXT,
|
||||||
|
favorite INTEGER NOT NULL DEFAULT 0,
|
||||||
|
note TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tarot_created
|
||||||
|
ON tarot_readings(created_at DESC)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tarot_favorite
|
||||||
|
ON tarot_readings(favorite, created_at DESC)
|
||||||
|
""")
|
||||||
# Seed default agent configs
|
# Seed default agent configs
|
||||||
for agent_id, name in [
|
for agent_id, name in [
|
||||||
("stock", "주식 트레이더"),
|
("stock", "주식 트레이더"),
|
||||||
@@ -203,12 +263,24 @@ def get_task(task_id: str) -> Optional[Dict[str, Any]]:
|
|||||||
return _task_to_dict(r) if r else None
|
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:
|
with _conn() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(sql, params).fetchall()
|
||||||
"SELECT * FROM agent_tasks WHERE agent_id=? ORDER BY created_at DESC LIMIT ?",
|
|
||||||
(agent_id, limit),
|
|
||||||
).fetchall()
|
|
||||||
return [_task_to_dict(r) for r in rows]
|
return [_task_to_dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
@@ -556,3 +628,282 @@ def get_latest_youtube_research_job() -> Optional[Dict[str, Any]]:
|
|||||||
"started_at": row["started_at"],
|
"started_at": row["started_at"],
|
||||||
"completed_at": row["completed_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]
|
||||||
|
|
||||||
|
|
||||||
|
# --- tarot_readings CRUD ---
|
||||||
|
|
||||||
|
def save_tarot_reading(data: Dict[str, Any]) -> int:
|
||||||
|
interp = data.get("interpretation_json") or {}
|
||||||
|
summary = interp.get("summary", "") if isinstance(interp, dict) else ""
|
||||||
|
with _conn() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"""INSERT INTO tarot_readings
|
||||||
|
(spread_type, category, question, cards, interpretation_json,
|
||||||
|
summary, model, tokens_in, tokens_out, cost_usd, confidence)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(
|
||||||
|
data["spread_type"],
|
||||||
|
data.get("category"),
|
||||||
|
data.get("question"),
|
||||||
|
json.dumps(data.get("cards") or [], ensure_ascii=False),
|
||||||
|
json.dumps(interp, ensure_ascii=False) if interp else None,
|
||||||
|
summary,
|
||||||
|
data.get("model"),
|
||||||
|
data.get("tokens_in"),
|
||||||
|
data.get("tokens_out"),
|
||||||
|
data.get("cost_usd"),
|
||||||
|
data.get("confidence"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return int(cur.lastrowid)
|
||||||
|
|
||||||
|
|
||||||
|
def get_tarot_reading(reading_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
with _conn() as conn:
|
||||||
|
r = conn.execute("SELECT * FROM tarot_readings WHERE id=?", (reading_id,)).fetchone()
|
||||||
|
return _tarot_row_to_dict(r) if r else None
|
||||||
|
|
||||||
|
|
||||||
|
def list_tarot_readings(
|
||||||
|
page: int = 1, size: int = 20,
|
||||||
|
favorite: Optional[bool] = None,
|
||||||
|
spread_type: Optional[str] = None,
|
||||||
|
category: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
wheres, params = [], []
|
||||||
|
if favorite is not None:
|
||||||
|
wheres.append("favorite=?")
|
||||||
|
params.append(1 if favorite else 0)
|
||||||
|
if spread_type:
|
||||||
|
wheres.append("spread_type=?")
|
||||||
|
params.append(spread_type)
|
||||||
|
if category:
|
||||||
|
wheres.append("category=?")
|
||||||
|
params.append(category)
|
||||||
|
where_sql = ("WHERE " + " AND ".join(wheres)) if wheres else ""
|
||||||
|
offset = (page - 1) * size
|
||||||
|
with _conn() as conn:
|
||||||
|
total = conn.execute(
|
||||||
|
f"SELECT COUNT(*) c FROM tarot_readings {where_sql}", params
|
||||||
|
).fetchone()["c"]
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT * FROM tarot_readings {where_sql} ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||||
|
params + [size, offset],
|
||||||
|
).fetchall()
|
||||||
|
return {
|
||||||
|
"items": [_tarot_row_to_dict(r) for r in rows],
|
||||||
|
"page": page, "size": size, "total": int(total),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def update_tarot_reading(reading_id: int, **kwargs) -> None:
|
||||||
|
sets, vals = [], []
|
||||||
|
if "favorite" in kwargs and kwargs["favorite"] is not None:
|
||||||
|
sets.append("favorite=?")
|
||||||
|
vals.append(1 if kwargs["favorite"] else 0)
|
||||||
|
if "note" in kwargs and kwargs["note"] is not None:
|
||||||
|
sets.append("note=?")
|
||||||
|
vals.append(kwargs["note"])
|
||||||
|
if not sets:
|
||||||
|
return
|
||||||
|
vals.append(reading_id)
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute(f"UPDATE tarot_readings SET {','.join(sets)} WHERE id=?", vals)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_tarot_reading(reading_id: int) -> None:
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute("DELETE FROM tarot_readings WHERE id=?", (reading_id,))
|
||||||
|
|
||||||
|
|
||||||
|
def _tarot_row_to_dict(r) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
interp = json.loads(r["interpretation_json"]) if r["interpretation_json"] else None
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
interp = None
|
||||||
|
try:
|
||||||
|
cards = json.loads(r["cards"]) if r["cards"] else []
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
cards = []
|
||||||
|
return {
|
||||||
|
"id": r["id"],
|
||||||
|
"created_at": r["created_at"],
|
||||||
|
"spread_type": r["spread_type"],
|
||||||
|
"category": r["category"],
|
||||||
|
"question": r["question"],
|
||||||
|
"cards": cards,
|
||||||
|
"interpretation_json": interp,
|
||||||
|
"summary": r["summary"],
|
||||||
|
"model": r["model"],
|
||||||
|
"tokens_in": r["tokens_in"],
|
||||||
|
"tokens_out": r["tokens_out"],
|
||||||
|
"cost_usd": r["cost_usd"],
|
||||||
|
"confidence": r["confidence"],
|
||||||
|
"favorite": int(r["favorite"]),
|
||||||
|
"note": r["note"],
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
from typing import Optional
|
||||||
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
@@ -11,9 +12,11 @@ from .agents import init_agents, get_agent, get_all_agent_states, AGENT_REGISTRY
|
|||||||
from .scheduler import init_scheduler
|
from .scheduler import init_scheduler
|
||||||
from . import telegram_bot
|
from . import telegram_bot
|
||||||
from .routers import notify as notify_router
|
from .routers import notify as notify_router
|
||||||
|
from .routers import tarot as tarot_router
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
app.include_router(notify_router.router)
|
app.include_router(notify_router.router)
|
||||||
|
app.include_router(tarot_router.router)
|
||||||
|
|
||||||
_cors_origins = CORS_ALLOW_ORIGINS.split(",")
|
_cors_origins = CORS_ALLOW_ORIGINS.split(",")
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
@@ -104,8 +107,15 @@ def update_agent(agent_id: str, body: AgentConfigUpdate):
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@app.get("/api/agent-office/agents/{agent_id}/tasks")
|
@app.get("/api/agent-office/agents/{agent_id}/tasks")
|
||||||
def agent_tasks(agent_id: str, limit: int = 20):
|
def agent_tasks(
|
||||||
return {"tasks": get_agent_tasks(agent_id, limit)}
|
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")
|
@app.get("/api/agent-office/agents/{agent_id}/logs")
|
||||||
def agent_logs(agent_id: str, limit: int = 50):
|
def agent_logs(agent_id: str, limit: int = 50):
|
||||||
@@ -227,3 +237,30 @@ def youtube_research_status():
|
|||||||
if not job:
|
if not job:
|
||||||
return {"status": "never_run"}
|
return {"status": "never_run"}
|
||||||
return job
|
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)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
from typing import Optional
|
from typing import Optional, List, Literal
|
||||||
|
|
||||||
|
|
||||||
class CommandRequest(BaseModel):
|
class CommandRequest(BaseModel):
|
||||||
@@ -33,3 +33,46 @@ class ComposeCommand(BaseModel):
|
|||||||
style: Optional[str] = None
|
style: Optional[str] = None
|
||||||
model: Optional[str] = "V4"
|
model: Optional[str] = "V4"
|
||||||
instrumental: Optional[bool] = False
|
instrumental: Optional[bool] = False
|
||||||
|
|
||||||
|
|
||||||
|
class TarotCardDraw(BaseModel):
|
||||||
|
position: str
|
||||||
|
card_id: str
|
||||||
|
reversed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class TarotInterpretRequest(BaseModel):
|
||||||
|
spread_type: Literal["one_card", "three_card"]
|
||||||
|
category: Optional[str] = None
|
||||||
|
question: Optional[str] = None
|
||||||
|
cards: List[TarotCardDraw]
|
||||||
|
cards_reference: str = Field(..., min_length=1)
|
||||||
|
context_meta: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class TarotInterpretResponse(BaseModel):
|
||||||
|
interpretation_json: dict
|
||||||
|
model: str
|
||||||
|
tokens_in: int
|
||||||
|
tokens_out: int
|
||||||
|
cost_usd: float
|
||||||
|
latency_ms: int
|
||||||
|
reroll_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class TarotSaveRequest(BaseModel):
|
||||||
|
spread_type: Literal["one_card", "three_card"]
|
||||||
|
category: Optional[str] = None
|
||||||
|
question: Optional[str] = None
|
||||||
|
cards: List[TarotCardDraw]
|
||||||
|
interpretation_json: dict
|
||||||
|
model: str
|
||||||
|
tokens_in: int
|
||||||
|
tokens_out: int
|
||||||
|
cost_usd: float
|
||||||
|
confidence: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TarotPatchRequest(BaseModel):
|
||||||
|
favorite: Optional[bool] = None
|
||||||
|
note: Optional[str] = None
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""로또 큐레이션·당첨 알림 — 텔레그램 푸시."""
|
"""로또 큐레이션·당첨 알림 — 텔레그램 푸시."""
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any, List
|
||||||
|
|
||||||
# 기존 에이전트들과 동일한 패턴: send_raw(text, reply_markup=None, chat_id=None)
|
# 기존 에이전트들과 동일한 패턴: send_raw(text, reply_markup=None, chat_id=None)
|
||||||
# chat_id 생략 시 기본 TELEGRAM_CHAT_ID로 자동 발송.
|
# chat_id 생략 시 기본 TELEGRAM_CHAT_ID로 자동 발송.
|
||||||
@@ -59,3 +59,169 @@ async def send_prize_alert(event: Dict[str, Any]) -> None:
|
|||||||
await send_raw(text)
|
await send_raw(text)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[telegram_lotto] prize alert send failed: {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}")
|
||||||
|
|||||||
70
agent-office/app/routers/tarot.py
Normal file
70
agent-office/app/routers/tarot.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"""Tarot Lab 엔드포인트 — interpret + readings CRUD."""
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
|
from ..models import (
|
||||||
|
TarotInterpretRequest,
|
||||||
|
TarotInterpretResponse,
|
||||||
|
TarotSaveRequest,
|
||||||
|
TarotPatchRequest,
|
||||||
|
)
|
||||||
|
from ..tarot import pipeline
|
||||||
|
from .. import db as db_module
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/agent-office/tarot")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/interpret", response_model=TarotInterpretResponse)
|
||||||
|
async def interpret_endpoint(req: TarotInterpretRequest):
|
||||||
|
try:
|
||||||
|
result = await pipeline.interpret(req)
|
||||||
|
except pipeline.TarotError as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/readings")
|
||||||
|
async def save_reading(req: TarotSaveRequest):
|
||||||
|
rid = db_module.save_tarot_reading(req.model_dump())
|
||||||
|
row = db_module.get_tarot_reading(rid)
|
||||||
|
return {"id": rid, "created_at": row["created_at"]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/readings")
|
||||||
|
async def list_readings(
|
||||||
|
page: int = 1,
|
||||||
|
size: int = 20,
|
||||||
|
favorite: bool | None = None,
|
||||||
|
spread_type: str | None = None,
|
||||||
|
category: str | None = None,
|
||||||
|
):
|
||||||
|
return db_module.list_tarot_readings(
|
||||||
|
page=page, size=size,
|
||||||
|
favorite=favorite, spread_type=spread_type, category=category,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/readings/{reading_id}")
|
||||||
|
async def get_reading(reading_id: int):
|
||||||
|
row = db_module.get_tarot_reading(reading_id)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="reading not found")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/readings/{reading_id}")
|
||||||
|
async def patch_reading(reading_id: int, req: TarotPatchRequest):
|
||||||
|
row = db_module.get_tarot_reading(reading_id)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="reading not found")
|
||||||
|
db_module.update_tarot_reading(reading_id, **req.model_dump(exclude_none=True))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/readings/{reading_id}")
|
||||||
|
async def delete_reading(reading_id: int):
|
||||||
|
row = db_module.get_tarot_reading(reading_id)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="reading not found")
|
||||||
|
db_module.delete_tarot_reading(reading_id)
|
||||||
|
return {"ok": True}
|
||||||
@@ -36,6 +36,36 @@ async def _run_lotto_schedule():
|
|||||||
if agent:
|
if agent:
|
||||||
await agent.on_schedule()
|
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():
|
async def _run_youtube_research():
|
||||||
agent = AGENT_REGISTRY.get("youtube")
|
agent = AGENT_REGISTRY.get("youtube")
|
||||||
if agent:
|
if agent:
|
||||||
@@ -70,9 +100,20 @@ def init_scheduler():
|
|||||||
id="stock_ai_news_sentiment",
|
id="stock_ai_news_sentiment",
|
||||||
)
|
)
|
||||||
scheduler.add_job(_run_insta_schedule, "cron", hour=9, minute=30, id="insta_pipeline")
|
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)
|
# 외부 트렌드 수집은 장 마감 후 16:40 — 9시 주식 활발 시간대 NAS 자원 회피.
|
||||||
scheduler.add_job(_run_insta_trends_collect, "cron", hour=9, minute=0, id="insta_trends_collect")
|
# 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_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(_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(_send_youtube_weekly_report, "cron", day_of_week="mon", hour=8, minute=0, id="youtube_weekly_report")
|
||||||
scheduler.add_job(_poll_pipelines, "interval", seconds=30, id="pipeline_poll")
|
scheduler.add_job(_poll_pipelines, "interval", seconds=30, id="pipeline_poll")
|
||||||
|
|||||||
@@ -338,3 +338,59 @@ async def lookup_pipeline_by_msg(msg_id: int) -> Optional[dict]:
|
|||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
return resp.json()
|
return resp.json()
|
||||||
return None
|
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()
|
||||||
|
|||||||
1
agent-office/app/tarot/__init__.py
Normal file
1
agent-office/app/tarot/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Tarot Lab — Claude Sonnet 기반 evidence·interactions 해석 파이프라인."""
|
||||||
139
agent-office/app/tarot/pipeline.py
Normal file
139
agent-office/app/tarot/pipeline.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"""Tarot 파이프라인 — Claude Sonnet 호출 + 파싱 폴백 + reroll 1회."""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ..config import (
|
||||||
|
ANTHROPIC_API_KEY,
|
||||||
|
TAROT_MODEL,
|
||||||
|
TAROT_COST_INPUT_PER_M,
|
||||||
|
TAROT_COST_OUTPUT_PER_M,
|
||||||
|
TAROT_TIMEOUT_SEC,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("agent-office.tarot")
|
||||||
|
from ..models import TarotInterpretRequest
|
||||||
|
from .prompt import SYSTEM_PROMPT, build_user_message
|
||||||
|
from .schema import validate_interpretation
|
||||||
|
|
||||||
|
|
||||||
|
API_URL = "https://api.anthropic.com/v1/messages"
|
||||||
|
|
||||||
|
|
||||||
|
class TarotError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def calc_cost(tokens_in: int, tokens_out: int) -> float:
|
||||||
|
return (
|
||||||
|
tokens_in / 1_000_000 * TAROT_COST_INPUT_PER_M
|
||||||
|
+ tokens_out / 1_000_000 * TAROT_COST_OUTPUT_PER_M
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_codeblock(text: str) -> str:
|
||||||
|
t = text.strip()
|
||||||
|
if t.startswith("```"):
|
||||||
|
t = t.strip("`")
|
||||||
|
if t.startswith("json"):
|
||||||
|
t = t[4:]
|
||||||
|
t = t.strip()
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_json(raw: str) -> dict:
|
||||||
|
cleaned = _strip_codeblock(raw)
|
||||||
|
try:
|
||||||
|
return json.loads(cleaned)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
start, end = cleaned.find("{"), cleaned.rfind("}")
|
||||||
|
if start >= 0 and end > start:
|
||||||
|
try:
|
||||||
|
return json.loads(cleaned[start : end + 1])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_claude(user_text: str, feedback: str = "") -> tuple[dict, dict, str]:
|
||||||
|
if not ANTHROPIC_API_KEY:
|
||||||
|
raise TarotError("ANTHROPIC_API_KEY missing")
|
||||||
|
if feedback:
|
||||||
|
user_text = f"이전 응답이 다음 이유로 거절됨: {feedback}\n올바른 스키마(시스템 지침)로 다시 응답.\n\n{user_text}"
|
||||||
|
payload = {
|
||||||
|
"model": TAROT_MODEL,
|
||||||
|
"max_tokens": 1400, # 응답 시간 단축 — 3-card spread evidence·interactions 포함 충분
|
||||||
|
"system": [{"type": "text", "text": SYSTEM_PROMPT,
|
||||||
|
"cache_control": {"type": "ephemeral"}}],
|
||||||
|
"messages": [{"role": "user", "content": [{"type": "text", "text": user_text}]}],
|
||||||
|
}
|
||||||
|
headers = {
|
||||||
|
"x-api-key": ANTHROPIC_API_KEY,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
"anthropic-beta": "prompt-caching-2024-07-31",
|
||||||
|
"content-type": "application/json",
|
||||||
|
}
|
||||||
|
started = time.monotonic()
|
||||||
|
async with httpx.AsyncClient(timeout=TAROT_TIMEOUT_SEC) as client:
|
||||||
|
r = await client.post(API_URL, headers=headers, json=payload)
|
||||||
|
r.raise_for_status()
|
||||||
|
resp = r.json()
|
||||||
|
latency_ms = int((time.monotonic() - started) * 1000)
|
||||||
|
raw_text = "".join(
|
||||||
|
b.get("text", "") for b in resp.get("content", []) if b.get("type") == "text"
|
||||||
|
)
|
||||||
|
usage = resp.get("usage", {}) or {}
|
||||||
|
tokens_in = int(usage.get("input_tokens", 0) or 0)
|
||||||
|
tokens_out = int(usage.get("output_tokens", 0) or 0)
|
||||||
|
logger.info("tarot claude call: latency=%dms, in=%d, out=%d", latency_ms, tokens_in, tokens_out)
|
||||||
|
parsed = _extract_json(raw_text)
|
||||||
|
meta = {
|
||||||
|
"tokens_in": tokens_in,
|
||||||
|
"tokens_out": tokens_out,
|
||||||
|
"latency_ms": latency_ms,
|
||||||
|
}
|
||||||
|
return parsed, meta, raw_text
|
||||||
|
|
||||||
|
|
||||||
|
async def interpret(req: TarotInterpretRequest) -> Dict[str, Any]:
|
||||||
|
user_text = build_user_message(
|
||||||
|
question=req.question or "",
|
||||||
|
category=req.category or "",
|
||||||
|
spread_type=req.spread_type,
|
||||||
|
cards_reference=req.cards_reference,
|
||||||
|
context_meta=req.context_meta or {},
|
||||||
|
spread_count=len(req.cards),
|
||||||
|
)
|
||||||
|
|
||||||
|
total_in, total_out, total_latency = 0, 0, 0
|
||||||
|
last_error = ""
|
||||||
|
for attempt in range(2):
|
||||||
|
try:
|
||||||
|
parsed, meta, _raw = await _call_claude(user_text, feedback=last_error)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise TarotError(f"Claude HTTP error: {e}") from e
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
last_error = f"JSON 파싱 실패: {e}"
|
||||||
|
continue
|
||||||
|
total_in += meta["tokens_in"]
|
||||||
|
total_out += meta["tokens_out"]
|
||||||
|
total_latency += meta["latency_ms"]
|
||||||
|
|
||||||
|
ok, err = validate_interpretation(parsed, req.spread_type)
|
||||||
|
if ok:
|
||||||
|
return {
|
||||||
|
"interpretation_json": parsed,
|
||||||
|
"model": TAROT_MODEL,
|
||||||
|
"tokens_in": total_in,
|
||||||
|
"tokens_out": total_out,
|
||||||
|
"cost_usd": calc_cost(total_in, total_out),
|
||||||
|
"latency_ms": total_latency,
|
||||||
|
"reroll_count": attempt,
|
||||||
|
}
|
||||||
|
last_error = err
|
||||||
|
|
||||||
|
raise TarotError(f"검증 실패 (reroll 2회): {last_error}")
|
||||||
108
agent-office/app/tarot/prompt.py
Normal file
108
agent-office/app/tarot/prompt.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"""Tarot 프롬프트 — SYSTEM + build_user_message."""
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = """당신은 라이더-웨이트(RWS) 타로 덱의 전통 상징체계에 정통한 타로 리더입니다.
|
||||||
|
사용자의 질문, 카테고리, 뽑힌 카드 각각의 정·역방향과 위치를 받아 근거 기반으로 해석합니다.
|
||||||
|
|
||||||
|
# 해석 원칙
|
||||||
|
1. 데이터 우선: "참고 카드 정보" 블록의 키워드·기본의미·상징만을 1차 근거로 사용.
|
||||||
|
외부 변형 의미·다른 덱 해석은 사용하지 않음.
|
||||||
|
2. 위치 의미 결합: 카드의 의미와 위치(과거/현재/미래 또는 오늘)를 명시적으로 결합해서 해석. evidence에 근거 기록.
|
||||||
|
3. 카드 간 상호작용 분석 (3장 스프레드):
|
||||||
|
- 시너지: 같은 슈트, 같은 원소, 메이저 비율, 정·역 흐름
|
||||||
|
- 충돌·전환: 슈트 충돌(컵-소드, 완드-펜타클), 정→역 전환, 메이저↔마이너 전환
|
||||||
|
4. 자기 성찰 톤: 운명론 단정 금지. "…할 가능성이 있어 보입니다" 같은 표현.
|
||||||
|
5. 카테고리 컨텍스트: 동일 카드라도 카테고리에 따라 강조점이 달라야 함.
|
||||||
|
6. 질문 직접 응답: 사용자 질문을 evidence·advice에서 인용·반영.
|
||||||
|
|
||||||
|
# 응답 형식 (strict JSON only — 코드블록 없이 raw JSON)
|
||||||
|
{
|
||||||
|
"summary": "전체 흐름 한 단락 (3~4문장)",
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"position": "<위치 라벨>",
|
||||||
|
"card": "<card_id>",
|
||||||
|
"reversed": <bool>,
|
||||||
|
"interpretation": "3~4문장",
|
||||||
|
"evidence": {
|
||||||
|
"card_meaning_used": "참고 카드 정보에서 인용한 키워드·상징",
|
||||||
|
"position_logic": "왜 이 위치에 이렇게 적용되는지 (1~2문장)",
|
||||||
|
"category_lens": "카테고리 관점에서 부각되는 면 (1문장)"
|
||||||
|
},
|
||||||
|
"advice": "1문장"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"interactions": [
|
||||||
|
{ "type": "synergy"|"conflict"|"transition",
|
||||||
|
"between": ["<card_id>", "<card_id>"],
|
||||||
|
"explanation": "1~2문장" }
|
||||||
|
],
|
||||||
|
"advice": "2문장. interactions를 1개 이상 참조할 것.",
|
||||||
|
"warning": "역방향·충돌 경계 (없으면 null)",
|
||||||
|
"confidence": "high"|"medium"|"low"
|
||||||
|
}
|
||||||
|
|
||||||
|
# confidence 판정 기준
|
||||||
|
- high: 3장 모두 한 방향 서사 또는 명확한 전환
|
||||||
|
- medium: 2장 일관, 1장 별도 신호
|
||||||
|
- low: 카드 간 의미 충돌이 커서 명확한 흐름 잡기 어려움
|
||||||
|
|
||||||
|
# 금지사항
|
||||||
|
- 참고 카드 정보에 없는 상징 도입 금지
|
||||||
|
- 역방향 카드를 정방향처럼 다루지 말 것
|
||||||
|
- "신비롭게 들리는" 문구로 채우지 말 것 — evidence에 인용·근거 명시
|
||||||
|
- JSON 외 텍스트 금지
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
SPREAD_NAMES = {
|
||||||
|
"one_card": "오늘의 카드",
|
||||||
|
"three_card": "3장 스프레드 (과거·현재·미래)",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_user_message(
|
||||||
|
question: str,
|
||||||
|
category: str,
|
||||||
|
spread_type: str,
|
||||||
|
cards_reference: str,
|
||||||
|
context_meta: dict,
|
||||||
|
spread_count: int,
|
||||||
|
) -> str:
|
||||||
|
q = question or "(질문 없음)"
|
||||||
|
cat = category or "일반"
|
||||||
|
spread_name = SPREAD_NAMES.get(spread_type, spread_type)
|
||||||
|
|
||||||
|
meta_lines = []
|
||||||
|
if context_meta:
|
||||||
|
if "major_minor_ratio" in context_meta:
|
||||||
|
meta_lines.append(f"- 메이저:마이너 비율: {context_meta['major_minor_ratio']}")
|
||||||
|
if "element_distribution" in context_meta:
|
||||||
|
ed = context_meta["element_distribution"]
|
||||||
|
meta_lines.append(
|
||||||
|
f"- 원소 분포: 공기 {ed.get('air',0)}, 물 {ed.get('water',0)}, 불 {ed.get('fire',0)}, 흙 {ed.get('earth',0)}"
|
||||||
|
)
|
||||||
|
if "orientation_flow" in context_meta:
|
||||||
|
meta_lines.append(f"- 정역 흐름: {context_meta['orientation_flow']}")
|
||||||
|
meta_block = "\n".join(meta_lines) if meta_lines else "(추가 컨텍스트 없음)"
|
||||||
|
|
||||||
|
return f"""# 질문
|
||||||
|
{q}
|
||||||
|
|
||||||
|
# 카테고리
|
||||||
|
{cat}
|
||||||
|
|
||||||
|
# 스프레드
|
||||||
|
{spread_name} ({spread_count}장)
|
||||||
|
|
||||||
|
# 뽑힌 카드와 참고 카드 정보
|
||||||
|
{cards_reference}
|
||||||
|
|
||||||
|
## 추가 컨텍스트
|
||||||
|
{meta_block}
|
||||||
|
|
||||||
|
# 작업
|
||||||
|
위 정보만을 근거로 사용해, 시스템 지침의 JSON 형식으로 응답하세요.
|
||||||
|
- 각 카드의 evidence.card_meaning_used에는 위 "참고 카드 정보"에서 발췌한 키워드·의미를 그대로 인용.
|
||||||
|
- interactions는 3장 간 슈트·원소·정역방향 패턴을 분석해 최소 1개 이상 도출 (1장 스프레드면 빈 배열 허용).
|
||||||
|
- confidence는 카드 흐름의 일관성에 따라 정직하게 판정.
|
||||||
|
"""
|
||||||
36
agent-office/app/tarot/schema.py
Normal file
36
agent-office/app/tarot/schema.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""Tarot 응답 스키마 검증 — 누락·빈 필드 reroll 트리거."""
|
||||||
|
|
||||||
|
VALID_CONFIDENCE = {"high", "medium", "low"}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_interpretation(parsed: dict, spread_type: str) -> tuple[bool, str]:
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
return False, "응답이 dict가 아님"
|
||||||
|
for k in ("summary", "cards", "interactions", "advice", "confidence"):
|
||||||
|
if k not in parsed:
|
||||||
|
return False, f"필수 필드 누락: {k}"
|
||||||
|
if parsed.get("confidence") not in VALID_CONFIDENCE:
|
||||||
|
return False, f"confidence 값 비정상: {parsed.get('confidence')}"
|
||||||
|
cards = parsed.get("cards")
|
||||||
|
if not isinstance(cards, list) or not cards:
|
||||||
|
return False, "cards가 빈 배열"
|
||||||
|
for i, c in enumerate(cards):
|
||||||
|
if not isinstance(c, dict):
|
||||||
|
return False, f"cards[{i}] dict 아님"
|
||||||
|
for k in ("position", "card", "reversed", "interpretation", "advice", "evidence"):
|
||||||
|
if k not in c:
|
||||||
|
return False, f"cards[{i}].{k} 누락"
|
||||||
|
ev = c["evidence"]
|
||||||
|
if not isinstance(ev, dict):
|
||||||
|
return False, f"cards[{i}].evidence dict 아님"
|
||||||
|
for k in ("card_meaning_used", "position_logic", "category_lens"):
|
||||||
|
if k not in ev:
|
||||||
|
return False, f"cards[{i}].evidence.{k} 누락"
|
||||||
|
if not isinstance(ev[k], str) or not ev[k].strip():
|
||||||
|
return False, f"cards[{i}].evidence.{k} 빈 문자열"
|
||||||
|
interactions = parsed.get("interactions")
|
||||||
|
if not isinstance(interactions, list):
|
||||||
|
return False, "interactions가 list 아님"
|
||||||
|
if spread_type == "three_card" and len(interactions) == 0:
|
||||||
|
return False, "three_card는 interactions 1개 이상 필요"
|
||||||
|
return True, ""
|
||||||
@@ -4,5 +4,6 @@ apscheduler==3.10.4
|
|||||||
websockets>=12.0
|
websockets>=12.0
|
||||||
httpx>=0.27
|
httpx>=0.27
|
||||||
respx>=0.21
|
respx>=0.21
|
||||||
|
pytest-asyncio>=0.23
|
||||||
google-api-python-client>=2.100.0
|
google-api-python-client>=2.100.0
|
||||||
pytrends>=4.9.2
|
pytrends>=4.9.2
|
||||||
|
|||||||
55
agent-office/tests/test_insta_keyword_filter.py
Normal file
55
agent-office/tests/test_insta_keyword_filter.py
Normal 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
|
||||||
87
agent-office/tests/test_lotto_evolution_format.py
Normal file
87
agent-office/tests/test_lotto_evolution_format.py
Normal 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
|
||||||
116
agent-office/tests/test_lotto_signal_runner.py
Normal file
116
agent-office/tests/test_lotto_signal_runner.py
Normal 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"])
|
||||||
130
agent-office/tests/test_lotto_signals.py
Normal file
130
agent-office/tests/test_lotto_signals.py
Normal 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"
|
||||||
154
agent-office/tests/test_lotto_task_wrap.py
Normal file
154
agent-office/tests/test_lotto_task_wrap.py
Normal 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
|
||||||
49
agent-office/tests/test_lotto_telegram_signal.py
Normal file
49
agent-office/tests/test_lotto_telegram_signal.py
Normal 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 == ""
|
||||||
123
agent-office/tests/test_sync_evolver_activity.py
Normal file
123
agent-office/tests/test_sync_evolver_activity.py
Normal 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
|
||||||
70
agent-office/tests/test_tarot_db.py
Normal file
70
agent-office/tests/test_tarot_db.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import db as db_module
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def fresh_db(monkeypatch, tmp_path):
|
||||||
|
db_file = tmp_path / "test_tarot.db"
|
||||||
|
monkeypatch.setattr(db_module, "DB_PATH", str(db_file))
|
||||||
|
db_module.init_db()
|
||||||
|
yield
|
||||||
|
if db_file.exists():
|
||||||
|
db_file.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_and_get_tarot_reading():
|
||||||
|
rid = db_module.save_tarot_reading({
|
||||||
|
"spread_type": "three_card",
|
||||||
|
"category": "연애",
|
||||||
|
"question": "Q",
|
||||||
|
"cards": [{"position": "과거", "card_id": "the-fool", "reversed": False}],
|
||||||
|
"interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "A", "warning": None, "confidence": "medium"},
|
||||||
|
"model": "claude-sonnet-4-6",
|
||||||
|
"tokens_in": 100, "tokens_out": 200, "cost_usd": 0.005,
|
||||||
|
"confidence": "medium",
|
||||||
|
})
|
||||||
|
assert rid > 0
|
||||||
|
row = db_module.get_tarot_reading(rid)
|
||||||
|
assert row["id"] == rid
|
||||||
|
assert row["category"] == "연애"
|
||||||
|
assert row["interpretation_json"]["summary"] == "S"
|
||||||
|
assert row["favorite"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_tarot_readings_filters_and_pagination():
|
||||||
|
for cat in ["연애", "연애", "재물"]:
|
||||||
|
db_module.save_tarot_reading({
|
||||||
|
"spread_type": "three_card", "category": cat, "question": "Q",
|
||||||
|
"cards": [], "interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "", "warning": None, "confidence": "low"},
|
||||||
|
"model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "confidence": "low",
|
||||||
|
})
|
||||||
|
res = db_module.list_tarot_readings(page=1, size=10, category="연애")
|
||||||
|
assert res["total"] == 2
|
||||||
|
assert all(r["category"] == "연애" for r in res["items"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_tarot_reading_favorite_and_note():
|
||||||
|
rid = db_module.save_tarot_reading({
|
||||||
|
"spread_type": "one_card", "category": None, "question": None,
|
||||||
|
"cards": [], "interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "", "warning": None, "confidence": "high"},
|
||||||
|
"model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "confidence": "high",
|
||||||
|
})
|
||||||
|
db_module.update_tarot_reading(rid, favorite=True, note="기억하고 싶음")
|
||||||
|
row = db_module.get_tarot_reading(rid)
|
||||||
|
assert row["favorite"] == 1
|
||||||
|
assert row["note"] == "기억하고 싶음"
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_tarot_reading():
|
||||||
|
rid = db_module.save_tarot_reading({
|
||||||
|
"spread_type": "one_card", "category": None, "question": None,
|
||||||
|
"cards": [], "interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "", "warning": None, "confidence": "high"},
|
||||||
|
"model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "confidence": "high",
|
||||||
|
})
|
||||||
|
db_module.delete_tarot_reading(rid)
|
||||||
|
assert db_module.get_tarot_reading(rid) is None
|
||||||
113
agent-office/tests/test_tarot_pipeline.py
Normal file
113
agent-office/tests/test_tarot_pipeline.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
import respx
|
||||||
|
from httpx import Response
|
||||||
|
|
||||||
|
from app.tarot import pipeline as p
|
||||||
|
from app.models import TarotInterpretRequest
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_response_text():
|
||||||
|
return json.dumps({
|
||||||
|
"summary": "S",
|
||||||
|
"cards": [
|
||||||
|
{"position": "과거", "card": "the-fool", "reversed": False,
|
||||||
|
"interpretation": "i", "advice": "a",
|
||||||
|
"evidence": {"card_meaning_used": "k", "position_logic": "p", "category_lens": "c"}},
|
||||||
|
{"position": "현재", "card": "the-lovers", "reversed": True,
|
||||||
|
"interpretation": "i", "advice": "a",
|
||||||
|
"evidence": {"card_meaning_used": "k", "position_logic": "p", "category_lens": "c"}},
|
||||||
|
{"position": "미래", "card": "ten-of-cups", "reversed": False,
|
||||||
|
"interpretation": "i", "advice": "a",
|
||||||
|
"evidence": {"card_meaning_used": "k", "position_logic": "p", "category_lens": "c"}},
|
||||||
|
],
|
||||||
|
"interactions": [{"type": "synergy", "between": ["the-fool", "ten-of-cups"], "explanation": "."}],
|
||||||
|
"advice": "A", "warning": None, "confidence": "medium",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _claude_resp(text, in_tok=100, out_tok=200):
|
||||||
|
return {
|
||||||
|
"content": [{"type": "text", "text": text}],
|
||||||
|
"usage": {"input_tokens": in_tok, "output_tokens": out_tok},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _req():
|
||||||
|
return TarotInterpretRequest(
|
||||||
|
spread_type="three_card",
|
||||||
|
category="연애",
|
||||||
|
question="Q",
|
||||||
|
cards=[
|
||||||
|
{"position": "과거", "card_id": "the-fool", "reversed": False},
|
||||||
|
{"position": "현재", "card_id": "the-lovers", "reversed": True},
|
||||||
|
{"position": "미래", "card_id": "ten-of-cups", "reversed": False},
|
||||||
|
],
|
||||||
|
cards_reference="REFERENCE",
|
||||||
|
context_meta={"major_minor_ratio": "2:1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_interpret_happy_path(monkeypatch):
|
||||||
|
monkeypatch.setattr(p, "ANTHROPIC_API_KEY", "sk-test")
|
||||||
|
with respx.mock(base_url="https://api.anthropic.com") as mock:
|
||||||
|
mock.post("/v1/messages").mock(return_value=Response(200, json=_claude_resp(_valid_response_text())))
|
||||||
|
out = await p.interpret(_req())
|
||||||
|
assert out["interpretation_json"]["confidence"] == "medium"
|
||||||
|
assert out["tokens_in"] == 100
|
||||||
|
assert out["tokens_out"] == 200
|
||||||
|
assert out["reroll_count"] == 0
|
||||||
|
assert out["cost_usd"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_interpret_codeblock_strip(monkeypatch):
|
||||||
|
monkeypatch.setattr(p, "ANTHROPIC_API_KEY", "sk-test")
|
||||||
|
wrapped = "```json\n" + _valid_response_text() + "\n```"
|
||||||
|
with respx.mock(base_url="https://api.anthropic.com") as mock:
|
||||||
|
mock.post("/v1/messages").mock(return_value=Response(200, json=_claude_resp(wrapped)))
|
||||||
|
out = await p.interpret(_req())
|
||||||
|
assert out["interpretation_json"]["summary"] == "S"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_interpret_reroll_on_validation_fail(monkeypatch):
|
||||||
|
monkeypatch.setattr(p, "ANTHROPIC_API_KEY", "sk-test")
|
||||||
|
bad = json.loads(_valid_response_text())
|
||||||
|
bad["cards"][0]["evidence"]["card_meaning_used"] = ""
|
||||||
|
bad_text = json.dumps(bad)
|
||||||
|
with respx.mock(base_url="https://api.anthropic.com") as mock:
|
||||||
|
route = mock.post("/v1/messages")
|
||||||
|
route.side_effect = [
|
||||||
|
Response(200, json=_claude_resp(bad_text)),
|
||||||
|
Response(200, json=_claude_resp(_valid_response_text())),
|
||||||
|
]
|
||||||
|
out = await p.interpret(_req())
|
||||||
|
assert out["reroll_count"] == 1
|
||||||
|
assert out["interpretation_json"]["cards"][0]["evidence"]["card_meaning_used"] == "k"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_interpret_raises_when_both_attempts_fail(monkeypatch):
|
||||||
|
monkeypatch.setattr(p, "ANTHROPIC_API_KEY", "sk-test")
|
||||||
|
bad = json.loads(_valid_response_text())
|
||||||
|
bad["cards"][0]["evidence"]["card_meaning_used"] = ""
|
||||||
|
bad_text = json.dumps(bad)
|
||||||
|
with respx.mock(base_url="https://api.anthropic.com") as mock:
|
||||||
|
mock.post("/v1/messages").mock(return_value=Response(200, json=_claude_resp(bad_text)))
|
||||||
|
with pytest.raises(p.TarotError):
|
||||||
|
await p.interpret(_req())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_interpret_raises_when_api_key_missing(monkeypatch):
|
||||||
|
monkeypatch.setattr(p, "ANTHROPIC_API_KEY", "")
|
||||||
|
with pytest.raises(p.TarotError):
|
||||||
|
await p.interpret(_req())
|
||||||
|
|
||||||
|
|
||||||
|
def test_calc_cost():
|
||||||
|
assert p.calc_cost(1_000_000, 0) == pytest.approx(3.0)
|
||||||
|
assert p.calc_cost(0, 1_000_000) == pytest.approx(15.0)
|
||||||
|
assert p.calc_cost(500_000, 500_000) == pytest.approx(9.0)
|
||||||
86
agent-office/tests/test_tarot_routes.py
Normal file
86
agent-office/tests/test_tarot_routes.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app import db as db_module
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def fresh_db(monkeypatch, tmp_path):
|
||||||
|
db_file = tmp_path / "test_routes.db"
|
||||||
|
monkeypatch.setattr(db_module, "DB_PATH", str(db_file))
|
||||||
|
db_module.init_db()
|
||||||
|
from app.main import app
|
||||||
|
yield app
|
||||||
|
|
||||||
|
|
||||||
|
def test_interpret_calls_pipeline(monkeypatch, fresh_db):
|
||||||
|
async def fake_interpret(req):
|
||||||
|
return {
|
||||||
|
"interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "A", "warning": None, "confidence": "high"},
|
||||||
|
"model": "claude-sonnet-4-6", "tokens_in": 100, "tokens_out": 200,
|
||||||
|
"cost_usd": 0.005, "latency_ms": 1234, "reroll_count": 0,
|
||||||
|
}
|
||||||
|
from app.tarot import pipeline
|
||||||
|
monkeypatch.setattr(pipeline, "interpret", fake_interpret)
|
||||||
|
client = TestClient(fresh_db)
|
||||||
|
r = client.post("/api/agent-office/tarot/interpret", json={
|
||||||
|
"spread_type": "one_card",
|
||||||
|
"category": "일반",
|
||||||
|
"question": "Q",
|
||||||
|
"cards": [{"position": "오늘", "card_id": "the-fool", "reversed": False}],
|
||||||
|
"cards_reference": "REF",
|
||||||
|
"context_meta": {},
|
||||||
|
})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["interpretation_json"]["confidence"] == "high"
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_and_list(fresh_db):
|
||||||
|
client = TestClient(fresh_db)
|
||||||
|
save = client.post("/api/agent-office/tarot/readings", json={
|
||||||
|
"spread_type": "three_card", "category": "연애", "question": "Q",
|
||||||
|
"cards": [{"position": "과거", "card_id": "the-fool", "reversed": False}],
|
||||||
|
"interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "A", "warning": None, "confidence": "medium"},
|
||||||
|
"model": "claude-sonnet-4-6", "tokens_in": 1, "tokens_out": 2, "cost_usd": 0.01,
|
||||||
|
"confidence": "medium",
|
||||||
|
})
|
||||||
|
assert save.status_code == 200, save.text
|
||||||
|
rid = save.json()["id"]
|
||||||
|
lst = client.get("/api/agent-office/tarot/readings?page=1&size=10")
|
||||||
|
assert lst.json()["total"] == 1
|
||||||
|
assert lst.json()["items"][0]["id"] == rid
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_favorite(fresh_db):
|
||||||
|
client = TestClient(fresh_db)
|
||||||
|
save = client.post("/api/agent-office/tarot/readings", json={
|
||||||
|
"spread_type": "one_card", "cards": [],
|
||||||
|
"interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "A", "warning": None, "confidence": "low"},
|
||||||
|
"model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "confidence": "low",
|
||||||
|
})
|
||||||
|
rid = save.json()["id"]
|
||||||
|
p = client.patch(f"/api/agent-office/tarot/readings/{rid}", json={"favorite": True})
|
||||||
|
assert p.status_code == 200
|
||||||
|
g = client.get(f"/api/agent-office/tarot/readings/{rid}")
|
||||||
|
assert g.json()["favorite"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete(fresh_db):
|
||||||
|
client = TestClient(fresh_db)
|
||||||
|
save = client.post("/api/agent-office/tarot/readings", json={
|
||||||
|
"spread_type": "one_card", "cards": [],
|
||||||
|
"interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "A", "warning": None, "confidence": "low"},
|
||||||
|
"model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "confidence": "low",
|
||||||
|
})
|
||||||
|
rid = save.json()["id"]
|
||||||
|
d = client.delete(f"/api/agent-office/tarot/readings/{rid}")
|
||||||
|
assert d.status_code == 200
|
||||||
|
g = client.get(f"/api/agent-office/tarot/readings/{rid}")
|
||||||
|
assert g.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_missing_reading_404(fresh_db):
|
||||||
|
client = TestClient(fresh_db)
|
||||||
|
r = client.get("/api/agent-office/tarot/readings/99999")
|
||||||
|
assert r.status_code == 404
|
||||||
75
agent-office/tests/test_tarot_schema.py
Normal file
75
agent-office/tests/test_tarot_schema.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.tarot.schema import validate_interpretation
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_three():
|
||||||
|
return {
|
||||||
|
"summary": "S",
|
||||||
|
"cards": [
|
||||||
|
{"position": "과거", "card": "the-fool", "reversed": False,
|
||||||
|
"interpretation": "...", "advice": "a",
|
||||||
|
"evidence": {"card_meaning_used": "키워드", "position_logic": "p", "category_lens": "c"}},
|
||||||
|
{"position": "현재", "card": "the-lovers", "reversed": True,
|
||||||
|
"interpretation": "...", "advice": "a",
|
||||||
|
"evidence": {"card_meaning_used": "키워드", "position_logic": "p", "category_lens": "c"}},
|
||||||
|
{"position": "미래", "card": "ten-of-cups", "reversed": False,
|
||||||
|
"interpretation": "...", "advice": "a",
|
||||||
|
"evidence": {"card_meaning_used": "키워드", "position_logic": "p", "category_lens": "c"}},
|
||||||
|
],
|
||||||
|
"interactions": [{"type": "synergy", "between": ["the-fool", "ten-of-cups"], "explanation": "..."}],
|
||||||
|
"advice": "A",
|
||||||
|
"warning": None,
|
||||||
|
"confidence": "medium",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_three_card_passes():
|
||||||
|
ok, msg = validate_interpretation(_valid_three(), "three_card")
|
||||||
|
assert ok, msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_evidence_fails():
|
||||||
|
bad = _valid_three()
|
||||||
|
del bad["cards"][0]["evidence"]
|
||||||
|
ok, msg = validate_interpretation(bad, "three_card")
|
||||||
|
assert not ok
|
||||||
|
assert "evidence" in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_card_meaning_used_fails():
|
||||||
|
bad = _valid_three()
|
||||||
|
bad["cards"][0]["evidence"]["card_meaning_used"] = ""
|
||||||
|
ok, msg = validate_interpretation(bad, "three_card")
|
||||||
|
assert not ok
|
||||||
|
assert "card_meaning_used" in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_three_card_requires_interactions():
|
||||||
|
bad = _valid_three()
|
||||||
|
bad["interactions"] = []
|
||||||
|
ok, msg = validate_interpretation(bad, "three_card")
|
||||||
|
assert not ok
|
||||||
|
assert "interactions" in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_card_accepts_empty_interactions():
|
||||||
|
one = {
|
||||||
|
"summary": "S",
|
||||||
|
"cards": [{"position": "오늘", "card": "the-fool", "reversed": False,
|
||||||
|
"interpretation": "...", "advice": "a",
|
||||||
|
"evidence": {"card_meaning_used": "k", "position_logic": "p", "category_lens": "c"}}],
|
||||||
|
"interactions": [],
|
||||||
|
"advice": "A",
|
||||||
|
"warning": None,
|
||||||
|
"confidence": "high",
|
||||||
|
}
|
||||||
|
ok, msg = validate_interpretation(one, "one_card")
|
||||||
|
assert ok, msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_confidence_fails():
|
||||||
|
bad = _valid_three()
|
||||||
|
bad["confidence"] = "very high"
|
||||||
|
ok, msg = validate_interpretation(bad, "three_card")
|
||||||
|
assert not ok
|
||||||
@@ -113,6 +113,28 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
|
image-lab:
|
||||||
|
build: ./image-lab
|
||||||
|
container_name: image-lab
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "18802:8000"
|
||||||
|
environment:
|
||||||
|
- TZ=${TZ:-Asia/Seoul}
|
||||||
|
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||||
|
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
|
||||||
|
- IMAGE_DATA_DIR=/app/data
|
||||||
|
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||||
|
volumes:
|
||||||
|
- ${RUNTIME_PATH}/data/image:/app/data
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
insta-lab:
|
insta-lab:
|
||||||
build:
|
build:
|
||||||
context: ./insta-lab
|
context: ./insta-lab
|
||||||
@@ -289,6 +311,7 @@ services:
|
|||||||
- packs-lab
|
- packs-lab
|
||||||
- travel-proxy
|
- travel-proxy
|
||||||
- video-lab
|
- video-lab
|
||||||
|
- image-lab
|
||||||
ports:
|
ports:
|
||||||
- "8080:80"
|
- "8080:80"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
1651
docs/superpowers/plans/2026-05-20-lotto-active-agent.md
Normal file
1651
docs/superpowers/plans/2026-05-20-lotto-active-agent.md
Normal file
File diff suppressed because it is too large
Load Diff
1587
docs/superpowers/plans/2026-05-22-lotto-weight-evolver.md
Normal file
1587
docs/superpowers/plans/2026-05-22-lotto-weight-evolver.md
Normal file
File diff suppressed because it is too large
Load Diff
929
docs/superpowers/plans/2026-05-22-plan-b-infra.md
Normal file
929
docs/superpowers/plans/2026-05-22-plan-b-infra.md
Normal 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:00–16: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:00–16: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:00–16: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 대비)
|
||||||
1618
docs/superpowers/plans/2026-05-23-lotto-evolver-ui.md
Normal file
1618
docs/superpowers/plans/2026-05-23-lotto-evolver-ui.md
Normal file
File diff suppressed because it is too large
Load Diff
3547
docs/superpowers/plans/2026-05-23-tarot-lab.md
Normal file
3547
docs/superpowers/plans/2026-05-23-tarot-lab.md
Normal file
File diff suppressed because it is too large
Load Diff
1373
docs/superpowers/plans/2026-05-23-video-studio-backend.md
Normal file
1373
docs/superpowers/plans/2026-05-23-video-studio-backend.md
Normal file
File diff suppressed because it is too large
Load Diff
301
docs/superpowers/specs/2026-05-20-lotto-active-agent-design.md
Normal file
301
docs/superpowers/specs/2026-05-20-lotto-active-agent-design.md
Normal 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 추천번호 패턴 학습)
|
||||||
419
docs/superpowers/specs/2026-05-22-lotto-weight-evolver-design.md
Normal file
419
docs/superpowers/specs/2026-05-22-lotto-weight-evolver-design.md
Normal 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장 자동 발주, 사람 승인 후)
|
||||||
368
docs/superpowers/specs/2026-05-23-lotto-evolver-ui-design.md
Normal file
368
docs/superpowers/specs/2026-05-23-lotto-evolver-ui-design.md
Normal 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개 일치 났는지 등)
|
||||||
559
docs/superpowers/specs/2026-05-23-tarot-lab-design.md
Normal file
559
docs/superpowers/specs/2026-05-23-tarot-lab-design.md
Normal file
@@ -0,0 +1,559 @@
|
|||||||
|
# Tarot Lab v1 — Design Spec
|
||||||
|
|
||||||
|
**작성일:** 2026-05-23
|
||||||
|
**상태:** 디자인 승인 완료, 구현 계획 작성 대기
|
||||||
|
**관련 자산:**
|
||||||
|
- `source/images/tarot_page/tarot_main_landing_page.png` (랜딩 시안)
|
||||||
|
- `source/images/tarot_page/tarot_card_select_page.png` (카드 선택 시안)
|
||||||
|
- `source/images/tarot_page/tarot_background.png` (정적 배경 폴백)
|
||||||
|
- `source/images/tarot_page/tarot_cards.png` (카드 콜라주 참고)
|
||||||
|
- `source/videos/tarot_main_background.mp4` (히어로 영상)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 목표와 배경
|
||||||
|
|
||||||
|
개인 웹 플랫폼에 라이더-웨이트(RWS) 기반 타로 리딩 기능을 추가한다. v1은 **오늘의 카드 / 3장 스프레드 / 리딩 히스토리·마이페이지** 3개 핵심 흐름을 한 번에 배포하고, AI 해석은 Claude Sonnet 4.6을 통해 **근거 기반(evidence)** 으로 생성한다. 켈틱 크로스 10장 스프레드와 카드 78장 정식 이미지 자산은 v2 분리.
|
||||||
|
|
||||||
|
### 비목표 (v2 이후)
|
||||||
|
- 켈틱 크로스 10장 스프레드
|
||||||
|
- 사용자가 제공할 카드 78장 정식 이미지 자산의 정식 매핑 (v1은 placeholder/CSS)
|
||||||
|
- 78장 의미 텍스트 완성본 (v1은 메이저 22 + 마이너 키워드만)
|
||||||
|
- 텔레그램 자동 push ("매일 오늘의 카드")
|
||||||
|
- 카드 78장 도감 화면
|
||||||
|
- 즐겨찾기 메모 편집 UI (백엔드 endpoint는 v1에 포함, UI는 v2)
|
||||||
|
- **카드 시각 효과 보강** — 카드 이미지 자산 도착 이후 보강:
|
||||||
|
- 카드 hover·focus 시 보더 주변 황금 글로우·sparkle particles
|
||||||
|
- 카드 뒤집기 애니메이션 (3D rotateY transform, 0.6~0.8s ease-out, 뒷면→앞면 전환)
|
||||||
|
- 우주 입자 floating · 별 깜빡임 등 분위기 효과
|
||||||
|
- v1은 hover lift + 단순 fade-in 정도의 미니멀 모션만
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
web-ui (React + Vite)
|
||||||
|
/tarot 랜딩 (히어로 영상 + 3-tier)
|
||||||
|
/tarot/today 오늘의 카드 (원카드)
|
||||||
|
/tarot/reading 3장 스프레드 (메인 인터랙션)
|
||||||
|
/tarot/history 마이페이지 (리딩 이력)
|
||||||
|
│
|
||||||
|
│ /api/agent-office/tarot/*
|
||||||
|
▼
|
||||||
|
agent-office (FastAPI 확장)
|
||||||
|
app/routes/tarot.py 4 endpoint
|
||||||
|
app/agents/tarot.py TarotAgent (Claude Sonnet 호출 + 응답 검증)
|
||||||
|
app/db.py tarot_readings 테이블 추가
|
||||||
|
│
|
||||||
|
▼ Anthropic API
|
||||||
|
Claude Sonnet 4.6
|
||||||
|
```
|
||||||
|
|
||||||
|
### 경계 결정 이유
|
||||||
|
- **카드 78장 메타데이터는 프론트 정적 JSON** — 자주 안 변하고 셔플·선택에 백엔드 호출 불필요. 라운드트립 절약.
|
||||||
|
- **AI 해석만 백엔드** — API key 보호 + 호출 로깅·검증·reroll 가능.
|
||||||
|
- **히스토리도 백엔드** — localStorage는 기기 의존, 사용자가 영속화 요구.
|
||||||
|
- **신규 컨테이너 없음** — agent-office 확장. nginx·docker-compose 변경 0건.
|
||||||
|
|
||||||
|
### Why agent-office인가
|
||||||
|
1. `ANTHROPIC_API_KEY` 이미 환경변수로 연결됨
|
||||||
|
2. Claude SDK + httpx 클라이언트 set up 완료
|
||||||
|
3. Agent FSM 패턴(idle→working→reporting)에 자연스럽게 맞음 — TarotAgent도 "리딩 수행" 작업으로 모델링
|
||||||
|
4. 텔레그램 봇 연결되어 있어 v2에서 "매일 오늘의 카드" push 확장 여지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 프론트 데이터 모델
|
||||||
|
|
||||||
|
### 정적 카드 데이터 (`web-ui/src/pages/tarot/data/cards.js`)
|
||||||
|
|
||||||
|
```js
|
||||||
|
export const TAROT_DECK = [
|
||||||
|
// Major Arcana 22장
|
||||||
|
{
|
||||||
|
id: 0,
|
||||||
|
slug: "the-fool",
|
||||||
|
name: "바보",
|
||||||
|
nameEn: "The Fool",
|
||||||
|
arcana: "major",
|
||||||
|
element: "air",
|
||||||
|
keywords: ["새로운 시작", "도약", "순수", "자유"],
|
||||||
|
reversedKeywords: ["무모함", "경솔함", "위험", "방향 상실"],
|
||||||
|
meaningUpright: "미지의 세계로 내딛는 첫걸음. 계산보다 직관과 신뢰로 시작하는 시기.",
|
||||||
|
meaningReversed: "준비 없이 뛰어들어 위험을 자초하거나, 두려움으로 첫걸음을 미루는 상태.",
|
||||||
|
image: null, // 사용자가 /images/tarot/cards/the-fool.png 추가 시 자동 매핑
|
||||||
|
},
|
||||||
|
// ... Major 21장 더
|
||||||
|
|
||||||
|
// Minor Arcana 56장
|
||||||
|
{
|
||||||
|
id: 22,
|
||||||
|
slug: "ace-of-wands",
|
||||||
|
name: "지팡이 에이스",
|
||||||
|
arcana: "minor",
|
||||||
|
suit: "wands",
|
||||||
|
rank: 1,
|
||||||
|
element: "fire",
|
||||||
|
keywords: ["창조의 불씨", "영감", "새로운 시작"],
|
||||||
|
reversedKeywords: ["지연", "동기 부족", "방향 상실"],
|
||||||
|
meaningUpright: "...",
|
||||||
|
meaningReversed: "...",
|
||||||
|
image: null,
|
||||||
|
},
|
||||||
|
// ... Minor 55장 더
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SPREADS = {
|
||||||
|
one_card: {
|
||||||
|
id: "one_card",
|
||||||
|
name: "오늘의 카드",
|
||||||
|
positions: [{ idx: 0, label: "오늘" }],
|
||||||
|
},
|
||||||
|
three_card: {
|
||||||
|
id: "three_card",
|
||||||
|
name: "3장 스프레드",
|
||||||
|
positions: [
|
||||||
|
{ idx: 0, label: "과거" },
|
||||||
|
{ idx: 1, label: "현재" },
|
||||||
|
{ idx: 2, label: "미래" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CATEGORIES = ["연애", "일·커리어", "관계", "재물", "건강", "일반"];
|
||||||
|
```
|
||||||
|
|
||||||
|
**v1 시드 데이터 작업량:**
|
||||||
|
- 메이저 22장: 정·역 키워드 + 정·역 의미 텍스트 완성 (필수)
|
||||||
|
- 마이너 56장: 정·역 키워드만 (필수) + 의미 텍스트는 짧은 요약 1문장씩 (v2에서 보강)
|
||||||
|
|
||||||
|
### 카드 이미지 자동 매핑 규칙
|
||||||
|
- 사용자가 `web-ui/public/images/tarot/cards/<slug>.png` 추가 시 자동 표시
|
||||||
|
- `cards.js`에서 `image: \`/images/tarot/cards/${slug}.png\`` 일관 패턴
|
||||||
|
- `onError` → CSS 카드 디자인 폴백 (그라데이션 보더 + 카드명 + 심볼)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 백엔드 데이터 모델
|
||||||
|
|
||||||
|
### tarot_readings 테이블 (`agent_office.db`)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS tarot_readings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_at TEXT NOT NULL, -- UTC ISO8601
|
||||||
|
spread_type TEXT NOT NULL, -- 'one_card' | 'three_card'
|
||||||
|
category TEXT, -- '연애' | '일·커리어' | …
|
||||||
|
question TEXT, -- 사용자 입력 (NULL 가능)
|
||||||
|
cards TEXT NOT NULL, -- JSON: [{position, card_id, reversed}]
|
||||||
|
interpretation_json TEXT, -- Claude 응답 파싱 결과 전체
|
||||||
|
summary TEXT, -- interpretation_json.summary 빠른 조회용
|
||||||
|
model TEXT, -- 'claude-sonnet-4-6'
|
||||||
|
tokens_in INTEGER,
|
||||||
|
tokens_out INTEGER,
|
||||||
|
cost_usd REAL,
|
||||||
|
confidence TEXT, -- 'high' | 'medium' | 'low'
|
||||||
|
favorite INTEGER DEFAULT 0,
|
||||||
|
note TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_tarot_created ON tarot_readings(created_at DESC);
|
||||||
|
CREATE INDEX idx_tarot_favorite ON tarot_readings(favorite, created_at DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
**저장 정책:**
|
||||||
|
- 모든 리딩은 자동 저장 (사용자가 "저장" 누르지 않아도). 사용자가 별도 액션 없이도 히스토리에서 확인 가능.
|
||||||
|
- `favorite` 토글 + `note` 편집은 별도 PATCH 호출
|
||||||
|
- 카드는 `card_id`(slug)만 저장 — 실제 이름·의미는 항상 프론트 데이터에서 조회 → 카드 데이터 수정이 과거 이력에 자동 반영
|
||||||
|
|
||||||
|
### interpretation_json 구조
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"summary": "전체 흐름 한 단락 (3~4문장)",
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"position": "과거",
|
||||||
|
"card": "the-fool",
|
||||||
|
"reversed": false,
|
||||||
|
"interpretation": "이 위치에서 이 카드가 의미하는 바 (3~4문장)",
|
||||||
|
"evidence": {
|
||||||
|
"card_meaning_used": "참고 카드 정보에서 인용한 키워드·상징",
|
||||||
|
"position_logic": "왜 이 의미가 이 위치에 그렇게 적용되는지 (1~2문장)",
|
||||||
|
"category_lens": "카테고리 관점에서 부각되는 면 (1문장)"
|
||||||
|
},
|
||||||
|
"advice": "이 카드가 주는 짧고 구체적인 조언 (1문장)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"type": "synergy" | "conflict" | "transition",
|
||||||
|
"between": ["the-fool", "the-lovers"],
|
||||||
|
"explanation": "두 카드의 슈트·원소·정역방향 흐름 근거 (1~2문장)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"advice": "3장(또는 1장) 종합 조언 (2문장)",
|
||||||
|
"warning": null,
|
||||||
|
"confidence": "high" | "medium" | "low"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. API 명세
|
||||||
|
|
||||||
|
### 5.1 `POST /api/agent-office/tarot/interpret`
|
||||||
|
AI 해석만 수행 (저장과 분리). 응답 받은 후 사용자가 별도 액션 없으면 자동 저장 호출.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"spread_type": "three_card",
|
||||||
|
"category": "연애",
|
||||||
|
"question": "다음 달 그 사람과의 관계는?",
|
||||||
|
"cards": [
|
||||||
|
{ "position": "과거", "card_id": "the-fool", "reversed": false },
|
||||||
|
{ "position": "현재", "card_id": "the-lovers", "reversed": true },
|
||||||
|
{ "position": "미래", "card_id": "ten-of-cups", "reversed": false }
|
||||||
|
],
|
||||||
|
"cards_reference": "## 1. 위치: 과거 | 카드: The Fool ...",
|
||||||
|
"context_meta": {
|
||||||
|
"major_minor_ratio": "2:1",
|
||||||
|
"element_distribution": { "air": 2, "water": 1, "fire": 0, "earth": 0 },
|
||||||
|
"orientation_flow": "upright→reversed→upright"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`cards_reference`와 `context_meta`는 프론트가 `cards.js`를 기반으로 빌드해서 전송. 백엔드가 카드 데이터를 따로 가지고 있을 필요 없음 (DRY).
|
||||||
|
|
||||||
|
**Response:** `interpretation_json` 구조 + 호출 메타.
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"interpretation_json": { /* 위 4절 구조 */ },
|
||||||
|
"model": "claude-sonnet-4-6",
|
||||||
|
"tokens_in": 712,
|
||||||
|
"tokens_out": 942,
|
||||||
|
"cost_usd": 0.0163,
|
||||||
|
"latency_ms": 5240,
|
||||||
|
"reroll_count": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**에러:**
|
||||||
|
- 400 — spread_type 미지원 / cards 길이 불일치 / cards_reference 빈 문자열
|
||||||
|
- 429 — Anthropic API rate limit
|
||||||
|
- 500 — Claude 호출 실패 (Retry-After 헤더 포함) 또는 reroll 2회 모두 실패
|
||||||
|
|
||||||
|
### 5.2 `POST /api/agent-office/tarot/readings`
|
||||||
|
리딩 저장. interpret 결과를 그대로 + 사용자 컨텍스트.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"spread_type": "three_card",
|
||||||
|
"category": "연애",
|
||||||
|
"question": "...",
|
||||||
|
"cards": [...],
|
||||||
|
"interpretation_json": { ... },
|
||||||
|
"model": "claude-sonnet-4-6",
|
||||||
|
"tokens_in": 712, "tokens_out": 942, "cost_usd": 0.0163,
|
||||||
|
"confidence": "medium"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** `{ "id": 123, "created_at": "2026-05-23T07:42:11Z" }`
|
||||||
|
|
||||||
|
### 5.3 `GET /api/agent-office/tarot/readings`
|
||||||
|
페이지네이션 + 필터.
|
||||||
|
|
||||||
|
**Query:** `?page=1&size=20&favorite=true&spread_type=three_card&category=연애`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{ "id": 123, "created_at": "...", "spread_type": "three_card",
|
||||||
|
"category": "연애", "question": "...", "cards": [...],
|
||||||
|
"summary": "한 줄 요약", "confidence": "medium", "favorite": 1 }
|
||||||
|
],
|
||||||
|
"page": 1, "size": 20, "total": 47
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 `PATCH /api/agent-office/tarot/readings/{id}`
|
||||||
|
즐겨찾기 토글·메모.
|
||||||
|
|
||||||
|
**Request:** `{ "favorite": true }` 또는 `{ "note": "메모" }`
|
||||||
|
|
||||||
|
### 5.5 `DELETE /api/agent-office/tarot/readings/{id}`
|
||||||
|
이력 삭제.
|
||||||
|
|
||||||
|
### Nginx 라우팅
|
||||||
|
변경 없음. 기존 `/api/agent-office/` 매칭에 흡수됨.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. AI 프롬프트 설계
|
||||||
|
|
||||||
|
### SYSTEM_PROMPT
|
||||||
|
|
||||||
|
```text
|
||||||
|
당신은 라이더-웨이트(RWS) 타로 덱의 전통 상징체계에 정통한 타로 리더입니다.
|
||||||
|
사용자의 질문, 카테고리, 뽑힌 카드 각각의 정·역방향과 위치를 받아 근거 기반으로 해석합니다.
|
||||||
|
|
||||||
|
# 해석 원칙
|
||||||
|
1. 데이터 우선: "참고 카드 정보" 블록의 키워드·기본의미·상징만을 1차 근거로 사용.
|
||||||
|
외부 변형 의미·다른 덱 해석은 사용하지 않음.
|
||||||
|
2. 위치 의미 결합: 카드의 의미와 위치(과거/현재/미래 또는 오늘)를 명시적으로 결합해서 해석. evidence에 근거 기록.
|
||||||
|
3. 카드 간 상호작용 분석 (3장 스프레드):
|
||||||
|
- 시너지: 같은 슈트, 같은 원소, 메이저 비율, 정·역 흐름
|
||||||
|
- 충돌·전환: 슈트 충돌(컵-소드, 완드-펜타클), 정→역 전환, 메이저↔마이너 전환
|
||||||
|
4. 자기 성찰 톤: 운명론 단정 금지. "…할 가능성이 있어 보입니다" 같은 표현.
|
||||||
|
5. 카테고리 컨텍스트: 동일 카드라도 카테고리에 따라 강조점이 달라야 함.
|
||||||
|
6. 질문 직접 응답: 사용자 질문을 evidence·advice에서 인용·반영.
|
||||||
|
|
||||||
|
# 응답 형식 (strict JSON only — 코드블록 없이 raw JSON)
|
||||||
|
{
|
||||||
|
"summary": "전체 흐름 한 단락 (3~4문장)",
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"position": "<위치 라벨>",
|
||||||
|
"card": "<card_id>",
|
||||||
|
"reversed": <bool>,
|
||||||
|
"interpretation": "3~4문장",
|
||||||
|
"evidence": {
|
||||||
|
"card_meaning_used": "참고 카드 정보에서 인용한 키워드·상징",
|
||||||
|
"position_logic": "왜 이 위치에 이렇게 적용되는지 (1~2문장)",
|
||||||
|
"category_lens": "카테고리 관점에서 부각되는 면 (1문장)"
|
||||||
|
},
|
||||||
|
"advice": "1문장"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"interactions": [
|
||||||
|
{ "type": "synergy"|"conflict"|"transition",
|
||||||
|
"between": ["<card_id>", "<card_id>"],
|
||||||
|
"explanation": "1~2문장" }
|
||||||
|
],
|
||||||
|
"advice": "2문장. interactions를 1개 이상 참조할 것.",
|
||||||
|
"warning": "역방향·충돌 경계 (없으면 null)",
|
||||||
|
"confidence": "high"|"medium"|"low"
|
||||||
|
}
|
||||||
|
|
||||||
|
# confidence 판정 기준
|
||||||
|
- high: 3장 모두 한 방향 서사 또는 명확한 전환
|
||||||
|
- medium: 2장 일관, 1장 별도 신호
|
||||||
|
- low: 카드 간 의미 충돌이 커서 명확한 흐름 잡기 어려움
|
||||||
|
|
||||||
|
# 금지사항
|
||||||
|
- 참고 카드 정보에 없는 상징 도입 금지
|
||||||
|
- 역방향 카드를 정방향처럼 다루지 말 것
|
||||||
|
- "신비롭게 들리는" 문구로 채우지 말 것 — evidence에 인용·근거 명시
|
||||||
|
- JSON 외 텍스트 금지
|
||||||
|
```
|
||||||
|
|
||||||
|
### USER_PROMPT_TEMPLATE
|
||||||
|
|
||||||
|
```text
|
||||||
|
# 질문
|
||||||
|
{question}
|
||||||
|
|
||||||
|
# 카테고리
|
||||||
|
{category}
|
||||||
|
|
||||||
|
# 스프레드
|
||||||
|
{spread_name} ({spread_count}장)
|
||||||
|
|
||||||
|
# 뽑힌 카드와 참고 카드 정보
|
||||||
|
{cards_with_reference_block}
|
||||||
|
|
||||||
|
# 작업
|
||||||
|
위 정보만을 근거로 사용해, 시스템 지침의 JSON 형식으로 응답하세요.
|
||||||
|
- 각 카드의 evidence.card_meaning_used에는 위 "참고 카드 정보"에서 발췌한 키워드·의미를 그대로 인용.
|
||||||
|
- interactions는 3장 간 슈트·원소·정역방향 패턴을 분석해 최소 1개 이상 도출.
|
||||||
|
- confidence는 카드 흐름의 일관성에 따라 정직하게 판정.
|
||||||
|
```
|
||||||
|
|
||||||
|
### cards_with_reference_block 예시
|
||||||
|
|
||||||
|
```
|
||||||
|
## 1. 위치: 과거 | 카드: The Fool (정방향)
|
||||||
|
- 아르카나: Major (0)
|
||||||
|
- 원소: 공기 (Air)
|
||||||
|
- 정방향 키워드: 새로운 시작, 도약, 순수, 자유
|
||||||
|
- 정방향 의미: 미지의 세계로 내딛는 첫걸음. 계산보다 직관과 신뢰로 시작하는 시기.
|
||||||
|
|
||||||
|
## 2. 위치: 현재 | 카드: The Lovers (역방향)
|
||||||
|
- 아르카나: Major (6)
|
||||||
|
- 원소: 공기 (Air)
|
||||||
|
- 역방향 키워드: 관계 갈등, 선택의 어려움
|
||||||
|
- 역방향 의미: 두 길 사이에서 머뭇거리거나, 이미 내린 선택의 의구심이 커지는 시기.
|
||||||
|
|
||||||
|
## 3. 위치: 미래 | 카드: Ten of Cups (정방향)
|
||||||
|
- 아르카나: Minor (Cups, 10)
|
||||||
|
- 원소: 물 (Water)
|
||||||
|
- 정방향 키워드: 정서적 충만, 가족·공동체의 행복
|
||||||
|
- 정방향 의미: 컵 슈트의 완성 단계. 감정적 만족이 안정된 형태로 자리잡는 시기.
|
||||||
|
|
||||||
|
## 추가 컨텍스트
|
||||||
|
- 메이저:마이너 비율: 2:1 (메이저 우세 → 큰 인생 주제)
|
||||||
|
- 원소 분포: 공기 2, 물 1
|
||||||
|
- 정역 흐름: 정→역→정 (일시적 정체 후 회복 가능성)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 응답 검증 (백엔드)
|
||||||
|
- `cards[].evidence.card_meaning_used`가 비어있으면 → reroll 1회 (max 1 retry, 총 2회 호출)
|
||||||
|
- `interactions`가 비어있고 spread_type == "three_card"이면 → reroll 1회
|
||||||
|
- reroll 2회 모두 실패 → 받은 응답 그대로 저장 + log warning + 500 응답
|
||||||
|
- JSON 파싱 실패 → codeblock 추출 시도 → raw 추출 시도 → 텍스트 그대로 summary에 박고 cards=[]
|
||||||
|
|
||||||
|
### 비용
|
||||||
|
- Sonnet 4.6 입력 $3/1M, 출력 $15/1M
|
||||||
|
- 회당 입력 ~700, 출력 ~900 토큰
|
||||||
|
- 회당 비용 ~$0.015~0.022
|
||||||
|
- 환경변수로 가격 오버라이드: `TAROT_COST_INPUT_PER_M`, `TAROT_COST_OUTPUT_PER_M`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. UI 흐름
|
||||||
|
|
||||||
|
### 7.1 Route 구조
|
||||||
|
| Path | 화면 | 컴포넌트 |
|
||||||
|
|---|---|---|
|
||||||
|
| `/tarot` | 랜딩 | `Tarot.jsx` |
|
||||||
|
| `/tarot/today` | 오늘의 카드 | `TodayCard.jsx` |
|
||||||
|
| `/tarot/reading` | 3장 스프레드 메인 | `Reading.jsx` |
|
||||||
|
| `/tarot/history` | 마이페이지 | `History.jsx` |
|
||||||
|
|
||||||
|
### 7.2 랜딩 (`/tarot`)
|
||||||
|
- 영상 배경 (`tarot_main_background.mp4` autoplay muted loop, `prefers-reduced-motion` 시 정지 이미지)
|
||||||
|
- Overlay: `linear-gradient(rgba(15,4,40,.5) → rgba(15,4,40,.85))`
|
||||||
|
- 헤더 sticky nav: 오늘의 카드 / 타로 리딩 / 가이드 / 히스토리
|
||||||
|
- Hero: h1 "당신의 오늘을 비추는 타로" + sub + 2 CTA (지금 시작하기 / 오늘의 카드)
|
||||||
|
- 3-tier 카드: 🌙 오늘의 운세 / 🃏 3장 스프레드 / ✨ AI 해석 (hover lift)
|
||||||
|
|
||||||
|
### 7.3 3장 스프레드 (`/tarot/reading`)
|
||||||
|
3-step 진행, 한 화면 안에서 step 전환.
|
||||||
|
|
||||||
|
**Step 1 — 질문 입력 (좌측 panel)**
|
||||||
|
- 질문 textarea
|
||||||
|
- 카테고리 chip 선택 (`CATEGORIES` 중 1개)
|
||||||
|
- 스프레드 라디오 (3장 / 1장)
|
||||||
|
- [⊃ 카드 셔플하기] 버튼
|
||||||
|
|
||||||
|
**Step 2 — 카드 선택 (중앙)**
|
||||||
|
- 셔플된 카드 16장 그리드 (4×4, 카드 뒷면)
|
||||||
|
- 카드 hover 시 lift + glow
|
||||||
|
- 카드 click 시 자리(과거→현재→미래)로 날아가며 flip + 위치 라벨 표시
|
||||||
|
- 3장 모두 채워지면 [AI 해석 시작] 버튼 활성
|
||||||
|
|
||||||
|
**Step 3 — AI 해석 (우측 panel)**
|
||||||
|
- 좌측: 3장 카드 자리 (카드 click으로 우측 panel 전환)
|
||||||
|
- 우측 panel: 선택된 카드명 + 키워드 chip + 기본 의미 + AI interpretation + AI evidence(접을 수 있음) + advice
|
||||||
|
- 하단: 종합 summary + advice + warning(있을 때) + confidence 배지
|
||||||
|
- 액션: [⭐ 즐겨찾기 토글] / [다시 뽑기]
|
||||||
|
|
||||||
|
### 7.4 오늘의 카드 (`/tarot/today`)
|
||||||
|
- 단일 큰 카드 슬롯 + "운명을 묻다" 버튼
|
||||||
|
- 카테고리·질문 옵션 (default = "일반 / 없음")
|
||||||
|
- 클릭 → 1장 추출 + flip 애니메이션 + Claude 호출 → 우측 텍스트로 해석 표시
|
||||||
|
- 하루 1회 제한은 v1에 없음 (소비 자유)
|
||||||
|
|
||||||
|
### 7.5 히스토리 (`/tarot/history`)
|
||||||
|
- 카드 리스트형: 날짜 · 스프레드 종류 · 질문 · 카드 미니 · 요약 한 줄 · confidence 배지 · ⭐ 토글
|
||||||
|
- 클릭 → 디테일 모달 (원본 해석 전체)
|
||||||
|
- 필터: 즐겨찾기만 / 스프레드 종류 / 카테고리
|
||||||
|
- 페이지네이션 20개씩
|
||||||
|
|
||||||
|
### 7.6 공용 컴포넌트
|
||||||
|
- `TarotCard.jsx` — 단일 카드 (앞·뒷면 토글, props: cardId / reversed / size / clickable)
|
||||||
|
- `CardGrid.jsx` — 셔플 16장 그리드 (props: deckSlice / onPick)
|
||||||
|
- `SpreadSlots.jsx` — 위치별 슬롯 (props: spread / cards)
|
||||||
|
- `InterpretationPanel.jsx` — 우측 패널 (카드 의미 + AI 텍스트 + evidence 접기)
|
||||||
|
- `useTarotShuffle.js` — Fisher–Yates + 16장 슬라이스 hook
|
||||||
|
- `useTarotReading.js` — 카드 선택 상태 + reference 블록 빌더 + AI 호출 + 저장 hook
|
||||||
|
|
||||||
|
### 7.7 디자인 토큰
|
||||||
|
- 배경 그라데이션: `#0a0420 → #1a0d2e → #2a1648`
|
||||||
|
- 금색 액센트: `#d4af37`
|
||||||
|
- 카드 보더 글로우: `0 0 24px rgba(212, 175, 55, .35)`
|
||||||
|
- 폰트: 본문 기존 / 타이틀 세리프 (Cormorant Garamond + Noto Serif KR 폴백)
|
||||||
|
- 네임스페이스: `.tarot-*`
|
||||||
|
|
||||||
|
### 7.8 navLinks 추가
|
||||||
|
- id: `tarot`, label: `Tarot`, path: `/tarot`, subtitle: `ARCANA`,
|
||||||
|
description: "라이더-웨이트 카드로 오늘과 내일을 비추는 리딩 랩",
|
||||||
|
icon: sparkle 아이콘, accent: `#a78bfa`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 미디어 자산
|
||||||
|
|
||||||
|
### 히어로 영상
|
||||||
|
- 원본: `source/videos/tarot_main_background.mp4`
|
||||||
|
- 배포 위치: `web-ui/public/videos/tarot_hero.mp4` (Vite public/ 직접 서빙)
|
||||||
|
- 권장 압축: 1920×1080 H.264 ≤4Mbps, ≤15초 loop
|
||||||
|
- 폴백: `prefers-reduced-motion` 또는 `navigator.connection.saveData` 시 `tarot_background.png` 정지 이미지
|
||||||
|
|
||||||
|
### 배경 이미지
|
||||||
|
- 원본: `source/images/tarot_page/tarot_background.png`
|
||||||
|
- 배포 위치: `web-ui/public/images/tarot_background.png`
|
||||||
|
- 사용: 영상 fallback + 카드 선택 페이지 배경 layer
|
||||||
|
|
||||||
|
### 카드 자산
|
||||||
|
- v1: `web-ui/public/images/tarot/card_back.svg` — 단일 카드 뒷면 SVG (보라+금 + ARCANA TAROT 모노그램)
|
||||||
|
- v1 카드 앞면: 78장 모두 CSS 카드 디자인 (그라데이션 보더 + 카드명 세리프 + 심볼 이모지)
|
||||||
|
- 사용자 자산 추가 시: `web-ui/public/images/tarot/cards/<slug>.png` 자동 매핑, 누락 시 `onError` → CSS 폴백
|
||||||
|
- 정적 파일이므로 이미지 추가 후 별도 빌드 불필요. NAS의 `frontend/images/tarot/cards/`에 robocopy 또는 직접 업로드 → 페이지 reload만으로 즉시 반영
|
||||||
|
- 사용자가 78장을 한 번에 추가하지 않아도 됨 — 매핑된 것은 이미지로, 안 된 것은 CSS 폴백으로 자연스럽게 혼용
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 테스트 전략
|
||||||
|
|
||||||
|
### 프론트 (Vitest)
|
||||||
|
- `data/cards.js` 검증: 78장 총수, slug 중복 없음, 메이저 22 + 마이너 56, 모든 카드 keywords·meaningUpright·meaningReversed 존재
|
||||||
|
- `useTarotShuffle.js`: Fisher–Yates 정확성 (중복 없음, 분포)
|
||||||
|
- `useTarotReading.js`: 카드 선택 상태 전환, reference 블록 빌더 단위 테스트
|
||||||
|
- `TarotCard.jsx`: 정·역 토글, flip 상태, 이미지 onError 폴백
|
||||||
|
- `Reading.jsx`: step 1→2→3 전환
|
||||||
|
|
||||||
|
### 백엔드 (pytest)
|
||||||
|
- `tarot.py::interpret`: 응답 파싱 (raw JSON / codeblock 감싸진 JSON / 깨진 JSON 폴백)
|
||||||
|
- `tarot.py::interpret`: evidence·interactions 누락 시 reroll 1회 → 실패 시 그대로 저장
|
||||||
|
- `db.py`: tarot_readings CRUD 정확성, favorite 필터, 페이지네이션
|
||||||
|
- Anthropic 호출은 mock — 실제 호출은 통합 테스트 1건만
|
||||||
|
|
||||||
|
### 제외
|
||||||
|
- AI 응답 품질 자체는 자동 테스트 불가 — manual QA로 검수
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 배포
|
||||||
|
|
||||||
|
1. **백엔드 (agent-office 수정만)**: `git push` → Gitea Webhook → agent-office 재빌드 + 자동 마이그레이션 (`CREATE TABLE IF NOT EXISTS`)
|
||||||
|
2. **프론트**: 로컬 빌드 → `npm run release:nas` → robocopy (영상·이미지 포함)
|
||||||
|
3. **docker-compose 변경 없음**
|
||||||
|
4. **nginx 변경 없음**
|
||||||
|
5. **`scripts/deploy*.sh` 변경 없음** — 컨테이너 리스트 그대로
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 위험·완화
|
||||||
|
|
||||||
|
| 위험 | 완화 |
|
||||||
|
|---|---|
|
||||||
|
| Claude 응답 JSON 깨짐 | 파싱 폴백 3단(codeblock→raw→텍스트) + reroll 1회 |
|
||||||
|
| 영상 파일 NAS 트래픽↑ | 압축 후 사이즈 체크 — 5MB 초과 시 사용자 노티 |
|
||||||
|
| 카드 이미지 미준비로 임팩트↓ | CSS 카드 디자인을 시안 톤(보라+금)에 맞춰 정교화 |
|
||||||
|
| AI 비용 폭주 | 회당 ~$0.02, 일 50회 가정 시 월 ~$30 — 개인 사용 OK |
|
||||||
|
| 78장 의미 텍스트 작성 부담 | v1 plan에 별도 "데이터 시드 task" 분리, 메이저 22 우선 + 마이너 키워드만 |
|
||||||
|
| reference 블록을 프론트가 빌드 → 백엔드 검증 누락 | reference 블록 빈 문자열·길이 단순 검증만 추가 (carot 검증은 v2) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. v1 작업량 추산
|
||||||
|
- 백엔드: agent-office 추가 ~300 LOC (`agents/tarot.py` + `routes/tarot.py` + `db.py` 마이그레이션 + 테스트)
|
||||||
|
- 프론트: ~1500~2000 LOC (4 페이지 + 5~7 컴포넌트 + 데이터 + CSS)
|
||||||
|
- 카드 시드 데이터: 메이저 22장 완성 + 마이너 56장 키워드만 + 짧은 의미 1문장
|
||||||
|
- 예상 plan task: 15~18개
|
||||||
7
image-lab/Dockerfile
Normal file
7
image-lab/Dockerfile
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY app ./app
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||||
0
image-lab/app/__init__.py
Normal file
0
image-lab/app/__init__.py
Normal file
13
image-lab/app/auth.py
Normal file
13
image-lab/app/auth.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"""Windows image-render worker → NAS image-lab internal webhook 인증."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from fastapi import Header, HTTPException
|
||||||
|
|
||||||
|
|
||||||
|
def verify_internal_key(x_internal_key: str = Header(...)):
|
||||||
|
expected = os.getenv("INTERNAL_API_KEY")
|
||||||
|
if not expected:
|
||||||
|
raise HTTPException(401, "INTERNAL_API_KEY not configured on server")
|
||||||
|
if x_internal_key != expected:
|
||||||
|
raise HTTPException(401, "Invalid X-Internal-Key")
|
||||||
83
image-lab/app/db.py
Normal file
83
image-lab/app/db.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""SQLite persistence for image_tasks. Single table — task 단위 추적만."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
DB_PATH = os.path.join(os.getenv("IMAGE_DATA_DIR", "/app/data"), "image.db")
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _conn():
|
||||||
|
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA busy_timeout=5000")
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def init_db() -> None:
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS image_tasks (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
params TEXT NOT NULL,
|
||||||
|
status TEXT DEFAULT 'queued',
|
||||||
|
progress INTEGER DEFAULT 0,
|
||||||
|
message TEXT DEFAULT '',
|
||||||
|
image_url TEXT,
|
||||||
|
error TEXT,
|
||||||
|
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||||
|
updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_dict(row) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": row["id"], "provider": row["provider"], "params": row["params"],
|
||||||
|
"status": row["status"], "progress": row["progress"], "message": row["message"],
|
||||||
|
"image_url": row["image_url"], "error": row["error"],
|
||||||
|
"created_at": row["created_at"], "updated_at": row["updated_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_task(task_id: str, provider: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO image_tasks (id, provider, params) VALUES (?, ?, ?)",
|
||||||
|
(task_id, provider, json.dumps(params)),
|
||||||
|
)
|
||||||
|
row = conn.execute("SELECT * FROM image_tasks WHERE id = ?", (task_id,)).fetchone()
|
||||||
|
return _row_to_dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
def update_task(task_id: str, status: str, progress: int, message: str = "",
|
||||||
|
image_url: Optional[str] = None, error: Optional[str] = None) -> None:
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE image_tasks
|
||||||
|
SET status = ?, progress = ?, message = ?, image_url = ?, error = ?,
|
||||||
|
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(status, progress, message, image_url, error, task_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_task(task_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
with _conn() as conn:
|
||||||
|
row = conn.execute("SELECT * FROM image_tasks WHERE id = ?", (task_id,)).fetchone()
|
||||||
|
return _row_to_dict(row) if row else None
|
||||||
52
image-lab/app/internal_router.py
Normal file
52
image-lab/app/internal_router.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""Windows image-render → NAS image-lab internal webhook.
|
||||||
|
|
||||||
|
POST /api/internal/image/update
|
||||||
|
- X-Internal-Key 인증 필수
|
||||||
|
- image_tasks row update (status, progress, message, image_url, error)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
from .auth import verify_internal_key
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class UpdatePayload(BaseModel):
|
||||||
|
task_id: str
|
||||||
|
status: str = Field(..., description="processing|succeeded|failed")
|
||||||
|
progress: int = Field(..., ge=0, le=100)
|
||||||
|
message: str = ""
|
||||||
|
image_url: Optional[str] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/internal/image/update",
|
||||||
|
dependencies=[Depends(verify_internal_key)],
|
||||||
|
)
|
||||||
|
def image_update(payload: UpdatePayload):
|
||||||
|
task = db.get_task(payload.task_id)
|
||||||
|
if task is None:
|
||||||
|
raise HTTPException(404, f"task not found: {payload.task_id}")
|
||||||
|
|
||||||
|
db.update_task(
|
||||||
|
payload.task_id,
|
||||||
|
payload.status,
|
||||||
|
payload.progress,
|
||||||
|
message=payload.message,
|
||||||
|
image_url=payload.image_url,
|
||||||
|
error=payload.error,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"internal/image/update task=%s status=%s progress=%d",
|
||||||
|
payload.task_id, payload.status, payload.progress,
|
||||||
|
)
|
||||||
|
return {"ok": True}
|
||||||
113
image-lab/app/main.py
Normal file
113
image-lab/app/main.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"""FastAPI entrypoint for image-lab.
|
||||||
|
|
||||||
|
POST /api/image/generate — provider + prompt → Redis push → task_id
|
||||||
|
GET /api/image/tasks/{id} — DB 조회
|
||||||
|
GET /api/image/providers — 3 provider 메타
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
from .internal_router import router as internal_router
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CORS_ALLOW_ORIGINS = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080")
|
||||||
|
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379")
|
||||||
|
redis_client = aioredis.from_url(REDIS_URL, decode_responses=False)
|
||||||
|
|
||||||
|
SUPPORTED_PROVIDERS = {"gpt_image", "nano_banana", "flux"}
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(internal_router)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=[o.strip() for o in CORS_ALLOW_ORIGINS.split(",")],
|
||||||
|
allow_credentials=False,
|
||||||
|
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
|
||||||
|
allow_headers=["Content-Type"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def on_startup():
|
||||||
|
db.init_db()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"ok": True, "service": "image-lab"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/image/providers")
|
||||||
|
def list_providers():
|
||||||
|
"""3 provider 항상 노출 (key 누락은 worker가 failed 보고)."""
|
||||||
|
return {"providers": [
|
||||||
|
{"id": "gpt_image", "name": "GPT Image 2.0", "models": ["gpt-image-1"],
|
||||||
|
"sizes": ["1024x1024", "1024x1536", "1536x1024"]},
|
||||||
|
{"id": "nano_banana", "name": "Nano Banana (Gemini)", "models": ["gemini-2.5-flash-image"],
|
||||||
|
"sizes": ["1024x1024"]},
|
||||||
|
{"id": "flux", "name": "FLUX (local)", "models": ["flux-schnell", "flux-dev"],
|
||||||
|
"sizes": ["1024x1024", "832x1216", "1216x832"]},
|
||||||
|
]}
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateRequest(BaseModel):
|
||||||
|
provider: str = Field(..., description="gpt_image|nano_banana|flux")
|
||||||
|
model: Optional[str] = None
|
||||||
|
prompt: str
|
||||||
|
size: Optional[str] = None
|
||||||
|
negative_prompt: Optional[str] = None
|
||||||
|
# Provider 별 추가 키는 extra 허용
|
||||||
|
extra: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
extra = "allow"
|
||||||
|
|
||||||
|
|
||||||
|
async def _push_render_job(task_id: str, job_type: str, params: dict) -> None:
|
||||||
|
"""Redis queue:image-render에 push."""
|
||||||
|
kst = timezone(timedelta(hours=9))
|
||||||
|
payload = {
|
||||||
|
"task_id": task_id,
|
||||||
|
"kind": "image",
|
||||||
|
"job_type": job_type,
|
||||||
|
"params": params,
|
||||||
|
"submitted_at": datetime.now(kst).isoformat(),
|
||||||
|
}
|
||||||
|
await redis_client.rpush("queue:image-render", json.dumps(payload))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/image/generate")
|
||||||
|
async def generate_image(req: GenerateRequest):
|
||||||
|
"""이미지 생성 — Redis 큐로 Windows image-render에 위임."""
|
||||||
|
if req.provider not in SUPPORTED_PROVIDERS:
|
||||||
|
raise HTTPException(400, f"지원하지 않는 provider: {req.provider} (supported: {sorted(SUPPORTED_PROVIDERS)})")
|
||||||
|
|
||||||
|
task_id = str(uuid.uuid4())
|
||||||
|
params = req.model_dump(exclude_none=True)
|
||||||
|
db.create_task(task_id, req.provider, params)
|
||||||
|
|
||||||
|
job_type = f"{req.provider}_generation" # gpt_image_generation, nano_banana_generation, flux_generation
|
||||||
|
await _push_render_job(task_id, job_type, params)
|
||||||
|
return {"task_id": task_id, "provider": req.provider}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/image/tasks/{task_id}")
|
||||||
|
def get_task_status(task_id: str):
|
||||||
|
t = db.get_task(task_id)
|
||||||
|
if not t:
|
||||||
|
raise HTTPException(404, "task not found")
|
||||||
|
return t
|
||||||
4
image-lab/env.example
Normal file
4
image-lab/env.example
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
INTERNAL_API_KEY=replace-me
|
||||||
|
IMAGE_DATA_DIR=/app/data
|
||||||
|
CORS_ALLOW_ORIGINS=http://localhost:3007,http://localhost:8080
|
||||||
|
REDIS_URL=redis://redis:6379
|
||||||
5
image-lab/requirements.txt
Normal file
5
image-lab/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn[standard]==0.30.6
|
||||||
|
pydantic==2.9.2
|
||||||
|
redis==5.0.8
|
||||||
|
httpx==0.27.2
|
||||||
0
image-lab/tests/__init__.py
Normal file
0
image-lab/tests/__init__.py
Normal file
19
image-lab/tests/test_auth.py
Normal file
19
image-lab/tests/test_auth.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from app.auth import verify_internal_key
|
||||||
|
|
||||||
|
def test_no_server_key_rejects(monkeypatch):
|
||||||
|
monkeypatch.delenv("INTERNAL_API_KEY", raising=False)
|
||||||
|
with pytest.raises(HTTPException) as e:
|
||||||
|
verify_internal_key("anything")
|
||||||
|
assert e.value.status_code == 401
|
||||||
|
|
||||||
|
def test_wrong_key_rejects(monkeypatch):
|
||||||
|
monkeypatch.setenv("INTERNAL_API_KEY", "secret")
|
||||||
|
with pytest.raises(HTTPException) as e:
|
||||||
|
verify_internal_key("wrong")
|
||||||
|
assert e.value.status_code == 401
|
||||||
|
|
||||||
|
def test_correct_key_passes(monkeypatch):
|
||||||
|
monkeypatch.setenv("INTERNAL_API_KEY", "secret")
|
||||||
|
assert verify_internal_key("secret") is None
|
||||||
29
image-lab/tests/test_db.py
Normal file
29
image-lab/tests/test_db.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import os, tempfile, importlib
|
||||||
|
|
||||||
|
def _fresh_db(monkeypatch, tmp):
|
||||||
|
monkeypatch.setenv("IMAGE_DATA_DIR", tmp)
|
||||||
|
import app.db as db
|
||||||
|
importlib.reload(db)
|
||||||
|
db.init_db()
|
||||||
|
return db
|
||||||
|
|
||||||
|
def test_create_and_get_task(monkeypatch):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
db = _fresh_db(monkeypatch, tmp)
|
||||||
|
row = db.create_task("t1", "gpt_image", {"prompt": "a cat"})
|
||||||
|
assert row["id"] == "t1"
|
||||||
|
assert row["provider"] == "gpt_image"
|
||||||
|
assert row["status"] == "queued"
|
||||||
|
got = db.get_task("t1")
|
||||||
|
assert got["id"] == "t1"
|
||||||
|
assert db.get_task("nope") is None
|
||||||
|
|
||||||
|
def test_update_task_sets_image_url(monkeypatch):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
db = _fresh_db(monkeypatch, tmp)
|
||||||
|
db.create_task("t2", "nano_banana", {"prompt": "x"})
|
||||||
|
db.update_task("t2", "succeeded", 100, message="done", image_url="/media/image/t2.png")
|
||||||
|
got = db.get_task("t2")
|
||||||
|
assert got["status"] == "succeeded"
|
||||||
|
assert got["image_url"] == "/media/image/t2.png"
|
||||||
|
assert got["progress"] == 100
|
||||||
38
image-lab/tests/test_internal_router.py
Normal file
38
image-lab/tests/test_internal_router.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import os, tempfile, importlib
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
def _client(monkeypatch, tmp):
|
||||||
|
monkeypatch.setenv("IMAGE_DATA_DIR", tmp)
|
||||||
|
monkeypatch.setenv("INTERNAL_API_KEY", "secret")
|
||||||
|
import app.db as db; importlib.reload(db); db.init_db()
|
||||||
|
import app.internal_router as ir; importlib.reload(ir)
|
||||||
|
app = FastAPI(); app.include_router(ir.router)
|
||||||
|
return TestClient(app), db
|
||||||
|
|
||||||
|
def test_update_requires_key(monkeypatch):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
client, db = _client(monkeypatch, tmp)
|
||||||
|
db.create_task("t1", "gpt_image", {"prompt": "x"})
|
||||||
|
r = client.post("/api/internal/image/update",
|
||||||
|
json={"task_id": "t1", "status": "succeeded", "progress": 100})
|
||||||
|
assert r.status_code == 422 or r.status_code == 401 # header 누락
|
||||||
|
|
||||||
|
def test_update_succeeds_with_key(monkeypatch):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
client, db = _client(monkeypatch, tmp)
|
||||||
|
db.create_task("t1", "gpt_image", {"prompt": "x"})
|
||||||
|
r = client.post("/api/internal/image/update",
|
||||||
|
headers={"X-Internal-Key": "secret"},
|
||||||
|
json={"task_id": "t1", "status": "succeeded", "progress": 100,
|
||||||
|
"image_url": "/media/image/t1.png"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert db.get_task("t1")["image_url"] == "/media/image/t1.png"
|
||||||
|
|
||||||
|
def test_update_unknown_task_404(monkeypatch):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
client, db = _client(monkeypatch, tmp)
|
||||||
|
r = client.post("/api/internal/image/update",
|
||||||
|
headers={"X-Internal-Key": "secret"},
|
||||||
|
json={"task_id": "nope", "status": "failed", "progress": 0})
|
||||||
|
assert r.status_code == 404
|
||||||
43
image-lab/tests/test_main.py
Normal file
43
image-lab/tests/test_main.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import os, tempfile, importlib
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
def _client(monkeypatch, tmp):
|
||||||
|
monkeypatch.setenv("IMAGE_DATA_DIR", tmp)
|
||||||
|
import app.db as db
|
||||||
|
importlib.reload(db)
|
||||||
|
db.init_db()
|
||||||
|
import app.main as main
|
||||||
|
importlib.reload(main)
|
||||||
|
pushed = []
|
||||||
|
|
||||||
|
async def fake_push(task_id, job_type, params):
|
||||||
|
pushed.append((task_id, job_type, params))
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "_push_render_job", fake_push)
|
||||||
|
return TestClient(main.app), db, pushed
|
||||||
|
|
||||||
|
|
||||||
|
def test_providers_lists_three(monkeypatch):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
client, _, _ = _client(monkeypatch, tmp)
|
||||||
|
r = client.get("/api/image/providers")
|
||||||
|
ids = {p["id"] for p in r.json()["providers"]}
|
||||||
|
assert ids == {"gpt_image", "nano_banana", "flux"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_rejects_unknown_provider(monkeypatch):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
client, _, _ = _client(monkeypatch, tmp)
|
||||||
|
r = client.post("/api/image/generate", json={"provider": "midjourney", "prompt": "x"})
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_creates_task_and_pushes(monkeypatch):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
client, db, pushed = _client(monkeypatch, tmp)
|
||||||
|
r = client.post("/api/image/generate", json={"provider": "gpt_image", "prompt": "a cat"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
task_id = r.json()["task_id"]
|
||||||
|
assert db.get_task(task_id)["status"] == "queued"
|
||||||
|
assert pushed[0][1] == "gpt_image_generation"
|
||||||
@@ -271,12 +271,40 @@ class TemplateBody(BaseModel):
|
|||||||
description: str = ""
|
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}")
|
@app.get("/api/insta/templates/prompts/{name}")
|
||||||
def get_prompt(name: str):
|
def get_prompt(name: str):
|
||||||
pt = db.get_prompt_template(name)
|
pt = db.get_prompt_template(name)
|
||||||
if not pt:
|
if pt:
|
||||||
raise HTTPException(404)
|
return 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}")
|
@app.put("/api/insta/templates/prompts/{name}")
|
||||||
|
|||||||
63
insta-lab/tests/test_main_prompt_defaults.py
Normal file
63
insta-lab/tests/test_main_prompt_defaults.py
Normal 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")
|
||||||
@@ -170,7 +170,11 @@ def build_number_weights(cache: Dict[str, Any]) -> Dict[int, float]:
|
|||||||
return weights
|
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 범위 정규화).
|
6개 번호 조합의 통계적 품질 점수 계산 (0~1 범위 정규화).
|
||||||
|
|
||||||
@@ -181,6 +185,13 @@ def score_combination(numbers: List[int], cache: Dict[str, Any]) -> Dict[str, fl
|
|||||||
- score_cooccur (15%): 공동 출현 기댓값 대비
|
- score_cooccur (15%): 공동 출현 기댓값 대비
|
||||||
- score_diversity (10%): 연속번호, 범위, 구간 다양성
|
- 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:
|
Returns:
|
||||||
{"score_total": ..., "score_frequency": ..., ...}
|
{"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_total = (
|
||||||
score_frequency * 0.25
|
score_frequency * weights[0]
|
||||||
+ score_fingerprint * 0.30
|
+ score_fingerprint * weights[1]
|
||||||
+ score_gap * 0.20
|
+ score_gap * weights[2]
|
||||||
+ score_cooccur * 0.15
|
+ score_cooccur * weights[3]
|
||||||
+ score_diversity * 0.10
|
+ score_diversity * weights[4]
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
237
lotto/app/db.py
237
lotto/app/db.py
@@ -300,7 +300,51 @@ def init_db() -> None:
|
|||||||
_ensure_column(conn, "lotto_briefings", "tier_rationale",
|
_ensure_column(conn, "lotto_briefings", "tier_rationale",
|
||||||
"ALTER TABLE lotto_briefings ADD COLUMN tier_rationale TEXT NOT NULL DEFAULT '{}'")
|
"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:
|
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]]:
|
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:
|
with _conn() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, numbers, score_total, rank_in_run, source_run_id, based_on_draw, created_at
|
SELECT bp.id, bp.numbers, bp.score_total, bp.rank_in_run,
|
||||||
FROM best_picks
|
bp.source_run_id, bp.based_on_draw, bp.created_at,
|
||||||
WHERE is_active = 1
|
sc.score_frequency, sc.score_fingerprint,
|
||||||
ORDER BY score_total DESC
|
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 ?
|
||||||
""",
|
""",
|
||||||
(limit,),
|
(limit,),
|
||||||
).fetchall()
|
).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"]),
|
"id": int(r["id"]),
|
||||||
"numbers": json.loads(r["numbers"]),
|
"numbers": json.loads(r["numbers"]),
|
||||||
"score_total": r["score_total"],
|
"score_total": r["score_total"],
|
||||||
|
"scores": scores,
|
||||||
"rank_in_run": r["rank_in_run"],
|
"rank_in_run": r["rank_in_run"],
|
||||||
"source_run_id": r["source_run_id"],
|
"source_run_id": r["source_run_id"],
|
||||||
"based_on_draw": r["based_on_draw"],
|
"based_on_draw": r["based_on_draw"],
|
||||||
"created_at": r["created_at"],
|
"created_at": r["created_at"],
|
||||||
}
|
})
|
||||||
for r in rows
|
return result
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def get_simulation_runs(limit: int = 10) -> List[Dict[str, Any]]:
|
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()
|
).fetchall()
|
||||||
return [_review_row(r) for r in rows]
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from .db import (
|
|||||||
)
|
)
|
||||||
from .analyzer import build_analysis_cache, build_number_weights, score_combination
|
from .analyzer import build_analysis_cache, build_number_weights, score_combination
|
||||||
from .utils import weighted_sample_6
|
from .utils import weighted_sample_6
|
||||||
|
from .weight_evolver import get_active_weight
|
||||||
|
|
||||||
|
|
||||||
def run_simulation(
|
def run_simulation(
|
||||||
@@ -54,6 +55,7 @@ def run_simulation(
|
|||||||
# ── 1. 통계 캐시 및 가중치 구성 (시뮬레이션 전체에서 재사용) ────────────
|
# ── 1. 통계 캐시 및 가중치 구성 (시뮬레이션 전체에서 재사용) ────────────
|
||||||
cache = build_analysis_cache(draws)
|
cache = build_analysis_cache(draws)
|
||||||
weights = build_number_weights(cache)
|
weights = build_number_weights(cache)
|
||||||
|
active_weights = get_active_weight() # None → analyzer uses fixed default
|
||||||
|
|
||||||
# ── 2. 후보 생성 및 스코어링 ──────────────────────────────────────────────
|
# ── 2. 후보 생성 및 스코어링 ──────────────────────────────────────────────
|
||||||
candidates: List[Dict[str, Any]] = []
|
candidates: List[Dict[str, Any]] = []
|
||||||
@@ -69,7 +71,7 @@ def run_simulation(
|
|||||||
continue
|
continue
|
||||||
seen_keys.add(key)
|
seen_keys.add(key)
|
||||||
|
|
||||||
scores = score_combination(nums, cache)
|
scores = score_combination(nums, cache, weights=active_weights)
|
||||||
candidates.append({
|
candidates.append({
|
||||||
"numbers": sorted(nums),
|
"numbers": sorted(nums),
|
||||||
**scores,
|
**scores,
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ from .strategy_evolver import (
|
|||||||
get_weights_with_trend, recalculate_weights,
|
get_weights_with_trend, recalculate_weights,
|
||||||
generate_smart_recommendation,
|
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 curator as curator_router
|
||||||
from .routers import briefing as briefing_router
|
from .routers import briefing as briefing_router
|
||||||
from .routers import review as review_router
|
from .routers import review as review_router
|
||||||
@@ -111,9 +116,42 @@ def on_startup():
|
|||||||
id="grade_weekly_review",
|
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()
|
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")
|
@app.get("/health")
|
||||||
def health():
|
def health():
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
@@ -383,6 +421,62 @@ def api_strategy_evolve():
|
|||||||
return {"ok": True, "weights": new_weights}
|
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 ────────────────────────────────────────────────────────
|
# ── 스마트 추천 API ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/api/lotto/recommend/smart")
|
@app.get("/api/lotto/recommend/smart")
|
||||||
@@ -435,6 +529,7 @@ def api_best_picks(limit: int = 20):
|
|||||||
"rank": p["rank_in_run"],
|
"rank": p["rank_in_run"],
|
||||||
"numbers": nums,
|
"numbers": nums,
|
||||||
"score_total": p["score_total"],
|
"score_total": p["score_total"],
|
||||||
|
"scores": p["scores"],
|
||||||
"based_on_draw": p["based_on_draw"],
|
"based_on_draw": p["based_on_draw"],
|
||||||
"simulation_run_id": p["source_run_id"],
|
"simulation_run_id": p["source_run_id"],
|
||||||
"created_at": p["created_at"],
|
"created_at": p["created_at"],
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ requests==2.32.3
|
|||||||
httpx==0.27.2
|
httpx==0.27.2
|
||||||
beautifulsoup4==4.12.3
|
beautifulsoup4==4.12.3
|
||||||
APScheduler==3.10.4
|
APScheduler==3.10.4
|
||||||
|
numpy>=1.26
|
||||||
|
|||||||
314
lotto/app/weight_evolver.py
Normal file
314
lotto/app/weight_evolver.py
Normal 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,
|
||||||
|
}
|
||||||
45
lotto/tests/test_analyzer_weighted.py
Normal file
45
lotto/tests/test_analyzer_weighted.py
Normal 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])
|
||||||
122
lotto/tests/test_weight_evolver.py
Normal file
122
lotto/tests/test_weight_evolver.py
Normal 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"
|
||||||
@@ -276,6 +276,26 @@ server {
|
|||||||
proxy_pass http://$video_internal_backend$request_uri;
|
proxy_pass http://$video_internal_backend$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Video Studio — Windows image-render → NAS image-lab internal webhook
|
||||||
|
# Layer 1·2: nginx IP 화이트리스트 (LAN + Tailscale)
|
||||||
|
# Layer 3: X-Internal-Key (FastAPI dependency)
|
||||||
|
location /api/internal/image/ {
|
||||||
|
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 $image_internal_backend image-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://$image_internal_backend$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
# portfolio API (Stock) — trailing slash 유무 모두 매칭
|
# portfolio API (Stock) — trailing slash 유무 모두 매칭
|
||||||
location /api/portfolio {
|
location /api/portfolio {
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -337,6 +357,8 @@ server {
|
|||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
proxy_read_timeout 86400s;
|
proxy_read_timeout 86400s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
proxy_pass http://$agent_office_backend$request_uri;
|
proxy_pass http://$agent_office_backend$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
set -euo pipefail
|
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 image-lab nginx scripts"
|
||||||
|
|
||||||
# 1. 자동 감지: Docker 컨테이너 내부인가?
|
# 1. 자동 감지: Docker 컨테이너 내부인가?
|
||||||
if [ -d "/repo" ] && [ -d "/runtime" ]; then
|
if [ -d "/repo" ] && [ -d "/runtime" ]; then
|
||||||
|
|||||||
@@ -15,15 +15,15 @@ flock -n 200 || { echo "Deploy already running, skipping"; exit 0; }
|
|||||||
|
|
||||||
# ── 서비스 목록 (한 곳에서만 관리) ──
|
# ── 서비스 목록 (한 곳에서만 관리) ──
|
||||||
# docker compose 서비스명 (deployer 제외 — 자기 자신을 재빌드하면 스크립트 중단)
|
# 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 image-lab frontend"
|
||||||
# 컨테이너 이름 (고아 정리용 — blog-lab은 폐기 대상으로 정리 리스트에 유지)
|
# 컨테이너 이름 (고아 정리용 — 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 image-lab frontend"
|
||||||
# Infra 서비스 (image-based, 영속 데이터 보존을 위해 stop/rm 없이 up만)
|
# Infra 서비스 (image-based, 영속 데이터 보존을 위해 stop/rm 없이 up만)
|
||||||
INFRA_SERVICES="redis"
|
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 image-lab redis"
|
||||||
# data 디렉토리 (packs-lab은 별도 media/packs 사용)
|
# data 디렉토리 (packs-lab은 별도 media/packs 사용)
|
||||||
DATA_DIRS="music stock insta realestate agent-office personal"
|
DATA_DIRS="music stock insta realestate agent-office personal video image"
|
||||||
|
|
||||||
# 1. 자동 감지: Docker 컨테이너 내부인가?
|
# 1. 자동 감지: Docker 컨테이너 내부인가?
|
||||||
if [ -d "/repo" ] && [ -d "/runtime" ]; then
|
if [ -d "/repo" ] && [ -d "/runtime" ]; then
|
||||||
|
|||||||
@@ -162,6 +162,17 @@ def get_indices():
|
|||||||
"""주요 지표(KOSPI 등) 실시간 크롤링 조회"""
|
"""주요 지표(KOSPI 등) 실시간 크롤링 조회"""
|
||||||
return fetch_major_indices()
|
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")
|
@app.post("/api/stock/scrap")
|
||||||
def trigger_scrap():
|
def trigger_scrap():
|
||||||
"""수동 스크랩 트리거"""
|
"""수동 스크랩 트리거"""
|
||||||
|
|||||||
@@ -199,11 +199,21 @@ def fetch_major_indices() -> Dict[str, Any]:
|
|||||||
value = usd_item.select_one(".value").get_text(strip=True)
|
value = usd_item.select_one(".value").get_text(strip=True)
|
||||||
change_val = usd_item.select_one(".change").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 = ""
|
direction = ""
|
||||||
blind_txt = usd_item.select_one(".blind").get_text(strip=True)
|
head_info = usd_item.select_one(".head_info")
|
||||||
if "상승" in blind_txt: direction = "red"
|
hi_classes = head_info.get("class", []) if head_info else []
|
||||||
elif "하락" in blind_txt: direction = "blue"
|
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 기반으로 부호 붙여줌
|
# change_val은 네이버 HTML에서 부호 없이 숫자만 옴 → direction 기반으로 부호 붙여줌
|
||||||
# (프론트 getDirection()이 부호로 색/화살표를 판별하므로)
|
# (프론트 getDirection()이 부호로 색/화살표를 판별하므로)
|
||||||
|
|||||||
@@ -15,9 +15,15 @@ PROMPT_TEMPLATE = """다음은 종목 {name}({ticker})에 대한 최근 뉴스 {
|
|||||||
|
|
||||||
{news_block}
|
{news_block}
|
||||||
|
|
||||||
이 뉴스들이 종목에 호재인지 악재인지 평가하세요.
|
이 뉴스들이 종목 주가에 호재인지 악재인지 종합 평가하세요.
|
||||||
score: -10(매우 강한 악재) ~ +10(매우 강한 호재) 사이의 실수. 0은 중립.
|
|
||||||
reason: 30자 이내 한 줄 근거.
|
규칙:
|
||||||
|
- score: -10(매우 강한 악재) ~ +10(매우 강한 호재) 사이의 실수. 명확한 방향성이 없으면 0(중립).
|
||||||
|
- 뉴스가 호재·악재로 섞여 있으면 주가에 더 우세한 쪽을 기준으로 부호를 정하세요.
|
||||||
|
- reason은 반드시 score 부호와 같은 방향의 근거만 쓰세요.
|
||||||
|
· score가 양수(호재)면 호재 근거만, 음수(악재)면 악재 근거만 적습니다.
|
||||||
|
· 호재 평가에 악재 내용을, 악재 평가에 호재 내용을 섞지 마세요.
|
||||||
|
- reason: 30자 이내 한 줄.
|
||||||
|
|
||||||
JSON으로만 응답하세요. 다른 텍스트 금지:
|
JSON으로만 응답하세요. 다른 텍스트 금지:
|
||||||
{{"score": <float>, "reason": "<string>"}}"""
|
{{"score": <float>, "reason": "<string>"}}"""
|
||||||
|
|||||||
@@ -124,8 +124,10 @@ async def refresh_daily(
|
|||||||
if successes:
|
if successes:
|
||||||
_upsert_news_sentiment(conn, asof, successes, source="articles")
|
_upsert_news_sentiment(conn, asof, successes, source="articles")
|
||||||
|
|
||||||
top_pos = sorted(successes, key=lambda r: -r["score_raw"])[:5]
|
# 부호 게이트: 호재(score>0)·악재(score<0)만 분류. score 미만 종목이 5개 미만이어도
|
||||||
top_neg = sorted(successes, key=lambda r: r["score_raw"])[: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 {
|
return {
|
||||||
"asof": asof.isoformat(),
|
"asof": asof.isoformat(),
|
||||||
|
|||||||
22
stock/app/test_holidays_endpoint.py
Normal file
22
stock/app/test_holidays_endpoint.py
Normal 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 안 하면 통과
|
||||||
@@ -140,6 +140,71 @@ async def test_refresh_daily_no_match_ticker_skipped(conn):
|
|||||||
assert {r["ticker"] for r in rows} == {"005930"}
|
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):
|
def test_top_market_cap_tickers(conn):
|
||||||
out = pipeline._top_market_cap_tickers(conn, n=2)
|
out = pipeline._top_market_cap_tickers(conn, n=2)
|
||||||
assert out == ["005930", "000660"]
|
assert out == ["005930", "000660"]
|
||||||
|
|||||||
Reference in New Issue
Block a user