Fix empty AI review content from gemma reasoning token use.

Parse alternate reasoning fields, retry with a larger max_tokens budget, cap review images, and fall back to text-only when vision returns empty.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-17 13:21:06 +08:00
parent 4126784056
commit c2a40e3cc5
2 changed files with 123 additions and 14 deletions
+64 -14
View File
@@ -119,16 +119,29 @@ def _openai_message_text(msg: dict) -> str:
parts.append(str(part.get("text") or ""))
content = "".join(parts)
text = str(content or "").strip()
if not text:
text = str(msg.get("reasoning_content") or "").strip()
return text
if text:
return text
# 部分网关/模型把正文放在 reasoning_content;gemma 系则常写在 reasoning
for key in ("reasoning_content", "reasoning"):
alt = str(msg.get(key) or "").strip()
if not alt:
continue
# 英文链式思考不算可交付正文,留给上层按 finish=length 重试
low = alt[:80].lower()
if low.startswith("here's a thinking process") or low.startswith("here is a thinking process"):
continue
if low.startswith("thinking process") or "analyze the request" in low:
continue
return alt
return ""
def _apply_max_tokens(body: dict, max_tokens: int | None, *, chat: bool = False) -> None:
if max_tokens is not None and max_tokens > 0:
mt = int(max_tokens)
body["max_tokens"] = mt
if not chat:
# 部分 OpenAI 兼容网关对 max_tokens + max_completion_tokens 双写不友好
if chat:
body["max_completion_tokens"] = mt
@@ -169,9 +182,13 @@ def _openai_chat_completion(
msg = choice.get("message") or {}
text = _openai_message_text(msg)
finish = str(choice.get("finish_reason") or "")
if not text and chat and max_tokens:
# gemma 等会先把 token 花在 reasoning 上:过小 max_tokens 时 content 为空且 finish=length
if not text:
retry_body = dict(body)
retry_body.pop("max_completion_tokens", None)
cur = int(retry_body.get("max_tokens") or 0)
retry_body["max_tokens"] = max(cur, 4096 if chat else 8192)
r2 = requests.post(
_openai_chat_url(),
headers=headers,
@@ -182,12 +199,15 @@ def _openai_chat_completion(
data2 = r2.json()
choices2 = data2.get("choices") or []
if choices2:
msg2 = (choices2[0] or {}).get("message") or {}
choice2 = choices2[0] or {}
msg2 = choice2.get("message") or {}
text2 = _openai_message_text(msg2)
finish2 = str(choice2.get("finish_reason") or finish)
if text2:
return text2, str((choices2[0] or {}).get("finish_reason") or finish)
return text2, finish2
finish = finish2 or finish
if not text:
return "AI 生成失败:空内容", finish or "error"
return f"AI 生成失败:空内容(finish={finish or '?'})", finish or "error"
return text, finish
@@ -261,9 +281,20 @@ def ai_generate(
images = _collect_images(image_paths, images_b64)
try:
if _use_openai():
return _generate_openai(prompt, images, temperature, max_tokens=max_tokens)
text, _reason = _generate_ollama(prompt, images, temperature, max_tokens=max_tokens)
return text
out = _generate_openai(prompt, images, temperature, max_tokens=max_tokens)
else:
out, _reason = _generate_ollama(prompt, images, temperature, max_tokens=max_tokens)
# 附图导致空正文时,降级为纯文本再试一次(复盘仍可用)
if (
images
and isinstance(out, str)
and (out.startswith("AI 生成失败:空内容") or out.startswith("AI 调用失败"))
):
if _use_openai():
return _generate_openai(prompt, [], temperature, max_tokens=max_tokens or 8192)
text, _reason = _generate_ollama(prompt, [], temperature, max_tokens=max_tokens or 8192)
return text
return out
except requests.HTTPError as e:
detail = ""
try:
@@ -420,10 +451,20 @@ def ai_generate_chat(
def ai_review(trades_text: str, period_title: str, image_paths=None) -> str:
n_img = len(image_paths or [])
# 附图过多时网关易超时/空回复;保留前几张即可支撑第5节
raw_paths = [p for p in (image_paths or []) if p]
try:
max_imgs = max(0, int(_env_str("AI_REVIEW_MAX_IMAGES", "4") or "4"))
except ValueError:
max_imgs = 4
capped_paths = raw_paths[:max_imgs] if max_imgs else []
n_img = len(capped_paths)
n_skipped = max(0, len(raw_paths) - n_img)
period_label = "" if "" in str(period_title) else ""
attach_note = (
f"️ 【系统说明:已向模型附带 {n_img} 张复盘附图(自动K线或上传截图),请结合附图分析第5节.】\n\n"
f"️ 【系统说明:已向模型附带 {n_img} 张复盘附图(自动K线或上传截图)"
+ (f",另跳过 {n_skipped} 张以控制体积" if n_skipped else "")
+ ",请结合附图分析第5节.】\n\n"
if n_img
else "ℹ️ 【系统说明:本次未附带复盘附图,第5节请写明「无附图,无法看图」;保存复盘记录时可勾选「自动生成K线图」.】\n\n"
)
@@ -455,7 +496,16 @@ def ai_review(trades_text: str, period_title: str, image_paths=None) -> str:
交易记录:
{trades_text}
""".strip()
return attach_note + ai_generate(prompt, image_paths=image_paths, temperature=0.2)
try:
review_max = max(1024, int(_env_str("AI_REVIEW_MAX_TOKENS", "8192") or "8192"))
except ValueError:
review_max = 8192
return attach_note + ai_generate(
prompt,
image_paths=capped_paths,
temperature=0.2,
max_tokens=review_max,
)
def ai_short_advice(prompt_text: str) -> str:
+59
View File
@@ -0,0 +1,59 @@
"""ai_client message parsing / empty-content retries."""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from lib.ai.ai_client import _openai_message_text, ai_review # noqa: E402
class TestOpenaiMessageText(unittest.TestCase):
def test_prefers_content(self):
self.assertEqual(
_openai_message_text({"content": "正文", "reasoning": "think"}),
"正文",
)
def test_falls_back_to_reasoning_content(self):
self.assertEqual(
_openai_message_text({"content": "", "reasoning_content": "备选正文"}),
"备选正文",
)
def test_skips_english_chain_of_thought(self):
self.assertEqual(
_openai_message_text(
{
"content": "",
"reasoning": "Here's a thinking process that leads to the answer...",
}
),
"",
)
class TestAiReviewImageCap(unittest.TestCase):
def test_caps_images_and_sets_max_tokens(self):
captured = {}
def fake_generate(prompt, **kwargs):
captured["prompt"] = prompt
captured.update(kwargs)
return "OK_REVIEW"
with mock.patch("lib.ai.ai_client.ai_generate", side_effect=fake_generate):
with mock.patch.dict("os.environ", {"AI_REVIEW_MAX_IMAGES": "2"}, clear=False):
out = ai_review("记录", "每日", image_paths=["a.png", "b.png", "c.png"])
self.assertIn("OK_REVIEW", out)
self.assertEqual(captured.get("image_paths"), ["a.png", "b.png"])
self.assertEqual(captured.get("max_tokens"), 8192)
self.assertIn("另跳过 1 张", out)
if __name__ == "__main__":
unittest.main()