Files
US2KR/us2kr/llm/prompts.py
T
2026-08-31 14:33:53 +09:00

208 lines
9.0 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 배열(각 항목이 한 문장인 문자열 배열)로 출력하고, 여러 문장을 하나의 문자열로 이어 쓰지
않습니다.
## 입력 데이터 용어집
사용자 메시지의 시세는 아래 그룹으로 나뉘어 제공됩니다.
{_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 ["(없음)"]
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"