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) # exchange_calendars에 없는 한국 임시휴장일 (CSV, 예: 2026-06-03)
US2KR_KRX_EXTRA_HOLIDAYS= US2KR_KRX_EXTRA_HOLIDAYS=
US2KR_PREMARKET_UPDATE_ENABLED=false US2KR_PREMARKET_UPDATE_ENABLED=false
# 월요일 아침 주간 리뷰 (지난주 시세·헤드라인 요약)
US2KR_WEEKLY_ENABLED=true
US2KR_WEEKLY_TIME_KST=07:00
US2KR_LOG_LEVEL=INFO US2KR_LOG_LEVEL=INFO
+3
View File
@@ -17,6 +17,9 @@
| **브리핑 발송** (마감 +75분, 기본) | 17:15 | **06:15 / 07:15** | | **브리핑 발송** (마감 +75분, 기본) | 17:15 | **06:15 / 07:15** |
| 한국 개장 | — | 09:00 | | 한국 개장 | — | 09:00 |
- **월요일 아침(기본 07:00 KST)에는 주간 리뷰 발행** — 월요일 새벽엔 마감되는 미국 세션이 없으므로,
저장된 지난주 시세(주간 변동)와 일일 브리핑 헤드라인으로 주간 요약을 만들어 같은 채널로 발행
(`US2KR_WEEKLY_ENABLED` / `US2KR_WEEKLY_TIME_KST`, 수동: `run-once --weekly [--date 월요일]`)
- 시각은 하드코딩이 아니라 `exchange_calendars`(XNYS/XKRX)로 계산 → 미국 휴장·조기폐장·서머타임 자동 대응 - 시각은 하드코딩이 아니라 `exchange_calendars`(XNYS/XKRX)로 계산 → 미국 휴장·조기폐장·서머타임 자동 대응
- 미국 휴장일은 스킵, 한국 휴장일은 리포트에 "오늘 한국 휴장" 표기. `US2KR_SKIP_WHEN_KRX_CLOSED=true`면 - 미국 휴장일은 스킵, 한국 휴장일은 리포트에 "오늘 한국 휴장" 표기. `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.setAttribute("aria-current", String(s.krx_date === selected));
button.append(el("span", "briefing-date", `${s.krx_date} (KRX)`)); button.append(el("span", "briefing-date", `${s.krx_date} (KRX)`));
const line = el("span", "briefing-line"); 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}`)); if (s.data_gaps.length > 0) line.append(el("span", "badge badge-warn", `결측 ${s.data_gaps.length}`));
button.append(line); button.append(line);
if (s.headline) button.append(el("span", "briefing-line", s.headline)); if (s.headline) button.append(el("span", "briefing-line", s.headline));
@@ -179,8 +183,11 @@ function renderList(
function metaChips(result: BriefingResult): HTMLElement[] { function metaChips(result: BriefingResult): HTMLElement[] {
const p = result.payload; const p = result.payload;
const close = new Date(p.us_close_time); const close = new Date(p.us_close_time);
const weekly = result.kind === "weekly";
const chips: HTMLElement[] = [ 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( el(
"span", "span",
undefined, undefined,
+1
View File
@@ -67,6 +67,7 @@ async function listDates(): Promise<string[]> {
function summarize(krxDate: string, r: BriefingResult): BriefingSummary { function summarize(krxDate: string, r: BriefingResult): BriefingSummary {
return { return {
krx_date: krxDate, krx_date: krxDate,
kind: r.kind ?? "briefing",
session_date: r.payload.session_date, session_date: r.payload.session_date,
generated_at: r.payload.generated_at, generated_at: r.payload.generated_at,
krx_open_today: r.payload.krx_open_today, krx_open_today: r.payload.krx_open_today,
+3
View File
@@ -93,12 +93,15 @@ export interface BriefingResult {
error: string | null; error: string | null;
/** Absent/null: verification not run (disabled, degraded, verifier failed, or pre-feature archive). */ /** Absent/null: verification not run (disabled, degraded, verifier failed, or pre-feature archive). */
verification?: Verification | null; verification?: Verification | null;
/** "weekly": Monday recap (quotes carry week-over-week changes). Absent on older archives. */
kind?: "briefing" | "weekly";
} }
// ---------------------------------------------------------------- API shapes // ---------------------------------------------------------------- API shapes
export interface BriefingSummary { export interface BriefingSummary {
krx_date: string; krx_date: string;
kind: "briefing" | "weekly";
session_date: string; session_date: string;
generated_at: string; generated_at: string;
krx_open_today: boolean; 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-ok { background: var(--badge-ok-bg); color: var(--badge-ok-ink); }
.badge-warn { background: var(--badge-warn-bg); color: var(--badge-warn-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-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 */
.summary-card { .summary-card {
+11
View File
@@ -152,6 +152,9 @@ class FakeStore:
self.fail_history = fail_history self.fail_history = fail_history
self.closed = False self.closed = False
self._next_id = 1 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: def save_quotes(self, session_date: date, quotes: list) -> None:
self.quotes[session_date] = list(quotes) 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: def save_raw(self, session_date: date, name: str, payload_json: str) -> None:
self.raw[(session_date, name)] = payload_json 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: def close(self) -> None:
self.closed = True self.closed = True
+8
View File
@@ -255,3 +255,11 @@ def test_build_deliverers_includes_blogger_when_configured(tmp_path):
blogger = deliverers[-1] blogger = deliverers[-1]
assert blogger.labels == ["미국증시", "시황"] # type: ignore[attr-defined] assert blogger.labels == ["미국증시", "시황"] # type: ignore[attr-defined]
assert blogger.blog_id == "1" # 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]: def summarize(self, payload: MarketPayload) -> tuple[Report, str]:
raise AssertionError("summarize must not be called") 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.verify_calls += 1
self.last_data_message = data_message
return self._verdicts.pop(0), "stub" 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.revise_calls += 1
self.last_system_prompt = system_prompt
if self._revise_fails: if self._revise_fails:
raise LLMError("revise down") raise LLMError("revise down")
assert self._revised is not None 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) out, verification = verify_and_revise(make_payload(), report, provider)
assert out is report and verification is verdict assert out is report and verification is verdict
assert provider.verify_calls == 1 and provider.revise_calls == 1 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.logging_setup import setup_logging
from us2kr.market_time import MarketClock from us2kr.market_time import MarketClock
from us2kr.pipeline import SKIP_ALREADY_SENT, SKIP_KRX_CLOSED, delivery_failed, run_briefing from us2kr.pipeline import SKIP_ALREADY_SENT, SKIP_KRX_CLOSED, delivery_failed, run_briefing
from us2kr.weekly import run_weekly
log = logging.getLogger(__name__) 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("--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("--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("--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("daemon", help="run the resident scheduler")
sub.add_parser("next-run", help="show the next scheduled briefing") sub.add_parser("next-run", help="show the next scheduled briefing")
@@ -107,7 +113,8 @@ def build_parser() -> argparse.ArgumentParser:
# ---------------------------------------------------------------------------- commands # ---------------------------------------------------------------------------- commands
def cmd_run_once(args: argparse.Namespace, settings: Settings) -> int: 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): if result.error in (SKIP_ALREADY_SENT, SKIP_KRX_CLOSED):
print(f"skipped: {result.error} (KRX date {result.payload.krx_date})", file=sys.stderr) print(f"skipped: {result.error} (KRX date {result.payload.krx_date})", file=sys.stderr)
return EXIT_SKIPPED 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") 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_update_enabled: bool = Field(default=False, alias="US2KR_PREMARKET_UPDATE_ENABLED")
premarket_time_kst: str = Field(default="08:20", alias="US2KR_PREMARKET_TIME_KST") 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 --- # --- Collection policy ---
news_lookback_hours: int = Field(default=18, alias="US2KR_NEWS_LOOKBACK_HOURS") 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.market_time import MarketClock
from us2kr.pipeline import run_briefing from us2kr.pipeline import run_briefing
from us2kr.store import Store from us2kr.store import Store
from us2kr.weekly import run_weekly
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
BRIEFING_JOB_ID = "briefing" BRIEFING_JOB_ID = "briefing"
RECOVERY_JOB_ID = "briefing-recover" RECOVERY_JOB_ID = "briefing-recover"
PREMARKET_JOB_ID = "premarket" PREMARKET_JOB_ID = "premarket"
WEEKLY_JOB_ID = "weekly"
HEARTBEAT_JOB_ID = "heartbeat" HEARTBEAT_JOB_ID = "heartbeat"
# None => APScheduler runs the one-shot briefing job whenever it becomes due, even hours late (machine # 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, now_fn: Callable[[], datetime] | None = None,
deliverers_fn: Callable[[Settings], list[Any]] | None = None, deliverers_fn: Callable[[Settings], list[Any]] | None = None,
premarket_fn: Callable[[], Any] | None = None, premarket_fn: Callable[[], Any] | None = None,
weekly_fn: Callable[[date], Any] | None = None,
) -> None: ) -> None:
self.settings = settings self.settings = settings
self.scheduler = scheduler self.scheduler = scheduler
@@ -74,6 +77,8 @@ class Daemon:
self.deliverers_fn = deliverers_fn or build_deliverers self.deliverers_fn = deliverers_fn or build_deliverers
# Extension point: plug a real pre-market update here (PLAN.md P6). # Extension point: plug a real pre-market update here (PLAN.md P6).
self.premarket_fn = premarket_fn 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.next_run: tuple[date, datetime] | None = None
self.stop_requested = False self.stop_requested = False
# Serialises pipeline runs (scheduled job / catch-up / heartbeat recovery) across executor threads. # 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 if self.stop_requested: # signal arrived during catch-up, before the scheduler was running
log.info("shutdown requested during catch-up - not scheduling jobs") log.info("shutdown requested during catch-up - not scheduling jobs")
return 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_next()
self.schedule_premarket() self.schedule_premarket()
self.schedule_weekly()
self.schedule_heartbeat() self.schedule_heartbeat()
def install_signal_handlers(self) -> None: def install_signal_handlers(self) -> None:
@@ -181,6 +191,46 @@ class Daemon:
) )
log.info("premarket update scheduled Mon-Fri %02d:%02d KST", hour, minute) 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: def schedule_heartbeat(self) -> None:
self.scheduler.add_job( self.scheduler.add_job(
self._heartbeat_job, self._heartbeat_job,
@@ -224,6 +274,18 @@ class Daemon:
except Exception: except Exception:
log.exception("premarket update failed") 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: def _heartbeat_job(self) -> None:
"""Hourly liveness log; also the backstop that re-arms a missing or stale briefing schedule.""" """Hourly liveness log; also the backstop that re-arms a missing or stale briefing schedule."""
now = self.now_fn() now = self.now_fn()
@@ -314,6 +376,7 @@ def run_daemon(settings: Settings) -> None:
clock=clock, clock=clock,
store=store, store=store,
run_fn=lambda session: run_briefing(session, settings), run_fn=lambda session: run_briefing(session, settings),
weekly_fn=lambda monday: run_weekly(monday, settings),
) )
daemon.install_signal_handlers() daemon.install_signal_handlers()
log.info( log.info(
+3 -2
View File
@@ -54,11 +54,12 @@ def briefing_html(markdown_text: str) -> str:
def post_title_prefix(result: BriefingResult) -> str: def post_title_prefix(result: BriefingResult) -> str:
"""Stable per-day prefix - resends update the existing post instead of creating a duplicate.""" """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: 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) base = post_title_prefix(result)
headline = (result.report.headline or "").strip() if result.report else "" headline = (result.report.headline or "").strip() if result.report else ""
return f"{base} — {headline}" if headline else f"{base} (미 {result.payload.session_date.isoformat()} 세션)" 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.""" """Return (validated Report, model id that produced it). Raise LLMError on total failure."""
... ...
def verify(self, payload: MarketPayload, report: Report) -> tuple[Verification, str]: def summarize_weekly(self, payload: MarketPayload, week_sessions: list) -> tuple[Report, str]:
"""Fact-check `report` against the payload. Raise LLMError on total failure.""" """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( 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]: ) -> tuple[Report, str]:
"""Return a corrected Report addressing `issues`. Raise LLMError on total failure.""" """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, REVISE_INSTRUCTION,
SYSTEM_PROMPT, SYSTEM_PROMPT,
VERIFY_SYSTEM_PROMPT, VERIFY_SYSTEM_PROMPT,
WEEKLY_SYSTEM_PROMPT,
build_user_message, build_user_message,
build_verify_message, build_verify_message_from,
build_weekly_user_message,
format_issues, format_issues,
) )
from us2kr.models import MarketPayload, Report, Verification, VerificationIssue from us2kr.models import MarketPayload, Report, Verification, VerificationIssue
@@ -216,21 +218,38 @@ class MiniMaxProvider:
] ]
return self._run(messages, Report, "report", task="summarize") return self._run(messages, Report, "report", task="summarize")
def verify(self, payload: MarketPayload, report: Report) -> tuple[Verification, str]: def summarize_weekly(self, payload: MarketPayload, week_sessions: list) -> tuple[Report, str]:
"""Fact-check `report` against the payload. Raise LLMError on total failure.""" """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]] = [ messages: list[dict[str, Any]] = [
{"role": "system", "content": VERIFY_SYSTEM_PROMPT}, {"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") return self._run(messages, Verification, "verification", task="verify")
def revise( 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]: ) -> tuple[Report, str]:
"""Ask for a corrected Report given verification issues. Raise LLMError on total failure.""" """Ask for a corrected Report given verification issues. Raise LLMError on total failure."""
messages: list[dict[str, Any]] = [ messages: list[dict[str, Any]] = [
{"role": "system", "content": SYSTEM_PROMPT}, {"role": "system", "content": system_prompt or SYSTEM_PROMPT},
{"role": "user", "content": build_user_message(payload)}, {"role": "user", "content": data_message or build_user_message(payload)},
{"role": "assistant", "content": report.model_dump_json()}, {"role": "assistant", "content": report.model_dump_json()},
{"role": "user", "content": REVISE_INSTRUCTION + format_issues(issues)}, {"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 ["(없음)"] 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 = """당신은 미국 시장 브리핑의 팩트체커입니다. 브리핑 초안이 함께 제공된 원본 데이터와 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: def build_verify_message(payload: MarketPayload, report: Report) -> str:
"""User message for the fact-check pass: the payload serialisation plus the draft report.""" """User message for the fact-check pass: the payload serialisation plus the draft report."""
return ( return build_verify_message_from(build_user_message(payload), report)
build_user_message(payload)
+ "\n## 검증 대상 브리핑 초안 (JSON)\n"
+ report.model_dump_json(indent=2)
+ "\n"
)
def format_issues(issues: list) -> str: def format_issues(issues: list) -> str:
@@ -213,6 +240,36 @@ def format_issues(issues: list) -> str:
return "\n".join(lines) or "(없음)" 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: def build_user_message(payload: MarketPayload) -> str:
"""Serialise the payload into a deterministic Korean text block for the user message.""" """Serialise the payload into a deterministic Korean text block for the user message."""
out: list[str] = [] 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 degraded: bool = False # True when LLM failed and only the numeric table was produced
error: str | None = None error: str | None = None
verification: Verification | None = None # None: not run (disabled / degraded / verifier failed) 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" 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 # ------------------------------------------------------------------ Telegram HTML
_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)\s]+)\)") _LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)\s]+)\)")
+22
View File
@@ -146,6 +146,17 @@ class Store:
) )
return list(cur.fetchall()) 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]: def _history(self, symbol: str, before: date, n: int) -> list[_Row]:
"""Up to ``n`` stored rows for ``symbol`` strictly before ``before``, oldest first.""" """Up to ``n`` stored rows for ``symbol`` strictly before ``before``, oldest first."""
cur = self._conn.execute( 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) log.info("saved report id=%d krx_date=%s kind=%s degraded=%s", report_id, krx, kind, result.degraded)
return report_id 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: def mark_sent(self, report_id: int, channel: str, receipt: str) -> None:
with self._conn: with self._conn:
self._conn.execute( self._conn.execute(
+35 -10
View File
@@ -17,16 +17,32 @@ def build_provider(settings: Settings) -> LLMProvider:
return MiniMaxProvider(settings) return MiniMaxProvider(settings)
def summarize_payload(payload: MarketPayload, provider: LLMProvider) -> tuple[Report, str]: def _merge_gaps(payload: MarketPayload, report: Report) -> Report:
"""Summarise via the provider and guarantee `report.data_gaps` includes every payload gap.""" """Guarantee `report.data_gaps` includes every payload gap (deduplicated, payload first)."""
report, model_used = provider.summarize(payload)
merged: list[str] = [] merged: list[str] = []
for gap in [*payload.data_gaps, *report.data_gaps]: for gap in [*payload.data_gaps, *report.data_gaps]:
if gap and gap not in merged: if gap and gap not in merged:
merged.append(gap) merged.append(gap)
if merged != list(report.data_gaps): if merged != list(report.data_gaps):
report = report.model_copy(update={"data_gaps": merged}) 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 return report, model_used
@@ -37,14 +53,21 @@ def _consistent(verification: Verification) -> Verification:
def verify_and_revise( 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]: ) -> tuple[Report, Verification]:
"""Fact-check the report; on major issues ask for one revision and re-check it. """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 `data_message`/`system_prompt` override the default daily-briefing serialisation and author persona
the *initial* verification cannot be produced; a failed revision keeps the original report. (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) verification = _consistent(raw_verification)
log.info( log.info(
"verification by %s: ok=%s issues=%d (major=%s)", "verification by %s: ok=%s issues=%d (major=%s)",
@@ -59,9 +82,11 @@ def verify_and_revise(
for issue in verification.issues: for issue in verification.issues:
log.warning("verification issue [%s] %s -> %s", issue.severity, issue.claim, issue.problem) log.warning("verification issue [%s] %s -> %s", issue.severity, issue.claim, issue.problem)
try: 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) 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) re_verification = _consistent(re_verification)
log.info( log.info(
"re-verification by %s: ok=%s issues=%d", model, re_verification.ok, len(re_verification.issues) "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()