diff --git a/lib/ai/ai_client.py b/lib/ai/ai_client.py index 2725ed2..454bffd 100644 --- a/lib/ai/ai_client.py +++ b/lib/ai/ai_client.py @@ -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: diff --git a/tests/test_ai_client_empty_content.py b/tests/test_ai_client_empty_content.py new file mode 100644 index 0000000..e6f391d --- /dev/null +++ b/tests/test_ai_client_empty_content.py @@ -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()