From f5fa7ec70f02b3baea98089e32bccbe81e7d6d21 Mon Sep 17 00:00:00 2001 From: jsahn Date: Sat, 12 Sep 2026 23:52:31 +0900 Subject: [PATCH] Add a Monday-morning weekly recap briefing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 "미국 주간 리뷰 ") and the dashboard (주간 리뷰 badge) work unchanged. Manual runs via `run-once --weekly [--date ]`. Co-Authored-By: Claude Fable 5 --- .env.example | 3 + README.md | 3 + dashboard/src/client/main.ts | 11 +- dashboard/src/server.ts | 1 + dashboard/src/shared/types.ts | 3 + dashboard/web/styles.css | 1 + tests/_orchestration_stubs.py | 11 + tests/test_blogger.py | 8 + tests/test_llm.py | 56 ++++- tests/test_weekly.py | 380 ++++++++++++++++++++++++++++++++++ us2kr/cli.py | 9 +- us2kr/config.py | 3 + us2kr/daemon.py | 63 ++++++ us2kr/deliver/blogger.py | 5 +- us2kr/llm/base.py | 18 +- us2kr/llm/minimax.py | 33 ++- us2kr/llm/prompts.py | 69 +++++- us2kr/models.py | 3 + us2kr/render.py | 39 ++++ us2kr/store.py | 22 ++ us2kr/summarize.py | 45 +++- us2kr/weekly.py | 242 ++++++++++++++++++++++ 22 files changed, 995 insertions(+), 33 deletions(-) create mode 100644 tests/test_weekly.py create mode 100644 us2kr/weekly.py diff --git a/.env.example b/.env.example index 500632a..7dce34e 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 6fc8cec..5df0bad 100644 --- a/README.md +++ b/README.md @@ -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`면 한국 휴장 연휴 중 **다음 미국 세션이 먼저 마감되어 무의미해지는 브리핑만** 스킵 — 금요일 세션(토요일 도착)은 diff --git a/dashboard/src/client/main.ts b/dashboard/src/client/main.ts index e357fd8..6454723 100644 --- a/dashboard/src/client/main.ts +++ b/dashboard/src/client/main.ts @@ -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, diff --git a/dashboard/src/server.ts b/dashboard/src/server.ts index 15c34f4..0c7b948 100644 --- a/dashboard/src/server.ts +++ b/dashboard/src/server.ts @@ -67,6 +67,7 @@ async function listDates(): Promise { 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, diff --git a/dashboard/src/shared/types.ts b/dashboard/src/shared/types.ts index 551dc09..7ba156d 100644 --- a/dashboard/src/shared/types.ts +++ b/dashboard/src/shared/types.ts @@ -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; diff --git a/dashboard/web/styles.css b/dashboard/web/styles.css index 3c3d9f2..f824b62 100644 --- a/dashboard/web/styles.css +++ b/dashboard/web/styles.css @@ -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 { diff --git a/tests/_orchestration_stubs.py b/tests/_orchestration_stubs.py index 2baa2a2..de8dc2d 100644 --- a/tests/_orchestration_stubs.py +++ b/tests/_orchestration_stubs.py @@ -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 diff --git a/tests/test_blogger.py b/tests/test_blogger.py index 3df4dfe..4866d8c 100644 --- a/tests/test_blogger.py +++ b/tests/test_blogger.py @@ -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 (미 ") diff --git a/tests/test_llm.py b/tests/test_llm.py index d3f541f..e1e5028 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -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"] == "## 주간 데이터" diff --git a/tests/test_weekly.py b/tests/test_weekly.py new file mode 100644 index 0000000..d133639 --- /dev/null +++ b/tests/test_weekly.py @@ -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] diff --git a/us2kr/cli.py b/us2kr/cli.py index fc0840f..9738b85 100644 --- a/us2kr/cli.py +++ b/us2kr/cli.py @@ -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 diff --git a/us2kr/config.py b/us2kr/config.py index 8561b79..4ccdffa 100644 --- a/us2kr/config.py +++ b/us2kr/config.py @@ -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") diff --git a/us2kr/daemon.py b/us2kr/daemon.py index c4be155..23be64b 100644 --- a/us2kr/daemon.py +++ b/us2kr/daemon.py @@ -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( diff --git a/us2kr/deliver/blogger.py b/us2kr/deliver/blogger.py index dfb8934..15d8e35 100644 --- a/us2kr/deliver/blogger.py +++ b/us2kr/deliver/blogger.py @@ -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()} 세션)" diff --git a/us2kr/llm/base.py b/us2kr/llm/base.py index 48652f5..0f8948b 100644 --- a/us2kr/llm/base.py +++ b/us2kr/llm/base.py @@ -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.""" ... diff --git a/us2kr/llm/minimax.py b/us2kr/llm/minimax.py index 8898c2e..ca2fa9d 100644 --- a/us2kr/llm/minimax.py +++ b/us2kr/llm/minimax.py @@ -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)}, ] diff --git a/us2kr/llm/prompts.py b/us2kr/llm/prompts.py index 1074ecf..b23ef82 100644 --- a/us2kr/llm/prompts.py +++ b/us2kr/llm/prompts.py @@ -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] = [] diff --git a/us2kr/models.py b/us2kr/models.py index d936e85..c630b1f 100644 --- a/us2kr/models.py +++ b/us2kr/models.py @@ -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" diff --git a/us2kr/render.py b/us2kr/render.py index 5829c18..9b32f70 100644 --- a/us2kr/render.py +++ b/us2kr/render.py @@ -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]+)\)") diff --git a/us2kr/store.py b/us2kr/store.py index cb1c339..c3929db 100644 --- a/us2kr/store.py +++ b/us2kr/store.py @@ -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( diff --git a/us2kr/summarize.py b/us2kr/summarize.py index ff83bde..30a18a3 100644 --- a/us2kr/summarize.py +++ b/us2kr/summarize.py @@ -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) diff --git a/us2kr/weekly.py b/us2kr/weekly.py new file mode 100644 index 0000000..81b7402 --- /dev/null +++ b/us2kr/weekly.py @@ -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()