mirror of
https://github.com/ZhuLinsen/daily_stock_analysis.git
synced 2026-10-06 14:33:11 +08:00
fix: handle MiniMax string reasoning wrappers (#2039)
This commit is contained in:
@@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
- [新功能] 多策略观点结构化输出第一版:新增策略观点标准化、基础冲突检测与聚合 metadata,作为 #1964 的阶段性基础契约;本次不声明完成并发执行、2–4 策略完整调度 MVP 或前端完整多语言展示。
|
||||
- [修复] 多策略综合报告统一兼容历史与外部 dashboard 的宽松字段形状,避免非法计数、非字典综合块或异常策略列表导致通知、微信、Jinja 与历史 Markdown 渲染失败。
|
||||
- [修复] MiniMax 分析与渠道 JSON 测试仅提取最终文本块,避免推理内容与 JSON 拼接后导致结果无法解析和持久化。
|
||||
- [修复] MiniMax 字符串响应仅剥离开头完整的 `<think>` 推理包装,兼容流式分片并保留 JSON 内容中的同名字面标签。
|
||||
<!-- 新条目格式:- [类型] 描述(类型取值:新功能/改进/修复/文档/测试/chore)-->
|
||||
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->
|
||||
|
||||
|
||||
+5
-4
@@ -76,6 +76,7 @@ from src.llm.provider_cache import (
|
||||
build_provider_cache_route_context,
|
||||
filter_prompt_cache_telemetry,
|
||||
)
|
||||
from src.llm.response_content import strip_leading_think_wrapper
|
||||
from src.storage import persist_llm_usage
|
||||
from src.data.stock_mapping import STOCK_NAME_MAP
|
||||
from src.report_language import (
|
||||
@@ -2863,7 +2864,7 @@ class GeminiAnalyzer:
|
||||
content_blocks = self._get_response_field(message, "content_blocks")
|
||||
block_text = self._extract_text_blocks(content_blocks)
|
||||
if block_text:
|
||||
return block_text
|
||||
return strip_leading_think_wrapper(block_text)
|
||||
|
||||
content = None
|
||||
if message is not None:
|
||||
@@ -2872,9 +2873,9 @@ class GeminiAnalyzer:
|
||||
content = self._get_response_field(choice, "content")
|
||||
|
||||
if isinstance(content, list):
|
||||
return self._extract_text_blocks(content)
|
||||
return strip_leading_think_wrapper(self._extract_text_blocks(content))
|
||||
if isinstance(content, str):
|
||||
return content.strip()
|
||||
return strip_leading_think_wrapper(content)
|
||||
return str(content).strip() if content is not None else ""
|
||||
|
||||
def _extract_stream_text(self, chunk: Any) -> str:
|
||||
@@ -2947,7 +2948,7 @@ class GeminiAnalyzer:
|
||||
partial_received=chars_received > 0,
|
||||
) from exc
|
||||
|
||||
response_text = "".join(chunks).strip()
|
||||
response_text = strip_leading_think_wrapper("".join(chunks))
|
||||
if not response_text:
|
||||
raise _LiteLLMStreamError(
|
||||
f"{model} stream returned empty response",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Helpers for normalizing provider response text."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
_THINK_OPEN_TAG = "<think>"
|
||||
_THINK_CLOSE_TAG = "</think>"
|
||||
|
||||
|
||||
def strip_leading_think_wrapper(value: str) -> str:
|
||||
"""Remove one complete leading ``<think>`` wrapper from a response.
|
||||
|
||||
The match is deliberately anchored at the start of the response. This
|
||||
keeps literal ``<think>`` markup inside a JSON string or ordinary answer
|
||||
untouched and leaves malformed/unclosed wrappers for strict validation to
|
||||
reject instead of guessing where the final answer begins.
|
||||
"""
|
||||
|
||||
text = str(value or "").strip()
|
||||
lowered = text.lower()
|
||||
if not lowered.startswith(_THINK_OPEN_TAG):
|
||||
return text
|
||||
|
||||
close_index = lowered.find(_THINK_CLOSE_TAG, len(_THINK_OPEN_TAG))
|
||||
if close_index < 0:
|
||||
return text
|
||||
|
||||
return text[close_index + len(_THINK_CLOSE_TAG):].strip()
|
||||
@@ -67,6 +67,7 @@ from src.llm.backend_registry import (
|
||||
)
|
||||
from src.llm.generation_params import apply_litellm_generation_params
|
||||
from src.llm.local_cli_backend import resolve_local_cli_preset
|
||||
from src.llm.response_content import strip_leading_think_wrapper
|
||||
from src.notification_contracts import (
|
||||
FEISHU_APP_BOT_ENV_GROUP,
|
||||
FEISHU_WEBHOOK_ENV_GROUP,
|
||||
@@ -4336,7 +4337,7 @@ class SystemConfigService:
|
||||
text = _field(block, "content")
|
||||
if isinstance(text, str) and text:
|
||||
text_parts.append(text)
|
||||
return "".join(text_parts).strip()
|
||||
return strip_leading_think_wrapper("".join(text_parts))
|
||||
|
||||
if response is None:
|
||||
return "", "empty_response", "Completion returned no response object", "null_response"
|
||||
@@ -4367,7 +4368,11 @@ class SystemConfigService:
|
||||
raw_content = _field(message, "content")
|
||||
if raw_content is None:
|
||||
return "", "empty_response", "Completion returned null message content", "null_content"
|
||||
content = _text_from_blocks(raw_content) if isinstance(raw_content, list) else str(raw_content).strip()
|
||||
content = (
|
||||
_text_from_blocks(raw_content)
|
||||
if isinstance(raw_content, list)
|
||||
else strip_leading_think_wrapper(str(raw_content))
|
||||
)
|
||||
if not content:
|
||||
return "", "empty_response", "Completion returned an empty message content", "empty_content"
|
||||
return content, None, None, None
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import pytest
|
||||
|
||||
from src.llm.response_content import strip_leading_think_wrapper
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("response", "expected"),
|
||||
[
|
||||
(
|
||||
"<THINK>internal reasoning</THINK>\n{\"status\":\"ok\"}",
|
||||
'{"status":"ok"}',
|
||||
),
|
||||
(
|
||||
'{"summary":"literal <think>text</think>"}',
|
||||
'{"summary":"literal <think>text</think>"}',
|
||||
),
|
||||
(
|
||||
"prefix <think>internal reasoning</think>{\"status\":\"ok\"}",
|
||||
"prefix <think>internal reasoning</think>{\"status\":\"ok\"}",
|
||||
),
|
||||
(
|
||||
"<think>unclosed reasoning{\"status\":\"ok\"}",
|
||||
"<think>unclosed reasoning{\"status\":\"ok\"}",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_strip_leading_think_wrapper_is_anchored_and_fail_closed(
|
||||
response: str,
|
||||
expected: str,
|
||||
) -> None:
|
||||
assert strip_leading_think_wrapper(response) == expected
|
||||
@@ -1485,6 +1485,63 @@ class TestAnalyzerGenerateText:
|
||||
assert text == '{"sentiment_score": 72}'
|
||||
assert model_used == "openai/MiniMax-M3"
|
||||
|
||||
def test_call_litellm_minimax_strips_leading_think_wrapper(self):
|
||||
analyzer = self._make_analyzer()
|
||||
analyzer._config_override = SimpleNamespace(
|
||||
litellm_model="openai/MiniMax-M3",
|
||||
litellm_fallback_models=[],
|
||||
llm_model_list=[],
|
||||
)
|
||||
response = SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
content_blocks=None,
|
||||
message=SimpleNamespace(
|
||||
content='<think>Internal reasoning</think>\n{"sentiment_score": 72}'
|
||||
),
|
||||
)
|
||||
],
|
||||
usage=None,
|
||||
)
|
||||
|
||||
with patch.object(analyzer, "_dispatch_litellm_completion", return_value=response):
|
||||
text, model_used, _usage = analyzer._call_litellm(
|
||||
"prompt",
|
||||
{"max_tokens": 128, "temperature": 0.2},
|
||||
response_validator=analyzer._validate_json_response,
|
||||
)
|
||||
|
||||
assert text == '{"sentiment_score": 72}'
|
||||
assert model_used == "openai/MiniMax-M3"
|
||||
|
||||
def test_call_litellm_preserves_think_markup_inside_json_string(self):
|
||||
analyzer = self._make_analyzer()
|
||||
analyzer._config_override = SimpleNamespace(
|
||||
litellm_model="openai/MiniMax-M3",
|
||||
litellm_fallback_models=[],
|
||||
llm_model_list=[],
|
||||
)
|
||||
expected = '{"analysis_summary":"literal <think>text</think>"}'
|
||||
response = SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
content_blocks=None,
|
||||
message=SimpleNamespace(content=expected),
|
||||
)
|
||||
],
|
||||
usage=None,
|
||||
)
|
||||
|
||||
with patch.object(analyzer, "_dispatch_litellm_completion", return_value=response):
|
||||
text, model_used, _usage = analyzer._call_litellm(
|
||||
"prompt",
|
||||
{"max_tokens": 128, "temperature": 0.2},
|
||||
response_validator=analyzer._validate_json_response,
|
||||
)
|
||||
|
||||
assert text == expected
|
||||
assert model_used == "openai/MiniMax-M3"
|
||||
|
||||
def test_call_litellm_minimax_stream_ignores_reasoning_blocks(self):
|
||||
analyzer = self._make_analyzer()
|
||||
analyzer._config_override = SimpleNamespace(
|
||||
@@ -1529,6 +1586,37 @@ class TestAnalyzerGenerateText:
|
||||
assert text == '{"sentiment_score": 72}'
|
||||
assert model_used == "openai/MiniMax-M3"
|
||||
|
||||
def test_call_litellm_minimax_stream_strips_split_think_wrapper(self):
|
||||
analyzer = self._make_analyzer()
|
||||
analyzer._config_override = SimpleNamespace(
|
||||
litellm_model="openai/MiniMax-M3",
|
||||
litellm_fallback_models=[],
|
||||
llm_model_list=[],
|
||||
)
|
||||
|
||||
def stream_response():
|
||||
for content in (
|
||||
"<thi",
|
||||
"nk>Internal reasoning</think>",
|
||||
'{"sentiment_score": ',
|
||||
"72}",
|
||||
):
|
||||
yield SimpleNamespace(
|
||||
choices=[SimpleNamespace(delta=SimpleNamespace(content=content))],
|
||||
usage=None,
|
||||
)
|
||||
|
||||
with patch.object(analyzer, "_dispatch_litellm_completion", return_value=stream_response()):
|
||||
text, model_used, _usage = analyzer._call_litellm(
|
||||
"prompt",
|
||||
{"max_tokens": 128, "temperature": 0.2},
|
||||
stream=True,
|
||||
response_validator=analyzer._validate_json_response,
|
||||
)
|
||||
|
||||
assert text == '{"sentiment_score": 72}'
|
||||
assert model_used == "openai/MiniMax-M3"
|
||||
|
||||
def test_call_litellm_falls_back_to_message_content_when_blocks_empty(self):
|
||||
analyzer = self._make_analyzer()
|
||||
analyzer._config_override = SimpleNamespace(
|
||||
|
||||
@@ -3591,6 +3591,25 @@ class SystemConfigServiceTestCase(unittest.TestCase):
|
||||
self.assertTrue(payload["success"])
|
||||
self.assertEqual(payload["capability_results"]["json"]["status"], "passed")
|
||||
|
||||
@patch("litellm.completion")
|
||||
def test_test_llm_channel_json_capability_strips_minimax_think_wrapper(self, mock_completion) -> None:
|
||||
mock_completion.side_effect = [
|
||||
self._mock_completion_response("OK"),
|
||||
self._mock_completion_response('<think>Internal reasoning</think>{"status":"ok"}'),
|
||||
]
|
||||
|
||||
payload = self.service.test_llm_channel(
|
||||
name="minimax",
|
||||
protocol="openai",
|
||||
base_url="https://api.minimax.io/v1",
|
||||
api_key="sk-test-value",
|
||||
models=["MiniMax-M3"],
|
||||
capability_checks=["json"],
|
||||
)
|
||||
|
||||
self.assertTrue(payload["success"])
|
||||
self.assertEqual(payload["capability_results"]["json"]["status"], "passed")
|
||||
|
||||
@patch("litellm.completion")
|
||||
def test_test_llm_channel_reports_json_capability_failures(self, mock_completion) -> None:
|
||||
mock_completion.side_effect = [
|
||||
|
||||
Reference in New Issue
Block a user