"""MiniMax provider: OpenAI-compatible chat completions with JSON-schema output and model fallback.""" from __future__ import annotations import copy import json import logging import re from typing import Any, get_origin import openai from pydantic import BaseModel, ValidationError from us2kr.config import Settings from us2kr.llm.base import LLMError from us2kr.llm.prompts import ( REVISE_INSTRUCTION, SYSTEM_PROMPT, VERIFY_SYSTEM_PROMPT, WEEKLY_SYSTEM_PROMPT, build_user_message, build_verify_message_from, build_weekly_user_message, format_issues, ) from us2kr.models import MarketPayload, Report, Verification, VerificationIssue log = logging.getLogger(__name__) # Errors from the openai SDK that mean "this model/attempt failed; try the next model". OPENAI_ERRORS: tuple[type[Exception], ...] = ( openai.APIStatusError, openai.APIConnectionError, openai.APITimeoutError, openai.RateLimitError, ) _THINK_BLOCK = re.compile(r".*?", re.DOTALL) _CODE_FENCE = re.compile(r"```(?:json|JSON)?\s*(.*?)```", re.DOTALL) REPAIR_INSTRUCTION = ( "위 응답은 JSON 스키마 검증에 실패했습니다. 아래 오류를 참고하여, 설명 없이 스키마에 맞는 " "완전한 JSON 객체 하나만 다시 출력하세요.\n\n오류:\n" ) def _strip_titles(node: Any) -> None: """Recursively remove JSON-schema "title" keys (noise for the model, not needed for validation).""" if isinstance(node, dict): node.pop("title", None) for value in node.values(): _strip_titles(value) elif isinstance(node, list): for value in node: _strip_titles(value) def model_json_schema(model_cls: type[BaseModel]) -> dict[str, Any]: """JSON schema for a pydantic model suitable for `response_format` (no titles, closed object).""" schema = copy.deepcopy(model_cls.model_json_schema()) _strip_titles(schema) schema["additionalProperties"] = False return schema def model_response_format(model_cls: type[BaseModel], name: str) -> dict[str, Any]: return {"type": "json_schema", "json_schema": {"name": name, "schema": model_json_schema(model_cls)}} def report_json_schema() -> dict[str, Any]: return model_json_schema(Report) def report_response_format() -> dict[str, Any]: return model_response_format(Report, "report") def _first_json_object(text: str) -> str | None: """Return the first balanced `{...}` block in `text` (string-aware), or None.""" start = text.find("{") if start < 0: return None depth = 0 in_str = False escape = False for i in range(start, len(text)): ch = text[i] if in_str: if escape: escape = False elif ch == "\\": escape = True elif ch == '"': in_str = False continue if ch == '"': in_str = True elif ch == "{": depth += 1 elif ch == "}": depth -= 1 if depth == 0: return text[start : i + 1] return None def extract_json_text(raw: str) -> str: """Strip blocks and code fences from a model reply, returning the JSON candidate text. Handles: complete ... blocks anywhere, an unterminated (take text after the last , or the first {...} object), and ```json fences. Raises ValueError if nothing remains. """ text = _THINK_BLOCK.sub("", raw) if "" in text: if "" in raw: text = raw.rsplit("", 1)[1] else: text = _first_json_object(raw) or "" fence = _CODE_FENCE.search(text) if fence: text = fence.group(1) text = _first_json_object(text.strip()) or "" if not text: raise ValueError("no JSON object found in model reply") return text def _list_fields(model_cls: type[BaseModel]) -> frozenset[str]: """Names of the model's plain string-list fields (targets for the string->list coercion).""" return frozenset( name for name, info in model_cls.model_fields.items() if get_origin(info.annotation) is list and info.annotation == list[str] ) _BULLET_SPLIT = re.compile(r"(?:^|\n)\s*(?:[-*•]|\d+[.)])\s+") _SENTENCE_SPLIT = re.compile(r"(?<=[.!?。])\s+") def _split_prose(text: str) -> list[str]: """Split a prose string into list items: bullets/newlines first, then sentence boundaries.""" parts = [p.strip() for p in _BULLET_SPLIT.split(text) if p.strip()] if len(parts) <= 1: parts = [p.strip() for p in re.split(r"\n+", text) if p.strip()] if len(parts) <= 1: parts = [p.strip() for p in _SENTENCE_SPLIT.split(text) if p.strip()] return parts or [text.strip()] def coerce_model_data(data: Any, model_cls: type[BaseModel]) -> tuple[Any, list[str]]: """Fix the common MiniMax schema slip of emitting string-list fields as a single string. Returns (data, names of coerced fields). Anything that is not a dict is returned untouched so that pydantic can report the real error. """ if not isinstance(data, dict): return data, [] coerced: list[str] = [] for name in _list_fields(model_cls): value = data.get(name) if isinstance(value, str): data[name] = _split_prose(value) coerced.append(name) return data, coerced def coerce_report_data(data: Any) -> tuple[Any, list[str]]: return coerce_model_data(data, Report) def parse_model[T: BaseModel](raw: str, model_cls: type[T]) -> T: """Turn raw model content into a validated instance (raises ValueError / ValidationError).""" text = extract_json_text(raw) try: data = json.loads(text) except json.JSONDecodeError: return model_cls.model_validate_json(text) # let pydantic produce the canonical error data, coerced = coerce_model_data(data, model_cls) if coerced: log.warning("coerced string -> list for %s fields: %s", model_cls.__name__, ", ".join(sorted(coerced))) return model_cls.model_validate(data) def parse_report(raw: str) -> Report: return parse_model(raw, Report) class MiniMaxProvider: """LLMProvider backed by MiniMax's OpenAI-compatible endpoint with a model fallback chain.""" def __init__(self, settings: Settings, client: Any | None = None) -> None: self._settings = settings models = [settings.minimax_model, *settings.minimax_fallback_models] self._models = list(dict.fromkeys(m for m in models if m)) if not self._models: raise LLMError("no MiniMax model configured") if client is None: if not settings.minimax_api_key: raise LLMError("MINIMAX_API_KEY is not set") client = openai.OpenAI( api_key=settings.minimax_api_key, base_url=settings.minimax_base_url, timeout=settings.minimax_timeout_seconds, max_retries=2, ) self._client = client @property def models(self) -> list[str]: return list(self._models) # ------------------------------------------------------------------ public def summarize(self, payload: MarketPayload) -> tuple[Report, str]: messages: list[dict[str, Any]] = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": build_user_message(payload)}, ] return self._run(messages, Report, "report", task="summarize") def summarize_weekly(self, payload: MarketPayload, week_sessions: list) -> tuple[Report, str]: """Weekly recap: payload.quotes carry week-over-week changes, history_notes the daily headlines.""" messages: list[dict[str, Any]] = [ {"role": "system", "content": WEEKLY_SYSTEM_PROMPT}, {"role": "user", "content": build_weekly_user_message(payload, week_sessions)}, ] return self._run(messages, Report, "report", task="summarize-weekly") def verify( self, payload: MarketPayload, report: Report, data_message: str | None = None ) -> tuple[Verification, str]: """Fact-check `report` against the payload (or an explicit serialisation of it).""" source = data_message if data_message is not None else build_user_message(payload) messages: list[dict[str, Any]] = [ {"role": "system", "content": VERIFY_SYSTEM_PROMPT}, {"role": "user", "content": build_verify_message_from(source, report)}, ] return self._run(messages, Verification, "verification", task="verify") def revise( self, payload: MarketPayload, report: Report, issues: list[VerificationIssue], *, system_prompt: str | None = None, data_message: str | None = None, ) -> tuple[Report, str]: """Ask for a corrected Report given verification issues. Raise LLMError on total failure.""" messages: list[dict[str, Any]] = [ {"role": "system", "content": system_prompt or SYSTEM_PROMPT}, {"role": "user", "content": data_message or build_user_message(payload)}, {"role": "assistant", "content": report.model_dump_json()}, {"role": "user", "content": REVISE_INSTRUCTION + format_issues(issues)}, ] return self._run(messages, Report, "report", task="revise") # ----------------------------------------------------------------- helpers def _run[T: BaseModel]( self, messages: list[dict[str, Any]], model_cls: type[T], schema_name: str, task: str ) -> tuple[T, str]: """Try every configured model in order; return (parsed instance, model id).""" response_format = model_response_format(model_cls, schema_name) attempts: list[str] = [] for model in self._models: result = self._attempt_model(model, messages, attempts, model_cls, response_format) if result is not None: return result, model raise LLMError(f"all MiniMax models failed ({task}): " + " | ".join(attempts)) def _attempt_model[T: BaseModel]( self, model: str, messages: list[dict[str, Any]], attempts: list[str], model_cls: type[T], response_format: dict[str, Any], ) -> T | None: """One model: initial call plus at most one repair retry. Returns None when the model failed.""" content = self._call(model, messages, attempts, response_format) if content is None: return None try: return parse_model(content, model_cls) except (ValidationError, ValueError) as exc: error_text = _short(str(exc), 800) attempts.append(f"{model}: invalid report ({_short(str(exc), 160)})") log.warning( "model %s returned invalid report, retrying once with repair hint: %s", model, _short(str(exc), 300), ) repair_messages = [ *messages, {"role": "assistant", "content": _short(content, 12000)}, {"role": "user", "content": REPAIR_INSTRUCTION + error_text}, ] content = self._call(model, repair_messages, attempts, response_format, label="repair") if content is None: return None try: return parse_model(content, model_cls) except (ValidationError, ValueError) as exc: attempts.append(f"{model} (repair): invalid report ({_short(str(exc), 160)})") log.warning("model %s repair retry still invalid: %s", model, _short(str(exc), 300)) return None def _call( self, model: str, messages: list[dict[str, Any]], attempts: list[str], response_format: dict[str, Any], label: str = "initial", ) -> str | None: """Perform one chat completion. Returns content text, or None (attempt recorded) on failure.""" tag = model if label == "initial" else f"{model} ({label})" extra_body: dict[str, Any] = {"reasoning_split": True} if model.startswith("MiniMax-M3"): extra_body["thinking"] = {"type": "adaptive"} try: completion = self._client.chat.completions.create( model=model, messages=messages, max_completion_tokens=self._settings.minimax_max_completion_tokens, response_format=response_format, extra_body=extra_body, ) except OPENAI_ERRORS as exc: status = getattr(exc, "status_code", None) detail = f"{type(exc).__name__}" + (f" {status}" if status else "") + f": {_short(str(exc), 160)}" attempts.append(f"{tag}: {detail}") log.warning("MiniMax call failed for %s: %s", tag, detail) return None choices = getattr(completion, "choices", None) or [] if not choices: attempts.append(f"{tag}: empty choices") log.warning("MiniMax %s returned no choices", tag) return None choice = choices[0] finish_reason = getattr(choice, "finish_reason", None) self._log_usage(model, completion, finish_reason) if finish_reason == "length": attempts.append(f"{tag}: truncated (finish_reason=length)") log.warning("MiniMax %s output truncated (finish_reason=length)", tag) return None content = getattr(choice.message, "content", None) if not content: attempts.append(f"{tag}: empty content") log.warning("MiniMax %s returned empty content", tag) return None return content @staticmethod def _log_usage(model: str, completion: Any, finish_reason: str | None) -> None: usage = getattr(completion, "usage", None) prompt_tokens = getattr(usage, "prompt_tokens", None) completion_tokens = getattr(usage, "completion_tokens", None) details = getattr(usage, "prompt_tokens_details", None) cached = getattr(details, "cached_tokens", None) if details is not None else None log.info( "MiniMax response model=%s served_as=%s finish=%s prompt_tokens=%s completion_tokens=%s " "cached_tokens=%s", model, getattr(completion, "model", None), finish_reason, prompt_tokens, completion_tokens, cached, ) def _short(text: str, limit: int) -> str: text = " ".join(text.split()) return text if len(text) <= limit else text[: limit - 1] + "…" __all__ = [ "OPENAI_ERRORS", "MiniMaxProvider", "coerce_model_data", "coerce_report_data", "extract_json_text", "model_json_schema", "model_response_format", "parse_model", "parse_report", "report_json_schema", "report_response_format", ]