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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-12 23:52:31 +09:00

314 lines
16 KiB
Python

"""Prompt construction for the briefing LLM.
`SYSTEM_PROMPT` is static (no dates, no timestamps) so that the provider's prompt cache can hit on
every run. All per-day data goes into the user message built by `build_user_message`, which is a
deterministic, human-readable serialisation of a `MarketPayload`.
"""
from __future__ import annotations
from datetime import datetime
from typing import get_origin
from zoneinfo import ZoneInfo
from us2kr.models import EarningsItem, EconEvent, MarketPayload, NewsItem, Quote, Report
from us2kr.symbols import GROUP_TITLES_KO, POINT_CHANGE_GROUPS, SYMBOL_GROUPS
_KST = ZoneInfo("Asia/Seoul")
# Maximum characters of a news summary carried into the prompt (token budget control).
NEWS_SUMMARY_MAX_CHARS = 160
_GROUP_GLOSSARY = "\n".join(f"- {group}: {title}" for group, title in GROUP_TITLES_KO.items())
def _is_list_field(annotation: object) -> bool:
return get_origin(annotation) is list
def _field_guide() -> str:
lines = []
for name, info in Report.model_fields.items():
kind = "문자열 배열(각 항목 한 문장)" if _is_list_field(info.annotation) else "문자열"
lines.append(f"- {name}: {kind} - {info.description or ''}".rstrip(" -"))
return "\n".join(lines)
def _skeleton() -> str:
parts = []
for name, info in Report.model_fields.items():
value = '["문장 1", "문장 2"]' if _is_list_field(info.annotation) else '"문장"'
parts.append(f' "{name}": {value}')
return "{\n" + ",\n".join(parts) + "\n}"
_FIELD_GUIDE = _field_guide()
_SKELETON = _skeleton()
SYSTEM_PROMPT: str = f"""당신은 한국 투자자를 위한 미국 시장 마감 브리핑 작성자입니다.
미국 정규장이 마감된 직후, 한국 장이 열리기 전에 읽히는 브리핑을 작성합니다.
## 작성 규칙
1. 숫자는 사용자 메시지에 제공된 값만 사용합니다. 제공되지 않은 숫자(가격, 변동률, 예상치 등)를 새로 만들거나 추정하지 않습니다.
계산이 필요하면 제공된 값으로만 하고, 확실하지 않으면 숫자를 생략하고 방향(상승/하락/보합)만 서술합니다.
2. 뉴스는 제공된 헤드라인·요약 범위 안에서만 언급합니다. 헤드라인만으로 확인되지 않은 배경이나 인과관계는
"보도에 따르면"으로 시작하는 문장으로 표기하고 단정하지 않습니다.
3. 투자 조언(매수/매도/비중 조절 권고)을 하지 않습니다. 사실과 흐름을 정리하는 정보 요약 톤을 유지합니다.
4. 한국 장 관점을 반드시 포함합니다: 반도체(SOX·SMH·엔비디아·마이크론·TSMC 등)의 흐름, 달러/원 환율, 한국 ADR과
MSCI Korea ETF(EWY)의 움직임, 미국 국채 금리 변화가 한국 시장에 주는 시사점을 다룹니다.
5. 간결한 한국어 경어체(~습니다)로 작성합니다. 모든 필드는 한국어로만 작성하며 중국어·일본어·영어 문장을 섞지
않습니다(티커, 기관명, 약어 등 고유명사는 예외). 불필요한 수식어와 반복을 피합니다. 종목·지수 이름은 제공된
표기를 그대로 사용합니다.
6. 오늘 한국 장이 휴장이면 "오늘 한국 휴장"임을 감안해 다음 거래일 관점으로 서술합니다.
7. 수집에 실패해 제공되지 않은 항목은 언급을 피하고, 그 사실을 data_gaps에 그대로 적습니다.
8. 출력은 지정된 JSON 스키마만 반환합니다. 스키마 밖의 텍스트, 마크다운 코드 펜스, 설명을 추가하지 않습니다.
배열 필드는 반드시 JSON 배열(각 항목이 한 문장인 문자열 배열)로 출력하고, 여러 문장을 하나의 문자열로 이어 쓰지
않습니다.
9. 날짜·요일·"첫 거래일 / 마지막 거래일"·"월초 / 월말" 같은 시점 표현은 전부 **미국 거래일**(세션 정보의
'미국 거래일') 기준으로만 씁니다. 미국 장 마감이 한국 시간으로 다음 날 새벽이라 '한국 거래일'은 하루 뒤인
경우가 많은데, 한국 거래일 날짜를 미국 장 서술에 쓰면 안 됩니다.
예: 미국 거래일이 8월 31일(월)이면 이 세션은 "8월 마지막 거래일"입니다 — 한국 거래일이 9월 1일이어도
"9월 첫 거래일"이라고 쓰면 틀립니다.
## 입력 데이터 용어집
사용자 메시지의 시세는 아래 그룹으로 나뉘어 제공됩니다.
{_GROUP_GLOSSARY}
각 시세 줄의 형식: `심볼 이름: 종가 (전일 대비)`. 전일 대비는 기본적으로 퍼센트(%)이며, 국채 금리는 수익률 변화폭(%p)입니다.
`애프터` 표기는 애프터마켓 가격과 정규장 종가 대비 변동률입니다. 값이 없는 항목은 "자료 없음"으로 표시됩니다.
"히스토리 노트"는 저장된 과거 데이터에서 계산한 연속 상승/하락, 평균 대비 변동 폭, 레벨 돌파 등의 참고 메모입니다.
"경제지표"의 [발표]는 이미 발표된 지표, [예정]은 다음 하루 안에 예정된 이벤트입니다.
"실적"의 amc는 장 마감 후 발표, bmo는 다음 개장 전 발표를 뜻합니다.
## 출력 스키마 필드 안내 (필드명: 타입 - 내용)
{_FIELD_GUIDE}
출력 형태 예시(값은 자리표시자):
{_SKELETON}
"""
def _fmt_num(value: float | None, decimals: int = 2) -> str:
if value is None:
return "자료 없음"
return f"{value:,.{decimals}f}"
def _fmt_signed(value: float | None, decimals: int, suffix: str) -> str:
if value is None:
return "변동 자료 없음"
return f"{value:+.{decimals}f}{suffix}"
def _fmt_time_kst(dt: datetime | None, with_date: bool = True) -> str:
if dt is None:
return "시간 미정"
if dt.tzinfo is not None:
dt = dt.astimezone(_KST)
fmt = "%Y-%m-%d %H:%M KST" if with_date else "%H:%M KST"
return dt.strftime(fmt)
def _fmt_quote(q: Quote) -> str:
is_points = q.group in POINT_CHANGE_GROUPS
if is_points:
close = _fmt_num(q.close, 3)
change = _fmt_signed(q.change_abs, 3, "%p")
else:
close = _fmt_num(q.close, 2)
change = _fmt_signed(q.change_pct, 2, "%")
line = f"- {q.symbol} {q.name}: {close} ({change})"
if q.after_hours_price is not None:
ah_change = _fmt_signed(q.after_hours_change_pct, 2, "%")
line += f" | 애프터 {_fmt_num(q.after_hours_price, 2)} ({ah_change})"
return line
def _group_order(groups: list[str]) -> list[str]:
known = [g for g in SYMBOL_GROUPS if g in groups]
unknown = sorted(g for g in groups if g not in SYMBOL_GROUPS)
return known + unknown
def _fmt_news(item: NewsItem) -> list[str]:
lines = [f"- {item.title.strip()} | {item.source} | {_fmt_time_kst(item.published)}"]
summary = " ".join(item.summary.split())
if summary:
if len(summary) > NEWS_SUMMARY_MAX_CHARS:
summary = summary[: NEWS_SUMMARY_MAX_CHARS - 1] + "…"
lines.append(f" 요약: {summary}")
return lines
def _fmt_event(ev: EconEvent) -> str:
tag = "발표" if ev.kind == "released" else "예정"
parts = [f"- [{tag}] {ev.name} ({ev.country}, 중요도 {ev.importance}, {_fmt_time_kst(ev.time)})"]
details = []
if ev.actual is not None:
details.append(f"실제 {ev.actual}")
if ev.forecast is not None:
details.append(f"예상 {ev.forecast}")
if ev.previous is not None:
details.append(f"이전 {ev.previous}")
if details:
parts.append(": " + " / ".join(details))
return "".join(parts)
def _fmt_earnings(e: EarningsItem) -> str:
head = f"- {e.symbol} {e.name} ({e.report_date.isoformat()} {e.timing})"
details = []
if e.eps_actual is not None or e.eps_estimate is not None:
details.append(f"EPS 실제 {_fmt_num(e.eps_actual)} / 예상 {_fmt_num(e.eps_estimate)}")
if e.revenue_actual is not None or e.revenue_estimate is not None:
details.append(f"매출 실제 {_fmt_num(e.revenue_actual, 0)} / 예상 {_fmt_num(e.revenue_estimate, 0)}")
return head + (": " + ", ".join(details) if details else "")
def _bullets(items: list[str]) -> list[str]:
return [f"- {item}" for item in items] or ["(없음)"]
WEEKLY_SYSTEM_PROMPT: str = f"""당신은 한국 투자자를 위한 미국 시장 주간 리뷰 작성자입니다.
주말이 지난 월요일 아침, 한국 장이 열리기 전에 읽히는 "지난주 미국 시장 정리"를 작성합니다.
## 작성 규칙
1. 숫자는 사용자 메시지에 제공된 값만 사용합니다. 시세의 변동률은 **한 주 누적 변동**이므로 반드시
"주간" 관점으로 서술하고, 하루 변동처럼 쓰지 않습니다. 제공되지 않은 숫자를 만들거나 추정하지 않습니다.
2. "일별 요약"은 그 주의 일일 브리핑 헤드라인입니다. 사건 서술은 이 범위 안에서만 하고, 헤드라인만으로
확인되지 않는 배경·인과는 "보도에 따르면"으로 표기하거나 생략합니다.
3. 투자 조언(매수/매도/비중 조절 권고)을 하지 않습니다. 사실과 흐름을 정리하는 정보 요약 톤을 유지합니다.
4. 한국 장 관점을 반드시 포함합니다: 반도체(SOX 등)·환율·한국 ADR/EWY의 주간 흐름과, 이번 주 한국 시장에
주는 시사점을 다룹니다.
5. 간결한 한국어 경어체(~습니다)로 작성합니다. 모든 필드는 한국어로만 작성합니다(고유명사 예외).
6. 날짜·요일·시점 표현은 전부 **미국 거래일** 기준입니다. 세션 정보에 주어진 주간 범위(첫 세션~마지막 세션)
밖의 시점을 서술하지 않습니다.
7. 출력 스키마는 일일 브리핑과 같지만 의미를 주간으로 바꿔 씁니다:
headline=주간 총평 한 줄, market_overview=주간 시장 개요, key_drivers=지난주 주요 사건/요인,
rates_fx_commodities=금리·환율·원자재의 주간 흐름, korea_implications=이번 주 한국 장 시사점,
watchlist_today=이번 주 한국 장에서 확인할 것, risk_flags=이번 주 리스크.
8. 출력은 지정된 JSON 스키마만 반환합니다. 배열 필드는 반드시 JSON 배열(각 항목 한 문장)로 출력합니다.
## 입력 데이터 용어집
시세는 아래 그룹으로 나뉘어 제공되며, 각 줄의 형식은 `심볼 이름: 주말 종가 (주간 변동)` 입니다.
국채 금리의 변동은 주간 수익률 변화폭(%p)입니다. 값이 없는 항목은 "자료 없음"으로 표시됩니다.
{_GROUP_GLOSSARY}
"""
VERIFY_SYSTEM_PROMPT: str = """당신은 미국 시장 브리핑의 팩트체커입니다. 브리핑 초안이 함께 제공된 원본 데이터와
모순되지 않는지만 검사합니다. 문체·구성·의견은 평가하지 않습니다.
## 검사 규칙
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:
"""Serialise the payload into a deterministic Korean text block for the user message."""
out: list[str] = []
out.append("## 세션 정보")
out.append(f"- 미국 거래일: {payload.session_date.isoformat()}")
out.append(f"- 미국 정규장 마감: {_fmt_time_kst(payload.us_close_time)}")
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("## 뉴스")
news_lines = [line for item in payload.news for line in _fmt_news(item)]
out.extend(news_lines or ["(없음)"])
out.append("")
out.append("## 경제지표")
out.extend([_fmt_event(ev) for ev in payload.events] or ["(없음)"])
out.append("")
out.append("## 실적")
out.extend([_fmt_earnings(e) for e in payload.earnings] or ["(없음)"])
out.append("")
out.append("## 수집 누락")
out.extend(_bullets(payload.data_gaps))
return "\n".join(out) + "\n"