After summarisation the report is verified against the collected payload (numbers, directions, unsupported claims) by a second LLM call with a Verification JSON schema. Major issues trigger one revision and a re-check; minor-only verdicts are normalized to ok. Failures never block delivery. The verdict is stored in the result JSON and shown on the dashboard (badge + 검증 메모). Disable with US2KR_VERIFY_ENABLED. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
250 lines
11 KiB
Python
250 lines
11 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 ["(없음)"]
|
|
|
|
|
|
VERIFY_SYSTEM_PROMPT: str = """당신은 미국 시장 브리핑의 팩트체커입니다. 브리핑 초안이 함께 제공된 원본 데이터와
|
|
모순되지 않는지만 검사합니다. 문체·구성·의견은 평가하지 않습니다.
|
|
|
|
## 검사 규칙
|
|
1. 판단 근거는 사용자 메시지에 제공된 데이터(시세·히스토리 노트·뉴스·경제지표·실적)뿐입니다.
|
|
당신의 배경지식이나 최신 시황과 달라 보여도, 제공된 데이터와 일치하면 문제가 아닙니다.
|
|
2. major: 브리핑의 숫자가 데이터와 다르거나(반올림 오차 ±0.05는 허용), 상승/하락 방향이 반대이거나,
|
|
데이터에 없는 숫자·사건을 사실처럼 서술한 경우.
|
|
3. minor: 뉴스 헤드라인만으로 확인되지 않는 인과관계를 단정, 제공된 표기와 다른 종목명, 모호하지만
|
|
틀렸다고 단정할 수 없는 서술.
|
|
4. 각 이슈의 claim에는 브리핑 원문을 그대로 인용하고, problem에는 데이터의 실제 값과 함께 한 문장으로
|
|
설명합니다.
|
|
5. major 이슈가 하나도 없으면 ok=true 입니다. 사소한 트집을 만들지 말고, 문제가 없으면 빈 issues로
|
|
반환합니다.
|
|
6. 출력은 지정된 JSON 스키마만 반환합니다. 스키마 밖 텍스트·설명·코드 펜스를 추가하지 않습니다.
|
|
"""
|
|
|
|
REVISE_INSTRUCTION: str = (
|
|
"위 브리핑 초안에서 아래 검증 이슈가 발견되었습니다. 제공된 원본 데이터에 맞게 해당 서술만 고치고,"
|
|
" 문제없는 부분은 그대로 유지하여 동일한 JSON 스키마로 전체 브리핑을 다시 출력하세요."
|
|
" 설명 없이 JSON 객체 하나만 출력합니다.\n\n검증 이슈:\n"
|
|
)
|
|
|
|
|
|
def build_verify_message(payload: MarketPayload, report: Report) -> str:
|
|
"""User message for the fact-check pass: the payload serialisation plus the draft report."""
|
|
return (
|
|
build_user_message(payload)
|
|
+ "\n## 검증 대상 브리핑 초안 (JSON)\n"
|
|
+ report.model_dump_json(indent=2)
|
|
+ "\n"
|
|
)
|
|
|
|
|
|
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_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"
|