11 Commits

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 01:17:12 +09:00
9 changed files with 2468 additions and 81 deletions

View File

@@ -28,30 +28,32 @@ class LottoAgent(BaseAgent):
pass pass
async def run_signal_check(self, source: str = "light") -> dict: async def run_signal_check(self, source: str = "light") -> dict:
"""비-LLM 시그널 평가 (light/sim) 또는 deep_check (LLM 호출 후). """비-LLM 시그널 평가. task_id wrap 적용."""
Phase 3 (Task 9): urgent 시그널 텔레그램 발송 + throttle/daily-cap 추가.
"""
from ..curator.signal_runner import run_signal_check from ..curator.signal_runner import run_signal_check
from ..config import LOTTO_Z_NORMAL, LOTTO_Z_URGENT from ..config import (
from ..db import add_log 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"): if self.state not in ("idle", "reporting"):
return {"ok": False, "message": f"busy ({self.state})"} return {"ok": False, "message": f"busy ({self.state})"}
task_id = create_task("lotto", "signal_check", {"source": source})
try: try:
curate_result = None curate_result = None
# 회차 단위 메트릭(drift/confidence) 가드를 위해 항상 최신 회차 가져옴
from ..service_proxy import lotto_latest_draw
current_draw_no = await lotto_latest_draw() current_draw_no = await lotto_latest_draw()
if source == "deep": if source == "deep":
from ..curator.pipeline import curate_weekly from ..curator.pipeline import curate_weekly
cw = await curate_weekly(source="signal_deep") cw = await curate_weekly(source="signal_deep")
# curate_weekly returns {"ok", "draw_no", "confidence", "tokens", "payload"}
curate_result = {"confidence": cw.get("confidence")} curate_result = {"confidence": cw.get("confidence")}
# deep_check 시 curate_weekly가 반환하는 draw_no를 우선 사용 (직접 수집)
if cw.get("draw_no"): if cw.get("draw_no"):
current_draw_no = cw.get("draw_no") current_draw_no = cw.get("draw_no")
@@ -62,35 +64,19 @@ class LottoAgent(BaseAgent):
curate_result=curate_result, curate_result=curate_result,
current_draw_no=current_draw_no, current_draw_no=current_draw_no,
) )
add_log(
self.agent_id,
f"signal_check({source}) → overall={outcome['overall_fire']} results={len(outcome['results'])}",
)
# --- Throttle + 텔레그램 urgent 발송 ---
from ..config import LOTTO_THROTTLE_HOURS, LOTTO_URGENT_DAILY_MAX
from ..db import (
get_last_signal_notification, get_recent_urgent_count,
mark_signal_notified,
)
from ..notifiers.telegram_lotto import send_urgent_signal
# urgent 텔레그램 + throttle (기존 동작 유지)
if outcome["overall_fire"] == "urgent": if outcome["overall_fire"] == "urgent":
if get_recent_urgent_count(hours=24) >= LOTTO_URGENT_DAILY_MAX: if get_recent_urgent_count(hours=24) >= LOTTO_URGENT_DAILY_MAX:
add_log( add_log("lotto", "urgent daily cap 도달 → normal로 강등", level="warning", task_id=task_id)
self.agent_id,
"urgent daily cap 도달 → normal로 강등 (digest 합류)",
level="warning",
)
else: else:
blocked = False blocked = False
for r in outcome["results"]: for r in outcome["results"]:
if r["fire_level"] in ("normal", "urgent"): if r["fire_level"] in ("normal", "urgent"):
last = get_last_signal_notification( if get_last_signal_notification(
metric=r["metric"], fire_level=r["fire_level"], metric=r["metric"], fire_level=r["fire_level"],
hours=LOTTO_THROTTLE_HOURS, hours=LOTTO_THROTTLE_HOURS,
) ):
if last:
blocked = True blocked = True
break break
if not blocked: if not blocked:
@@ -104,69 +90,151 @@ class LottoAgent(BaseAgent):
for r in outcome["results"]: for r in outcome["results"]:
if r["fire_level"] in ("normal", "urgent"): if r["fire_level"] in ("normal", "urgent"):
mark_signal_notified(r["signal_id"]) mark_signal_notified(r["signal_id"])
add_log(self.agent_id, f"urgent 텔레그램 발송 완료 (시그널 {len(outcome['results'])}마킹)") 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} return {"ok": True, **outcome}
except Exception as e: except Exception as e:
add_log(self.agent_id, f"signal_check 예외: {e}", level="error") update_task_status(task_id, "failed", result_data={"error": str(e)})
add_log("lotto", f"signal_check 예외: {e}", level="error", task_id=task_id)
return {"ok": False, "message": f"{type(e).__name__}: {e}"} return {"ok": False, "message": f"{type(e).__name__}: {e}"}
async def run_daily_digest(self) -> dict: async def run_daily_digest(self) -> dict:
"""일일 요약 — 지난 24h normal/urgent 발화를 묶어 텔레그램 1통.""" """일일 요약 — 지난 24h normal/urgent 발화 텔레그램 1통. task_id wrap."""
from ..db import ( from ..db import (
get_recent_lotto_signals, get_signals_history, add_log, create_task, update_task_status, add_log,
get_baseline, get_recent_lotto_signals, get_signals_history, get_baseline,
) )
from ..notifiers.telegram_lotto import send_signal_summary from ..notifiers.telegram_lotto import send_signal_summary
sigs = get_recent_lotto_signals(hours=24, min_fire="normal") task_id = create_task("lotto", "daily_digest", {})
total_24h = get_signals_history(days=1)
evaluated = len(total_24h)
# weights_trend: drift_weights_cache의 prev/curr 차이
trend = {}
try: try:
cache = get_baseline("drift_weights_cache") sigs = get_recent_lotto_signals(hours=24, min_fire="normal")
if cache and isinstance(cache["window_values"], list) and len(cache["window_values"]) >= 2: total_24h = get_signals_history(days=1)
prev_w = cache["window_values"][-2] evaluated = len(total_24h)
curr_w = cache["window_values"][-1]
trend = {
k: curr_w.get(k, 0.0) - prev_w.get(k, 0.0)
for k in (set(prev_w) | set(curr_w))
}
except Exception as e:
add_log(self.agent_id, f"weights_trend 계산 실패: {e}", level="warning")
digest = { trend = {}
"evaluated": evaluated, try:
"fired": len(sigs), cache = get_baseline("drift_weights_cache")
"signals": sigs, if cache and isinstance(cache["window_values"], list) and len(cache["window_values"]) >= 2:
"weights_trend": trend, prev_w = cache["window_values"][-2]
} curr_w = cache["window_values"][-1]
await send_signal_summary(digest) trend = {
add_log(self.agent_id, f"daily_digest 발송: 평가 {evaluated} / 발화 {len(sigs)}") k: curr_w.get(k, 0.0) - prev_w.get(k, 0.0)
return {"ok": True, **digest} 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: async def run_weekly_evolution_report(self) -> dict:
"""토 22:15 — lotto-lab evaluate-now 트리거 후 텔레그램 리포트.""" """토 22:15 — lotto-lab evaluate-now 트리거 후 텔레그램 리포트. task_id wrap."""
from ..service_proxy import lotto_evolver_evaluate, lotto_evolver_status from ..service_proxy import lotto_evolver_evaluate, lotto_evolver_status
from ..notifiers.telegram_lotto import send_evolution_report from ..notifiers.telegram_lotto import send_evolution_report
from ..db import add_log from ..db import create_task, update_task_status, add_log
task_id = create_task("lotto", "weekly_evolution_report", {})
try: try:
eval_result = await lotto_evolver_evaluate() eval_result = await lotto_evolver_evaluate()
status = await lotto_evolver_status() status = await lotto_evolver_status()
current_base = status.get("current_base") or [0.2] * 5 current_base = status.get("current_base") or [0.2] * 5
await send_evolution_report(eval_result, current_base) await send_evolution_report(eval_result, current_base)
add_log(
self.agent_id, winner = eval_result.get("winner") or {}
f"weekly_evolution_report 발송: draw={eval_result.get('draw_no')} reason={eval_result.get('update_reason')}", 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} return {"ok": True, **eval_result}
except Exception as e: except Exception as e:
add_log(self.agent_id, f"weekly_evolution_report 예외: {e}", level="error") 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}"} 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)

View File

@@ -236,12 +236,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]
@@ -739,3 +751,18 @@ def get_all_baselines() -> List[Dict[str, Any]]:
d["window_values"] = json.loads(d["window_values"]) d["window_values"] = json.loads(d["window_values"])
out.append(d) out.append(d)
return out return out
def get_tasks_by_agent_date_kind(agent_id: str, date_iso: str, task_type: str) -> List[Dict[str, Any]]:
"""같은 (agent, date, task_type)으로 이미 생성된 task 조회. 멱등 guard."""
with _conn() as conn:
rows = conn.execute(
"""
SELECT * FROM agent_tasks
WHERE agent_id = ? AND task_type = ?
AND substr(created_at, 1, 10) = ?
ORDER BY created_at DESC
""",
(agent_id, task_type, date_iso),
).fetchall()
return [_task_to_dict(r) for r in rows]

View File

@@ -1,5 +1,6 @@
import os import 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
@@ -104,8 +105,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):

View File

@@ -61,6 +61,11 @@ async def _run_lotto_weekly_evolution_report():
if agent: if agent:
await agent.run_weekly_evolution_report() 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:
@@ -95,14 +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_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_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_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_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_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")

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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()이 부호로 색/화살표를 판별하므로)