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 ""))
|
parts.append(str(part.get("text") or ""))
|
||||||
content = "".join(parts)
|
content = "".join(parts)
|
||||||
text = str(content or "").strip()
|
text = str(content or "").strip()
|
||||||
if not text:
|
if text:
|
||||||
text = str(msg.get("reasoning_content") or "").strip()
|
return 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:
|
def _apply_max_tokens(body: dict, max_tokens: int | None, *, chat: bool = False) -> None:
|
||||||
if max_tokens is not None and max_tokens > 0:
|
if max_tokens is not None and max_tokens > 0:
|
||||||
mt = int(max_tokens)
|
mt = int(max_tokens)
|
||||||
body["max_tokens"] = mt
|
body["max_tokens"] = mt
|
||||||
if not chat:
|
# 部分 OpenAI 兼容网关对 max_tokens + max_completion_tokens 双写不友好
|
||||||
|
if chat:
|
||||||
body["max_completion_tokens"] = mt
|
body["max_completion_tokens"] = mt
|
||||||
|
|
||||||
|
|
||||||
@@ -169,9 +182,13 @@ def _openai_chat_completion(
|
|||||||
msg = choice.get("message") or {}
|
msg = choice.get("message") or {}
|
||||||
text = _openai_message_text(msg)
|
text = _openai_message_text(msg)
|
||||||
finish = str(choice.get("finish_reason") or "")
|
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 = dict(body)
|
||||||
retry_body.pop("max_completion_tokens", None)
|
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(
|
r2 = requests.post(
|
||||||
_openai_chat_url(),
|
_openai_chat_url(),
|
||||||
headers=headers,
|
headers=headers,
|
||||||
@@ -182,12 +199,15 @@ def _openai_chat_completion(
|
|||||||
data2 = r2.json()
|
data2 = r2.json()
|
||||||
choices2 = data2.get("choices") or []
|
choices2 = data2.get("choices") or []
|
||||||
if choices2:
|
if choices2:
|
||||||
msg2 = (choices2[0] or {}).get("message") or {}
|
choice2 = choices2[0] or {}
|
||||||
|
msg2 = choice2.get("message") or {}
|
||||||
text2 = _openai_message_text(msg2)
|
text2 = _openai_message_text(msg2)
|
||||||
|
finish2 = str(choice2.get("finish_reason") or finish)
|
||||||
if text2:
|
if text2:
|
||||||
return text2, str((choices2[0] or {}).get("finish_reason") or finish)
|
return text2, finish2
|
||||||
|
finish = finish2 or finish
|
||||||
if not text:
|
if not text:
|
||||||
return "AI 生成失败:空内容", finish or "error"
|
return f"AI 生成失败:空内容(finish={finish or '?'})", finish or "error"
|
||||||
return text, finish
|
return text, finish
|
||||||
|
|
||||||
|
|
||||||
@@ -261,9 +281,20 @@ def ai_generate(
|
|||||||
images = _collect_images(image_paths, images_b64)
|
images = _collect_images(image_paths, images_b64)
|
||||||
try:
|
try:
|
||||||
if _use_openai():
|
if _use_openai():
|
||||||
return _generate_openai(prompt, images, temperature, max_tokens=max_tokens)
|
out = _generate_openai(prompt, images, temperature, max_tokens=max_tokens)
|
||||||
text, _reason = _generate_ollama(prompt, images, temperature, max_tokens=max_tokens)
|
else:
|
||||||
return text
|
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:
|
except requests.HTTPError as e:
|
||||||
detail = ""
|
detail = ""
|
||||||
try:
|
try:
|
||||||
@@ -420,10 +451,20 @@ def ai_generate_chat(
|
|||||||
|
|
||||||
|
|
||||||
def ai_review(trades_text: str, period_title: str, image_paths=None) -> str:
|
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 "日"
|
period_label = "周" if "周" in str(period_title) else "日"
|
||||||
attach_note = (
|
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
|
if n_img
|
||||||
else "ℹ️ 【系统说明:本次未附带复盘附图,第5节请写明「无附图,无法看图」;保存复盘记录时可勾选「自动生成K线图」.】\n\n"
|
else "ℹ️ 【系统说明:本次未附带复盘附图,第5节请写明「无附图,无法看图」;保存复盘记录时可勾选「自动生成K线图」.】\n\n"
|
||||||
)
|
)
|
||||||
@@ -455,7 +496,16 @@ def ai_review(trades_text: str, period_title: str, image_paths=None) -> str:
|
|||||||
交易记录:
|
交易记录:
|
||||||
{trades_text}
|
{trades_text}
|
||||||
""".strip()
|
""".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:
|
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