561 lines
23 KiB
Python
561 lines
23 KiB
Python
"""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),
|
|
"<think>먼저 지수를 본다...</think>\n" + json.dumps(GOOD_REPORT, ensure_ascii=False),
|
|
"```json\n" + json.dumps(GOOD_REPORT, ensure_ascii=False) + "\n```",
|
|
"<think>a</think><think>b</think>```\n" + json.dumps(GOOD_REPORT, ensure_ascii=False) + "```",
|
|
# unterminated <think> with an earlier closed one: take text after the last </think>
|
|
"<think>x</think>" + json.dumps(GOOD_REPORT, ensure_ascii=False) + "\n<think>trailing",
|
|
# unterminated <think> and no </think>: fall back to first JSON object
|
|
"<think>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("<think>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"<think>추론...</think>\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 == []
|