Compare commits
11
Commits
449b6d8ca3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd83b7a7b2 | ||
|
|
f5fa7ec70f | ||
|
|
1e6272da74 | ||
|
|
3ee37bfa49 | ||
|
|
830e4e0522 | ||
|
|
b75bf0b887 | ||
|
|
5232631b44 | ||
|
|
12d7b47f46 | ||
|
|
fecc767a5c | ||
|
|
325ea6622a | ||
|
|
ed48f4c96b |
@@ -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
|
||||||
|
|||||||
@@ -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`) |
|
||||||
|
|
||||||
## 산출물
|
## 산출물
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>/bin/bash</string>
|
<string>/bin/bash</string>
|
||||||
<string>-c</string>
|
<string>-c</string>
|
||||||
<string>__CONTAINER__ system start && __CONTAINER__ start us2kr; __CONTAINER__ start us2kr-dashboard</string>
|
<string>__CONTAINER__ system start && __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>
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)."""
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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] = []
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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]+)\)")
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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()
|
||||||
Reference in New Issue
Block a user