Compare commits

...
11 Commits
Author SHA1 Message Date
jsahnandClaude Fable 5 bd83b7a7b2 Start the searxng container from the boot agent
A mini reboot left searxng stopped, so briefings silently lost the
meta-search source (per-source isolation kept them publishing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-02 08:32:22 +09:00
jsahnandClaude Fable 5 f5fa7ec70f 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>
2026-09-12 23:52:31 +09:00
jsahnandClaude Fable 5 1e6272da74 Fix table assertion after inline-style change
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 14:21:07 +09:00
jsahnandClaude Fable 5 3ee37bfa49 Give Blogger tables inline borders
Blogger themes ship no table styling, so the quote tables rendered as
borderless text. Inline styles survive every theme and the mobile view.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 14:20:41 +09:00
jsahnandClaude Fable 5 830e4e0522 Fix date framing in briefings and make Blogger posts idempotent
The 9/1 briefing called the US 8/31 session "9월 첫 거래일" - the LLM
was anchoring on the KRX date. Both prompts now pin every temporal
phrase (weekday, first/last trading day, month boundaries) to the US
session date, and the verifier treats a mismatch as a major issue.
BloggerDeliverer now updates the existing post for the same KRX date
instead of creating a duplicate on resends.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 14:13:01 +09:00
jsahnandClaude Fable 5 b75bf0b887 Surface blog-list errors in blogger-auth instead of an empty list
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 13:39:40 +09:00
jsahnandClaude Fable 5 5232631b44 Allow a fixed loopback port in blogger-auth
Supports web-application OAuth clients with a pre-registered
http://127.0.0.1:<port> redirect URI; desktop clients keep the random
port default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 12:19:54 +09:00
jsahnandClaude Fable 5 12d7b47f46 Add a Blogger delivery channel with one-time OAuth setup
BloggerDeliverer posts each briefing (markdown -> HTML, H1 becomes the
post title with the report headline) via the Blogger API v3 using a
long-lived refresh token; `us2kr blogger-auth` runs the loopback OAuth
flow once and prints the BLOGGER_* lines for .env. Enabled only when
all four BLOGGER_* settings are present.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 12:04:49 +09:00
jsahnandClaude Fable 5 fecc767a5c Remove the verification badge from the dashboard
Verification now runs silently; the verdict lives only in the result
JSON.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 10:55:28 +09:00
jsahnandClaude Fable 5 325ea6622a Drop the verification memo from the dashboard
Keep the pass/issue-count badge; the full issue list stays in the
result JSON for diagnostics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 10:50:28 +09:00
jsahnandClaude Fable 5 ed48f4c96b Add an LLM fact-check pass over the generated briefing
After summarisation the report is verified against the collected
payload (numbers, directions, unsupported claims) by a second LLM call
with a Verification JSON schema. Major issues trigger one revision and
a re-check; minor-only verdicts are normalized to ok. Failures never
block delivery. The verdict is stored in the result JSON and shown on
the dashboard (badge + 검증 메모). Disable with US2KR_VERIFY_ENABLED.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 10:37:39 +09:00
27 changed files with 2074 additions and 59 deletions
+12
View File
@@ -8,6 +8,15 @@ MINIMAX_FALLBACK_MODELS=MiniMax-M2.7,MiniMax-M2.5
TELEGRAM_BOT_TOKEN= TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID= TELEGRAM_CHAT_ID=
# Blogger 자동 발행 (선택) — 4개 모두 채우면 브리핑을 블로그에 게시.
# 발급: Google Cloud에서 Blogger API 활성화 + OAuth 데스크톱 클라이언트 생성 후 `us2kr blogger-auth`
BLOGGER_CLIENT_ID=
BLOGGER_CLIENT_SECRET=
BLOGGER_REFRESH_TOKEN=
BLOGGER_BLOG_ID=
# 게시물 라벨 CSV (기본: 미국증시,마감브리핑)
BLOGGER_LABELS=
# FRED (선택) — 경제지표 실제 발표값 # FRED (선택) — 경제지표 실제 발표값
FRED_API_KEY= FRED_API_KEY=
@@ -23,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
+9 -1
View File
@@ -5,7 +5,8 @@
설계 문서는 [PLAN.md](PLAN.md)를 참고하세요. 설계 문서는 [PLAN.md](PLAN.md)를 참고하세요.
``` ```
수집(yfinance · RSS · FRED) → SQLite 저장 → MiniMax-M3 요약(JSON 스키마) → Markdown 렌더 → Telegram / 파일 수집(yfinance · RSS · SearXNG · FRED) → SQLite 저장 → MiniMax 요약(JSON 스키마)
→ LLM 교차 검증(수치·방향 팩트체크, major 이슈면 1회 자동 수정) → Markdown 렌더 → Telegram / 파일
``` ```
## 실행 시각 ## 실행 시각
@@ -16,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`면
한국 휴장 연휴 중 **다음 미국 세션이 먼저 마감되어 무의미해지는 브리핑만** 스킵 — 금요일 세션(토요일 도착)은 한국 휴장 연휴 중 **다음 미국 세션이 먼저 마감되어 무의미해지는 브리핑만** 스킵 — 금요일 세션(토요일 도착)은
@@ -41,6 +45,7 @@ cp .env.example .env # MINIMAX_API_KEY 필수, TELEGRAM_* 선택
.venv/bin/python -m us2kr.cli run-once --date 2026-08-24 # 특정 미국 세션 기준 생성·발송(마감된 세션만) .venv/bin/python -m us2kr.cli run-once --date 2026-08-24 # 특정 미국 세션 기준 생성·발송(마감된 세션만)
.venv/bin/python -m us2kr.cli run-once --force # 이미 발송된 날이어도 다시(재발송) .venv/bin/python -m us2kr.cli run-once --force # 이미 발송된 날이어도 다시(재발송)
.venv/bin/python -m us2kr.cli test-notify # 발송 채널 점검 .venv/bin/python -m us2kr.cli test-notify # 발송 채널 점검
.venv/bin/python -m us2kr.cli blogger-auth # Blogger OAuth 1회 발급(.env에 넣을 값 출력)
.venv/bin/python -m us2kr.cli daemon # 상주 실행(스케줄러) .venv/bin/python -m us2kr.cli daemon # 상주 실행(스케줄러)
``` ```
@@ -76,6 +81,8 @@ LaunchAgent(`container system start` + `container start us2kr`)가 담당합니
| `MINIMAX_MODEL` | `MiniMax-M3` | 기본 모델 | | `MINIMAX_MODEL` | `MiniMax-M3` | 기본 모델 |
| `MINIMAX_FALLBACK_MODELS` | `MiniMax-M2.7,MiniMax-M2.5` | 실패 시 순차 폴백 | | `MINIMAX_FALLBACK_MODELS` | `MiniMax-M2.7,MiniMax-M2.5` | 실패 시 순차 폴백 |
| `TELEGRAM_BOT_TOKEN` / `TELEGRAM_CHAT_ID` | — | 둘 다 있으면 Telegram 발송 | | `TELEGRAM_BOT_TOKEN` / `TELEGRAM_CHAT_ID` | — | 둘 다 있으면 Telegram 발송 |
| `BLOGGER_CLIENT_ID` `BLOGGER_CLIENT_SECRET` `BLOGGER_REFRESH_TOKEN` `BLOGGER_BLOG_ID` | — | 4개 모두 있으면 Blogger에 자동 발행. Google Cloud에서 Blogger API v3 활성화 + OAuth 데스크톱 클라이언트 생성 후 `us2kr blogger-auth`로 발급 |
| `BLOGGER_LABELS` | `미국증시,마감브리핑` | Blogger 게시물 라벨 CSV |
| `FRED_API_KEY` | — | 있으면 경제지표 발표 일정 포함 | | `FRED_API_KEY` | — | 있으면 경제지표 발표 일정 포함 |
| `US2KR_RUN_OFFSET_MINUTES` | `75` | 미국 마감 후 몇 분 뒤 실행 | | `US2KR_RUN_OFFSET_MINUTES` | `75` | 미국 마감 후 몇 분 뒤 실행 |
| `US2KR_CATCHUP_WINDOW_HOURS` | `3` | 재시작 시 따라잡기 허용 창 | | `US2KR_CATCHUP_WINDOW_HOURS` | `3` | 재시작 시 따라잡기 허용 창 |
@@ -83,6 +90,7 @@ LaunchAgent(`container system start` + `container start us2kr`)가 담당합니
| `US2KR_NEWS_LOOKBACK_HOURS` / `US2KR_NEWS_MAX_ITEMS` | `18` / `40` | 뉴스 수집 범위 | | `US2KR_NEWS_LOOKBACK_HOURS` / `US2KR_NEWS_MAX_ITEMS` | `18` / `40` | 뉴스 수집 범위 |
| `SEARXNG_BASE_URL` | — | 있으면 RSS 외에 SearXNG 뉴스 검색 결과도 병합 (인스턴스에 `json` 포맷 활성화 필요) | | `SEARXNG_BASE_URL` | — | 있으면 RSS 외에 SearXNG 뉴스 검색 결과도 병합 (인스턴스에 `json` 포맷 활성화 필요) |
| `US2KR_SEARXNG_QUERIES` | 기본 검색어 3개 | SearXNG 검색어 CSV (기본: `us2kr/collectors/searx.py`의 `DEFAULT_QUERIES`) | | `US2KR_SEARXNG_QUERIES` | 기본 검색어 3개 | SearXNG 검색어 CSV (기본: `us2kr/collectors/searx.py`의 `DEFAULT_QUERIES`) |
| `US2KR_VERIFY_ENABLED` | `true` | 요약 후 LLM 팩트체크(수치·방향을 수집 데이터와 대조). major 이슈 발견 시 1회 자동 수정 후 재검증. 검증 실패는 발송을 막지 않고 결과는 JSON·대시보드에 기록 |
| `US2KR_KRX_EXTRA_HOLIDAYS` | — | 캘린더에 없는 한국 임시휴장일 CSV (예: `2026-06-03`) | | `US2KR_KRX_EXTRA_HOLIDAYS` | — | 캘린더에 없는 한국 임시휴장일 CSV (예: `2026-06-03`) |
## 산출물 ## 산출물
+8 -1
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");
if (s.kind === "weekly") {
line.append(el("span", "badge badge-week", "주간 리뷰"), `~${s.session_date}`, badgeFor(s));
} else {
line.append(`세션 ${s.session_date}`, badgeFor(s)); 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,
+16
View File
@@ -73,6 +73,17 @@ export interface Report {
data_gaps: string[]; data_gaps: string[];
} }
export interface VerificationIssue {
claim: string;
problem: string;
severity: "minor" | "major";
}
export interface Verification {
ok: boolean;
issues: VerificationIssue[];
}
export interface BriefingResult { export interface BriefingResult {
payload: MarketPayload; payload: MarketPayload;
report: Report | null; report: Report | null;
@@ -80,12 +91,17 @@ export interface BriefingResult {
model_used: string | null; model_used: string | null;
degraded: boolean; degraded: boolean;
error: string | null; 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 // ---------------------------------------------------------------- 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 {
+1 -1
View File
@@ -9,7 +9,7 @@
<array> <array>
<string>/bin/bash</string> <string>/bin/bash</string>
<string>-c</string> <string>-c</string>
<string>__CONTAINER__ system start &amp;&amp; __CONTAINER__ start us2kr; __CONTAINER__ start us2kr-dashboard</string> <string>__CONTAINER__ system start &amp;&amp; __CONTAINER__ start searxng; __CONTAINER__ start us2kr; __CONTAINER__ start us2kr-dashboard</string>
</array> </array>
<key>RunAtLoad</key><true/> <key>RunAtLoad</key><true/>
<key>StandardOutPath</key><string>__ROOT__/logs/container-boot.log</string> <key>StandardOutPath</key><string>__ROOT__/logs/container-boot.log</string>
+1
View File
@@ -15,6 +15,7 @@ dependencies = [
"python-dotenv>=1.0", "python-dotenv>=1.0",
"httpx>=0.27", "httpx>=0.27",
"requests>=2.31", "requests>=2.31",
"markdown>=3.6",
] ]
[project.optional-dependencies] [project.optional-dependencies]
+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
+265
View File
@@ -0,0 +1,265 @@
"""Tests for us2kr.deliver.blogger (no network: the httpx client is a stub)."""
from __future__ import annotations
import json
from datetime import UTC, date, datetime
from typing import Any
import httpx
import pytest
from us2kr.config import Settings
from us2kr.deliver import build_deliverers
from us2kr.deliver.base import DeliveryError
from us2kr.deliver.blogger import BloggerDeliverer, briefing_html, post_title
from us2kr.models import BriefingResult, MarketPayload, Report
TOKEN_OK = {"access_token": "at-1", "expires_in": 3600}
POST_OK = {"kind": "blogger#post", "id": "42", "url": "https://nybrief.blogspot.com/2026/09/post.html"}
def make_result(headline: str | None = "9월 첫 거래일 하락") -> BriefingResult:
payload = MarketPayload(
session_date=date(2026, 8, 31),
us_close_time=datetime(2026, 8, 31, 20, 0, tzinfo=UTC),
generated_at=datetime(2026, 9, 1, 6, 15, tzinfo=UTC),
krx_date=date(2026, 9, 1),
krx_open_today=True,
)
report = None
if headline is not None:
report = Report(
headline=headline,
market_overview="지수가 내렸습니다.",
key_drivers=["금리 상승"],
rates_fx_commodities="금리 상승.",
korea_implications=["반도체 부담"],
watchlist_today=["환율"],
risk_flags=["변동성"],
)
markdown = (
"# 🇺🇸→🇰🇷 미국 마감 브리핑 — 2026-09-01\n"
"미국 마감: 2026-09-01 05:00 KST\n\n"
"## 총평\n**하락 마감**\n\n"
"| 종목 | 종가 |\n|---|---|\n| S&P 500 | 7,686.42 |\n"
)
return BriefingResult(payload=payload, report=report, markdown=markdown, degraded=report is None)
class _Resp:
def __init__(self, status: int, body: dict[str, Any]):
self.status_code = status
self._body = body
self.text = json.dumps(body)
def json(self) -> dict[str, Any]:
return self._body
class FakeHttp:
"""Minimal stand-in for httpx.Client: scripted responses, recorded calls."""
def __init__(self, responses: list[Any]):
self._responses = list(responses)
self.calls: list[dict[str, Any]] = []
def request(self, method: str, url: str, **kwargs: Any) -> Any:
self.calls.append({"method": method, "url": url, **kwargs})
if not self._responses:
raise AssertionError("unexpected extra HTTP call")
item = self._responses.pop(0)
if isinstance(item, Exception):
raise item
return item
def post(self, url: str, **kwargs: Any) -> Any:
return self.request("POST", url, **kwargs)
def make_deliverer(responses: list[Any]) -> tuple[BloggerDeliverer, FakeHttp]:
client = FakeHttp(responses)
deliverer = BloggerDeliverer(
"cid", "csecret", "rtoken", "12345", labels=["미국증시"], client=client # type: ignore[arg-type]
)
return deliverer, client
# ------------------------------------------------------------------ html & title
def test_briefing_html_drops_h1_and_renders_tables():
html = briefing_html(make_result().markdown)
assert "미국 마감 브리핑 — 2026-09-01" not in html # H1 is the post title, not body
assert "<h2>총평</h2>" in html
assert "<strong>하락 마감</strong>" in html
# tables carry inline borders - Blogger themes style nothing themselves
assert '<table style="border-collapse:collapse' in html
assert ">7,686.42</td>" in html and 'border:1px solid #ccc' in html
assert "<table>" not in html and "<td>" not in html # every cell got the inline style
def test_post_title_with_and_without_headline():
assert post_title(make_result()) == "미국 마감 브리핑 2026-09-01 — 9월 첫 거래일 하락"
assert post_title(make_result(headline=None)) == "미국 마감 브리핑 2026-09-01 (미 2026-08-31 세션)"
# ------------------------------------------------------------------ delivery
EMPTY_LIST = {"kind": "blogger#postList"} # no items key on an empty blog
def test_deliver_refreshes_token_lists_then_inserts():
deliverer, client = make_deliverer(
[_Resp(200, TOKEN_OK), _Resp(200, EMPTY_LIST), _Resp(200, POST_OK)]
)
receipt = deliverer.deliver(make_result())
assert receipt == POST_OK["url"]
token_call, list_call, insert_call = client.calls
assert token_call["data"]["grant_type"] == "refresh_token"
assert list_call["method"] == "GET" and list_call["params"]["fetchBodies"] == "false"
assert insert_call["method"] == "POST"
assert "refresh_token" not in insert_call["url"] # secrets never in URLs
assert insert_call["url"].endswith("/blogs/12345/posts/")
assert insert_call["headers"]["Authorization"] == "Bearer at-1"
body = insert_call["json"]
assert body["title"].startswith("미국 마감 브리핑 2026-09-01")
assert body["labels"] == ["미국증시"] and "<table style=" in body["content"]
def test_deliver_updates_existing_post_for_same_day():
listing = {"items": [{"id": "42", "title": "미국 마감 브리핑 2026-09-01 — 이전 헤드라인"}]}
deliverer, client = make_deliverer(
[_Resp(200, TOKEN_OK), _Resp(200, listing), _Resp(200, POST_OK)]
)
receipt = deliverer.deliver(make_result())
assert receipt == POST_OK["url"]
update_call = client.calls[-1]
assert update_call["method"] == "PUT"
assert update_call["url"].endswith("/blogs/12345/posts/42")
def test_deliver_other_day_posts_do_not_match():
listing = {"items": [{"id": "41", "title": "미국 마감 브리핑 2026-08-29 — 다른 날"}]}
deliverer, client = make_deliverer(
[_Resp(200, TOKEN_OK), _Resp(200, listing), _Resp(200, POST_OK)]
)
deliverer.deliver(make_result())
assert client.calls[-1]["method"] == "POST" # inserted, not updated
def test_deliver_reuses_cached_token():
deliverer, client = make_deliverer(
[
_Resp(200, TOKEN_OK),
_Resp(200, EMPTY_LIST),
_Resp(200, POST_OK),
_Resp(200, EMPTY_LIST),
_Resp(200, POST_OK),
]
)
deliverer.deliver(make_result())
deliverer.deliver(make_result())
token_calls = [c for c in client.calls if "oauth2" in c["url"]]
assert len(token_calls) == 1
def test_deliver_retries_once_on_401_with_fresh_token():
deliverer, client = make_deliverer(
[
_Resp(200, TOKEN_OK),
_Resp(401, {"error": "invalid token"}), # list call rejected
_Resp(200, {"access_token": "at-2", "expires_in": 3600}),
_Resp(200, EMPTY_LIST),
_Resp(200, POST_OK),
]
)
receipt = deliverer.deliver(make_result())
assert receipt == POST_OK["url"]
assert client.calls[-1]["headers"]["Authorization"] == "Bearer at-2"
def test_deliver_lookup_failure_still_publishes(monkeypatch):
monkeypatch.setattr("us2kr.deliver.blogger.time.sleep", lambda _s: None)
deliverer, client = make_deliverer(
[_Resp(200, TOKEN_OK)] + [_Resp(503, {"error": "backend"})] * 3 + [_Resp(200, POST_OK)]
)
receipt = deliverer.deliver(make_result())
assert receipt == POST_OK["url"]
assert client.calls[-1]["method"] == "POST"
def test_deliver_retries_5xx_then_raises(monkeypatch):
monkeypatch.setattr("us2kr.deliver.blogger.time.sleep", lambda _s: None)
deliverer, _client = make_deliverer(
[_Resp(200, TOKEN_OK), _Resp(200, EMPTY_LIST)] + [_Resp(503, {"error": "backend"})] * 3
)
with pytest.raises(DeliveryError, match="HTTP 503"):
deliverer.deliver(make_result())
def test_deliver_does_not_retry_4xx():
deliverer, client = make_deliverer(
[_Resp(200, TOKEN_OK), _Resp(200, EMPTY_LIST), _Resp(403, {"error": "forbidden"})]
)
with pytest.raises(DeliveryError, match="HTTP 403"):
deliverer.deliver(make_result())
assert len(client.calls) == 3 # no extra attempts
def test_token_failure_raises_delivery_error():
deliverer, _ = make_deliverer([_Resp(400, {"error": "invalid_grant"})])
with pytest.raises(DeliveryError, match="token refresh failed"):
deliverer.deliver(make_result())
def test_network_error_wrapped(monkeypatch):
monkeypatch.setattr("us2kr.deliver.blogger.time.sleep", lambda _s: None)
deliverer, _ = make_deliverer(
[_Resp(200, TOKEN_OK), _Resp(200, EMPTY_LIST)] + [httpx.ConnectError("boom")] * 3
)
with pytest.raises(DeliveryError, match="ConnectError"):
deliverer.deliver(make_result())
def test_notify_error_never_raises():
deliverer, client = make_deliverer([])
deliverer.notify_error("subject", "detail") # logs only, no HTTP call
assert client.calls == []
def test_requires_all_credentials():
with pytest.raises(ValueError):
BloggerDeliverer("cid", "csecret", "", "12345")
# ------------------------------------------------------------------ wiring
def test_build_deliverers_includes_blogger_when_configured(tmp_path):
base = {
"US2KR_DATA_DIR": str(tmp_path),
"US2KR_REPORTS_DIR": str(tmp_path),
"US2KR_LOG_DIR": str(tmp_path),
}
plain = Settings(_env_file=None, **base)
assert [d.name for d in build_deliverers(plain)] == ["file"]
full = Settings(
_env_file=None,
**base,
BLOGGER_CLIENT_ID="cid",
BLOGGER_CLIENT_SECRET="cs",
BLOGGER_REFRESH_TOKEN="rt",
BLOGGER_BLOG_ID="1",
BLOGGER_LABELS="미국증시, 시황",
)
deliverers = build_deliverers(full)
assert [d.name for d in deliverers] == ["file", "blogger"]
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 (미 ")
+192 -2
View File
@@ -25,8 +25,17 @@ from us2kr.llm.minimax import (
report_response_format, report_response_format,
) )
from us2kr.llm.prompts import SYSTEM_PROMPT, build_user_message from us2kr.llm.prompts import SYSTEM_PROMPT, build_user_message
from us2kr.models import EarningsItem, EconEvent, MarketPayload, NewsItem, Quote, Report from us2kr.models import (
from us2kr.summarize import build_provider, summarize_payload EarningsItem,
EconEvent,
MarketPayload,
NewsItem,
Quote,
Report,
Verification,
VerificationIssue,
)
from us2kr.summarize import build_provider, summarize_payload, verify_and_revise
ET = ZoneInfo("America/New_York") ET = ZoneInfo("America/New_York")
KST = ZoneInfo("Asia/Seoul") KST = ZoneInfo("Asia/Seoul")
@@ -558,3 +567,184 @@ def test_summarize_payload_no_gaps_keeps_report() -> None:
payload = make_payload().model_copy(update={"data_gaps": []}) payload = make_payload().model_copy(update={"data_gaps": []})
report, _ = summarize_payload(payload, StubProvider()) report, _ = summarize_payload(payload, StubProvider())
assert report.data_gaps == [] assert report.data_gaps == []
# ---------------------------------------------------------------------- verification
GOOD_VERIFICATION: dict[str, Any] = {
"ok": False,
"issues": [
{
"claim": "S&P 500이 1.5% 하락했습니다.",
"problem": "제공된 데이터는 +0.81% 상승입니다.",
"severity": "major",
}
],
}
def test_verify_sends_payload_and_draft_and_parses_verdict() -> None:
provider, client = make_provider([make_completion(json.dumps({"ok": True, "issues": []}))])
verification, model = provider.verify(make_payload(), Report.model_validate(GOOD_REPORT))
assert verification.ok is True and verification.issues == [] and model == "MiniMax-M3"
call = client.calls[0]
assert call["response_format"]["json_schema"]["name"] == "verification"
assert "팩트체커" in call["messages"][0]["content"]
user = call["messages"][1]["content"]
assert "## 검증 대상 브리핑 초안 (JSON)" in user
assert "반도체 강세에 나스닥 상승 마감" in user # the draft under review
assert "S&P 500" in user # the source data to check against
def test_verify_verdict_has_major_property() -> None:
provider, _ = make_provider([make_completion(json.dumps(GOOD_VERIFICATION, ensure_ascii=False))])
verification, _ = provider.verify(make_payload(), Report.model_validate(GOOD_REPORT))
assert verification.ok is False and verification.has_major is True
def test_verify_exhausts_models_with_task_tag() -> None:
provider, client = make_provider([make_status_error(500)] * 3)
with pytest.raises(LLMError, match=r"\(verify\)"):
provider.verify(make_payload(), Report.model_validate(GOOD_REPORT))
assert client.models_called == ["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"]
def test_revise_replays_draft_and_issues() -> None:
revised = {**GOOD_REPORT, "headline": "수정된 헤드라인"}
provider, client = make_provider([make_completion(json.dumps(revised, ensure_ascii=False))])
issues = [
VerificationIssue(claim="S&P 500이 1.5% 하락", problem="데이터는 +0.81%", severity="major")
]
report, model = provider.revise(make_payload(), Report.model_validate(GOOD_REPORT), issues)
assert report.headline == "수정된 헤드라인" and model == "MiniMax-M3"
call = client.calls[0]
assert call["response_format"]["json_schema"]["name"] == "report"
messages = call["messages"]
assert messages[0]["content"] == SYSTEM_PROMPT # same author persona as summarize
assert messages[2]["role"] == "assistant" # the draft being corrected
assert "검증 이슈" in messages[3]["content"] and "[major]" in messages[3]["content"]
# ------------------------------------------------------------------ verify_and_revise
class GlueProvider:
"""LLMProvider stub with scripted verify verdicts and one revise result."""
def __init__(
self,
verdicts: list[Verification],
revised: Report | None = None,
revise_fails: bool = False,
) -> None:
self._verdicts = list(verdicts)
self._revised = revised
self._revise_fails = revise_fails
self.verify_calls = 0
self.revise_calls = 0
def summarize(self, payload: MarketPayload) -> tuple[Report, str]:
raise AssertionError("summarize must not be called")
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, *, 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
return self._revised, "stub"
def _issue(severity: str) -> VerificationIssue:
return VerificationIssue(claim="c", problem="p", severity=severity) # type: ignore[arg-type]
def test_verify_and_revise_clean_verdict_short_circuits() -> None:
provider = GlueProvider([Verification(ok=True)])
report = Report.model_validate(GOOD_REPORT)
out, verification = verify_and_revise(make_payload(), report, provider)
assert out is report and verification.ok is True
assert provider.verify_calls == 1 and provider.revise_calls == 0
def test_verify_and_revise_minor_issues_do_not_revise_and_normalize_ok() -> None:
verdict = Verification(ok=False, issues=[_issue("minor")]) # verifiers over-report minor as not-ok
provider = GlueProvider([verdict])
report = Report.model_validate(GOOD_REPORT)
out, verification = verify_and_revise(make_payload(), report, provider)
assert out is report
assert verification.ok is True and len(verification.issues) == 1 # ok normalized to "no major"
assert provider.revise_calls == 0
def test_verify_and_revise_major_issue_revises_and_rechecks() -> None:
revised = Report.model_validate({**GOOD_REPORT, "headline": "고친 헤드라인"})
provider = GlueProvider(
[Verification(ok=False, issues=[_issue("major")]), Verification(ok=True)], revised=revised
)
out, verification = verify_and_revise(make_payload(), Report.model_validate(GOOD_REPORT), provider)
assert out is revised and verification.ok is True
assert provider.verify_calls == 2 and provider.revise_calls == 1
def test_verify_and_revise_revision_failure_keeps_original() -> None:
verdict = Verification(ok=False, issues=[_issue("major")])
provider = GlueProvider([verdict], revise_fails=True)
report = Report.model_validate(GOOD_REPORT)
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"] == "## 주간 데이터"
+49 -1
View File
@@ -12,7 +12,7 @@ from us2kr import pipeline
from us2kr.config import Settings from us2kr.config import Settings
from us2kr.llm.base import LLMError from us2kr.llm.base import LLMError
from us2kr.market_time import MarketClock from us2kr.market_time import MarketClock
from us2kr.models import EarningsItem, EconEvent, NewsItem, Quote, Report from us2kr.models import EarningsItem, EconEvent, NewsItem, Quote, Report, Verification
KST = ZoneInfo("Asia/Seoul") KST = ZoneInfo("Asia/Seoul")
ET = ZoneInfo("America/New_York") ET = ZoneInfo("America/New_York")
@@ -103,6 +103,10 @@ class Env:
self.summarize_payloads.append(payload) self.summarize_payloads.append(payload)
return sample_report(), "MiniMax-M3" return sample_report(), "MiniMax-M3"
def verify_and_revise(payload, report, provider):
count("verify")
return report, Verification(ok=True)
def render_markdown(payload, report, model_used): def render_markdown(payload, report, model_used):
count("render") count("render")
tag = "degraded" if report is None else report.headline tag = "degraded" if report is None else report.headline
@@ -116,6 +120,7 @@ class Env:
monkeypatch.setattr(pipeline, "fetch_earnings", fetch_earnings) monkeypatch.setattr(pipeline, "fetch_earnings", fetch_earnings)
monkeypatch.setattr(pipeline, "build_provider", lambda settings: object()) monkeypatch.setattr(pipeline, "build_provider", lambda settings: object())
monkeypatch.setattr(pipeline, "summarize_payload", summarize_payload) monkeypatch.setattr(pipeline, "summarize_payload", summarize_payload)
monkeypatch.setattr(pipeline, "verify_and_revise", verify_and_revise)
monkeypatch.setattr(pipeline, "render_markdown", render_markdown) monkeypatch.setattr(pipeline, "render_markdown", render_markdown)
monkeypatch.setattr(pipeline, "build_deliverers", lambda settings: self.deliverers) monkeypatch.setattr(pipeline, "build_deliverers", lambda settings: self.deliverers)
monkeypatch.setattr(pipeline, "_now", lambda: NOW) monkeypatch.setattr(pipeline, "_now", lambda: NOW)
@@ -540,3 +545,46 @@ def test_superseded_helper_with_real_clock():
krx_date = clock.krx_date_for(session) krx_date = clock.krx_date_for(session)
assert clock.is_krx_session(krx_date) is False assert clock.is_krx_session(krx_date) is False
assert pipeline._superseded_before_krx_open(session, krx_date, clock) is expected, session assert pipeline._superseded_before_krx_open(session, krx_date, clock) is expected, session
# ---------------------------------------------------------------------- verification
def test_run_briefing_records_verification(env):
result = pipeline.run_briefing(SESSION, env.settings)
assert env.calls["verify"] == 1
assert result.verification is not None and result.verification.ok is True
# the verdict is persisted with the report
assert '"verification"' in env.store.saved_results[0].model_dump_json()
def test_run_briefing_verification_can_be_disabled(monkeypatch, tmp_path):
env = Env(monkeypatch, tmp_path, US2KR_VERIFY_ENABLED="false")
result = pipeline.run_briefing(SESSION, env.settings)
assert "verify" not in env.calls
assert result.verification is None and result.report is not None
def test_run_briefing_verification_failure_is_non_fatal(env):
def boom(payload, report, provider):
raise RuntimeError("verifier down")
env.monkeypatch.setattr(pipeline, "verify_and_revise", boom)
result = pipeline.run_briefing(SESSION, env.settings)
assert result.verification is None
assert result.report is not None and result.degraded is False and result.error is None
def test_run_briefing_no_llm_skips_verification(env):
result = pipeline.run_briefing(SESSION, env.settings, no_llm=True)
assert "verify" not in env.calls and result.verification is None
def test_run_briefing_verification_replaces_report_with_revision(env):
revised = sample_report().model_copy(update={"headline": "검증 후 수정된 총평"})
def verify_and_revise(payload, report, provider):
return revised, Verification(ok=True)
env.monkeypatch.setattr(pipeline, "verify_and_revise", verify_and_revise)
result = pipeline.run_briefing(SESSION, env.settings)
assert result.report is revised
assert "검증 후 수정된 총평" in result.markdown
+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]
+143 -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")
@@ -85,12 +91,30 @@ def build_parser() -> argparse.ArgumentParser:
notify.add_argument("--message", default="US2KR test notification", help="text to send") notify.add_argument("--message", default="US2KR test notification", help="text to send")
sub.add_parser("show-config", help="print effective settings (secrets masked)") sub.add_parser("show-config", help="print effective settings (secrets masked)")
auth = sub.add_parser(
"blogger-auth",
help="one-time OAuth flow for the Blogger channel (prints BLOGGER_* lines for .env)",
)
auth.add_argument("--client-id", default=None, help="OAuth client id (default: BLOGGER_CLIENT_ID)")
auth.add_argument(
"--client-secret", default=None, help="OAuth client secret (default: BLOGGER_CLIENT_SECRET)"
)
auth.add_argument("--timeout", type=int, default=300, help="seconds to wait for the browser consent")
auth.add_argument(
"--port",
type=int,
default=0,
help="fixed loopback port (0 = random). Use with a *web application* OAuth client whose "
"authorized redirect URI is registered as http://127.0.0.1:<port>",
)
return parser return parser
# ---------------------------------------------------------------------------- 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
@@ -140,16 +164,134 @@ def cmd_show_config(_args: argparse.Namespace, settings: Settings) -> int:
print(f"{name} = {shown}") print(f"{name} = {shown}")
print(f"db_path = {settings.db_path}") print(f"db_path = {settings.db_path}")
print(f"telegram_enabled = {settings.telegram_enabled}") print(f"telegram_enabled = {settings.telegram_enabled}")
print(f"blogger_enabled = {settings.blogger_enabled}")
print(f"project_root = {PROJECT_ROOT}") print(f"project_root = {PROJECT_ROOT}")
return EXIT_OK return EXIT_OK
def cmd_blogger_auth(args: argparse.Namespace, settings: Settings) -> int:
"""Run the loopback OAuth flow once; print the BLOGGER_* env lines and the user's blog ids."""
import threading
import urllib.parse
import webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer
from secrets import token_urlsafe
import httpx
client_id = args.client_id or settings.blogger_client_id
client_secret = args.client_secret or settings.blogger_client_secret
if not (client_id and client_secret):
print(
"error: pass --client-id/--client-secret or set BLOGGER_CLIENT_ID / BLOGGER_CLIENT_SECRET",
file=sys.stderr,
)
return EXIT_ERROR
state = token_urlsafe(16)
received: dict[str, str] = {}
done = threading.Event()
class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # http.server API naming
query = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
received.update({k: v[0] for k, v in query.items()})
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write("<h3>인증 완료 — 터미널로 돌아가세요.</h3>".encode())
done.set()
def log_message(self, *_args: object) -> None: # keep the terminal clean
pass
server = HTTPServer(("127.0.0.1", args.port), Handler)
port = server.server_address[1]
redirect_uri = f"http://127.0.0.1:{port}"
threading.Thread(target=server.serve_forever, daemon=True).start()
auth_url = "https://accounts.google.com/o/oauth2/v2/auth?" + urllib.parse.urlencode(
{
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": "https://www.googleapis.com/auth/blogger",
"access_type": "offline",
"prompt": "consent", # force a refresh_token even if previously consented
"state": state,
}
)
print("브라우저에서 Google 계정으로 승인하세요 (자동으로 열리지 않으면 URL을 직접 여세요):\n")
print(auth_url + "\n")
webbrowser.open(auth_url)
try:
if not done.wait(timeout=args.timeout):
print("error: timed out waiting for the browser consent", file=sys.stderr)
return EXIT_ERROR
finally:
server.shutdown()
if received.get("state") != state:
print("error: OAuth state mismatch - try again", file=sys.stderr)
return EXIT_ERROR
code = received.get("code")
if not code:
print(f"error: consent denied ({received.get('error', 'no code returned')})", file=sys.stderr)
return EXIT_ERROR
with httpx.Client(timeout=30.0) as client:
token_resp = client.post(
"https://oauth2.googleapis.com/token",
data={
"code": code,
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
},
)
if token_resp.status_code != 200:
print(f"error: token exchange failed: {token_resp.text[:300]}", file=sys.stderr)
return EXIT_ERROR
tokens = token_resp.json()
refresh_token = tokens.get("refresh_token")
if not refresh_token:
print("error: no refresh_token returned - remove the app's prior access and retry", file=sys.stderr)
return EXIT_ERROR
blogs_resp = client.get(
"https://www.googleapis.com/blogger/v3/users/self/blogs",
headers={"Authorization": f"Bearer {tokens['access_token']}"},
)
blogs = blogs_resp.json().get("items", []) if blogs_resp.status_code == 200 else []
if blogs_resp.status_code != 200:
print(
f"\n경고: 블로그 목록 조회 실패 (HTTP {blogs_resp.status_code}): {blogs_resp.text[:300]}",
file=sys.stderr,
)
elif not blogs:
print("\n경고: 이 계정에 블로그가 없습니다 - 블로그 소유 계정으로 승인했는지 확인하세요", file=sys.stderr)
else:
print("\n이 계정의 블로그:")
for blog in blogs:
print(f" - {blog.get('name')} id={blog.get('id')} url={blog.get('url')}")
blog_id = blogs[0]["id"] if len(blogs) == 1 else "<위 목록에서 선택>"
print("\n.env에 추가할 내용:\n")
print(f"BLOGGER_CLIENT_ID={client_id}")
print(f"BLOGGER_CLIENT_SECRET={client_secret}")
print(f"BLOGGER_REFRESH_TOKEN={refresh_token}")
print(f"BLOGGER_BLOG_ID={blog_id}")
return EXIT_OK
COMMANDS = { COMMANDS = {
"run-once": cmd_run_once, "run-once": cmd_run_once,
"daemon": cmd_daemon, "daemon": cmd_daemon,
"next-run": cmd_next_run, "next-run": cmd_next_run,
"test-notify": cmd_test_notify, "test-notify": cmd_test_notify,
"show-config": cmd_show_config, "show-config": cmd_show_config,
"blogger-auth": cmd_blogger_auth,
} }
+27 -1
View File
@@ -35,6 +35,15 @@ class Settings(BaseSettings):
telegram_bot_token: str | None = Field(default=None, alias="TELEGRAM_BOT_TOKEN") telegram_bot_token: str | None = Field(default=None, alias="TELEGRAM_BOT_TOKEN")
telegram_chat_id: str | None = Field(default=None, alias="TELEGRAM_CHAT_ID") telegram_chat_id: str | None = Field(default=None, alias="TELEGRAM_CHAT_ID")
# --- Blogger (optional; issue the refresh token once with `us2kr blogger-auth`) ---
blogger_client_id: str | None = Field(default=None, alias="BLOGGER_CLIENT_ID")
blogger_client_secret: str | None = Field(default=None, alias="BLOGGER_CLIENT_SECRET")
blogger_refresh_token: str | None = Field(default=None, alias="BLOGGER_REFRESH_TOKEN")
blogger_blog_id: str | None = Field(default=None, alias="BLOGGER_BLOG_ID")
blogger_labels: Annotated[list[str], NoDecode] = Field(
default_factory=lambda: ["미국증시", "마감브리핑"], alias="BLOGGER_LABELS"
)
# --- Optional data keys --- # --- Optional data keys ---
fred_api_key: str | None = Field(default=None, alias="FRED_API_KEY") fred_api_key: str | None = Field(default=None, alias="FRED_API_KEY")
@@ -60,14 +69,22 @@ 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")
news_max_items: int = Field(default=40, alias="US2KR_NEWS_MAX_ITEMS") news_max_items: int = Field(default=40, alias="US2KR_NEWS_MAX_ITEMS")
# --- LLM verification (fact-check pass over the generated report) ---
verify_enabled: bool = Field(default=True, alias="US2KR_VERIFY_ENABLED")
log_level: str = Field(default="INFO", alias="US2KR_LOG_LEVEL") log_level: str = Field(default="INFO", alias="US2KR_LOG_LEVEL")
@field_validator("minimax_fallback_models", "krx_extra_holidays", "searxng_queries", mode="before") @field_validator(
"minimax_fallback_models", "krx_extra_holidays", "searxng_queries", "blogger_labels", mode="before"
)
@classmethod @classmethod
def _split_csv(cls, v): def _split_csv(cls, v):
if isinstance(v, str): if isinstance(v, str):
@@ -82,6 +99,15 @@ class Settings(BaseSettings):
def telegram_enabled(self) -> bool: def telegram_enabled(self) -> bool:
return bool(self.telegram_bot_token and self.telegram_chat_id) return bool(self.telegram_bot_token and self.telegram_chat_id)
@property
def blogger_enabled(self) -> bool:
return bool(
self.blogger_client_id
and self.blogger_client_secret
and self.blogger_refresh_token
and self.blogger_blog_id
)
def get_settings() -> Settings: def get_settings() -> Settings:
"""Load settings from environment / .env (called once per process by the CLI).""" """Load settings from environment / .env (called once per process by the CLI)."""
+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(
+21 -3
View File
@@ -1,4 +1,4 @@
"""Delivery channels: local file archive (always) + Telegram (when configured).""" """Delivery channels: local file archive (always) + Telegram / Blogger (when configured)."""
from __future__ import annotations from __future__ import annotations
@@ -6,19 +6,37 @@ import logging
from us2kr.config import Settings from us2kr.config import Settings
from us2kr.deliver.base import Deliverer, DeliveryError from us2kr.deliver.base import Deliverer, DeliveryError
from us2kr.deliver.blogger import BloggerDeliverer
from us2kr.deliver.file import FileDeliverer from us2kr.deliver.file import FileDeliverer
from us2kr.deliver.telegram import TelegramDeliverer from us2kr.deliver.telegram import TelegramDeliverer
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
__all__ = ["Deliverer", "DeliveryError", "FileDeliverer", "TelegramDeliverer", "build_deliverers"] __all__ = [
"BloggerDeliverer",
"Deliverer",
"DeliveryError",
"FileDeliverer",
"TelegramDeliverer",
"build_deliverers",
]
def build_deliverers(settings: Settings) -> list[Deliverer]: def build_deliverers(settings: Settings) -> list[Deliverer]:
"""FileDeliverer always; TelegramDeliverer when both token and chat id are configured.""" """FileDeliverer always; Telegram / Blogger when fully configured."""
deliverers: list[Deliverer] = [FileDeliverer(settings.reports_dir)] deliverers: list[Deliverer] = [FileDeliverer(settings.reports_dir)]
if settings.telegram_enabled: if settings.telegram_enabled:
assert settings.telegram_bot_token and settings.telegram_chat_id # guaranteed by telegram_enabled assert settings.telegram_bot_token and settings.telegram_chat_id # guaranteed by telegram_enabled
deliverers.append(TelegramDeliverer(settings.telegram_bot_token, settings.telegram_chat_id)) deliverers.append(TelegramDeliverer(settings.telegram_bot_token, settings.telegram_chat_id))
if settings.blogger_enabled:
deliverers.append(
BloggerDeliverer(
settings.blogger_client_id or "",
settings.blogger_client_secret or "",
settings.blogger_refresh_token or "",
settings.blogger_blog_id or "",
labels=settings.blogger_labels,
)
)
log.info("deliverers: %s", ", ".join(d.name for d in deliverers)) log.info("deliverers: %s", ", ".join(d.name for d in deliverers))
return deliverers return deliverers
+202
View File
@@ -0,0 +1,202 @@
"""Blogger deliverer: publish each briefing as a post via the Blogger API v3.
Auth: a long-lived OAuth refresh token (issued once with ``us2kr blogger-auth``) is exchanged for a
short-lived access token, cached until shortly before expiry. Secrets travel only in POST bodies and
the Authorization header - never in URLs, so nothing needs log masking.
"""
from __future__ import annotations
import logging
import time
from typing import Any
import httpx
import markdown as md
from us2kr.deliver.base import DeliveryError
from us2kr.models import BriefingResult
log = logging.getLogger(__name__)
TOKEN_URL = "https://oauth2.googleapis.com/token"
API_BASE = "https://www.googleapis.com/blogger/v3"
TIMEOUT_SECONDS = 30.0
MAX_ATTEMPTS = 3
BACKOFF_BASE_SECONDS = 1.0
TOKEN_SAFETY_MARGIN_SECONDS = 60.0
_MD = md.Markdown(extensions=["tables"], output_format="html")
# Blogger themes ship no <table> styling, so the numbers render as a borderless jumble.
# Inline styles are the only thing every theme (and the mobile view) reliably honors.
_TABLE_STYLES: tuple[tuple[str, str], ...] = (
("<table>", '<table style="border-collapse:collapse;margin:10px 0;">'),
(
"<th>",
'<th style="border:1px solid #ccc;padding:4px 10px;background:#f2f2f2;text-align:left;">',
),
("<td>", '<td style="border:1px solid #ccc;padding:4px 10px;white-space:nowrap;">'),
)
def briefing_html(markdown_text: str) -> str:
"""Briefing markdown -> HTML body. The leading H1 is dropped (the post title carries it)."""
lines = markdown_text.splitlines()
if lines and lines[0].startswith("# "):
lines = lines[1:]
_MD.reset() # the converter keeps per-document state (e.g. reference links)
html = _MD.convert("\n".join(lines).strip())
for plain, styled in _TABLE_STYLES:
html = html.replace(plain, styled)
return html
def post_title_prefix(result: BriefingResult) -> str:
"""Stable per-day prefix - resends update the existing post instead of creating a duplicate."""
label = "미국 주간 리뷰" if result.kind == "weekly" else "미국 마감 브리핑"
return f"{label} {result.payload.krx_date.isoformat()}"
def post_title(result: BriefingResult) -> str:
"""'{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()} 세션)"
class BloggerDeliverer:
"""Publish the briefing to one Blogger blog."""
name = "blogger"
def __init__(
self,
client_id: str,
client_secret: str,
refresh_token: str,
blog_id: str,
labels: list[str] | None = None,
client: httpx.Client | None = None,
) -> None:
if not (client_id and client_secret and refresh_token and blog_id):
raise ValueError("BloggerDeliverer requires client_id, client_secret, refresh_token, blog_id")
self._client_id = client_id
self._client_secret = client_secret
self._refresh_token = refresh_token
self.blog_id = blog_id
self.labels = list(labels or [])
self._client = client or httpx.Client(timeout=httpx.Timeout(TIMEOUT_SECONDS))
self._access_token: str | None = None
self._token_deadline = 0.0
# ------------------------------------------------------------------ auth
def _token(self, force: bool = False) -> str:
if not force and self._access_token and time.monotonic() < self._token_deadline:
return self._access_token
try:
resp = self._client.post(
TOKEN_URL,
data={
"client_id": self._client_id,
"client_secret": self._client_secret,
"refresh_token": self._refresh_token,
"grant_type": "refresh_token",
},
)
except httpx.HTTPError as exc:
raise DeliveryError(f"blogger token refresh failed: {type(exc).__name__}: {exc}") from exc
if resp.status_code != 200:
raise DeliveryError(f"blogger token refresh failed: HTTP {resp.status_code} {resp.text[:200]}")
data = resp.json()
token = data.get("access_token")
if not token:
raise DeliveryError("blogger token refresh failed: no access_token in response")
self._access_token = token
self._token_deadline = (
time.monotonic() + float(data.get("expires_in", 3600)) - TOKEN_SAFETY_MARGIN_SECONDS
)
return token
# ------------------------------------------------------------------ delivery
def deliver(self, result: BriefingResult) -> str:
"""Create the day's post - or update it when one already exists - and return its public URL."""
body: dict[str, Any] = {
"kind": "blogger#post",
"title": post_title(result),
"content": briefing_html(result.markdown),
}
if self.labels:
body["labels"] = self.labels
existing_id = self._find_existing(post_title_prefix(result))
if existing_id:
data = self._request("PUT", f"{API_BASE}/blogs/{self.blog_id}/posts/{existing_id}", json_body=body)
action = "updated"
else:
data = self._request("POST", f"{API_BASE}/blogs/{self.blog_id}/posts/", json_body=body)
action = "published"
post_url = data.get("url") or f"post id {data.get('id', '?')}"
log.info("blogger post %s: %s", action, post_url)
return post_url
def _find_existing(self, title_prefix: str) -> str | None:
"""Id of the newest post whose title carries this day's prefix; None on miss or lookup failure."""
try:
data = self._request(
"GET",
f"{API_BASE}/blogs/{self.blog_id}/posts",
params={"fetchBodies": "false", "maxResults": "25"},
)
except DeliveryError as exc:
if "token refresh failed" in str(exc):
raise # a broken token will fail the publish too - surface it once, immediately
# any other lookup failure must not block a fresh publish
log.warning("blogger post lookup failed, publishing as a new post: %s", exc)
return None
for item in data.get("items", []):
if str(item.get("title", "")).startswith(title_prefix):
return str(item["id"])
return None
def _request(
self,
method: str,
url: str,
*,
params: dict[str, str] | None = None,
json_body: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""One authorized API call with retries (fresh token on 401, backoff on 429/5xx)."""
force_token = False
last_error = "unknown error"
for attempt in range(1, MAX_ATTEMPTS + 1):
try:
resp = self._client.request(
method,
url,
params=params,
json=json_body,
headers={"Authorization": f"Bearer {self._token(force=force_token)}"},
)
except httpx.HTTPError as exc:
last_error = f"{type(exc).__name__}: {exc}"
log.warning("blogger %s attempt %d/%d failed: %s", method, attempt, MAX_ATTEMPTS, last_error)
time.sleep(BACKOFF_BASE_SECONDS * attempt)
continue
if resp.status_code == 200:
return resp.json()
last_error = f"HTTP {resp.status_code} {resp.text[:200]}"
if resp.status_code == 401 and not force_token:
force_token = True # cached token stale/revoked - mint a fresh one and retry
continue
if resp.status_code in (429, 500, 502, 503, 504) and attempt < MAX_ATTEMPTS:
log.warning("blogger %s attempt %d/%d failed: %s", method, attempt, MAX_ATTEMPTS, last_error)
time.sleep(BACKOFF_BASE_SECONDS * attempt)
continue
break # non-retryable (4xx)
raise DeliveryError(f"blogger delivery failed: {last_error}")
def notify_error(self, subject: str, detail: str) -> None:
"""A public blog is not an operations channel - log only. Must never raise."""
log.warning("blogger notify_error (not published): %s: %s", subject, detail[:200])
+23 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Protocol from typing import Protocol
from us2kr.models import MarketPayload, Report from us2kr.models import MarketPayload, Report, Verification, VerificationIssue
class LLMError(RuntimeError): class LLMError(RuntimeError):
@@ -15,3 +15,25 @@ class LLMProvider(Protocol):
def summarize(self, payload: MarketPayload) -> tuple[Report, str]: def summarize(self, payload: MarketPayload) -> tuple[Report, str]:
"""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 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],
*,
system_prompt: str | None = None,
data_message: str | None = None,
) -> tuple[Report, str]:
"""Return a corrected Report addressing `issues`. Raise LLMError on total failure."""
...
+125 -38
View File
@@ -9,12 +9,21 @@ import re
from typing import Any, get_origin from typing import Any, get_origin
import openai import openai
from pydantic import ValidationError from pydantic import BaseModel, ValidationError
from us2kr.config import Settings from us2kr.config import Settings
from us2kr.llm.base import LLMError from us2kr.llm.base import LLMError
from us2kr.llm.prompts import SYSTEM_PROMPT, build_user_message from us2kr.llm.prompts import (
from us2kr.models import MarketPayload, Report REVISE_INSTRUCTION,
SYSTEM_PROMPT,
VERIFY_SYSTEM_PROMPT,
WEEKLY_SYSTEM_PROMPT,
build_user_message,
build_verify_message_from,
build_weekly_user_message,
format_issues,
)
from us2kr.models import MarketPayload, Report, Verification, VerificationIssue
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -46,16 +55,24 @@ def _strip_titles(node: Any) -> None:
_strip_titles(value) _strip_titles(value)
def report_json_schema() -> dict[str, Any]: def model_json_schema(model_cls: type[BaseModel]) -> dict[str, Any]:
"""JSON schema for `Report` suitable for `response_format` (no titles, closed object).""" """JSON schema for a pydantic model suitable for `response_format` (no titles, closed object)."""
schema = copy.deepcopy(Report.model_json_schema()) schema = copy.deepcopy(model_cls.model_json_schema())
_strip_titles(schema) _strip_titles(schema)
schema["additionalProperties"] = False schema["additionalProperties"] = False
return schema return schema
def model_response_format(model_cls: type[BaseModel], name: str) -> dict[str, Any]:
return {"type": "json_schema", "json_schema": {"name": name, "schema": model_json_schema(model_cls)}}
def report_json_schema() -> dict[str, Any]:
return model_json_schema(Report)
def report_response_format() -> dict[str, Any]: def report_response_format() -> dict[str, Any]:
return {"type": "json_schema", "json_schema": {"name": "report", "schema": report_json_schema()}} return model_response_format(Report, "report")
def _first_json_object(text: str) -> str | None: def _first_json_object(text: str) -> str | None:
@@ -108,9 +125,15 @@ def extract_json_text(raw: str) -> str:
return text return text
_LIST_FIELDS: frozenset[str] = frozenset( def _list_fields(model_cls: type[BaseModel]) -> frozenset[str]:
name for name, info in Report.model_fields.items() if get_origin(info.annotation) is list """Names of the model's plain string-list fields (targets for the string->list coercion)."""
) return frozenset(
name
for name, info in model_cls.model_fields.items()
if get_origin(info.annotation) is list and info.annotation == list[str]
)
_BULLET_SPLIT = re.compile(r"(?:^|\n)\s*(?:[-*•]|\d+[.)])\s+") _BULLET_SPLIT = re.compile(r"(?:^|\n)\s*(?:[-*•]|\d+[.)])\s+")
_SENTENCE_SPLIT = re.compile(r"(?<=[.!?。])\s+") _SENTENCE_SPLIT = re.compile(r"(?<=[.!?。])\s+")
@@ -125,8 +148,8 @@ def _split_prose(text: str) -> list[str]:
return parts or [text.strip()] return parts or [text.strip()]
def coerce_report_data(data: Any) -> tuple[Any, list[str]]: def coerce_model_data(data: Any, model_cls: type[BaseModel]) -> tuple[Any, list[str]]:
"""Fix the common MiniMax schema slip of emitting list fields as a single string. """Fix the common MiniMax schema slip of emitting string-list fields as a single string.
Returns (data, names of coerced fields). Anything that is not a dict is returned untouched so that Returns (data, names of coerced fields). Anything that is not a dict is returned untouched so that
pydantic can report the real error. pydantic can report the real error.
@@ -134,7 +157,7 @@ def coerce_report_data(data: Any) -> tuple[Any, list[str]]:
if not isinstance(data, dict): if not isinstance(data, dict):
return data, [] return data, []
coerced: list[str] = [] coerced: list[str] = []
for name in _LIST_FIELDS: for name in _list_fields(model_cls):
value = data.get(name) value = data.get(name)
if isinstance(value, str): if isinstance(value, str):
data[name] = _split_prose(value) data[name] = _split_prose(value)
@@ -142,17 +165,25 @@ def coerce_report_data(data: Any) -> tuple[Any, list[str]]:
return data, coerced return data, coerced
def parse_report(raw: str) -> Report: def coerce_report_data(data: Any) -> tuple[Any, list[str]]:
"""Turn raw model content into a validated Report (raises ValueError / ValidationError).""" return coerce_model_data(data, Report)
def parse_model[T: BaseModel](raw: str, model_cls: type[T]) -> T:
"""Turn raw model content into a validated instance (raises ValueError / ValidationError)."""
text = extract_json_text(raw) text = extract_json_text(raw)
try: try:
data = json.loads(text) data = json.loads(text)
except json.JSONDecodeError: except json.JSONDecodeError:
return Report.model_validate_json(text) # let pydantic produce the canonical error return model_cls.model_validate_json(text) # let pydantic produce the canonical error
data, coerced = coerce_report_data(data) data, coerced = coerce_model_data(data, model_cls)
if coerced: if coerced:
log.warning("coerced string -> list for report fields: %s", ", ".join(sorted(coerced))) log.warning("coerced string -> list for %s fields: %s", model_cls.__name__, ", ".join(sorted(coerced)))
return Report.model_validate(data) return model_cls.model_validate(data)
def parse_report(raw: str) -> Report:
return parse_model(raw, Report)
class MiniMaxProvider: class MiniMaxProvider:
@@ -174,7 +205,6 @@ class MiniMaxProvider:
max_retries=2, max_retries=2,
) )
self._client = client self._client = client
self._response_format = report_response_format()
@property @property
def models(self) -> list[str]: def models(self) -> list[str]:
@@ -182,28 +212,76 @@ class MiniMaxProvider:
# ------------------------------------------------------------------ public # ------------------------------------------------------------------ public
def summarize(self, payload: MarketPayload) -> tuple[Report, str]: def summarize(self, payload: MarketPayload) -> tuple[Report, str]:
user_message = build_user_message(payload) messages: list[dict[str, Any]] = [
base_messages: list[dict[str, Any]] = [
{"role": "system", "content": SYSTEM_PROMPT}, {"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_message}, {"role": "user", "content": build_user_message(payload)},
] ]
attempts: list[str] = [] return self._run(messages, Report, "report", task="summarize")
for model in self._models:
result = self._attempt_model(model, base_messages, attempts) def summarize_weekly(self, payload: MarketPayload, week_sessions: list) -> tuple[Report, str]:
if result is not None: """Weekly recap: payload.quotes carry week-over-week changes, history_notes the daily headlines."""
return result, model messages: list[dict[str, Any]] = [
raise LLMError("all MiniMax models failed: " + " | ".join(attempts)) {"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_from(source, report)},
]
return self._run(messages, Verification, "verification", task="verify")
def revise(
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 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)},
]
return self._run(messages, Report, "report", task="revise")
# ----------------------------------------------------------------- helpers # ----------------------------------------------------------------- helpers
def _attempt_model( def _run[T: BaseModel](
self, model: str, messages: list[dict[str, Any]], attempts: list[str] self, messages: list[dict[str, Any]], model_cls: type[T], schema_name: str, task: str
) -> Report | None: ) -> tuple[T, str]:
"""Try every configured model in order; return (parsed instance, model id)."""
response_format = model_response_format(model_cls, schema_name)
attempts: list[str] = []
for model in self._models:
result = self._attempt_model(model, messages, attempts, model_cls, response_format)
if result is not None:
return result, model
raise LLMError(f"all MiniMax models failed ({task}): " + " | ".join(attempts))
def _attempt_model[T: BaseModel](
self,
model: str,
messages: list[dict[str, Any]],
attempts: list[str],
model_cls: type[T],
response_format: dict[str, Any],
) -> T | None:
"""One model: initial call plus at most one repair retry. Returns None when the model failed.""" """One model: initial call plus at most one repair retry. Returns None when the model failed."""
content = self._call(model, messages, attempts) content = self._call(model, messages, attempts, response_format)
if content is None: if content is None:
return None return None
try: try:
return parse_report(content) return parse_model(content, model_cls)
except (ValidationError, ValueError) as exc: except (ValidationError, ValueError) as exc:
error_text = _short(str(exc), 800) error_text = _short(str(exc), 800)
attempts.append(f"{model}: invalid report ({_short(str(exc), 160)})") attempts.append(f"{model}: invalid report ({_short(str(exc), 160)})")
@@ -218,18 +296,23 @@ class MiniMaxProvider:
{"role": "assistant", "content": _short(content, 12000)}, {"role": "assistant", "content": _short(content, 12000)},
{"role": "user", "content": REPAIR_INSTRUCTION + error_text}, {"role": "user", "content": REPAIR_INSTRUCTION + error_text},
] ]
content = self._call(model, repair_messages, attempts, label="repair") content = self._call(model, repair_messages, attempts, response_format, label="repair")
if content is None: if content is None:
return None return None
try: try:
return parse_report(content) return parse_model(content, model_cls)
except (ValidationError, ValueError) as exc: except (ValidationError, ValueError) as exc:
attempts.append(f"{model} (repair): invalid report ({_short(str(exc), 160)})") attempts.append(f"{model} (repair): invalid report ({_short(str(exc), 160)})")
log.warning("model %s repair retry still invalid: %s", model, _short(str(exc), 300)) log.warning("model %s repair retry still invalid: %s", model, _short(str(exc), 300))
return None return None
def _call( def _call(
self, model: str, messages: list[dict[str, Any]], attempts: list[str], label: str = "initial" self,
model: str,
messages: list[dict[str, Any]],
attempts: list[str],
response_format: dict[str, Any],
label: str = "initial",
) -> str | None: ) -> str | None:
"""Perform one chat completion. Returns content text, or None (attempt recorded) on failure.""" """Perform one chat completion. Returns content text, or None (attempt recorded) on failure."""
tag = model if label == "initial" else f"{model} ({label})" tag = model if label == "initial" else f"{model} ({label})"
@@ -241,7 +324,7 @@ class MiniMaxProvider:
model=model, model=model,
messages=messages, messages=messages,
max_completion_tokens=self._settings.minimax_max_completion_tokens, max_completion_tokens=self._settings.minimax_max_completion_tokens,
response_format=self._response_format, response_format=response_format,
extra_body=extra_body, extra_body=extra_body,
) )
except OPENAI_ERRORS as exc: except OPENAI_ERRORS as exc:
@@ -297,8 +380,12 @@ def _short(text: str, limit: int) -> str:
__all__ = [ __all__ = [
"OPENAI_ERRORS", "OPENAI_ERRORS",
"MiniMaxProvider", "MiniMaxProvider",
"coerce_model_data",
"coerce_report_data", "coerce_report_data",
"extract_json_text", "extract_json_text",
"model_json_schema",
"model_response_format",
"parse_model",
"parse_report", "parse_report",
"report_json_schema", "report_json_schema",
"report_response_format", "report_response_format",
+106
View File
@@ -64,6 +64,11 @@ SYSTEM_PROMPT: str = f"""당신은 한국 투자자를 위한 미국 시장 마
8. 출력은 지정된 JSON 스키마만 반환합니다. 스키마 밖의 텍스트, 마크다운 코드 펜스, 설명을 추가하지 않습니다. 8. 출력은 지정된 JSON 스키마만 반환합니다. 스키마 밖의 텍스트, 마크다운 코드 펜스, 설명을 추가하지 않습니다.
배열 필드는 반드시 JSON 배열(각 항목이 한 문장인 문자열 배열)로 출력하고, 여러 문장을 하나의 문자열로 이어 쓰지 배열 필드는 반드시 JSON 배열(각 항목이 한 문장인 문자열 배열)로 출력하고, 여러 문장을 하나의 문자열로 이어 쓰지
않습니다. 않습니다.
9. 날짜·요일·"첫 거래일 / 마지막 거래일"·"월초 / 월말" 같은 시점 표현은 전부 **미국 거래일**(세션 정보의
'미국 거래일') 기준으로만 씁니다. 미국 장 마감이 한국 시간으로 다음 날 새벽이라 '한국 거래일'은 하루 뒤인
경우가 많은데, 한국 거래일 날짜를 미국 장 서술에 쓰면 안 됩니다.
예: 미국 거래일이 8월 31일(월)이면 이 세션은 "8월 마지막 거래일"입니다 — 한국 거래일이 9월 1일이어도
"9월 첫 거래일"이라고 쓰면 틀립니다.
## 입력 데이터 용어집 ## 입력 데이터 용어집
사용자 메시지의 시세는 아래 그룹으로 나뉘어 제공됩니다. 사용자 메시지의 시세는 아래 그룹으로 나뉘어 제공됩니다.
@@ -164,6 +169,107 @@ 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 = """당신은 미국 시장 브리핑의 팩트체커입니다. 브리핑 초안이 함께 제공된 원본 데이터와
모순되지 않는지만 검사합니다. 문체·구성·의견은 평가하지 않습니다.
## 검사 규칙
1. 판단 근거는 사용자 메시지에 제공된 데이터(시세·히스토리 노트·뉴스·경제지표·실적)뿐입니다.
당신의 배경지식이나 최신 시황과 달라 보여도, 제공된 데이터와 일치하면 문제가 아닙니다.
2. major: 브리핑의 숫자가 데이터와 다르거나(반올림 오차 ±0.05는 허용), 상승/하락 방향이 반대이거나,
데이터에 없는 숫자·사건을 사실처럼 서술한 경우. 시점 표현이 세션 정보의 '미국 거래일'과 어긋나는 경우도
major입니다 — 날짜·요일·"N월 첫/마지막 거래일"·"월초/월말"은 미국 거래일 기준이어야 하며, 한국 거래일
날짜로 미국 장을 서술하면 안 됩니다 (예: 미국 거래일이 8월 31일인 세션을 "9월 첫 거래일"로 서술).
3. minor: 뉴스 헤드라인만으로 확인되지 않는 인과관계를 단정, 제공된 표기와 다른 종목명, 모호하지만
틀렸다고 단정할 수 없는 서술.
4. 각 이슈의 claim에는 브리핑 원문을 그대로 인용하고, problem에는 데이터의 실제 값과 함께 한 문장으로
설명합니다.
5. major 이슈가 하나도 없으면 ok=true 입니다. 사소한 트집을 만들지 말고, 문제가 없으면 빈 issues로
반환합니다.
6. 출력은 지정된 JSON 스키마만 반환합니다. 스키마 밖 텍스트·설명·코드 펜스를 추가하지 않습니다.
"""
REVISE_INSTRUCTION: str = (
"위 브리핑 초안에서 아래 검증 이슈가 발견되었습니다. 제공된 원본 데이터에 맞게 해당 서술만 고치고,"
" 문제없는 부분은 그대로 유지하여 동일한 JSON 스키마로 전체 브리핑을 다시 출력하세요."
" 설명 없이 JSON 객체 하나만 출력합니다.\n\n검증 이슈:\n"
)
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_verify_message_from(build_user_message(payload), report)
def format_issues(issues: list) -> str:
"""Issues -> numbered plain-text block for the revise instruction."""
lines = []
for i, issue in enumerate(issues, 1):
lines.append(f"{i}. [{issue.severity}] 서술: {issue.claim}\n 문제: {issue.problem}")
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] = []
+23
View File
@@ -91,6 +91,25 @@ class Report(BaseModel):
data_gaps: list[str] = Field(default_factory=list, description="수집 실패로 반영하지 못한 항목") data_gaps: list[str] = Field(default_factory=list, description="수집 실패로 반영하지 못한 항목")
class VerificationIssue(BaseModel):
claim: str = Field(description="문제가 된 브리핑 속 서술 (원문 그대로 인용)")
problem: str = Field(description="제공된 데이터와 어떻게 다른지 한 문장")
severity: Literal["minor", "major"] = Field(
default="minor", description="major: 수치·방향이 데이터와 모순 / minor: 근거 불충분·표현 문제"
)
class Verification(BaseModel):
"""LLM fact-check verdict for a generated Report, judged only against the payload."""
ok: bool = Field(description="major 이슈가 하나도 없으면 true")
issues: list[VerificationIssue] = Field(default_factory=list, description="발견된 문제 목록 (없으면 빈 배열)")
@property
def has_major(self) -> bool:
return any(i.severity == "major" for i in self.issues)
class BriefingResult(BaseModel): class BriefingResult(BaseModel):
payload: MarketPayload payload: MarketPayload
report: Report | None report: Report | None
@@ -98,3 +117,7 @@ class BriefingResult(BaseModel):
model_used: str | None = None model_used: str | None = None
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)
# "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"
+18 -2
View File
@@ -23,10 +23,10 @@ from us2kr.config import KST, Settings
from us2kr.deliver import build_deliverers from us2kr.deliver import build_deliverers
from us2kr.llm.base import LLMError from us2kr.llm.base import LLMError
from us2kr.market_time import MarketClock from us2kr.market_time import MarketClock
from us2kr.models import BriefingResult, MarketPayload, Report from us2kr.models import BriefingResult, MarketPayload, Report, Verification
from us2kr.render import render_markdown from us2kr.render import render_markdown
from us2kr.store import Store from us2kr.store import Store
from us2kr.summarize import build_provider, summarize_payload from us2kr.summarize import build_provider, summarize_payload, verify_and_revise
from us2kr.symbols import SYMBOL_GROUPS from us2kr.symbols import SYMBOL_GROUPS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -162,6 +162,18 @@ def _summarize(payload: MarketPayload, settings: Settings) -> tuple[Report | Non
return None, None, f"{type(exc).__name__}: {exc}" return None, None, f"{type(exc).__name__}: {exc}"
def _verify(
payload: MarketPayload, report: Report, settings: Settings
) -> tuple[Report, Verification | None]:
"""Fact-check (and possibly revise) the report. Any failure keeps the report and returns None."""
try:
provider = build_provider(settings)
return verify_and_revise(payload, report, provider)
except Exception as exc: # noqa: BLE001 - verification must never block the briefing
log.warning("verification failed, delivering unverified report: %s: %s", type(exc).__name__, exc)
return report, None
def _superseded_before_krx_open(session: date, krx_date: date, clock: MarketClock) -> bool: def _superseded_before_krx_open(session: date, krx_date: date, clock: MarketClock) -> bool:
"""True when another US session closes before KRX next trades, so this briefing would be stale. """True when another US session closes before KRX next trades, so this briefing would be stale.
@@ -271,6 +283,7 @@ def run_briefing(
report: Report | None = None report: Report | None = None
model_used: str | None = None model_used: str | None = None
error: str | None = None error: str | None = None
verification: Verification | None = None
degraded = False degraded = False
if no_llm: if no_llm:
degraded = True degraded = True
@@ -278,6 +291,8 @@ def run_briefing(
else: else:
report, model_used, error = _summarize(payload, settings) report, model_used, error = _summarize(payload, settings)
degraded = report is None degraded = report is None
if report is not None and settings.verify_enabled:
report, verification = _verify(payload, report, settings)
markdown = render_markdown(payload, report, model_used) markdown = render_markdown(payload, report, model_used)
result = BriefingResult( result = BriefingResult(
@@ -287,6 +302,7 @@ def run_briefing(
model_used=model_used, model_used=model_used,
degraded=degraded, degraded=degraded,
error=error, error=error,
verification=verification,
) )
if dry_run: if dry_run:
print(markdown) print(markdown)
+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(
+73 -6
View File
@@ -5,9 +5,9 @@ from __future__ import annotations
import logging import logging
from us2kr.config import Settings from us2kr.config import Settings
from us2kr.llm.base import LLMProvider from us2kr.llm.base import LLMError, LLMProvider
from us2kr.llm.minimax import MiniMaxProvider from us2kr.llm.minimax import MiniMaxProvider
from us2kr.models import MarketPayload, Report from us2kr.models import MarketPayload, Report, Verification
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -17,14 +17,81 @@ 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 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
def _consistent(verification: Verification) -> Verification:
"""Force `ok` to mean "no major issues" - verifier models are inconsistent about minor-only cases."""
ok = not verification.has_major
return verification if verification.ok == ok else verification.model_copy(update={"ok": ok})
def verify_and_revise(
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.
`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, data_message)
verification = _consistent(raw_verification)
log.info(
"verification by %s: ok=%s issues=%d (major=%s)",
model,
verification.ok,
len(verification.issues),
verification.has_major,
)
if not verification.has_major:
return report, verification
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, 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, 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)
)
return revised, re_verification
except LLMError as exc:
log.warning("revision failed; keeping the original report and verdict: %s", exc)
return report, verification
+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()