Add a Monday-morning weekly recap briefing

No US session closes into Monday KST, so Mondays (default 07:00, cron
in the daemon with restart catch-up) now publish a weekly review built
entirely from stored data: the quotes table collapsed into week-over-
week changes and the week's daily briefing headlines as the narrative.
The recap is a BriefingResult kind="weekly" reusing MarketPayload, so
the store, verification pass, all delivery channels (Blogger title
"미국 주간 리뷰 <monday>") and the dashboard (주간 리뷰 badge) work
unchanged. Manual runs via `run-once --weekly [--date <monday>]`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jsahn
2026-09-12 23:52:31 +09:00
co-authored by Claude Fable 5
parent 1e6272da74
commit f5fa7ec70f
22 changed files with 995 additions and 33 deletions
+3
View File
@@ -32,4 +32,7 @@ US2KR_SKIP_WHEN_KRX_CLOSED=false
# exchange_calendars에 없는 한국 임시휴장일 (CSV, 예: 2026-06-03)
US2KR_KRX_EXTRA_HOLIDAYS=
US2KR_PREMARKET_UPDATE_ENABLED=false
# 월요일 아침 주간 리뷰 (지난주 시세·헤드라인 요약)
US2KR_WEEKLY_ENABLED=true
US2KR_WEEKLY_TIME_KST=07:00
US2KR_LOG_LEVEL=INFO
+3
View File
@@ -17,6 +17,9 @@
| **브리핑 발송** (마감 +75분, 기본) | 17:15 | **06:15 / 07:15** |
| 한국 개장 | — | 09:00 |
- **월요일 아침(기본 07:00 KST)에는 주간 리뷰 발행** — 월요일 새벽엔 마감되는 미국 세션이 없으므로,
저장된 지난주 시세(주간 변동)와 일일 브리핑 헤드라인으로 주간 요약을 만들어 같은 채널로 발행
(`US2KR_WEEKLY_ENABLED` / `US2KR_WEEKLY_TIME_KST`, 수동: `run-once --weekly [--date 월요일]`)
- 시각은 하드코딩이 아니라 `exchange_calendars`(XNYS/XKRX)로 계산 → 미국 휴장·조기폐장·서머타임 자동 대응
- 미국 휴장일은 스킵, 한국 휴장일은 리포트에 "오늘 한국 휴장" 표기. `US2KR_SKIP_WHEN_KRX_CLOSED=true`면
한국 휴장 연휴 중 **다음 미국 세션이 먼저 마감되어 무의미해지는 브리핑만** 스킵 — 금요일 세션(토요일 도착)은
+9 -2
View File
@@ -166,7 +166,11 @@ function renderList(
button.setAttribute("aria-current", String(s.krx_date === selected));
button.append(el("span", "briefing-date", `${s.krx_date} (KRX)`));
const line = el("span", "briefing-line");
line.append(`세션 ${s.session_date}`, badgeFor(s));
if (s.kind === "weekly") {
line.append(el("span", "badge badge-week", "주간 리뷰"), `~${s.session_date}`, badgeFor(s));
} else {
line.append(`세션 ${s.session_date}`, badgeFor(s));
}
if (s.data_gaps.length > 0) line.append(el("span", "badge badge-warn", `결측 ${s.data_gaps.length}`));
button.append(line);
if (s.headline) button.append(el("span", "briefing-line", s.headline));
@@ -179,8 +183,11 @@ function renderList(
function metaChips(result: BriefingResult): HTMLElement[] {
const p = result.payload;
const close = new Date(p.us_close_time);
const weekly = result.kind === "weekly";
const chips: HTMLElement[] = [
el("span", undefined, `미국 세션 ${p.session_date} → KRX ${p.krx_date}`),
weekly
? el("span", "badge badge-week", "주간 리뷰")
: el("span", undefined, `미국 세션 ${p.session_date} → KRX ${p.krx_date}`),
el(
"span",
undefined,
+1
View File
@@ -67,6 +67,7 @@ async function listDates(): Promise<string[]> {
function summarize(krxDate: string, r: BriefingResult): BriefingSummary {
return {
krx_date: krxDate,
kind: r.kind ?? "briefing",
session_date: r.payload.session_date,
generated_at: r.payload.generated_at,
krx_open_today: r.payload.krx_open_today,
+3
View File
@@ -93,12 +93,15 @@ export interface BriefingResult {
error: string | null;
/** Absent/null: verification not run (disabled, degraded, verifier failed, or pre-feature archive). */
verification?: Verification | null;
/** "weekly": Monday recap (quotes carry week-over-week changes). Absent on older archives. */
kind?: "briefing" | "weekly";
}
// ---------------------------------------------------------------- API shapes
export interface BriefingSummary {
krx_date: string;
kind: "briefing" | "weekly";
session_date: string;
generated_at: string;
krx_open_today: boolean;
+1
View File
@@ -205,6 +205,7 @@ button[aria-pressed="true"] { background: var(--grid); }
.badge-ok { background: var(--badge-ok-bg); color: var(--badge-ok-ink); }
.badge-warn { background: var(--badge-warn-bg); color: var(--badge-warn-ink); }
.badge-error { background: var(--badge-err-bg); color: var(--badge-err-ink); }
.badge-week { border: 1px solid var(--series-1); color: var(--series-1); background: transparent; }
/* ------------------------------------------------------------------ summary card */
.summary-card {
+11
View File
@@ -152,6 +152,9 @@ class FakeStore:
self.fail_history = fail_history
self.closed = False
self._next_id = 1
# weekly-recap sources: dict rows mimicking sqlite3.Row's __getitem__ access
self.quote_rows: list[dict] = []
self.report_rows: list[dict] = []
def save_quotes(self, session_date: date, quotes: list) -> None:
self.quotes[session_date] = list(quotes)
@@ -177,6 +180,14 @@ class FakeStore:
def save_raw(self, session_date: date, name: str, payload_json: str) -> None:
self.raw[(session_date, name)] = payload_json
def load_quotes_between(self, start: date, end: date) -> list[dict]:
rows = [r for r in self.quote_rows if start.isoformat() <= r["session_date"] <= end.isoformat()]
return sorted(rows, key=lambda r: (r["symbol"], r["session_date"]))
def reports_between(self, start: date, end: date, kind: str = "briefing") -> list[dict]:
rows = [r for r in self.report_rows if start.isoformat() <= r["krx_date"] <= end.isoformat()]
return sorted(rows, key=lambda r: r["krx_date"])
def close(self) -> None:
self.closed = True
+8
View File
@@ -255,3 +255,11 @@ def test_build_deliverers_includes_blogger_when_configured(tmp_path):
blogger = deliverers[-1]
assert blogger.labels == ["미국증시", "시황"] # type: ignore[attr-defined]
assert blogger.blog_id == "1" # type: ignore[attr-defined]
def test_post_title_weekly_kind():
result = make_result()
result = result.model_copy(update={"kind": "weekly"})
assert post_title(result) == "미국 주간 리뷰 2026-09-01 — 9월 첫 거래일 하락"
degraded = make_result(headline=None).model_copy(update={"kind": "weekly"})
assert post_title(degraded).startswith("미국 주간 리뷰 2026-09-01 (미 ")
+54 -2
View File
@@ -645,12 +645,16 @@ class GlueProvider:
def summarize(self, payload: MarketPayload) -> tuple[Report, str]:
raise AssertionError("summarize must not be called")
def verify(self, payload: MarketPayload, report: Report) -> tuple[Verification, str]:
def verify(
self, payload: MarketPayload, report: Report, data_message: str | None = None
) -> tuple[Verification, str]:
self.verify_calls += 1
self.last_data_message = data_message
return self._verdicts.pop(0), "stub"
def revise(self, payload, report, issues) -> tuple[Report, str]:
def revise(self, payload, report, issues, *, system_prompt=None, data_message=None) -> tuple[Report, str]:
self.revise_calls += 1
self.last_system_prompt = system_prompt
if self._revise_fails:
raise LLMError("revise down")
assert self._revised is not None
@@ -696,3 +700,51 @@ def test_verify_and_revise_revision_failure_keeps_original() -> None:
out, verification = verify_and_revise(make_payload(), report, provider)
assert out is report and verification is verdict
assert provider.verify_calls == 1 and provider.revise_calls == 1
# ---------------------------------------------------------------------- weekly summarize
def test_summarize_weekly_uses_weekly_prompt_and_schema() -> None:
from datetime import date as _date
from us2kr.llm.prompts import WEEKLY_SYSTEM_PROMPT
provider, client = make_provider([make_completion(json.dumps(GOOD_REPORT, ensure_ascii=False))])
report, model = provider.summarize_weekly(make_payload(), [_date(2026, 3, 9), _date(2026, 3, 10)])
assert report.headline and model == "MiniMax-M3"
call = client.calls[0]
assert call["messages"][0]["content"] == WEEKLY_SYSTEM_PROMPT
assert "## 주간 시세" in call["messages"][1]["content"]
assert call["response_format"]["json_schema"]["name"] == "report"
def test_summarize_weekly_failure_tags_task() -> None:
provider, _ = make_provider([make_status_error(500)] * 3)
from datetime import date as _date
with pytest.raises(LLMError, match=r"\(summarize-weekly\)"):
provider.summarize_weekly(make_payload(), [_date(2026, 3, 9)])
def test_verify_accepts_explicit_data_message() -> None:
provider, client = make_provider([make_completion(json.dumps({"ok": True, "issues": []}))])
provider.verify(make_payload(), Report.model_validate(GOOD_REPORT), "## 주간 데이터\n- 지표")
user = client.calls[0]["messages"][1]["content"]
assert user.startswith("## 주간 데이터")
assert "## 검증 대상 브리핑 초안 (JSON)" in user
def test_revise_accepts_weekly_persona_and_data() -> None:
from us2kr.llm.prompts import WEEKLY_SYSTEM_PROMPT
provider, client = make_provider([make_completion(json.dumps(GOOD_REPORT, ensure_ascii=False))])
provider.revise(
make_payload(),
Report.model_validate(GOOD_REPORT),
[VerificationIssue(claim="c", problem="p", severity="major")],
system_prompt=WEEKLY_SYSTEM_PROMPT,
data_message="## 주간 데이터",
)
messages = client.calls[0]["messages"]
assert messages[0]["content"] == WEEKLY_SYSTEM_PROMPT
assert messages[1]["content"] == "## 주간 데이터"
+380
View File
@@ -0,0 +1,380 @@
"""Weekly recap tests: collaborators are monkeypatched on us2kr.weekly; no network, no real DB."""
from __future__ import annotations
import json
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
import pytest
from _orchestration_stubs import FakeClock, FakeStore
from us2kr import weekly
from us2kr.config import Settings
from us2kr.llm.base import LLMError
from us2kr.llm.prompts import WEEKLY_SYSTEM_PROMPT, build_weekly_user_message
from us2kr.models import Report, Verification
from us2kr.pipeline import SKIP_ALREADY_SENT
from us2kr.render import render_weekly_markdown
from us2kr.weekly import build_weekly_payload, most_recent_monday, run_weekly, week_sessions_for
KST = ZoneInfo("Asia/Seoul")
MONDAY = date(2026, 9, 14)
WEEK = [date(2026, 9, 7) + timedelta(days=i) for i in range(5)] # Mon 9/7 .. Fri 9/11
NOW = datetime(2026, 9, 14, 7, 5, tzinfo=KST)
def make_settings(tmp_path, **overrides) -> Settings:
values = {
"US2KR_DATA_DIR": str(tmp_path / "data"),
"US2KR_REPORTS_DIR": str(tmp_path / "reports"),
"US2KR_LOG_DIR": str(tmp_path / "logs"),
"MINIMAX_API_KEY": "sk-test-not-a-real-key",
}
values.update(overrides)
return Settings(_env_file=None, **values)
def quote_row(session: date, symbol: str, close: float, prev_close: float, **extra) -> dict:
row = {
"session_date": session.isoformat(),
"symbol": symbol,
"close": close,
"prev_close": prev_close,
"change_pct": None,
"change_abs": None,
"day_high": extra.get("day_high"),
"day_low": extra.get("day_low"),
"volume": None,
}
return row
def report_row(krx_date: date, session: date, headline: str | None) -> dict:
result = {"payload": {"session_date": session.isoformat()}}
result["report"] = {"headline": headline} if headline is not None else None
return {
"krx_date": krx_date.isoformat(),
"session_date": session.isoformat(),
"result_json": json.dumps(result, ensure_ascii=False),
}
def sample_report() -> Report:
return Report(
headline="반도체 랠리로 주간 상승",
market_overview="한 주간 지수가 올랐다.",
key_drivers=["엔비디아 실적"],
rates_fx_commodities="금리는 주간 상승.",
korea_implications=["국내 반도체 우호적"],
watchlist_today=["삼성전자"],
risk_flags=["금리 재상승"],
)
def populated_store() -> FakeStore:
store = FakeStore()
# ^GSPC: Mon prev_close 100 -> Fri close 110 = +10% weekly
for i, session in enumerate(WEEK):
store.quote_rows.append(
quote_row(session, "^GSPC", 102.0 + 2 * i, 100.0 + 2 * i, day_high=103.0 + 2 * i, day_low=99.0)
)
# daily headlines Tue..Sat (krx dates for Mon..Fri sessions)
for session in WEEK:
store.report_rows.append(report_row(session + timedelta(days=1), session, f"{session.day}일 헤드라인"))
return store
# ------------------------------------------------------------------- date helpers
def test_most_recent_monday():
assert most_recent_monday(MONDAY) == MONDAY
assert most_recent_monday(date(2026, 9, 18)) == MONDAY # Friday -> that week's Monday
assert most_recent_monday(date(2026, 9, 13)) == date(2026, 9, 7) # Sunday -> previous Monday
def test_week_sessions_excludes_holidays():
clock = FakeClock()
assert week_sessions_for(MONDAY, clock) == WEEK
class HolidayClock(FakeClock):
def is_us_session(self, d: date) -> bool:
return super().is_us_session(d) and d != date(2026, 9, 7) # Labor-day-like Monday
assert week_sessions_for(MONDAY, HolidayClock()) == WEEK[1:]
# ------------------------------------------------------------------- payload builder
def test_build_weekly_payload_aggregates_week(tmp_path):
store = populated_store()
payload, sessions = build_weekly_payload(MONDAY, make_settings(tmp_path), FakeClock(), store)
assert sessions == WEEK
assert payload.krx_date == MONDAY and payload.session_date == WEEK[-1]
assert payload.krx_open_today is True
(q,) = payload.quotes
assert q.symbol == "^GSPC" and q.name == "S&P 500" and q.group == "indices"
assert q.prev_close == 100.0 and q.close == 110.0
assert q.change_pct == pytest.approx(10.0)
assert q.change_abs == pytest.approx(10.0)
assert q.day_high == 111.0 and q.day_low == 99.0
assert payload.history_notes == [f"미 {s.isoformat()}({w}): {s.day}일 헤드라인" for s, w in zip(WEEK, "월화수목금")]
assert payload.data_gaps == []
def test_build_weekly_payload_flags_missing_data(tmp_path):
store = FakeStore()
store.quote_rows.append(quote_row(WEEK[0], "^GSPC", 102.0, 100.0)) # only Monday stored
payload, _ = build_weekly_payload(MONDAY, make_settings(tmp_path), FakeClock(), store)
assert any(g.startswith("weekly quotes: missing sessions") for g in payload.data_gaps)
assert "daily headlines: no stored briefings for last week" in payload.data_gaps
(q,) = payload.quotes
assert q.close == 102.0 and q.prev_close == 100.0 # single-session week still aggregates
def test_build_weekly_payload_degraded_daily_report(tmp_path):
store = populated_store()
store.report_rows[0] = report_row(WEEK[0] + timedelta(days=1), WEEK[0], None) # degraded day
payload, _ = build_weekly_payload(MONDAY, make_settings(tmp_path), FakeClock(), store)
assert payload.history_notes[0].endswith("(요약 없음)")
# ------------------------------------------------------------------- orchestration
class Env:
def __init__(self, monkeypatch, tmp_path, **settings_overrides):
self.settings = make_settings(tmp_path, **settings_overrides)
self.clock = FakeClock()
self.store = populated_store()
self.calls: dict[str, int] = {}
self.delivered: list = []
def count(name):
self.calls[name] = self.calls.get(name, 0) + 1
def summarize_weekly_payload(payload, sessions, provider):
count("llm")
assert sessions == WEEK
return sample_report(), "MiniMax-M3"
def verify_and_revise(payload, report, provider, *, data_message=None, system_prompt=None):
count("verify")
assert system_prompt == WEEKLY_SYSTEM_PROMPT
assert data_message and "## 주간 시세" in data_message
return report, Verification(ok=True)
def deliver(result, settings, store, report_id):
count("deliver")
self.delivered.append(result)
return []
monkeypatch.setattr(weekly, "MarketClock", lambda offset, **_kw: self.clock)
monkeypatch.setattr(weekly, "Store", lambda path: self.store)
monkeypatch.setattr(weekly, "build_provider", lambda settings: object())
monkeypatch.setattr(weekly, "summarize_weekly_payload", summarize_weekly_payload)
monkeypatch.setattr(weekly, "verify_and_revise", verify_and_revise)
monkeypatch.setattr(weekly, "_deliver", deliver)
monkeypatch.setattr(weekly, "_now", lambda: NOW)
self.monkeypatch = monkeypatch
@pytest.fixture
def env(monkeypatch, tmp_path):
return Env(monkeypatch, tmp_path)
def test_run_weekly_happy_path(env):
result = run_weekly(MONDAY, env.settings)
assert result.kind == "weekly" and result.degraded is False and result.error is None
assert result.payload.krx_date == MONDAY
assert result.verification is not None and result.verification.ok is True
assert env.calls == {"llm": 1, "verify": 1, "deliver": 1}
assert env.store.reports == {(MONDAY, "weekly"): 1}
assert "미국 주간 리뷰" in result.markdown and "반도체 랠리로 주간 상승" in result.markdown
assert env.store.closed is True
def test_run_weekly_defaults_to_latest_monday(env):
result = run_weekly(None, env.settings)
assert result.payload.krx_date == MONDAY # NOW is that Monday morning
def test_run_weekly_skips_when_already_sent(env):
env.store.reports[(MONDAY, "weekly")] = 99
result = run_weekly(MONDAY, env.settings)
assert result.error == SKIP_ALREADY_SENT
assert "deliver" not in env.calls
def test_run_weekly_force_resends(env):
env.store.reports[(MONDAY, "weekly")] = 99
result = run_weekly(MONDAY, env.settings, force=True)
assert result.error is None and env.calls["deliver"] == 1
def test_run_weekly_rejects_non_monday(env):
with pytest.raises(ValueError, match="not a Monday"):
run_weekly(date(2026, 9, 15), env.settings)
def test_run_weekly_rejects_future_monday(env):
with pytest.raises(ValueError, match="future"):
run_weekly(date(2026, 9, 21), env.settings)
def test_run_weekly_dry_run_prints_and_saves_nothing(env, capsys):
result = run_weekly(MONDAY, env.settings, dry_run=True)
assert "미국 주간 리뷰" in capsys.readouterr().out
assert env.store.reports == {} and "deliver" not in env.calls
assert result.kind == "weekly"
def test_run_weekly_no_llm_degrades(env):
result = run_weekly(MONDAY, env.settings, no_llm=True)
assert result.degraded is True and result.report is None
assert "llm" not in env.calls and "verify" not in env.calls
assert env.calls["deliver"] == 1
assert "축소 모드" in result.markdown
def test_run_weekly_llm_failure_degrades(env):
def boom(payload, sessions, provider):
raise LLMError("all models down")
env.monkeypatch.setattr(weekly, "summarize_weekly_payload", boom)
result = run_weekly(MONDAY, env.settings)
assert result.degraded is True and result.error == "all models down"
assert env.calls["deliver"] == 1
def test_run_weekly_verify_failure_is_non_fatal(env):
def boom(payload, report, provider, **kwargs):
raise RuntimeError("verifier down")
env.monkeypatch.setattr(weekly, "verify_and_revise", boom)
result = run_weekly(MONDAY, env.settings)
assert result.report is not None and result.verification is None and result.error is None
def test_run_weekly_verification_disabled(monkeypatch, tmp_path):
env = Env(monkeypatch, tmp_path, US2KR_VERIFY_ENABLED="false")
result = run_weekly(MONDAY, env.settings)
assert "verify" not in env.calls and result.verification is None
# ------------------------------------------------------------------- prompt & render
def test_build_weekly_user_message_contents(env, tmp_path):
payload, sessions = build_weekly_payload(MONDAY, env.settings, FakeClock(), populated_store())
message = build_weekly_user_message(payload, sessions)
assert "지난주 미국 거래일: 2026-09-07 ~ 2026-09-11 (5거래일)" in message
assert "## 주간 시세 (변동 = 주간 누적)" in message
assert "^GSPC S&P 500: 110.00 (+10.00%)" in message
assert "## 일별 요약" in message and "미 2026-09-08(화): 8일 헤드라인" in message
def test_weekly_system_prompt_is_static_and_weekly_framed():
import re
assert "주간" in WEEKLY_SYSTEM_PROMPT and "미국 거래일" in WEEKLY_SYSTEM_PROMPT
assert not re.search(r"\d{4}-\d{2}-\d{2}", WEEKLY_SYSTEM_PROMPT)
assert not re.search(r"20\d{2}", WEEKLY_SYSTEM_PROMPT)
def test_render_weekly_markdown(env):
payload, sessions = build_weekly_payload(MONDAY, env.settings, FakeClock(), populated_store())
md = render_weekly_markdown(payload, sample_report(), sessions)
assert md.startswith("# 🇺🇸→🇰🇷 미국 주간 리뷰 — 2026-09-14 (미 2026-09-07 ~ 2026-09-11)")
assert "## 주간 총평\n**반도체 랠리로 주간 상승**" in md
assert "## 이번 주 한국 장 시사점" in md and "## 이번 주 볼 것" in md
assert "📊 주요 지수 — 주간 변동" in md and "| S&P 500 | 110.00 | +10.00% |" in md
assert "## 일별 헤드라인" in md
def test_render_weekly_markdown_degraded(env):
payload, sessions = build_weekly_payload(MONDAY, env.settings, FakeClock(), populated_store())
md = render_weekly_markdown(payload, None, sessions)
assert "🧩 축소 모드(LLM 요약 없음)" in md and "## 주간 총평" not in md
# ------------------------------------------------------------------- daemon wiring
from us2kr.daemon import WEEKLY_JOB_ID, Daemon
class MiniScheduler:
def __init__(self):
self.jobs: dict = {}
def add_job(self, func, trigger=None, **kwargs):
self.jobs[kwargs.get("id")] = {"func": func, "trigger": trigger, **kwargs}
def add_listener(self, callback, mask=0):
pass
def shutdown(self, wait=True):
pass
def make_daemon(tmp_path, *, now=NOW, weekly_fn=..., store=None, **settings_overrides):
calls: list[date] = []
if weekly_fn is ...:
weekly_fn = calls.append
daemon = Daemon(
make_settings(tmp_path, **settings_overrides),
scheduler=MiniScheduler(),
clock=FakeClock(),
store=store or FakeStore(),
run_fn=lambda session: None,
now_fn=lambda: now,
deliverers_fn=lambda s: [],
weekly_fn=weekly_fn,
)
return daemon, calls
def test_daemon_schedules_weekly_cron(tmp_path):
daemon, _ = make_daemon(tmp_path)
daemon.schedule_weekly()
job = daemon.scheduler.jobs[WEEKLY_JOB_ID]
fields = {f.name: str(f) for f in job["trigger"].fields}
assert fields["day_of_week"] == "mon" and fields["hour"] == "7" and fields["minute"] == "0"
def test_daemon_weekly_schedule_respects_disable_and_missing_fn(tmp_path):
daemon, _ = make_daemon(tmp_path, US2KR_WEEKLY_ENABLED="false")
daemon.schedule_weekly()
assert WEEKLY_JOB_ID not in daemon.scheduler.jobs
daemon, _ = make_daemon(tmp_path, weekly_fn=None)
daemon.schedule_weekly()
assert WEEKLY_JOB_ID not in daemon.scheduler.jobs
def test_daemon_weekly_catchup_runs_missed_recap(tmp_path):
daemon, calls = make_daemon(tmp_path, now=datetime(2026, 9, 14, 8, 40, tzinfo=KST))
assert daemon.run_weekly_catchup() is True
assert calls == [MONDAY]
def test_daemon_weekly_catchup_skips_when_sent_or_out_of_window(tmp_path):
store = FakeStore()
store.reports[(MONDAY, "weekly")] = 1
daemon, calls = make_daemon(tmp_path, now=datetime(2026, 9, 14, 8, 40, tzinfo=KST), store=store)
assert daemon.run_weekly_catchup() is False and calls == []
daemon, calls = make_daemon(tmp_path, now=datetime(2026, 9, 15, 8, 40, tzinfo=KST)) # Tuesday
assert daemon.run_weekly_catchup() is False and calls == []
daemon, calls = make_daemon(tmp_path, now=datetime(2026, 9, 14, 6, 30, tzinfo=KST)) # before 07:00
assert daemon.run_weekly_catchup() is False and calls == []
daemon, calls = make_daemon(tmp_path, now=datetime(2026, 9, 14, 10, 30, tzinfo=KST)) # window passed
assert daemon.run_weekly_catchup() is False and calls == []
def test_daemon_weekly_job_failure_notifies_but_survives(tmp_path):
daemon, _ = make_daemon(tmp_path, now=datetime(2026, 9, 14, 7, 0, tzinfo=KST))
errors: list[tuple[str, str]] = []
daemon._notify_error = lambda subject, detail: errors.append((subject, detail))
daemon.weekly_fn = lambda monday: (_ for _ in ()).throw(RuntimeError("boom"))
daemon._weekly_job() # must not raise
assert errors and "주간 리뷰" in errors[0][0]
+8 -1
View File
@@ -17,6 +17,7 @@ from us2kr.deliver import build_deliverers
from us2kr.logging_setup import setup_logging
from us2kr.market_time import MarketClock
from us2kr.pipeline import SKIP_ALREADY_SENT, SKIP_KRX_CLOSED, delivery_failed, run_briefing
from us2kr.weekly import run_weekly
log = logging.getLogger(__name__)
@@ -77,6 +78,11 @@ def build_parser() -> argparse.ArgumentParser:
once.add_argument("--dry-run", action="store_true", help="print markdown, do not save or deliver")
once.add_argument("--force", action="store_true", help="re-run (resend) even if already sent")
once.add_argument("--no-llm", action="store_true", help="skip the LLM (tables only)")
once.add_argument(
"--weekly",
action="store_true",
help="build the Monday weekly recap instead (--date must be a Monday KST; default: latest Monday)",
)
sub.add_parser("daemon", help="run the resident scheduler")
sub.add_parser("next-run", help="show the next scheduled briefing")
@@ -107,7 +113,8 @@ def build_parser() -> argparse.ArgumentParser:
# ---------------------------------------------------------------------------- commands
def cmd_run_once(args: argparse.Namespace, settings: Settings) -> int:
result = run_briefing(args.date, settings, dry_run=args.dry_run, force=args.force, no_llm=args.no_llm)
run = run_weekly if args.weekly else run_briefing
result = run(args.date, settings, dry_run=args.dry_run, force=args.force, no_llm=args.no_llm)
if result.error in (SKIP_ALREADY_SENT, SKIP_KRX_CLOSED):
print(f"skipped: {result.error} (KRX date {result.payload.krx_date})", file=sys.stderr)
return EXIT_SKIPPED
+3
View File
@@ -69,6 +69,9 @@ class Settings(BaseSettings):
krx_extra_holidays: Annotated[list[date], NoDecode] = Field(default_factory=list, alias="US2KR_KRX_EXTRA_HOLIDAYS")
premarket_update_enabled: bool = Field(default=False, alias="US2KR_PREMARKET_UPDATE_ENABLED")
premarket_time_kst: str = Field(default="08:20", alias="US2KR_PREMARKET_TIME_KST")
# Monday-morning weekly recap (no US session closes into Monday KST, so the slot is free).
weekly_enabled: bool = Field(default=True, alias="US2KR_WEEKLY_ENABLED")
weekly_time_kst: str = Field(default="07:00", alias="US2KR_WEEKLY_TIME_KST")
# --- Collection policy ---
news_lookback_hours: int = Field(default=18, alias="US2KR_NEWS_LOOKBACK_HOURS")
+63
View File
@@ -22,12 +22,14 @@ from us2kr.deliver import build_deliverers
from us2kr.market_time import MarketClock
from us2kr.pipeline import run_briefing
from us2kr.store import Store
from us2kr.weekly import run_weekly
log = logging.getLogger(__name__)
BRIEFING_JOB_ID = "briefing"
RECOVERY_JOB_ID = "briefing-recover"
PREMARKET_JOB_ID = "premarket"
WEEKLY_JOB_ID = "weekly"
HEARTBEAT_JOB_ID = "heartbeat"
# None => APScheduler runs the one-shot briefing job whenever it becomes due, even hours late (machine
@@ -64,6 +66,7 @@ class Daemon:
now_fn: Callable[[], datetime] | None = None,
deliverers_fn: Callable[[Settings], list[Any]] | None = None,
premarket_fn: Callable[[], Any] | None = None,
weekly_fn: Callable[[date], Any] | None = None,
) -> None:
self.settings = settings
self.scheduler = scheduler
@@ -74,6 +77,8 @@ class Daemon:
self.deliverers_fn = deliverers_fn or build_deliverers
# Extension point: plug a real pre-market update here (PLAN.md P6).
self.premarket_fn = premarket_fn
# Monday weekly recap; None disables (run_daemon wires us2kr.weekly.run_weekly).
self.weekly_fn = weekly_fn
self.next_run: tuple[date, datetime] | None = None
self.stop_requested = False
# Serialises pipeline runs (scheduled job / catch-up / heartbeat recovery) across executor threads.
@@ -87,8 +92,13 @@ class Daemon:
if self.stop_requested: # signal arrived during catch-up, before the scheduler was running
log.info("shutdown requested during catch-up - not scheduling jobs")
return
self.run_weekly_catchup()
if self.stop_requested:
log.info("shutdown requested during weekly catch-up - not scheduling jobs")
return
self.schedule_next()
self.schedule_premarket()
self.schedule_weekly()
self.schedule_heartbeat()
def install_signal_handlers(self) -> None:
@@ -181,6 +191,46 @@ class Daemon:
)
log.info("premarket update scheduled Mon-Fri %02d:%02d KST", hour, minute)
def schedule_weekly(self) -> None:
if not (self.settings.weekly_enabled and self.weekly_fn):
return
hour, minute = _parse_hhmm(self.settings.weekly_time_kst)
self.scheduler.add_job(
self._weekly_job,
CronTrigger(day_of_week="mon", hour=hour, minute=minute, timezone=ZoneInfo(KST)),
id=WEEKLY_JOB_ID,
name="weekly recap",
replace_existing=True,
misfire_grace_time=3600,
)
log.info("weekly recap scheduled Mon %02d:%02d KST", hour, minute)
def run_weekly_catchup(self) -> bool:
"""On a Monday restart, run a missed weekly recap within the catch-up window. True if run."""
if not (self.settings.weekly_enabled and self.weekly_fn):
return False
now = self.now_fn()
if now.weekday() != 0:
return False
hour, minute = _parse_hhmm(self.settings.weekly_time_kst)
due = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
if not (due <= now < due + timedelta(hours=self.settings.catchup_window_hours)):
return False
if self.store.has_report(now.date(), kind="weekly"):
log.info("weekly catch-up: recap for %s already sent", now.date())
return False
if not self._run_lock.acquire(blocking=False):
return False
try:
log.info("weekly catch-up: running missed recap for %s", now.date())
self.weekly_fn(now.date())
except Exception: # daemon must keep going
log.exception("weekly catch-up for %s failed", now.date())
self._notify_error("US2KR 주간 리뷰 실패", f"monday={now.date()}")
finally:
self._run_lock.release()
return True
def schedule_heartbeat(self) -> None:
self.scheduler.add_job(
self._heartbeat_job,
@@ -224,6 +274,18 @@ class Daemon:
except Exception:
log.exception("premarket update failed")
def _weekly_job(self) -> None:
"""Monday recap. run_weekly itself is idempotent per Monday (reports kind='weekly')."""
monday = self.now_fn().date()
try:
assert self.weekly_fn is not None # scheduled only when set
log.info("scheduled weekly recap starting for %s", monday)
with self._run_lock:
self.weekly_fn(monday)
except Exception as exc: # never let a job kill the scheduler
log.exception("weekly recap for %s failed", monday)
self._notify_error("US2KR 주간 리뷰 실패", f"monday={monday}: {type(exc).__name__}: {exc}"[:500])
def _heartbeat_job(self) -> None:
"""Hourly liveness log; also the backstop that re-arms a missing or stale briefing schedule."""
now = self.now_fn()
@@ -314,6 +376,7 @@ def run_daemon(settings: Settings) -> None:
clock=clock,
store=store,
run_fn=lambda session: run_briefing(session, settings),
weekly_fn=lambda monday: run_weekly(monday, settings),
)
daemon.install_signal_handlers()
log.info(
+3 -2
View File
@@ -54,11 +54,12 @@ def briefing_html(markdown_text: str) -> str:
def post_title_prefix(result: BriefingResult) -> str:
"""Stable per-day prefix - resends update the existing post instead of creating a duplicate."""
return f"미국 마감 브리핑 {result.payload.krx_date.isoformat()}"
label = "미국 주간 리뷰" if result.kind == "weekly" else "미국 마감 브리핑"
return f"{label} {result.payload.krx_date.isoformat()}"
def post_title(result: BriefingResult) -> str:
"""'미국 마감 브리핑 YYYY-MM-DD — 헤드라인' (falls back to the session date without a report)."""
"""'{prefix} — 헤드라인' (falls back to the session date without a report)."""
base = post_title_prefix(result)
headline = (result.report.headline or "").strip() if result.report else ""
return f"{base} — {headline}" if headline else f"{base} (미 {result.payload.session_date.isoformat()} 세션)"
+15 -3
View File
@@ -16,12 +16,24 @@ class LLMProvider(Protocol):
"""Return (validated Report, model id that produced it). Raise LLMError on total failure."""
...
def verify(self, payload: MarketPayload, report: Report) -> tuple[Verification, str]:
"""Fact-check `report` against the payload. Raise LLMError on total failure."""
def summarize_weekly(self, payload: MarketPayload, week_sessions: list) -> tuple[Report, str]:
"""Weekly recap over a payload whose quotes carry week-over-week changes."""
...
def verify(
self, payload: MarketPayload, report: Report, data_message: str | None = None
) -> tuple[Verification, str]:
"""Fact-check `report` against the payload (or `data_message`). Raise LLMError on total failure."""
...
def revise(
self, payload: MarketPayload, report: Report, issues: list[VerificationIssue]
self,
payload: MarketPayload,
report: Report,
issues: list[VerificationIssue],
*,
system_prompt: str | None = None,
data_message: str | None = None,
) -> tuple[Report, str]:
"""Return a corrected Report addressing `issues`. Raise LLMError on total failure."""
...
+26 -7
View File
@@ -17,8 +17,10 @@ from us2kr.llm.prompts import (
REVISE_INSTRUCTION,
SYSTEM_PROMPT,
VERIFY_SYSTEM_PROMPT,
WEEKLY_SYSTEM_PROMPT,
build_user_message,
build_verify_message,
build_verify_message_from,
build_weekly_user_message,
format_issues,
)
from us2kr.models import MarketPayload, Report, Verification, VerificationIssue
@@ -216,21 +218,38 @@ class MiniMaxProvider:
]
return self._run(messages, Report, "report", task="summarize")
def verify(self, payload: MarketPayload, report: Report) -> tuple[Verification, str]:
"""Fact-check `report` against the payload. Raise LLMError on total failure."""
def summarize_weekly(self, payload: MarketPayload, week_sessions: list) -> tuple[Report, str]:
"""Weekly recap: payload.quotes carry week-over-week changes, history_notes the daily headlines."""
messages: list[dict[str, Any]] = [
{"role": "system", "content": WEEKLY_SYSTEM_PROMPT},
{"role": "user", "content": build_weekly_user_message(payload, week_sessions)},
]
return self._run(messages, Report, "report", task="summarize-weekly")
def verify(
self, payload: MarketPayload, report: Report, data_message: str | None = None
) -> tuple[Verification, str]:
"""Fact-check `report` against the payload (or an explicit serialisation of it)."""
source = data_message if data_message is not None else build_user_message(payload)
messages: list[dict[str, Any]] = [
{"role": "system", "content": VERIFY_SYSTEM_PROMPT},
{"role": "user", "content": build_verify_message(payload, report)},
{"role": "user", "content": build_verify_message_from(source, report)},
]
return self._run(messages, Verification, "verification", task="verify")
def revise(
self, payload: MarketPayload, report: Report, issues: list[VerificationIssue]
self,
payload: MarketPayload,
report: Report,
issues: list[VerificationIssue],
*,
system_prompt: str | None = None,
data_message: str | None = None,
) -> tuple[Report, str]:
"""Ask for a corrected Report given verification issues. Raise LLMError on total failure."""
messages: list[dict[str, Any]] = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": build_user_message(payload)},
{"role": "system", "content": system_prompt or SYSTEM_PROMPT},
{"role": "user", "content": data_message or build_user_message(payload)},
{"role": "assistant", "content": report.model_dump_json()},
{"role": "user", "content": REVISE_INSTRUCTION + format_issues(issues)},
]
+63 -6
View File
@@ -169,6 +169,33 @@ def _bullets(items: list[str]) -> list[str]:
return [f"- {item}" for item in items] or ["(없음)"]
WEEKLY_SYSTEM_PROMPT: str = f"""당신은 한국 투자자를 위한 미국 시장 주간 리뷰 작성자입니다.
주말이 지난 월요일 아침, 한국 장이 열리기 전에 읽히는 "지난주 미국 시장 정리"를 작성합니다.
## 작성 규칙
1. 숫자는 사용자 메시지에 제공된 값만 사용합니다. 시세의 변동률은 **한 주 누적 변동**이므로 반드시
"주간" 관점으로 서술하고, 하루 변동처럼 쓰지 않습니다. 제공되지 않은 숫자를 만들거나 추정하지 않습니다.
2. "일별 요약"은 그 주의 일일 브리핑 헤드라인입니다. 사건 서술은 이 범위 안에서만 하고, 헤드라인만으로
확인되지 않는 배경·인과는 "보도에 따르면"으로 표기하거나 생략합니다.
3. 투자 조언(매수/매도/비중 조절 권고)을 하지 않습니다. 사실과 흐름을 정리하는 정보 요약 톤을 유지합니다.
4. 한국 장 관점을 반드시 포함합니다: 반도체(SOX 등)·환율·한국 ADR/EWY의 주간 흐름과, 이번 주 한국 시장에
주는 시사점을 다룹니다.
5. 간결한 한국어 경어체(~습니다)로 작성합니다. 모든 필드는 한국어로만 작성합니다(고유명사 예외).
6. 날짜·요일·시점 표현은 전부 **미국 거래일** 기준입니다. 세션 정보에 주어진 주간 범위(첫 세션~마지막 세션)
밖의 시점을 서술하지 않습니다.
7. 출력 스키마는 일일 브리핑과 같지만 의미를 주간으로 바꿔 씁니다:
headline=주간 총평 한 줄, market_overview=주간 시장 개요, key_drivers=지난주 주요 사건/요인,
rates_fx_commodities=금리·환율·원자재의 주간 흐름, korea_implications=이번 주 한국 장 시사점,
watchlist_today=이번 주 한국 장에서 확인할 것, risk_flags=이번 주 리스크.
8. 출력은 지정된 JSON 스키마만 반환합니다. 배열 필드는 반드시 JSON 배열(각 항목 한 문장)로 출력합니다.
## 입력 데이터 용어집
시세는 아래 그룹으로 나뉘어 제공되며, 각 줄의 형식은 `심볼 이름: 주말 종가 (주간 변동)` 입니다.
국채 금리의 변동은 주간 수익률 변화폭(%p)입니다. 값이 없는 항목은 "자료 없음"으로 표시됩니다.
{_GROUP_GLOSSARY}
"""
VERIFY_SYSTEM_PROMPT: str = """당신은 미국 시장 브리핑의 팩트체커입니다. 브리핑 초안이 함께 제공된 원본 데이터와
모순되지 않는지만 검사합니다. 문체·구성·의견은 평가하지 않습니다.
@@ -195,14 +222,14 @@ REVISE_INSTRUCTION: str = (
)
def build_verify_message_from(data_message: str, report: Report) -> str:
"""Fact-check user message from an explicit data serialisation plus the draft report."""
return data_message + "\n## 검증 대상 브리핑 초안 (JSON)\n" + report.model_dump_json(indent=2) + "\n"
def build_verify_message(payload: MarketPayload, report: Report) -> str:
"""User message for the fact-check pass: the payload serialisation plus the draft report."""
return (
build_user_message(payload)
+ "\n## 검증 대상 브리핑 초안 (JSON)\n"
+ report.model_dump_json(indent=2)
+ "\n"
)
return build_verify_message_from(build_user_message(payload), report)
def format_issues(issues: list) -> str:
@@ -213,6 +240,36 @@ def format_issues(issues: list) -> str:
return "\n".join(lines) or "(없음)"
def build_weekly_user_message(payload: MarketPayload, week_sessions: list) -> str:
"""Serialise a weekly payload (quotes = week-over-week changes, notes = daily headlines)."""
out: list[str] = []
first = week_sessions[0].isoformat() if week_sessions else "?"
last = week_sessions[-1].isoformat() if week_sessions else "?"
out.append("## 세션 정보 (주간)")
out.append(f"- 지난주 미국 거래일: {first} ~ {last} ({len(week_sessions)}거래일)")
out.append(f"- 한국 거래일(발행일): {payload.krx_date.isoformat()} (월요일)")
out.append(f"- 오늘 한국 장: {'개장' if payload.krx_open_today else '휴장'}")
out.append("")
out.append("## 주간 시세 (변동 = 주간 누적)")
by_group = payload.quotes_by_group()
if not by_group:
out.append("(시세 자료 없음)")
for group in _group_order(list(by_group)):
out.append(f"### {GROUP_TITLES_KO.get(group, group)}")
out.extend(_fmt_quote(q) for q in by_group[group])
out.append("")
out.append("## 일별 요약 (그 주의 일일 브리핑 헤드라인)")
out.extend(_bullets(payload.history_notes))
out.append("")
out.append("## 수집 누락")
out.extend(_bullets(payload.data_gaps))
return "\n".join(out) + "\n"
def build_user_message(payload: MarketPayload) -> str:
"""Serialise the payload into a deterministic Korean text block for the user message."""
out: list[str] = []
+3
View File
@@ -118,3 +118,6 @@ class BriefingResult(BaseModel):
degraded: bool = False # True when LLM failed and only the numeric table was produced
error: str | None = None
verification: Verification | None = None # None: not run (disabled / degraded / verifier failed)
# "briefing": daily post-close briefing. "weekly": Monday-morning recap, where payload.quotes carry
# week-over-week changes, payload.session_date is the week's last US session and krx_date the Monday.
kind: Literal["briefing", "weekly"] = "briefing"
+39
View File
@@ -221,6 +221,45 @@ def render_markdown(payload: MarketPayload, report: Report | None, model_used: s
return "\n".join(lines).rstrip() + "\n"
# ------------------------------------------------------------------ weekly recap
def _weekly_report_sections(report: Report) -> list[str]:
out: list[str] = []
if _text(report.headline):
out += _section("주간 총평", [f"**{_text(report.headline)}**"])
if _text(report.market_overview):
out += _section("주간 개요", [_text(report.market_overview)])
out += _section("지난주 주요 사건", _bullets(report.key_drivers))
if _text(report.rates_fx_commodities):
out += _section("금리·환율·원자재", [_text(report.rates_fx_commodities)])
out += _section("이번 주 한국 장 시사점", _bullets(report.korea_implications))
out += _section("이번 주 볼 것", _bullets(report.watchlist_today))
out += _section("리스크", _bullets(report.risk_flags))
return out
def render_weekly_markdown(payload: MarketPayload, report: Report | None, week_sessions: list) -> str:
"""Render the Monday weekly recap. `payload.quotes` carry week-over-week changes."""
first = week_sessions[0].isoformat() if week_sessions else "?"
last = week_sessions[-1].isoformat() if week_sessions else "?"
lines = [f"# 🇺🇸→🇰🇷 미국 주간 리뷰 — {payload.krx_date.isoformat()} (미 {first} ~ {last})"]
gen_kst = _kst(payload.generated_at).strftime("%Y-%m-%d %H:%M")
lines.append(f"지난주 미국 거래일 {len(week_sessions)}일 · 생성: {gen_kst} KST")
if not payload.krx_open_today:
lines.append("⚠️ 오늘 한국 휴장")
if report is None:
lines.append("🧩 축소 모드(LLM 요약 없음)")
lines.append("")
if report is not None:
lines += _weekly_report_sections(report)
for g, title, qs in _ordered_groups(payload):
lines += _section(f"📊 {title} — 주간 변동", _quote_table(g, qs))
lines += _section("일별 헤드라인", _bullets(payload.history_notes))
lines += _section("수집 누락", _bullets(payload.data_gaps))
return "\n".join(lines).rstrip() + "\n"
# ------------------------------------------------------------------ Telegram HTML
_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)\s]+)\)")
+22
View File
@@ -146,6 +146,17 @@ class Store:
)
return list(cur.fetchall())
def load_quotes_between(self, start: date, end: date) -> list[sqlite3.Row]:
"""Quote rows for every stored session in [start, end], ordered by (symbol, session_date)."""
cur = self._conn.execute(
"""
SELECT * FROM quotes WHERE session_date >= ? AND session_date <= ?
ORDER BY symbol, session_date
""",
(start.isoformat(), end.isoformat()),
)
return list(cur.fetchall())
def _history(self, symbol: str, before: date, n: int) -> list[_Row]:
"""Up to ``n`` stored rows for ``symbol`` strictly before ``before``, oldest first."""
cur = self._conn.execute(
@@ -250,6 +261,17 @@ class Store:
log.info("saved report id=%d krx_date=%s kind=%s degraded=%s", report_id, krx, kind, result.degraded)
return report_id
def reports_between(self, start: date, end: date, kind: str = "briefing") -> list[sqlite3.Row]:
"""(krx_date, session_date, result_json) rows with start <= krx_date <= end, oldest first."""
cur = self._conn.execute(
"""
SELECT krx_date, session_date, result_json FROM reports
WHERE kind = ? AND krx_date >= ? AND krx_date <= ? ORDER BY krx_date
""",
(kind, start.isoformat(), end.isoformat()),
)
return list(cur.fetchall())
def mark_sent(self, report_id: int, channel: str, receipt: str) -> None:
with self._conn:
self._conn.execute(
+35 -10
View File
@@ -17,16 +17,32 @@ def build_provider(settings: Settings) -> LLMProvider:
return MiniMaxProvider(settings)
def summarize_payload(payload: MarketPayload, provider: LLMProvider) -> tuple[Report, str]:
"""Summarise via the provider and guarantee `report.data_gaps` includes every payload gap."""
report, model_used = provider.summarize(payload)
def _merge_gaps(payload: MarketPayload, report: Report) -> Report:
"""Guarantee `report.data_gaps` includes every payload gap (deduplicated, payload first)."""
merged: list[str] = []
for gap in [*payload.data_gaps, *report.data_gaps]:
if gap and gap not in merged:
merged.append(gap)
if merged != list(report.data_gaps):
report = report.model_copy(update={"data_gaps": merged})
log.info("summary produced by %s (%d data gaps)", model_used, len(merged))
return report
def summarize_payload(payload: MarketPayload, provider: LLMProvider) -> tuple[Report, str]:
"""Summarise via the provider and guarantee `report.data_gaps` includes every payload gap."""
report, model_used = provider.summarize(payload)
report = _merge_gaps(payload, report)
log.info("summary produced by %s (%d data gaps)", model_used, len(report.data_gaps))
return report, model_used
def summarize_weekly_payload(
payload: MarketPayload, week_sessions: list, provider: LLMProvider
) -> tuple[Report, str]:
"""Weekly-recap variant of summarize_payload."""
report, model_used = provider.summarize_weekly(payload, week_sessions)
report = _merge_gaps(payload, report)
log.info("weekly summary produced by %s (%d data gaps)", model_used, len(report.data_gaps))
return report, model_used
@@ -37,14 +53,21 @@ def _consistent(verification: Verification) -> Verification:
def verify_and_revise(
payload: MarketPayload, report: Report, provider: LLMProvider
payload: MarketPayload,
report: Report,
provider: LLMProvider,
*,
data_message: str | None = None,
system_prompt: str | None = None,
) -> tuple[Report, Verification]:
"""Fact-check the report; on major issues ask for one revision and re-check it.
Returns (possibly revised report, verification of the returned report). Raises LLMError only when
the *initial* verification cannot be produced; a failed revision keeps the original report.
`data_message`/`system_prompt` override the default daily-briefing serialisation and author persona
(used by the weekly recap). Returns (possibly revised report, verification of the returned report).
Raises LLMError only when the *initial* verification cannot be produced; a failed revision keeps
the original report.
"""
raw_verification, model = provider.verify(payload, report)
raw_verification, model = provider.verify(payload, report, data_message)
verification = _consistent(raw_verification)
log.info(
"verification by %s: ok=%s issues=%d (major=%s)",
@@ -59,9 +82,11 @@ def verify_and_revise(
for issue in verification.issues:
log.warning("verification issue [%s] %s -> %s", issue.severity, issue.claim, issue.problem)
try:
revised, model = provider.revise(payload, report, verification.issues)
revised, model = provider.revise(
payload, report, verification.issues, system_prompt=system_prompt, data_message=data_message
)
log.info("report revised by %s after verification issues", model)
re_verification, model = provider.verify(payload, revised)
re_verification, model = provider.verify(payload, revised, data_message)
re_verification = _consistent(re_verification)
log.info(
"re-verification by %s: ok=%s issues=%d", model, re_verification.ok, len(re_verification.issues)
+242
View File
@@ -0,0 +1,242 @@
"""Monday-morning weekly recap: aggregate the stored week into one briefing.
No US session closes into Monday KST (the US Sunday never trades), so the Monday slot carries a
recap of the previous week's US sessions instead. Everything is built from what the daily pipeline
already stored - the `quotes` table for week-over-week changes and the daily reports for the
narrative - so no collector runs.
The result is a regular ``BriefingResult`` with ``kind="weekly"`` whose payload reuses
``MarketPayload``: ``quotes`` carry weekly changes (close = Friday close, prev_close = the previous
week's close), ``history_notes`` carry the daily briefing headlines, ``session_date`` is the week's
last US session and ``krx_date`` the Monday. That keeps the store, every deliverer and the
dashboard working unchanged.
"""
from __future__ import annotations
import json
import logging
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
from us2kr.config import KST, Settings
from us2kr.llm.base import LLMError
from us2kr.llm.prompts import WEEKLY_SYSTEM_PROMPT, build_weekly_user_message
from us2kr.market_time import MarketClock
from us2kr.models import BriefingResult, MarketPayload, Quote, Report, Verification
from us2kr.pipeline import SKIP_ALREADY_SENT, _deliver, _skip_result
from us2kr.render import render_weekly_markdown
from us2kr.store import Store
from us2kr.summarize import build_provider, summarize_weekly_payload, verify_and_revise
from us2kr.symbols import display_name, group_of
log = logging.getLogger(__name__)
WEEKDAY_KO = ("월", "화", "수", "목", "금", "토", "일")
def _now() -> datetime:
return datetime.now(ZoneInfo(KST))
def most_recent_monday(today: date) -> date:
"""Today if it is a Monday, otherwise the latest past Monday."""
return today - timedelta(days=today.weekday())
def week_sessions_for(monday: date, clock: MarketClock) -> list[date]:
"""The previous week's US sessions (Mon..Fri before `monday`), holidays excluded."""
return [
d
for d in (monday - timedelta(days=offset) for offset in range(7, 2, -1))
if clock.is_us_session(d)
]
def _weekly_quote(symbol: str, rows: list) -> Quote:
"""Collapse one symbol's session rows (oldest first) into a week-over-week Quote."""
first, last = rows[0], rows[-1]
base = first["prev_close"] if first["prev_close"] is not None else first["close"]
end = last["close"]
change_pct = None
change_abs = None
if end is not None and base:
change_pct = (end / base - 1.0) * 100.0
change_abs = end - base
highs = [r["day_high"] for r in rows if r["day_high"] is not None]
lows = [r["day_low"] for r in rows if r["day_low"] is not None]
return Quote(
symbol=symbol,
name=display_name(symbol),
group=group_of(symbol) or "etc",
close=end,
prev_close=base,
change_pct=change_pct,
change_abs=change_abs,
day_high=max(highs) if highs else None,
day_low=min(lows) if lows else None,
source="store",
)
def _daily_headline_notes(store: Store, monday: date, gaps: list[str]) -> list[str]:
"""'미 {세션}({요일}): {헤드라인}' lines from the week's stored daily briefings."""
notes: list[str] = []
for row in store.reports_between(monday - timedelta(days=6), monday - timedelta(days=1)):
try:
data = json.loads(row["result_json"] or "{}")
except json.JSONDecodeError:
gaps.append(f"daily report {row['krx_date']}: unreadable")
continue
session = row["session_date"] or data.get("payload", {}).get("session_date") or "?"
try:
weekday = WEEKDAY_KO[date.fromisoformat(session).weekday()]
label = f"미 {session}({weekday})"
except ValueError:
label = f"미 {session}"
report = data.get("report") or {}
headline = (report.get("headline") or "").strip()
notes.append(f"{label}: {headline}" if headline else f"{label}: (요약 없음)")
return notes
def build_weekly_payload(
monday: date, settings: Settings, clock: MarketClock, store: Store
) -> tuple[MarketPayload, list[date]]:
"""Assemble the weekly MarketPayload for the Monday `monday` from stored data only."""
sessions = week_sessions_for(monday, clock)
gaps: list[str] = []
quotes: list[Quote] = []
covered: set[str] = set()
if sessions:
by_symbol: dict[str, list] = {}
for row in store.load_quotes_between(sessions[0], sessions[-1]):
by_symbol.setdefault(row["symbol"], []).append(row)
covered.add(row["session_date"])
quotes = [_weekly_quote(symbol, rows) for symbol, rows in sorted(by_symbol.items())]
missing = [s for s in sessions if s.isoformat() not in covered]
if not quotes:
gaps.append("weekly quotes: no stored sessions for last week")
elif missing:
gaps.append("weekly quotes: missing sessions " + ", ".join(s.isoformat() for s in missing))
history_notes = _daily_headline_notes(store, monday, gaps)
if not history_notes:
gaps.append("daily headlines: no stored briefings for last week")
last_session = sessions[-1] if sessions else monday - timedelta(days=3)
payload = MarketPayload(
session_date=last_session,
us_close_time=clock.us_session_close(last_session),
generated_at=_now(),
krx_date=monday,
krx_open_today=clock.is_krx_session(monday),
quotes=quotes,
history_notes=history_notes,
data_gaps=gaps,
)
log.info(
"weekly payload for %s: %d sessions, %d symbols, %d headlines, %d gaps",
monday,
len(sessions),
len(quotes),
len(history_notes),
len(gaps),
)
return payload, sessions
def _summarize_weekly(
payload: MarketPayload, sessions: list[date], settings: Settings
) -> tuple[Report | None, str | None, str | None]:
"""Return (report, model_used, error); any failure degrades instead of aborting (pipeline policy)."""
try:
provider = build_provider(settings)
report, model_used = summarize_weekly_payload(payload, sessions, provider)
return report, model_used, None
except LLMError as exc:
log.error("weekly LLM summarisation failed, degrading: %s", exc)
return None, None, str(exc)
except Exception as exc:
log.exception("unexpected weekly LLM failure, degrading")
return None, None, f"{type(exc).__name__}: {exc}"
def _verify_weekly(
payload: MarketPayload, sessions: list[date], report: Report, settings: Settings
) -> tuple[Report, Verification | None]:
try:
provider = build_provider(settings)
return verify_and_revise(
payload,
report,
provider,
data_message=build_weekly_user_message(payload, sessions),
system_prompt=WEEKLY_SYSTEM_PROMPT,
)
except Exception as exc: # noqa: BLE001 - verification must never block the recap
log.warning("weekly verification failed, delivering unverified: %s: %s", type(exc).__name__, exc)
return report, None
def run_weekly(
monday: date | None,
settings: Settings,
*,
dry_run: bool = False,
force: bool = False,
no_llm: bool = False,
) -> BriefingResult:
"""Produce and deliver the weekly recap for ``monday`` (default: the most recent Monday, KST)."""
clock = MarketClock(settings.run_offset_minutes, extra_krx_holidays=settings.krx_extra_holidays)
store = Store(settings.db_path)
try:
now = _now()
if monday is None:
monday = most_recent_monday(now.date())
if monday.weekday() != 0:
raise ValueError(f"{monday} is not a Monday - the weekly recap is keyed by Mondays (KST)")
if monday > now.date():
raise ValueError(f"{monday} is in the future")
payload, sessions = build_weekly_payload(monday, settings, clock, store)
if not dry_run and not force and store.has_report(monday, kind="weekly"):
log.info("weekly recap for %s already sent - skipping (use --force to resend)", monday)
return _skip_result(payload, SKIP_ALREADY_SENT)
report: Report | None = None
model_used: str | None = None
error: str | None = None
verification: Verification | None = None
degraded = False
if no_llm:
degraded = True
log.info("--no-llm: skipping weekly summarisation")
else:
report, model_used, error = _summarize_weekly(payload, sessions, settings)
degraded = report is None
if report is not None and settings.verify_enabled:
report, verification = _verify_weekly(payload, sessions, report, settings)
markdown = render_weekly_markdown(payload, report, sessions)
result = BriefingResult(
payload=payload,
report=report,
markdown=markdown,
model_used=model_used,
degraded=degraded,
error=error,
verification=verification,
kind="weekly",
)
if dry_run:
print(markdown)
return result
report_id = store.save_report(result, kind="weekly")
log.info("weekly report %d saved for %s (degraded=%s)", report_id, monday, degraded)
_deliver(result, settings, store, report_id)
return result
finally:
store.close()