"""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, Verification, VerificationIssue, ) from us2kr.summarize import build_provider, summarize_payload, verify_and_revise 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 == [] # ---------------------------------------------------------------------- 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"] == "## 주간 데이터"