Why: spec (2026-05-23-lotto-evolver-ui-design.md)을 12개 atomic task로 분해. Phase 1-2 web-backend (task_id wrap + sync cron + API 확장), Phase 3 web-ui (Evolver 페이지 + 5 컴포넌트 + 라우터), Phase 4 배포 검증. TDD red→green→commit + 멱등 guard 패턴. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1619 lines
59 KiB
Markdown
1619 lines
59 KiB
Markdown
# Lotto Evolver UI + 활동 가시화 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:** /lotto/evolver 페이지 신설 + LottoAgent 모든 작업을 agent_tasks에 기록 (텔레그램 404 해결 + 활동 가시화)
|
||
|
||
**Architecture:** web-backend agent-office에서 LottoAgent 3개 메서드에 task_id wrap + lotto-lab activity sync cron 추가. web-ui에 /lotto/evolver 라우트 + 5 카드 단일 스크롤 페이지 + LottoActivityTimeline 컴포넌트. Recharts 활용. agent-office UI는 기존 TaskTab/LogTab이 새 task_type들을 자동 표시.
|
||
|
||
**Tech Stack:** Python 3.12, FastAPI, APScheduler, SQLite (agent-office); React 18, Vite, React Router v6, Recharts, @testing-library/react (web-ui)
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-05-23-lotto-evolver-ui-design.md`
|
||
|
||
---
|
||
|
||
## Important Spec Adjustment Noted in Plan
|
||
|
||
Spec section 2 / 5.3에서 `LottoActivityTimeline`을 `/agent-office`에서도 재사용한다고 명시했으나, **확인 결과 agent-office UI에는 이미 `TaskTab.jsx` + `LogTab.jsx`가 LottoAgent 카드 안에 task/log를 표시**. 백엔드 보강(task_id wrap + sync cron)만 하면 agent-office UI는 추가 변경 없이 새 task_type들이 자동 노출됨. 따라서 `LottoActivityTimeline`은 **`/lotto/evolver` 전용**으로 한정. spec 정신(한 곳에서 추적 가능)은 유지되고 작업량 감소.
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
### web-backend (agent-office)
|
||
|
||
| 경로 | 작업 | 책임 |
|
||
|---|---|---|
|
||
| `agent-office/app/db.py` | Modify | `get_tasks_by_agent_date_kind` helper 추가, `get_agent_tasks` task_type/days 필터 |
|
||
| `agent-office/app/agents/lotto.py` | Modify | run_signal_check / run_daily_digest / run_weekly_evolution_report에 task_id wrap + 신규 sync_evolver_activity |
|
||
| `agent-office/app/scheduler.py` | Modify | cron 1종 추가 (lotto_evolver_activity_sync 매일 09:30) |
|
||
| `agent-office/app/main.py` | Modify | GET /agents/{id}/tasks에 task_type query param 추가 |
|
||
| `agent-office/tests/test_lotto_task_wrap.py` | Create | task_id wrap 패턴 단위 테스트 |
|
||
| `agent-office/tests/test_sync_evolver_activity.py` | Create | sync 멱등 + 데이터 매핑 테스트 |
|
||
|
||
### web-ui
|
||
|
||
| 경로 | 작업 | 책임 |
|
||
|---|---|---|
|
||
| `web-ui/src/api.js` | Modify | evolver fetch helpers (3종) + lotto activity fetch |
|
||
| `web-ui/src/pages/lotto/Evolver.jsx` | Create | 페이지 진입점, useEvolverApi 호출 + 5 카드 조립 |
|
||
| `web-ui/src/pages/lotto/Evolver.css` | Create | 페이지 스타일 |
|
||
| `web-ui/src/pages/lotto/evolver/WinnerCard.jsx` | Create | Radar + 메타 정보 |
|
||
| `web-ui/src/pages/lotto/evolver/TrialsGrid.jsx` | Create | 6일 Bar + 펼치기 |
|
||
| `web-ui/src/pages/lotto/evolver/BaseDiff.jsx` | Create | 5개 metric-card (텍스트 diff) |
|
||
| `web-ui/src/pages/lotto/evolver/BaseHistory.jsx` | Create | LineChart 12주 시계열 |
|
||
| `web-ui/src/pages/lotto/evolver/EvolverActions.jsx` | Create | 수동 트리거 버튼 |
|
||
| `web-ui/src/pages/lotto/evolver/LottoActivityTimeline.jsx` | Create | 활동 timeline 컴포넌트 |
|
||
| `web-ui/src/pages/lotto/evolver/useEvolverApi.js` | Create | 4 fetch + activity merge hook |
|
||
| `web-ui/src/routes.jsx` | Modify | Evolver lazy import + route 추가 |
|
||
| `web-ui/src/pages/lotto/evolver/Evolver.test.jsx` | Create | 핵심 컴포넌트 smoke test |
|
||
|
||
---
|
||
|
||
# Phase 1 — agent-office 백엔드 보강
|
||
|
||
## Task 1: db.py에 멱등 helper + task_type 필터
|
||
|
||
**Files:**
|
||
- Modify: `agent-office/app/db.py:239` (`get_agent_tasks`), append at end (`get_tasks_by_agent_date_kind`)
|
||
- Test: `agent-office/tests/test_lotto_task_wrap.py` (다음 task에서 작성)
|
||
|
||
- [ ] **Step 1: Add task_type + days filter to get_agent_tasks**
|
||
|
||
`agent-office/app/db.py` line 239 함수를 다음으로 교체:
|
||
|
||
```python
|
||
def get_agent_tasks(
|
||
agent_id: str,
|
||
limit: int = 20,
|
||
task_type: Optional[str] = None,
|
||
days: Optional[int] = None,
|
||
) -> List[Dict[str, Any]]:
|
||
sql = "SELECT * FROM agent_tasks WHERE agent_id=?"
|
||
params: List[Any] = [agent_id]
|
||
if task_type is not None:
|
||
sql += " AND task_type=?"
|
||
params.append(task_type)
|
||
if days is not None and days > 0:
|
||
sql += " AND created_at >= datetime('now', ?)"
|
||
params.append(f"-{int(days)} days")
|
||
sql += " ORDER BY created_at DESC LIMIT ?"
|
||
params.append(limit)
|
||
with _conn() as conn:
|
||
rows = conn.execute(sql, params).fetchall()
|
||
return [_task_to_dict(r) for r in rows]
|
||
```
|
||
|
||
- [ ] **Step 2: Add idempotent guard helper at end of db.py**
|
||
|
||
`agent-office/app/db.py` 파일 끝에 추가:
|
||
|
||
```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 [_task_to_dict(r) for r in rows]
|
||
```
|
||
|
||
- [ ] **Step 3: Smoke verify**
|
||
|
||
```bash
|
||
cd agent-office && python -c "
|
||
from app.db import get_agent_tasks, get_tasks_by_agent_date_kind
|
||
print(get_agent_tasks('lotto', task_type='curate_weekly', days=7)[:1])
|
||
print(get_tasks_by_agent_date_kind('lotto', '2026-05-23', 'evolver_apply'))
|
||
"
|
||
```
|
||
Expected: 빈 리스트 (또는 기존 lotto curate task 1개)
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add agent-office/app/db.py
|
||
git commit -m "feat(lotto-agent): get_agent_tasks 필터 + get_tasks_by_agent_date_kind 멱등 guard"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: LottoAgent.run_signal_check task_id wrap + 테스트
|
||
|
||
**Files:**
|
||
- Modify: `agent-office/app/agents/lotto.py` (run_signal_check 메서드 전체)
|
||
- Create: `agent-office/tests/test_lotto_task_wrap.py`
|
||
|
||
- [ ] **Step 1: Write failing test**
|
||
|
||
```python
|
||
# agent-office/tests/test_lotto_task_wrap.py
|
||
import os
|
||
import sys
|
||
import asyncio
|
||
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():
|
||
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)
|
||
|
||
# 텔레그램 발송 stub
|
||
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
|
||
|
||
async def boom(**kwargs):
|
||
raise RuntimeError("boom")
|
||
monkeypatch.setattr(signal_runner, "run_signal_check", boom)
|
||
|
||
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"]
|
||
```
|
||
|
||
Run: `cd agent-office && pytest tests/test_lotto_task_wrap.py -v`
|
||
Expected: FAIL — current `run_signal_check` 은 task row를 만들지 않음.
|
||
|
||
- [ ] **Step 2: Modify run_signal_check in lotto.py**
|
||
|
||
`agent-office/app/agents/lotto.py`의 `run_signal_check` 메서드 본체를 다음으로 교체 (메서드 시그니처 그대로):
|
||
|
||
```python
|
||
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)
|
||
|
||
# --- task 성공 마킹 ---
|
||
fired_metrics = [r["metric"] for r in outcome["results"] if r["fire_level"] != "noop" and r["fire_level"] != "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}"}
|
||
```
|
||
|
||
- [ ] **Step 3: Run tests pass**
|
||
|
||
```bash
|
||
cd agent-office && pytest tests/test_lotto_task_wrap.py -v
|
||
cd agent-office && pytest tests/ -v 2>&1 | tail -5
|
||
```
|
||
Expected: 2 new tests pass + no regression.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add agent-office/app/agents/lotto.py agent-office/tests/test_lotto_task_wrap.py
|
||
git commit -m "feat(lotto-agent): run_signal_check task_id wrap + 테스트"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: run_daily_digest task_id wrap
|
||
|
||
**Files:**
|
||
- Modify: `agent-office/app/agents/lotto.py` (run_daily_digest 메서드)
|
||
|
||
- [ ] **Step 1: Modify run_daily_digest**
|
||
|
||
기존 `run_daily_digest` 메서드를 다음으로 교체:
|
||
|
||
```python
|
||
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}"}
|
||
```
|
||
|
||
- [ ] **Step 2: Append test for daily_digest task wrap**
|
||
|
||
`agent-office/tests/test_lotto_task_wrap.py`에 추가:
|
||
|
||
```python
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_run_daily_digest_creates_task(monkeypatch):
|
||
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"]
|
||
```
|
||
|
||
- [ ] **Step 3: Run tests pass**
|
||
|
||
```bash
|
||
cd agent-office && pytest tests/test_lotto_task_wrap.py -v
|
||
```
|
||
Expected: 3 tests pass.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add agent-office/app/agents/lotto.py agent-office/tests/test_lotto_task_wrap.py
|
||
git commit -m "feat(lotto-agent): run_daily_digest task_id wrap"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: run_weekly_evolution_report task_id wrap
|
||
|
||
**Files:**
|
||
- Modify: `agent-office/app/agents/lotto.py` (run_weekly_evolution_report)
|
||
|
||
- [ ] **Step 1: Modify run_weekly_evolution_report**
|
||
|
||
기존 메서드를 다음으로 교체:
|
||
|
||
```python
|
||
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}"}
|
||
```
|
||
|
||
- [ ] **Step 2: Append test**
|
||
|
||
`agent-office/tests/test_lotto_task_wrap.py`에 추가:
|
||
|
||
```python
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_run_weekly_evolution_report_creates_task(monkeypatch):
|
||
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
|
||
assert tasks[0]["status"] == "succeeded"
|
||
assert tasks[0]["result_data"]["draw_no"] == 1225
|
||
assert tasks[0]["result_data"]["update_reason"] == "winner_4plus"
|
||
assert tasks[0]["result_data"]["winner_day_of_week"] == 3
|
||
```
|
||
|
||
- [ ] **Step 3: Run tests pass**
|
||
|
||
```bash
|
||
cd agent-office && pytest tests/test_lotto_task_wrap.py -v
|
||
```
|
||
Expected: 4 tests pass.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add agent-office/app/agents/lotto.py agent-office/tests/test_lotto_task_wrap.py
|
||
git commit -m "feat(lotto-agent): run_weekly_evolution_report task_id wrap"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: sync_evolver_activity 신규 메서드 + 멱등 테스트 + cron
|
||
|
||
**Files:**
|
||
- Modify: `agent-office/app/agents/lotto.py` (append method)
|
||
- Modify: `agent-office/app/scheduler.py` (cron 추가)
|
||
- Create: `agent-office/tests/test_sync_evolver_activity.py`
|
||
|
||
- [ ] **Step 1: Write failing test**
|
||
|
||
```python
|
||
# agent-office/tests/test_sync_evolver_activity.py
|
||
import os
|
||
import sys
|
||
import asyncio
|
||
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():
|
||
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_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=1)이라 가정 — fixture로 강제 못 하니 실제 dow에 맞춰 status mock
|
||
from datetime import datetime, timezone, timedelta
|
||
KST = timezone(timedelta(hours=9))
|
||
dow = datetime.now(KST).weekday()
|
||
if dow == 6:
|
||
dow = 5
|
||
|
||
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": [{"id": j, "numbers": [1,2,3,4,5,6], "meta_score": 0.5} for j in range(5)] if i == dow else []}
|
||
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) == 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
|
||
|
||
from datetime import datetime, timezone, timedelta
|
||
KST = timezone(timedelta(hours=9))
|
||
dow = datetime.now(KST).weekday()
|
||
if dow == 6:
|
||
dow = 5
|
||
|
||
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": [{"id": j, "numbers": [1,2,3,4,5,6], "meta_score": 0.5} for j in range(5)] if i == dow else []}
|
||
for i in range(6)
|
||
],
|
||
}
|
||
monkeypatch.setattr(service_proxy, "lotto_evolver_status", fake_status)
|
||
|
||
agent = LottoAgent()
|
||
await agent.sync_evolver_activity()
|
||
await agent.sync_evolver_activity() # 두 번째 호출 — guard로 skip
|
||
|
||
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
|
||
```
|
||
|
||
- [ ] **Step 2: Implement sync_evolver_activity**
|
||
|
||
`agent-office/app/agents/lotto.py` 클래스 끝에 추가:
|
||
|
||
```python
|
||
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 = datetime.now(KST).date()
|
||
today_iso = today.isoformat()
|
||
dow = today.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": []}
|
||
|
||
# ① 오늘 trial에 picks가 있으면 evolver_apply task 기록 (멱등)
|
||
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_iso, "evolver_apply"):
|
||
tid = create_task("lotto", "evolver_apply", {
|
||
"date": today_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")
|
||
|
||
# ② 월요일 + 6 trials 완성 → evolver_generate task (멱등)
|
||
if today.weekday() == 0 and len(status.get("trials", [])) == 6:
|
||
if not get_tasks_by_agent_date_kind("lotto", today_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}
|
||
```
|
||
|
||
- [ ] **Step 3: Add cron to scheduler.py**
|
||
|
||
`agent-office/app/scheduler.py`에 추가:
|
||
|
||
```python
|
||
async def _run_lotto_sync_evolver_activity():
|
||
agent = AGENT_REGISTRY.get("lotto")
|
||
if agent:
|
||
await agent.sync_evolver_activity()
|
||
```
|
||
|
||
기존 lotto cron 등록 근처에:
|
||
|
||
```python
|
||
scheduler.add_job(
|
||
_run_lotto_sync_evolver_activity,
|
||
"cron", hour=9, minute=30,
|
||
id="lotto_evolver_activity_sync",
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests pass**
|
||
|
||
```bash
|
||
cd agent-office && pytest tests/test_sync_evolver_activity.py -v
|
||
cd agent-office && pytest tests/ -v 2>&1 | tail -10
|
||
```
|
||
Expected: 3 new tests pass + no regression.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add agent-office/app/agents/lotto.py agent-office/app/scheduler.py agent-office/tests/test_sync_evolver_activity.py
|
||
git commit -m "feat(lotto-agent): sync_evolver_activity 매일 09:30 cron + 멱등 가드"
|
||
```
|
||
|
||
---
|
||
|
||
# Phase 2 — agent-office API 확장
|
||
|
||
## Task 6: GET /agents/{id}/tasks에 task_type 필터
|
||
|
||
**Files:**
|
||
- Modify: `agent-office/app/main.py`
|
||
|
||
- [ ] **Step 1: Update endpoint signature**
|
||
|
||
`agent-office/app/main.py`에서 `/agents/{agent_id}/tasks` endpoint를 다음으로 교체:
|
||
|
||
```python
|
||
@app.get("/api/agent-office/agents/{agent_id}/tasks")
|
||
async def list_agent_tasks(
|
||
agent_id: str,
|
||
limit: int = 20,
|
||
task_type: Optional[str] = None,
|
||
days: Optional[int] = None,
|
||
):
|
||
return {"items": get_agent_tasks(agent_id, limit=limit, task_type=task_type, days=days)}
|
||
```
|
||
|
||
`Optional` 이 main.py 상단 typing import에 없으면 추가.
|
||
|
||
- [ ] **Step 2: Verify**
|
||
|
||
```bash
|
||
cd agent-office && python -c "
|
||
from app.main import app
|
||
for r in app.routes:
|
||
if 'agents' in r.path and 'tasks' in r.path:
|
||
print(r.path, r.methods)
|
||
"
|
||
cd agent-office && pytest tests/ -v 2>&1 | tail -5
|
||
```
|
||
Expected: route 표시 + no regression.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add agent-office/app/main.py
|
||
git commit -m "feat(agent-office): GET /agents/{id}/tasks에 task_type/days 필터 추가"
|
||
```
|
||
|
||
---
|
||
|
||
# Phase 3 — web-ui Evolver 페이지
|
||
|
||
## Task 7: api.js에 evolver fetch helpers
|
||
|
||
**Files:**
|
||
- Modify: `web-ui/src/api.js`
|
||
|
||
- [ ] **Step 1: Append 4 fetch helpers**
|
||
|
||
`web-ui/src/api.js` 파일 끝에 추가 (다른 fetch 함수들 패턴 따름):
|
||
|
||
```javascript
|
||
// --- Lotto Weight Evolver ---
|
||
|
||
export async function fetchEvolverStatus() {
|
||
const r = await fetch('/api/lotto/evolver/status');
|
||
if (!r.ok) throw new Error(`evolver/status ${r.status}`);
|
||
return r.json();
|
||
}
|
||
|
||
export async function fetchEvolverHistory(weeks = 12) {
|
||
const r = await fetch(`/api/lotto/evolver/history?weeks=${weeks}`);
|
||
if (!r.ok) throw new Error(`evolver/history ${r.status}`);
|
||
return r.json();
|
||
}
|
||
|
||
export async function fetchLottoTasks({ days = 7, taskType = null } = {}) {
|
||
const params = new URLSearchParams({ days: String(days), limit: '100' });
|
||
if (taskType) params.set('task_type', taskType);
|
||
const r = await fetch(`/api/agent-office/agents/lotto/tasks?${params}`);
|
||
if (!r.ok) throw new Error(`agent-office tasks ${r.status}`);
|
||
return r.json();
|
||
}
|
||
|
||
export async function fetchLottoLogs({ days = 7 } = {}) {
|
||
const r = await fetch(`/api/agent-office/agents/lotto/logs?limit=200`);
|
||
if (!r.ok) throw new Error(`agent-office logs ${r.status}`);
|
||
const data = await r.json();
|
||
// 클라이언트에서 days 필터링 (기존 endpoint가 limit만 받음)
|
||
if (!days) return data;
|
||
const cutoff = new Date(Date.now() - days * 24 * 3600 * 1000).toISOString();
|
||
return { items: (data.items || []).filter(l => (l.created_at || '') >= cutoff) };
|
||
}
|
||
|
||
export async function triggerEvolverGenerate() {
|
||
const r = await fetch('/api/lotto/evolver/generate-now', { method: 'POST' });
|
||
if (!r.ok) throw new Error(`generate-now ${r.status}`);
|
||
return r.json();
|
||
}
|
||
|
||
export async function triggerEvolverEvaluate() {
|
||
const r = await fetch('/api/lotto/evolver/evaluate-now', { method: 'POST' });
|
||
if (!r.ok) throw new Error(`evaluate-now ${r.status}`);
|
||
return r.json();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit (web-ui repo)**
|
||
|
||
```bash
|
||
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
||
git add src/api.js
|
||
git commit -m "feat(evolver): api.js에 evolver + lotto activity fetch helpers"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: useEvolverApi.js hook
|
||
|
||
**Files:**
|
||
- Create: `web-ui/src/pages/lotto/evolver/useEvolverApi.js`
|
||
|
||
- [ ] **Step 1: Create hook**
|
||
|
||
```javascript
|
||
// web-ui/src/pages/lotto/evolver/useEvolverApi.js
|
||
import { useEffect, useState, useCallback } from 'react';
|
||
import {
|
||
fetchEvolverStatus,
|
||
fetchEvolverHistory,
|
||
fetchLottoTasks,
|
||
fetchLottoLogs,
|
||
} from '../../../api';
|
||
|
||
|
||
function mergeActivityStream({ logs, tasks, evolverEvents }) {
|
||
const stream = [];
|
||
for (const l of logs.items || []) {
|
||
stream.push({ ts: l.created_at, kind: 'log', payload: l });
|
||
}
|
||
for (const t of tasks.items || []) {
|
||
stream.push({ ts: t.created_at, kind: 'task', payload: t });
|
||
}
|
||
for (const e of evolverEvents) {
|
||
stream.push({ ts: e.created_at, kind: 'evolver', payload: e });
|
||
}
|
||
stream.sort((a, b) => (b.ts || '').localeCompare(a.ts || ''));
|
||
return stream;
|
||
}
|
||
|
||
|
||
export function useEvolverApi({ days = 7, weeks = 12 } = {}) {
|
||
const [status, setStatus] = useState(null);
|
||
const [history, setHistory] = useState({ items: [] });
|
||
const [activity, setActivity] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState(null);
|
||
|
||
const refetch = useCallback(async () => {
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const [s, h, t, l] = await Promise.all([
|
||
fetchEvolverStatus(),
|
||
fetchEvolverHistory(weeks),
|
||
fetchLottoTasks({ days }),
|
||
fetchLottoLogs({ days }),
|
||
]);
|
||
setStatus(s);
|
||
setHistory(h);
|
||
setActivity(mergeActivityStream({
|
||
logs: l,
|
||
tasks: t,
|
||
evolverEvents: h.items || [],
|
||
}));
|
||
} catch (e) {
|
||
setError(e);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [days, weeks]);
|
||
|
||
useEffect(() => { refetch(); }, [refetch]);
|
||
|
||
return { status, history, activity, loading, error, refetch };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
||
git add src/pages/lotto/evolver/useEvolverApi.js
|
||
git commit -m "feat(evolver): useEvolverApi hook (status+history+activity)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: WinnerCard.jsx
|
||
|
||
**Files:**
|
||
- Create: `web-ui/src/pages/lotto/evolver/WinnerCard.jsx`
|
||
|
||
- [ ] **Step 1: Create WinnerCard**
|
||
|
||
```jsx
|
||
// web-ui/src/pages/lotto/evolver/WinnerCard.jsx
|
||
import React from 'react';
|
||
import {
|
||
RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis,
|
||
Radar, ResponsiveContainer, Legend,
|
||
} from 'recharts';
|
||
|
||
const DAY_NAMES = ['월', '화', '수', '목', '금', '토'];
|
||
const METRIC_NAMES = ['freq', 'finger', 'gap', 'cooccur', 'divers'];
|
||
|
||
export default function WinnerCard({ winner, previousBase, updateReason, drawNo }) {
|
||
if (!winner) {
|
||
return (
|
||
<div className="evolver-card winner-card empty">
|
||
<h2>🏆 Winner</h2>
|
||
<p className="muted">아직 회고 결과가 없습니다.</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const dayName = DAY_NAMES[winner.day_of_week] || '?';
|
||
const W = winner.weight || [];
|
||
const prev = previousBase || [0.2, 0.2, 0.2, 0.2, 0.2];
|
||
|
||
const data = METRIC_NAMES.map((name, i) => ({
|
||
metric: name,
|
||
winner: W[i] || 0,
|
||
previous: prev[i] || 0,
|
||
}));
|
||
|
||
return (
|
||
<div className="evolver-card winner-card">
|
||
<header>
|
||
<h2>🏆 Winner: {dayName}요일</h2>
|
||
<span className="badge">{updateReason}</span>
|
||
</header>
|
||
<div className="winner-meta">
|
||
<span>최고 적중 <strong>{winner.max_correct}개</strong></span>
|
||
<span>평균 점수 <strong>{(winner.avg_score || 0).toFixed(2)}</strong></span>
|
||
<span>{winner.n_picks}/5 picks</span>
|
||
{drawNo && <span>{drawNo}회차</span>}
|
||
</div>
|
||
<div className="winner-chart">
|
||
<ResponsiveContainer width="100%" height={300}>
|
||
<RadarChart data={data}>
|
||
<PolarGrid />
|
||
<PolarAngleAxis dataKey="metric" />
|
||
<PolarRadiusAxis angle={90} domain={[0, 0.5]} />
|
||
<Radar name="이번주 winner" dataKey="winner" stroke="#34d399" fill="#34d399" fillOpacity={0.4} />
|
||
<Radar name="이전 base" dataKey="previous" stroke="#999" fill="#999" fillOpacity={0.1} />
|
||
<Legend />
|
||
</RadarChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
||
git add src/pages/lotto/evolver/WinnerCard.jsx
|
||
git commit -m "feat(evolver): WinnerCard — Radar 차트 + 이전 base overlay"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: TrialsGrid.jsx + BaseDiff.jsx + BaseHistory.jsx
|
||
|
||
**Files:**
|
||
- Create: 3 컴포넌트
|
||
|
||
- [ ] **Step 1: Create TrialsGrid**
|
||
|
||
```jsx
|
||
// web-ui/src/pages/lotto/evolver/TrialsGrid.jsx
|
||
import React, { useState } from 'react';
|
||
|
||
const DAY_NAMES = ['월', '화', '수', '목', '금', '토'];
|
||
|
||
export default function TrialsGrid({ trials, perDay, winnerTrialId }) {
|
||
const [expanded, setExpanded] = useState(null);
|
||
|
||
const byDow = {};
|
||
for (const t of trials || []) byDow[t.day_of_week] = t;
|
||
|
||
const perDayByDow = {};
|
||
for (const d of perDay || []) perDayByDow[d.day_of_week] = d;
|
||
|
||
const maxScore = Math.max(...(perDay || []).map(d => d.avg_score || 0), 0.001);
|
||
|
||
return (
|
||
<div className="evolver-card trials-grid">
|
||
<h2>이번주 6일 Trials</h2>
|
||
<div className="grid">
|
||
{DAY_NAMES.map((name, dow) => {
|
||
const trial = byDow[dow];
|
||
const day = perDayByDow[dow];
|
||
const isWinner = trial && trial.id === winnerTrialId;
|
||
const heightPct = day ? (day.avg_score / maxScore) * 100 : 0;
|
||
return (
|
||
<button
|
||
key={dow}
|
||
className={`trial-cell ${isWinner ? 'winner' : ''} ${expanded === dow ? 'expanded' : ''}`}
|
||
onClick={() => setExpanded(expanded === dow ? null : dow)}
|
||
>
|
||
<div className="bar" style={{ height: `${heightPct}%` }} />
|
||
<span className="label">{name}{isWinner && '⭐'}</span>
|
||
{day && <span className="max-correct">max={day.max_correct}</span>}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
{expanded !== null && byDow[expanded] && (
|
||
<div className="trial-detail">
|
||
<h3>{DAY_NAMES[expanded]}요일 상세</h3>
|
||
<p>W = [{(byDow[expanded].weight || []).map(w => w.toFixed(2)).join(', ')}]</p>
|
||
<ul>
|
||
{(byDow[expanded].picks || []).map(p => (
|
||
<li key={p.id}>
|
||
{(p.numbers || []).join(', ')} —
|
||
score {(p.meta_score || 0).toFixed(3)}
|
||
{p.correct != null && ` · 적중 ${p.correct}개`}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create BaseDiff**
|
||
|
||
```jsx
|
||
// web-ui/src/pages/lotto/evolver/BaseDiff.jsx
|
||
import React from 'react';
|
||
|
||
const METRIC_NAMES = ['freq', 'finger', 'gap', 'cooccur', 'divers'];
|
||
|
||
function diffMarker(diff) {
|
||
if (Math.abs(diff) < 0.005) return { mark: '=', cls: 'eq' };
|
||
if (diff > 0) return diff < 0.05 ? { mark: '↑', cls: 'up' } : { mark: '↑↑', cls: 'up-big' };
|
||
return diff > -0.05 ? { mark: '↓', cls: 'down' } : { mark: '↓↓', cls: 'down-big' };
|
||
}
|
||
|
||
export default function BaseDiff({ previousBase, newBase, updateReason }) {
|
||
if (!previousBase || !newBase) {
|
||
return (
|
||
<div className="evolver-card base-diff empty">
|
||
<h2>다음주 base 변경</h2>
|
||
<p className="muted">아직 base 변경 이력 없음.</p>
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<div className="evolver-card base-diff">
|
||
<h2>다음주 base 변경 <span className="badge">{updateReason}</span></h2>
|
||
<div className="diff-grid">
|
||
{METRIC_NAMES.map((name, i) => {
|
||
const prev = previousBase[i] || 0;
|
||
const next = newBase[i] || 0;
|
||
const diff = next - prev;
|
||
const { mark, cls } = diffMarker(diff);
|
||
return (
|
||
<div key={name} className={`metric-card ${cls}`}>
|
||
<div className="metric-name">{name}</div>
|
||
<div className="metric-values">
|
||
{prev.toFixed(2)} → <strong>{next.toFixed(2)}</strong>
|
||
</div>
|
||
<div className="metric-diff">
|
||
{mark} {diff >= 0 ? '+' : ''}{(diff * 100).toFixed(0)}%p
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create BaseHistory**
|
||
|
||
```jsx
|
||
// web-ui/src/pages/lotto/evolver/BaseHistory.jsx
|
||
import React from 'react';
|
||
import {
|
||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
|
||
} from 'recharts';
|
||
|
||
const METRIC_NAMES = ['freq', 'finger', 'gap', 'cooccur', 'divers'];
|
||
const COLORS = ['#34d399', '#60a5fa', '#fbbf24', '#f43f5e', '#c084fc'];
|
||
|
||
export default function BaseHistory({ history }) {
|
||
if (!history || history.length === 0) {
|
||
return (
|
||
<div className="evolver-card base-history empty">
|
||
<h2>12주 Base 변화</h2>
|
||
<p className="muted">학습 이력이 부족합니다.</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const data = history
|
||
.slice()
|
||
.reverse()
|
||
.map(h => {
|
||
const w = h.weight || [0, 0, 0, 0, 0];
|
||
return {
|
||
date: (h.effective_from || '').slice(5), // 'MM-DD'
|
||
freq: w[0], finger: w[1], gap: w[2], cooccur: w[3], divers: w[4],
|
||
reason: h.update_reason,
|
||
};
|
||
});
|
||
|
||
return (
|
||
<div className="evolver-card base-history">
|
||
<h2>Base 변화 (최근 {history.length}주)</h2>
|
||
<ResponsiveContainer width="100%" height={280}>
|
||
<LineChart data={data}>
|
||
<CartesianGrid strokeDasharray="3 3" />
|
||
<XAxis dataKey="date" />
|
||
<YAxis domain={[0, 0.5]} />
|
||
<Tooltip />
|
||
<Legend />
|
||
{METRIC_NAMES.map((name, i) => (
|
||
<Line key={name} type="monotone" dataKey={name} stroke={COLORS[i]} dot />
|
||
))}
|
||
</LineChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
||
git add src/pages/lotto/evolver/TrialsGrid.jsx src/pages/lotto/evolver/BaseDiff.jsx src/pages/lotto/evolver/BaseHistory.jsx
|
||
git commit -m "feat(evolver): TrialsGrid + BaseDiff + BaseHistory 컴포넌트"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: LottoActivityTimeline + EvolverActions + Evolver page + 라우터
|
||
|
||
**Files:**
|
||
- Create: LottoActivityTimeline.jsx, EvolverActions.jsx, Evolver.jsx, Evolver.css
|
||
- Modify: routes.jsx
|
||
|
||
- [ ] **Step 1: LottoActivityTimeline**
|
||
|
||
```jsx
|
||
// web-ui/src/pages/lotto/evolver/LottoActivityTimeline.jsx
|
||
import React from 'react';
|
||
|
||
const ICONS = {
|
||
curate_weekly: '📋',
|
||
signal_check: '🔍',
|
||
daily_digest: '📊',
|
||
weekly_evolution_report: '🧬',
|
||
evolver_generate: '🌱',
|
||
evolver_apply: '🎲',
|
||
};
|
||
|
||
const STATUS_CLS = {
|
||
succeeded: 'ok',
|
||
failed: 'err',
|
||
working: 'pending',
|
||
pending: 'pending',
|
||
};
|
||
|
||
function renderItem(item) {
|
||
const ts = (item.ts || '').replace('T', ' ').slice(0, 19);
|
||
if (item.kind === 'task') {
|
||
const t = item.payload;
|
||
const icon = ICONS[t.task_type] || '⚙️';
|
||
const cls = STATUS_CLS[t.status] || '';
|
||
const detail = formatTaskDetail(t);
|
||
return (
|
||
<li key={`task-${t.id}`} className={`activity-item task ${cls}`}>
|
||
<span className="icon">{icon}</span>
|
||
<div className="body">
|
||
<div className="line"><strong>{t.task_type}</strong> · <span className={`status ${cls}`}>{t.status}</span></div>
|
||
{detail && <div className="detail">{detail}</div>}
|
||
</div>
|
||
<span className="ts">{ts}</span>
|
||
</li>
|
||
);
|
||
}
|
||
if (item.kind === 'log') {
|
||
const l = item.payload;
|
||
return (
|
||
<li key={`log-${l.id}`} className={`activity-item log level-${l.level}`}>
|
||
<span className="icon">{l.level === 'error' ? '❌' : l.level === 'warning' ? '⚠️' : '·'}</span>
|
||
<div className="body"><div className="line">{l.message}</div></div>
|
||
<span className="ts">{ts}</span>
|
||
</li>
|
||
);
|
||
}
|
||
if (item.kind === 'evolver') {
|
||
const e = item.payload;
|
||
return (
|
||
<li key={`evolver-${e.id}`} className="activity-item evolver">
|
||
<span className="icon">⚖️</span>
|
||
<div className="body">
|
||
<div className="line"><strong>weight_evolver_eval</strong> (lotto-lab)</div>
|
||
<div className="detail">reason={e.update_reason} winner_max={e.winner_max_correct}</div>
|
||
</div>
|
||
<span className="ts">{ts}</span>
|
||
</li>
|
||
);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function formatTaskDetail(t) {
|
||
const r = t.result_data || {};
|
||
switch (t.task_type) {
|
||
case 'signal_check': return `${r.source} → ${r.overall_fire} (${r.n_results} results)`;
|
||
case 'daily_digest': return `평가 ${r.evaluated} / 발화 ${r.fired}`;
|
||
case 'weekly_evolution_report': return `draw=${r.draw_no} reason=${r.update_reason}`;
|
||
case 'evolver_apply': return `${r.n_picks}세트 추출`;
|
||
case 'evolver_generate': return `${r.trials_count} trials 생성`;
|
||
case 'curate_weekly': return `draw=${r.draw_no || '?'} conf=${r.confidence || '?'}`;
|
||
default: return '';
|
||
}
|
||
}
|
||
|
||
export default function LottoActivityTimeline({ activity = [], days = 7 }) {
|
||
if (!activity || activity.length === 0) {
|
||
return (
|
||
<div className="evolver-card activity-card empty">
|
||
<h2>최근 활동</h2>
|
||
<p className="muted">지난 {days}일 활동 없음.</p>
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<div className="evolver-card activity-card">
|
||
<h2>최근 {days}일 에이전트 활동 ({activity.length})</h2>
|
||
<ul className="activity-list">{activity.map(renderItem)}</ul>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: EvolverActions**
|
||
|
||
```jsx
|
||
// web-ui/src/pages/lotto/evolver/EvolverActions.jsx
|
||
import React, { useState } from 'react';
|
||
import { triggerEvolverGenerate, triggerEvolverEvaluate } from '../../../api';
|
||
|
||
export default function EvolverActions({ onChange }) {
|
||
const [busy, setBusy] = useState(null);
|
||
const [out, setOut] = useState(null);
|
||
|
||
async function run(kind) {
|
||
setBusy(kind);
|
||
setOut(null);
|
||
try {
|
||
const fn = kind === 'generate' ? triggerEvolverGenerate : triggerEvolverEvaluate;
|
||
const res = await fn();
|
||
setOut(res);
|
||
onChange && onChange();
|
||
} catch (e) {
|
||
setOut({ error: String(e) });
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="evolver-card actions-card">
|
||
<h2>수동 트리거 (dev)</h2>
|
||
<div className="action-buttons">
|
||
<button disabled={!!busy} onClick={() => run('generate')}>
|
||
{busy === 'generate' ? '생성 중...' : 'generate-now (월요일 후보 생성)'}
|
||
</button>
|
||
<button disabled={!!busy} onClick={() => run('evaluate')}>
|
||
{busy === 'evaluate' ? '평가 중...' : 'evaluate-now (회고 + base 갱신)'}
|
||
</button>
|
||
</div>
|
||
{out && <pre className="action-output">{JSON.stringify(out, null, 2)}</pre>}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Evolver.jsx 페이지**
|
||
|
||
```jsx
|
||
// web-ui/src/pages/lotto/Evolver.jsx
|
||
import React from 'react';
|
||
import './Evolver.css';
|
||
import { useEvolverApi } from './evolver/useEvolverApi';
|
||
import WinnerCard from './evolver/WinnerCard';
|
||
import TrialsGrid from './evolver/TrialsGrid';
|
||
import BaseDiff from './evolver/BaseDiff';
|
||
import BaseHistory from './evolver/BaseHistory';
|
||
import LottoActivityTimeline from './evolver/LottoActivityTimeline';
|
||
import EvolverActions from './evolver/EvolverActions';
|
||
|
||
export default function Evolver() {
|
||
const { status, history, activity, loading, error, refetch } = useEvolverApi({ days: 7, weeks: 12 });
|
||
|
||
if (loading) return <div className="evolver"><p>로딩 중...</p></div>;
|
||
if (error) return <div className="evolver"><p>에러: {String(error)}</p></div>;
|
||
|
||
const latestBase = (history.items || [])[0];
|
||
const previousBase = (history.items || [])[1]?.weight || status?.current_base || [0.2, 0.2, 0.2, 0.2, 0.2];
|
||
const newBase = latestBase?.weight || status?.current_base;
|
||
|
||
// winner/perDay 정보는 latest base_history에 trial_id 있음 → trials에서 찾기
|
||
const trials = status?.trials || [];
|
||
const winnerTrialId = latestBase?.source_trial_id;
|
||
const winnerTrial = trials.find(t => t.id === winnerTrialId);
|
||
const winnerInfo = winnerTrial ? {
|
||
day_of_week: winnerTrial.day_of_week,
|
||
weight: winnerTrial.weight,
|
||
avg_score: latestBase?.winner_score,
|
||
max_correct: latestBase?.winner_max_correct,
|
||
n_picks: (winnerTrial.picks || []).length,
|
||
} : null;
|
||
|
||
const perDay = trials.map(t => ({
|
||
day_of_week: t.day_of_week,
|
||
trial_id: t.id,
|
||
avg_score: (t.picks || []).reduce((s, p) => s + (p.meta_score || 0), 0) / Math.max(1, (t.picks || []).length),
|
||
max_correct: Math.max(0, ...(t.picks || []).map(p => p.correct || 0)),
|
||
}));
|
||
|
||
const hasBase = (history.items || []).length > 0;
|
||
|
||
return (
|
||
<div className="evolver">
|
||
<header className="evolver-header">
|
||
<div>
|
||
<p className="evolver-kicker">Lotto · Weight Evolver</p>
|
||
<h1>자율 학습 루프</h1>
|
||
<p className="evolver-sub">
|
||
매주 6가지 가중치를 시도해서 best 조합을 다음주 base로 학습합니다.
|
||
{status?.latest_draw && ` 마지막 회차: ${status.latest_draw}회.`}
|
||
</p>
|
||
</div>
|
||
<button className="refresh-btn" onClick={refetch}>↻ 새로고침</button>
|
||
</header>
|
||
|
||
{!hasBase ? (
|
||
<div className="evolver-card empty-state">
|
||
<h2>아직 학습 시작 전</h2>
|
||
<p>다음 월요일 09:00에 자동 시작 또는 수동 트리거 사용.</p>
|
||
<EvolverActions onChange={refetch} />
|
||
</div>
|
||
) : (
|
||
<>
|
||
<WinnerCard
|
||
winner={winnerInfo}
|
||
previousBase={previousBase}
|
||
updateReason={latestBase?.update_reason}
|
||
drawNo={status?.latest_draw}
|
||
/>
|
||
<TrialsGrid trials={trials} perDay={perDay} winnerTrialId={winnerTrialId} />
|
||
<BaseDiff
|
||
previousBase={previousBase}
|
||
newBase={newBase}
|
||
updateReason={latestBase?.update_reason}
|
||
/>
|
||
<BaseHistory history={history.items || []} />
|
||
<LottoActivityTimeline activity={activity} days={7} />
|
||
<EvolverActions onChange={refetch} />
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Evolver.css**
|
||
|
||
```css
|
||
/* web-ui/src/pages/lotto/Evolver.css */
|
||
.evolver { max-width: 1100px; margin: 0 auto; padding: 24px 16px; }
|
||
.evolver-header { display: flex; justify-content: space-between; align-items: start; margin-bottom: 24px; gap: 16px; }
|
||
.evolver-kicker { letter-spacing: 0.12em; color: #6b7280; font-size: 0.75rem; margin: 0 0 4px; }
|
||
.evolver-header h1 { margin: 0 0 8px; font-size: 2rem; }
|
||
.evolver-sub { color: #6b7280; margin: 0; }
|
||
.refresh-btn { padding: 8px 14px; background: #f3f4f6; border: 1px solid #e5e7eb; border-radius: 6px; cursor: pointer; }
|
||
|
||
.evolver-card { background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; padding: 20px; margin-bottom: 16px; }
|
||
.evolver-card.empty .muted { color: #9ca3af; }
|
||
.evolver-card h2 { margin: 0 0 12px; font-size: 1.1rem; display: flex; justify-content: space-between; align-items: center; }
|
||
.evolver-card .badge { background: #ecfdf5; color: #065f46; padding: 2px 8px; border-radius: 4px; font-size: 0.7rem; font-weight: normal; }
|
||
|
||
.winner-card .winner-meta { display: flex; gap: 16px; flex-wrap: wrap; color: #6b7280; font-size: 0.9rem; margin-bottom: 12px; }
|
||
.winner-card .winner-meta strong { color: #111827; }
|
||
|
||
.trials-grid .grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; height: 140px; align-items: end; }
|
||
.trial-cell { border: none; background: #f9fafb; border-radius: 6px; padding: 8px 4px; display: flex; flex-direction: column; align-items: center; justify-content: end; cursor: pointer; height: 100%; }
|
||
.trial-cell.winner { background: #ecfdf5; }
|
||
.trial-cell .bar { width: 80%; background: #34d399; border-radius: 3px 3px 0 0; min-height: 4px; }
|
||
.trial-cell.winner .bar { background: #059669; }
|
||
.trial-cell .label { font-size: 0.85rem; margin-top: 6px; }
|
||
.trial-cell .max-correct { font-size: 0.7rem; color: #6b7280; }
|
||
.trial-detail { margin-top: 16px; padding: 12px; background: #f9fafb; border-radius: 6px; }
|
||
.trial-detail ul { margin: 8px 0 0; padding-left: 18px; }
|
||
|
||
.base-diff .diff-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; }
|
||
.metric-card { padding: 12px; background: #f9fafb; border-radius: 8px; text-align: center; }
|
||
.metric-card .metric-name { color: #6b7280; font-size: 0.75rem; text-transform: uppercase; }
|
||
.metric-card .metric-values { margin: 6px 0; font-size: 0.85rem; }
|
||
.metric-card .metric-diff { font-weight: bold; }
|
||
.metric-card.up .metric-diff, .metric-card.up-big .metric-diff { color: #059669; }
|
||
.metric-card.down .metric-diff, .metric-card.down-big .metric-diff { color: #dc2626; }
|
||
.metric-card.eq .metric-diff { color: #9ca3af; }
|
||
|
||
.activity-card .activity-list { list-style: none; padding: 0; margin: 0; }
|
||
.activity-item { display: grid; grid-template-columns: 24px 1fr auto; gap: 8px; padding: 8px 0; border-bottom: 1px solid #f3f4f6; }
|
||
.activity-item .ts { color: #9ca3af; font-size: 0.75rem; white-space: nowrap; }
|
||
.activity-item .status.ok { color: #059669; }
|
||
.activity-item .status.err { color: #dc2626; }
|
||
.activity-item .detail { color: #6b7280; font-size: 0.85rem; }
|
||
|
||
.actions-card .action-buttons { display: flex; gap: 8px; flex-wrap: wrap; }
|
||
.actions-card button { padding: 8px 14px; background: #1f2937; color: #fff; border: none; border-radius: 6px; cursor: pointer; }
|
||
.actions-card button:disabled { opacity: 0.5; cursor: wait; }
|
||
.action-output { background: #1f2937; color: #d1d5db; padding: 12px; border-radius: 6px; margin-top: 12px; max-height: 200px; overflow: auto; font-size: 0.8rem; }
|
||
|
||
@media (max-width: 640px) {
|
||
.trials-grid .grid { grid-template-columns: repeat(3, 1fr); height: auto; }
|
||
.base-diff .diff-grid { grid-template-columns: repeat(2, 1fr); }
|
||
.evolver-header { flex-direction: column; }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Register route in routes.jsx**
|
||
|
||
`web-ui/src/routes.jsx` 변경:
|
||
|
||
기존 `const Lotto = lazy(() => import('./pages/lotto/Lotto'));` 다음 줄에 추가:
|
||
```jsx
|
||
const Evolver = lazy(() => import('./pages/lotto/Evolver'));
|
||
```
|
||
|
||
`appRoutes` 배열의 `{ path: 'lotto', element: <Lotto /> }` 다음에 추가:
|
||
```jsx
|
||
{
|
||
path: 'lotto/evolver',
|
||
element: <Evolver />,
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 6: Local dev verify**
|
||
|
||
```bash
|
||
cd C:/Users/jaeoh/Desktop/workspace/web-ui && npm run dev
|
||
```
|
||
Open http://localhost:3007/lotto/evolver — 페이지 로드 확인. NAS API에 접근해야 하니 vite proxy 또는 직접 NAS URL 호출. 로컬 dev에선 cold start로 보일 가능성.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
||
git add src/pages/lotto/Evolver.jsx src/pages/lotto/Evolver.css src/pages/lotto/evolver/LottoActivityTimeline.jsx src/pages/lotto/evolver/EvolverActions.jsx src/routes.jsx
|
||
git commit -m "feat(evolver): Evolver 페이지 + LottoActivityTimeline + EvolverActions + 라우터 등록"
|
||
```
|
||
|
||
---
|
||
|
||
# Phase 4 — 배포 + 검증
|
||
|
||
## Task 12: 배포 + 검증 (사용자 수동)
|
||
|
||
- [ ] **Step 1: web-backend push (자동 배포)**
|
||
|
||
```bash
|
||
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||
git push
|
||
```
|
||
agent-office 컨테이너 재시작 → 새 cron `lotto_evolver_activity_sync` 등록 + task_id wrap 작동 시작.
|
||
|
||
- [ ] **Step 2: web-ui 빌드 + 배포**
|
||
|
||
```bash
|
||
cd C:/Users/jaeoh/Desktop/workspace/web-ui
|
||
npm run release:nas
|
||
```
|
||
robocopy로 Z:\docker\webpage\frontend\로 빌드 산출물 업로드.
|
||
|
||
- [ ] **Step 3: 텔레그램 링크 클릭 검증**
|
||
|
||
이전 토요일에 받은 텔레그램 메시지의 "[웹에서 차트 보기]" 링크 클릭 → `/lotto/evolver` 페이지 정상 로드. cold start면 안내 카드 + 수동 트리거 버튼만 보임.
|
||
|
||
- [ ] **Step 4: 수동 트리거 후 페이지 확인**
|
||
|
||
`[수동 generate-now]` 클릭 → 6 trials 생성. 페이지 새로고침 → TrialsGrid에 6 cells, BaseHistory에 cold_start row 1개 표시.
|
||
|
||
- [ ] **Step 5: agent-office 페이지에서 LottoAgent task 확인**
|
||
|
||
`/agent-office` 접속 → LottoAgent 카드 클릭 → Tasks 탭에서 새 task_type들 (`signal_check`, `daily_digest`, `evolver_apply` 등) 확인.
|
||
|
||
- [ ] **Step 6: 다음 토요일 22:15 자연 검증**
|
||
|
||
자동 cron으로 `weekly_evolution_report` task가 생성되고 텔레그램 도착 + 링크 클릭 시 새 winner 정보가 차트에 반영됨.
|
||
|
||
---
|
||
|
||
# Self-Review (수행 완료)
|
||
|
||
**1. Spec coverage check**
|
||
|
||
| Spec 섹션 | 구현 task |
|
||
|---|---|
|
||
| 3.1 컴포넌트 | T9-T11 (5 컴포넌트) + T11 라우터 |
|
||
| 4 페이지 layout | T11 Evolver.jsx 조립 + Evolver.css |
|
||
| 4.1 모바일 반응형 | T11 Evolver.css @media query |
|
||
| 5.1 useEvolverApi | T8 |
|
||
| 5.2 Recharts | T9 (Radar), T11 (Line), T10 (Bar는 trial cell에 inline) |
|
||
| 5.3 LottoActivityTimeline | T11 (Evolver 전용으로 한정 — spec adjustment 명시) |
|
||
| 5.4 cold start | T11 Evolver.jsx 분기 |
|
||
| 6.1 task_id wrap | T2-T4 |
|
||
| 6.2 sync_evolver_activity | T5 |
|
||
| 6.3 db helper | T1 |
|
||
| 6.4 cron | T5 |
|
||
| 6.5 API 확장 | T6 |
|
||
| 6.6 task_type 명세 | T2-T5 result_data, T11 icon mapping |
|
||
| 7 라우터 등록 | T11 |
|
||
| 9 비기능 요구 | T2-T5 단위 테스트 |
|
||
|
||
모두 매핑됨.
|
||
|
||
**2. Placeholder scan**: 없음. 모든 step에 실제 코드 또는 명령.
|
||
|
||
**3. Type consistency**:
|
||
- `create_task(agent_id, task_type, input_data)` → returns UUID string. T2-T5 호출 일치
|
||
- `update_task_status(task_id, status, result_data)` 시그니처 일관
|
||
- `get_agent_tasks(agent_id, limit, task_type, days)` T1 정의 + T6 API consumes + T8 fetchLottoTasks 호출 일치
|
||
- `useEvolverApi` 반환 `{status, history, activity, loading, error, refetch}` — T8 정의, T11 Evolver.jsx 사용 일치
|
||
- `mergeActivityStream({logs, tasks, evolverEvents})` shape — T8 정의, T11 LottoActivityTimeline 파싱 일치
|
||
|
||
이슈 없음.
|
||
|
||
---
|
||
|
||
# 비목표 (Out of scope, v3 후속)
|
||
|
||
- 과거 주 deep dive (`/trials/{week_start}` 사용한 별도 페이지)
|
||
- 차트 export / CSV
|
||
- 가중치 수동 편집 UI
|
||
- 다른 에이전트 활동 통합 timeline (현재 lotto only)
|
||
- WebSocket 실시간 푸시 (polling으로 충분)
|