"""Tests for us2kr.llm.prompts, us2kr.llm.minimax and us2kr.summarize (no network).""" from __future__ import annotations import json import re from datetime import date, datetime from typing import Any from zoneinfo import ZoneInfo import httpx2 import openai import pytest from openai.types.chat import ChatCompletion from pydantic import ValidationError from us2kr.config import Settings from us2kr.llm.base import LLMError from us2kr.llm.minimax import ( MiniMaxProvider, coerce_report_data, extract_json_text, parse_report, report_json_schema, report_response_format, ) from us2kr.llm.prompts import SYSTEM_PROMPT, build_user_message from us2kr.models import EarningsItem, EconEvent, MarketPayload, NewsItem, Quote, Report from us2kr.summarize import build_provider, summarize_payload ET = ZoneInfo("America/New_York") KST = ZoneInfo("Asia/Seoul") GOOD_REPORT: dict[str, Any] = { "headline": "반도체 강세에 나스닥 상승 마감", "market_overview": "주요 지수가 상승했습니다. 반도체가 강했습니다.", "key_drivers": ["엔비디아 실적 기대", "금리 하락"], "rates_fx_commodities": "10년물 금리가 하락했고 달러/원은 소폭 내렸습니다.", "korea_implications": ["SOX 상승은 국내 반도체에 긍정적입니다.", "EWY가 올랐습니다."], "watchlist_today": ["삼성전자 흐름"], "risk_flags": ["VIX 반등 가능성"], "data_gaps": ["news: TimeoutError"], } # --------------------------------------------------------------------------- fixtures def make_settings(**overrides: Any) -> Settings: kwargs: dict[str, Any] = { "MINIMAX_API_KEY": "test-key-not-real", "MINIMAX_MODEL": "MiniMax-M3", "MINIMAX_FALLBACK_MODELS": ["MiniMax-M2.7", "MiniMax-M2.5"], "MINIMAX_MAX_COMPLETION_TOKENS": 4096, } kwargs.update(overrides) return Settings(_env_file=None, **kwargs) def make_payload() -> MarketPayload: session = date(2026, 3, 10) return MarketPayload( session_date=session, us_close_time=datetime(2026, 3, 10, 16, 0, tzinfo=ET), generated_at=datetime(2026, 3, 11, 6, 15, tzinfo=KST), krx_date=date(2026, 3, 11), krx_open_today=True, quotes=[ Quote(symbol="^GSPC", name="S&P 500", group="indices", close=5912.345, change_pct=0.8123), Quote( symbol="^SOX", name="필라델피아 반도체(SOX)", group="indices", close=5321.1, change_pct=2.5 ), Quote(symbol="^TNX", name="미 10년물", group="rates", close=4.2534, change_abs=-0.0512), Quote(symbol="KRW=X", name="달러/원", group="fx", close=1385.25, change_pct=-0.31), Quote( symbol="NVDA", name="엔비디아", group="semis", close=912.5, change_pct=3.2, after_hours_price=920.0, after_hours_change_pct=0.82, ), Quote(symbol="EWY", name="MSCI Korea ETF(EWY)", group="korea_linked", close=None), ], news=[ NewsItem( title="Nvidia rallies ahead of earnings", summary="Shares rose as investors bet on strong data-center demand. " * 5, link="https://example.com/1", source="CNBC", published=datetime(2026, 3, 10, 15, 30, tzinfo=ET), ), NewsItem( title="Treasury yields slip", link="https://example.com/2", source="MarketWatch", published=datetime(2026, 3, 10, 14, 0, tzinfo=ET), ), ], events=[ EconEvent( name="CPI (YoY)", time=datetime(2026, 3, 10, 8, 30, tzinfo=ET), actual="3.1%", forecast="3.2%", previous="3.3%", importance="high", kind="released", ), EconEvent(name="FOMC 의사록", time=None, importance="medium", kind="upcoming"), ], earnings=[ EarningsItem( symbol="MU", name="마이크론", report_date=session, timing="amc", eps_actual=1.23, eps_estimate=1.10, revenue_actual=8_100_000_000, revenue_estimate=7_900_000_000, ) ], history_notes=["S&P 500 3일 연속 상승"], data_gaps=["events: HTTPError"], ) def make_completion( content: str | None, *, model: str = "MiniMax-M3", finish_reason: str = "stop", cached_tokens: int | None = 120, reasoning: str | None = "생각중...", ) -> ChatCompletion: message: dict[str, Any] = {"role": "assistant", "content": content} if reasoning is not None: message["reasoning_content"] = reasoning usage: dict[str, Any] = {"prompt_tokens": 1500, "completion_tokens": 400, "total_tokens": 1900} if cached_tokens is not None: usage["prompt_tokens_details"] = {"cached_tokens": cached_tokens} return ChatCompletion.model_validate( { "id": "chatcmpl-test", "object": "chat.completion", "created": 1_700_000_000, "model": model, "choices": [{"index": 0, "finish_reason": finish_reason, "message": message}], "usage": usage, } ) def make_status_error(status: int, message: str = "server error") -> openai.APIStatusError: request = httpx2.Request("POST", "https://api.minimax.io/v1/chat/completions") response = httpx2.Response(status, request=request, json={"error": message}) if status == 429: return openai.RateLimitError(message, response=response, body={"error": message}) return openai.APIStatusError(message, response=response, body={"error": message}) class FakeCompletions: def __init__(self, responses: list[Any]) -> None: self._responses = list(responses) self.calls: list[dict[str, Any]] = [] def create(self, **kwargs: Any) -> Any: self.calls.append(kwargs) if not self._responses: raise AssertionError("unexpected extra call to chat.completions.create") item = self._responses.pop(0) if isinstance(item, Exception): raise item return item class FakeChat: def __init__(self, completions: FakeCompletions) -> None: self.completions = completions class FakeClient: def __init__(self, responses: list[Any]) -> None: self.completions = FakeCompletions(responses) self.chat = FakeChat(self.completions) @property def calls(self) -> list[dict[str, Any]]: return self.completions.calls @property def models_called(self) -> list[str]: return [c["model"] for c in self.calls] def make_provider(responses: list[Any], **settings_overrides: Any) -> tuple[MiniMaxProvider, FakeClient]: client = FakeClient(responses) return MiniMaxProvider(make_settings(**settings_overrides), client=client), client # ---------------------------------------------------------------------------- prompts def test_system_prompt_has_no_dates_or_timestamps() -> None: assert SYSTEM_PROMPT.strip() assert not re.search(r"\d{4}-\d{2}-\d{2}", SYSTEM_PROMPT) assert not re.search(r"\d{4}년", SYSTEM_PROMPT) assert not re.search(r"\d{1,2}:\d{2}", SYSTEM_PROMPT) assert not re.search(r"20\d{2}", SYSTEM_PROMPT) for keyword in ("한국 투자자", "투자 조언", "보도에 따르면", "JSON", "반도체", "환율", "EWY", "습니다"): assert keyword in SYSTEM_PROMPT def test_system_prompt_contains_group_glossary() -> None: for title in ("주요 지수", "미국 국채 금리", "한국 연관(EWY·ADR)", "반도체", "빅테크"): assert title in SYSTEM_PROMPT def test_system_prompt_declares_field_types_and_korean_only() -> None: assert "한국어로만" in SYSTEM_PROMPT for name in Report.model_fields: assert f"- {name}: " in SYSTEM_PROMPT assert f'"{name}":' in SYSTEM_PROMPT # skeleton example assert "- key_drivers: 문자열 배열" in SYSTEM_PROMPT assert "- headline: 문자열 -" in SYSTEM_PROMPT def test_build_user_message_is_deterministic_and_korean() -> None: payload = make_payload() first = build_user_message(payload) second = build_user_message(make_payload()) assert first == second for title in ( "## 세션 정보", "## 시세", "### 주요 지수", "### 미국 국채 금리", "### 환율·달러", "### 반도체", ): assert title in first for title in ("## 히스토리 노트", "## 뉴스", "## 경제지표", "## 실적", "## 수집 누락"): assert title in first def test_build_user_message_content_and_rounding() -> None: text = build_user_message(make_payload()) # session block: close time converted to KST (16:00 EDT == 05:00 KST next day) assert "2026-03-10" in text assert "2026-03-11 05:00 KST" in text assert "오늘 한국 장: 개장" in text # prices rounded, percent vs points assert "^GSPC S&P 500: 5,912.35 (+0.81%)" in text assert "^TNX 미 10년물: 4.253 (-0.051%p)" in text assert "애프터 920.00 (+0.82%)" in text assert "EWY MSCI Korea ETF(EWY): 자료 없음" in text # news: title | source | KST time; summary whitespace-collapsed and truncated assert "Nvidia rallies ahead of earnings | CNBC | 2026-03-11 04:30 KST" in text assert "요약: Shares rose as investors bet on strong data-center demand." in text assert "…" in text # events / earnings / gaps / history assert "[발표] CPI (YoY)" in text and "실제 3.1% / 예상 3.2% / 이전 3.3%" in text assert "[예정] FOMC 의사록" in text and "시간 미정" in text assert "MU 마이크론 (2026-03-10 amc): EPS 실제 1.23 / 예상 1.10" in text assert "- S&P 500 3일 연속 상승" in text assert "- events: HTTPError" in text def test_build_user_message_empty_sections() -> None: payload = make_payload().model_copy( update={"quotes": [], "news": [], "events": [], "earnings": [], "history_notes": [], "data_gaps": []} ) payload = payload.model_copy(update={"krx_open_today": False}) text = build_user_message(payload) assert "(시세 자료 없음)" in text assert text.count("(없음)") == 5 assert "오늘 한국 장: 휴장" in text # ----------------------------------------------------------------------------- schema def _walk(node: Any): if isinstance(node, dict): yield node for v in node.values(): yield from _walk(v) elif isinstance(node, list): for v in node: yield from _walk(v) def test_report_json_schema_has_no_titles_and_is_closed() -> None: schema = report_json_schema() assert all("title" not in d for d in _walk(schema)) assert schema["additionalProperties"] is False assert schema["type"] == "object" assert set(schema["properties"]) == set(Report.model_fields) fmt = report_response_format() assert fmt["type"] == "json_schema" assert fmt["json_schema"]["name"] == "report" assert fmt["json_schema"]["schema"] == schema json.dumps(schema) # serialisable # Report itself is unchanged by the helper (deepcopy) assert "title" in Report.model_json_schema() # ------------------------------------------------------------------- extraction utils @pytest.mark.parametrize( "raw", [ json.dumps(GOOD_REPORT, ensure_ascii=False), "먼저 지수를 본다...\n" + json.dumps(GOOD_REPORT, ensure_ascii=False), "```json\n" + json.dumps(GOOD_REPORT, ensure_ascii=False) + "\n```", "ab```\n" + json.dumps(GOOD_REPORT, ensure_ascii=False) + "```", # unterminated with an earlier closed one: take text after the last "x" + json.dumps(GOOD_REPORT, ensure_ascii=False) + "\ntrailing", # unterminated and no : fall back to first JSON object "reasoning " + json.dumps(GOOD_REPORT, ensure_ascii=False), "설명입니다: " + json.dumps(GOOD_REPORT, ensure_ascii=False) + " 끝.", ], ) def test_extract_and_parse_variants(raw: str) -> None: text = extract_json_text(raw) assert json.loads(text) == GOOD_REPORT report = parse_report(raw) assert report.headline == GOOD_REPORT["headline"] def test_parse_report_coerces_string_list_fields(caplog: pytest.LogCaptureFixture) -> None: prose = { **GOOD_REPORT, "key_drivers": "상승 요인: 반도체가 이끌었습니다. 하락 요인: 유가가 내렸습니다.", "korea_implications": "- SOX 상승은 긍정적입니다.\n- 환율은 안정적입니다.\n- EWY가 올랐습니다.", "watchlist_today": "1. 삼성전자 흐름\n2. 환율 방향", "risk_flags": "단일 문장입니다", "data_gaps": "events: FRED key missing", } with caplog.at_level("WARNING", logger="us2kr.llm.minimax"): report = parse_report("```json\n" + json.dumps(prose, ensure_ascii=False) + "\n```") assert report.key_drivers == ["상승 요인: 반도체가 이끌었습니다.", "하락 요인: 유가가 내렸습니다."] assert report.korea_implications == [ "SOX 상승은 긍정적입니다.", "환율은 안정적입니다.", "EWY가 올랐습니다.", ] assert report.watchlist_today == ["삼성전자 흐름", "환율 방향"] assert report.risk_flags == ["단일 문장입니다"] assert report.data_gaps == ["events: FRED key missing"] assert report.headline == GOOD_REPORT["headline"] assert any("coerced" in r.getMessage() for r in caplog.records) def test_coerce_report_data_leaves_valid_and_non_dict_alone() -> None: data, coerced = coerce_report_data(dict(GOOD_REPORT)) assert coerced == [] and data == GOOD_REPORT assert coerce_report_data(["not", "a", "dict"]) == (["not", "a", "dict"], []) # wrong non-string types are not coerced -> validation error surfaces with pytest.raises(ValidationError): parse_report(json.dumps({**GOOD_REPORT, "key_drivers": 42})) def test_extract_json_text_raises_without_object() -> None: with pytest.raises(ValueError): extract_json_text("only thinking, no answer") with pytest.raises(ValueError): extract_json_text("no json here") # ----------------------------------------------------------------------------- provider def test_provider_requires_api_key_when_no_client() -> None: with pytest.raises(LLMError): MiniMaxProvider(make_settings(MINIMAX_API_KEY="")) def test_provider_builds_openai_client(monkeypatch: pytest.MonkeyPatch) -> None: captured: dict[str, Any] = {} class DummyOpenAI: def __init__(self, **kwargs: Any) -> None: captured.update(kwargs) monkeypatch.setattr(openai, "OpenAI", DummyOpenAI) settings = make_settings(MINIMAX_TIMEOUT_SECONDS=42.0) provider = MiniMaxProvider(settings) assert provider.models == ["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"] assert captured["api_key"] == "test-key-not-real" assert captured["base_url"] == settings.minimax_base_url assert captured["timeout"] == 42.0 assert captured["max_retries"] == 2 def test_summarize_clean_json(caplog: pytest.LogCaptureFixture) -> None: provider, client = make_provider([make_completion(json.dumps(GOOD_REPORT, ensure_ascii=False))]) with caplog.at_level("INFO", logger="us2kr.llm.minimax"): report, model = provider.summarize(make_payload()) assert model == "MiniMax-M3" assert report == Report.model_validate(GOOD_REPORT) assert len(client.calls) == 1 call = client.calls[0] assert call["model"] == "MiniMax-M3" assert call["max_completion_tokens"] == 4096 assert call["response_format"]["type"] == "json_schema" assert call["extra_body"]["reasoning_split"] is True assert call["extra_body"]["thinking"] == {"type": "adaptive"} assert [m["role"] for m in call["messages"]] == ["system", "user"] assert call["messages"][0]["content"] == SYSTEM_PROMPT assert "## 시세" in call["messages"][1]["content"] usage_logs = [r.getMessage() for r in caplog.records if "prompt_tokens=1500" in r.getMessage()] assert usage_logs and "cached_tokens=120" in usage_logs[0] and "completion_tokens=400" in usage_logs[0] # never log the key or the prompt body assert all("test-key-not-real" not in r.getMessage() for r in caplog.records) assert all("## 시세" not in r.getMessage() for r in caplog.records) def test_summarize_think_wrapped_and_code_fence() -> None: body = json.dumps(GOOD_REPORT, ensure_ascii=False) provider, _ = make_provider( [make_completion(f"추론...\n```json\n{body}\n```", reasoning=None)] ) report, model = provider.summarize(make_payload()) assert model == "MiniMax-M3" assert report.headline == GOOD_REPORT["headline"] def test_summarize_no_thinking_param_for_m2_models() -> None: provider, client = make_provider( [make_completion(json.dumps(GOOD_REPORT), model="MiniMax-M2.7")], MINIMAX_MODEL="MiniMax-M2.7" ) provider.summarize(make_payload()) assert "thinking" not in client.calls[0]["extra_body"] assert client.calls[0]["extra_body"]["reasoning_split"] is True def test_invalid_json_then_repaired_on_retry() -> None: broken = '{"headline": "x", "market_overview": 12' # truncated / invalid provider, client = make_provider( [make_completion(broken), make_completion(json.dumps(GOOD_REPORT, ensure_ascii=False))] ) report, model = provider.summarize(make_payload()) assert model == "MiniMax-M3" assert report.headline == GOOD_REPORT["headline"] assert client.models_called == ["MiniMax-M3", "MiniMax-M3"] repair_messages = client.calls[1]["messages"] assert [m["role"] for m in repair_messages] == ["system", "user", "assistant", "user"] assert repair_messages[2]["content"] == broken assert "검증에 실패" in repair_messages[3]["content"] def test_schema_violation_then_repaired() -> None: missing_fields = json.dumps({"headline": "only headline"}) provider, client = make_provider( [make_completion(missing_fields), make_completion(json.dumps(GOOD_REPORT, ensure_ascii=False))] ) report, _ = provider.summarize(make_payload()) assert report.market_overview == GOOD_REPORT["market_overview"] assert "market_overview" in client.calls[1]["messages"][3]["content"] def test_repair_failure_moves_to_next_model() -> None: provider, client = make_provider( [ make_completion("not json"), make_completion("still not json"), make_completion(json.dumps(GOOD_REPORT, ensure_ascii=False), model="MiniMax-M2.7"), ] ) _, model = provider.summarize(make_payload()) assert model == "MiniMax-M2.7" assert client.models_called == ["MiniMax-M3", "MiniMax-M3", "MiniMax-M2.7"] def test_api_status_error_falls_back_to_second_model() -> None: provider, client = make_provider( [ make_status_error(500), make_completion(json.dumps(GOOD_REPORT, ensure_ascii=False), model="MiniMax-M2.7"), ] ) report, model = provider.summarize(make_payload()) assert model == "MiniMax-M2.7" assert client.models_called == ["MiniMax-M3", "MiniMax-M2.7"] assert report.headline == GOOD_REPORT["headline"] def test_rate_limit_and_connection_errors_fall_through() -> None: request = httpx2.Request("POST", "https://api.minimax.io/v1/chat/completions") provider, client = make_provider( [ make_status_error(429, "rate limited"), openai.APITimeoutError(request=request), make_completion(json.dumps(GOOD_REPORT, ensure_ascii=False), model="MiniMax-M2.5"), ] ) _, model = provider.summarize(make_payload()) assert model == "MiniMax-M2.5" assert client.models_called == ["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"] def test_finish_reason_length_moves_to_next_model() -> None: provider, client = make_provider( [ make_completion('{"headline": "cut off', finish_reason="length"), make_completion(json.dumps(GOOD_REPORT, ensure_ascii=False), model="MiniMax-M2.7"), ] ) _, model = provider.summarize(make_payload()) assert model == "MiniMax-M2.7" assert client.models_called == ["MiniMax-M3", "MiniMax-M2.7"] def test_all_models_fail_raises_llm_error_with_attempts() -> None: request = httpx2.Request("POST", "https://api.minimax.io/v1/chat/completions") provider, client = make_provider( [ make_status_error(503, "unavailable"), make_completion(None), # empty content openai.APIConnectionError(request=request), ] ) with pytest.raises(LLMError) as excinfo: provider.summarize(make_payload()) msg = str(excinfo.value) assert "MiniMax-M3" in msg and "503" in msg assert "MiniMax-M2.7" in msg and "empty content" in msg assert "MiniMax-M2.5" in msg and "APIConnectionError" in msg assert client.models_called == ["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"] def test_unexpected_exception_propagates() -> None: provider, _ = make_provider([RuntimeError("programming error")]) with pytest.raises(RuntimeError): provider.summarize(make_payload()) # ---------------------------------------------------------------------------- summarize def test_build_provider_returns_minimax(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(openai, "OpenAI", lambda **kwargs: object()) provider = build_provider(make_settings()) assert isinstance(provider, MiniMaxProvider) def test_summarize_payload_merges_data_gaps() -> None: class StubProvider: def summarize(self, payload: MarketPayload) -> tuple[Report, str]: report = Report.model_validate( {**GOOD_REPORT, "data_gaps": ["news: TimeoutError", "events: HTTPError"]} ) return report, "stub-model" payload = make_payload().model_copy(update={"data_gaps": ["prices: ^VIX missing", "events: HTTPError"]}) report, model = summarize_payload(payload, StubProvider()) assert model == "stub-model" assert report.data_gaps == ["prices: ^VIX missing", "events: HTTPError", "news: TimeoutError"] def test_summarize_payload_no_gaps_keeps_report() -> None: class StubProvider: def summarize(self, payload: MarketPayload) -> tuple[Report, str]: return Report.model_validate({**GOOD_REPORT, "data_gaps": []}), "m" payload = make_payload().model_copy(update={"data_gaps": []}) report, _ = summarize_payload(payload, StubProvider()) assert report.data_gaps == []