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:
+64
-14
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user