No US session closes into Monday KST, so Mondays (default 07:00, cron in the daemon with restart catch-up) now publish a weekly review built entirely from stored data: the quotes table collapsed into week-over- week changes and the week's daily briefing headlines as the narrative. The recap is a BriefingResult kind="weekly" reusing MarketPayload, so the store, verification pass, all delivery channels (Blogger title "미국 주간 리뷰 <monday>") and the dashboard (주간 리뷰 badge) work unchanged. Manual runs via `run-once --weekly [--date <monday>]`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
393 lines
15 KiB
Python
393 lines
15 KiB
Python
"""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"<think>.*?</think>", 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 <think> blocks and code fences from a model reply, returning the JSON candidate text.
|
|
|
|
Handles: complete <think>...</think> blocks anywhere, an unterminated <think> (take text after the
|
|
last </think>, or the first {...} object), and ```json fences. Raises ValueError if nothing remains.
|
|
"""
|
|
text = _THINK_BLOCK.sub("", raw)
|
|
if "<think>" in text:
|
|
if "</think>" in raw:
|
|
text = raw.rsplit("</think>", 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",
|
|
]
|