From 7f1015f8525eafdb4c7ce30f289c5aced8ba6b81 Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 11 Jun 2026 00:53:45 +0800 Subject: [PATCH] fix: AI coach chat continuation now carries full draft text Co-authored-by: Cursor --- ai_client.py | 81 ++++++++++++++++++---------- manual_trading_hub/.env.example | 2 +- manual_trading_hub/hub_ai/config.py | 2 +- manual_trading_hub/hub_ai/prompts.py | 2 +- 4 files changed, 56 insertions(+), 31 deletions(-) diff --git a/ai_client.py b/ai_client.py index 636bc8f..71cc991 100644 --- a/ai_client.py +++ b/ai_client.py @@ -7,6 +7,7 @@ from __future__ import annotations import base64 import os +import re from typing import List, Optional, Sequence, Tuple import requests @@ -259,26 +260,43 @@ def ai_generate( _CHAT_CONTINUE_USER = ( "你上一条回复在中途截断了。请从断点处继续写完,不要重复已写内容," - "保持同一语气,写完给出完整结尾。" + "保持同一语气;编号列表每条单独一行。" ) _CHAT_END_CHARS = "。!?.!?\"」』))>】" +_INCOMPLETE_TAIL_RE = re.compile( + r"(会不会|是不是|够不够|能不能|要不要|如何|怎么|什么|哪里|多少|对吗|怎么样|" + r"这个\.\.\.|这个…|\.\.\.\d+\.|\d+\.)$" +) def _looks_truncated(text: str) -> bool: t = (text or "").rstrip() - if len(t) < 48: + if len(t) < 16: return False if t[-1] in _CHAT_END_CHARS: return False + if _INCOMPLETE_TAIL_RE.search(t): + return True if t.endswith("…") or t.endswith("..."): return True + if re.search(r"\d+\.\s*$", t): + return True return t[-1] not in ",、,;;::\n" -def _should_continue(reason: str, chunk: str) -> bool: - if reason == "length": +def _should_continue(reason: str, full_text: str) -> bool: + if reason in ("length", "max_tokens", "model_length"): return True - return _looks_truncated(chunk) + return _looks_truncated(full_text) + + +def _chat_continue_message(full_text: str) -> str: + tail = full_text[-900:] if len(full_text) > 900 else full_text + return ( + f"{_CHAT_CONTINUE_USER}\n\n" + f"已写到最后这几句:\n「{tail}」\n\n" + f"请从断点接着写完。不要重复前文;最后一句话必须以句号、问号或感叹号结束。" + ) def ai_generate_chat( @@ -290,28 +308,30 @@ def ai_generate_chat( max_tokens: int = 8192, max_continuations: int = 3, ) -> str: - """聊天专用:system/user 分消息;输出触顶时自动续写。""" + """聊天专用:system/user 分消息;输出触顶时自动续写(携带已写全文)。""" images = _collect_images(None, images_b64) + max_rounds = max(1, int(max_continuations) + 1) try: if _use_openai(): - messages: list[dict] = [ - {"role": "system", "content": system.strip()}, - ] if images: - content: List[dict] = [{"type": "text", "text": user.strip()}] + user_content: List[dict] | str = [{"type": "text", "text": user.strip()}] for b64, mime in images: - content.append( + user_content.append( { "type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}, } ) - messages.append({"role": "user", "content": content}) else: - messages.append({"role": "user", "content": user.strip()}) + user_content = user.strip() + base_user_msg = {"role": "user", "content": user_content} + messages: list[dict] = [ + {"role": "system", "content": system.strip()}, + base_user_msg, + ] parts: list[str] = [] - for _ in range(max(1, int(max_continuations) + 1)): + for attempt in range(max_rounds): chunk, reason = _openai_chat_completion( messages, temperature=temperature, @@ -320,18 +340,28 @@ def ai_generate_chat( chat=True, ) if chunk.startswith("AI 调用失败") or chunk.startswith("AI 生成失败"): - return chunk if not parts else "".join(parts) + return chunk if not parts else "".join(parts).strip() parts.append(chunk) - if not _should_continue(reason, chunk): + full = "".join(parts) + if not _should_continue(reason, full) or attempt >= max_rounds - 1: break - messages.append({"role": "assistant", "content": chunk}) - messages.append({"role": "user", "content": _CHAT_CONTINUE_USER}) + messages = [ + {"role": "system", "content": system.strip()}, + base_user_msg, + {"role": "assistant", "content": full}, + {"role": "user", "content": _chat_continue_message(full)}, + ] return "".join(parts).strip() or "AI 生成失败:空内容" prompt = f"{system.strip()}\n\n---\n\n{user.strip()}" - parts = [] - current_prompt = prompt - for _ in range(max(1, int(max_continuations) + 1)): + parts: list[str] = [] + for attempt in range(max_rounds): + current_prompt = prompt + if parts: + full = "".join(parts) + current_prompt = ( + f"{prompt}\n\n【你已写道】\n{full}\n\n{_chat_continue_message(full)}" + ) chunk, reason = _generate_ollama( current_prompt, images if not parts else [], @@ -342,14 +372,9 @@ def ai_generate_chat( if chunk.startswith("AI 生成失败") and not parts: return chunk parts.append(chunk) - if not _should_continue(reason, chunk): + full = "".join(parts) + if not _should_continue(reason, full) or attempt >= max_rounds - 1: break - tail = chunk[-400:] if len(chunk) > 400 else chunk - current_prompt = ( - f"{prompt}\n\n{''.join(parts)}\n\n" - f"{_CHAT_CONTINUE_USER}\n\n" - f"(已写结尾片段供衔接:…{tail})" - ) return "".join(parts).strip() or "AI 生成失败" except requests.HTTPError as e: detail = "" diff --git a/manual_trading_hub/.env.example b/manual_trading_hub/.env.example index 3076be4..8ec4b43 100644 --- a/manual_trading_hub/.env.example +++ b/manual_trading_hub/.env.example @@ -87,7 +87,7 @@ HUB_TRUST_LAN=true AI_TIMEOUT_SECONDS=120 # AI 教练聊天:单次输出 token 上限与截断自动续写次数(默认 8192 / 3) # CHAT_MAX_OUTPUT_TOKENS=8192 -# CHAT_MAX_CONTINUATIONS=3 +# CHAT_MAX_CONTINUATIONS=8 # CHAT_AI_TIMEOUT_SECONDS=300 # AI 提供方:openai(默认,OpenAI 兼容网关)| ollama(本机 Ollama) diff --git a/manual_trading_hub/hub_ai/config.py b/manual_trading_hub/hub_ai/config.py index aefa313..8608db4 100644 --- a/manual_trading_hub/hub_ai/config.py +++ b/manual_trading_hub/hub_ai/config.py @@ -17,7 +17,7 @@ SUMMARY_TEMPERATURE = 0.15 CHAT_TEMPERATURE = 0.5 CHAT_MAX_HISTORY_TURNS = 40 CHAT_MAX_OUTPUT_TOKENS = _int_env("CHAT_MAX_OUTPUT_TOKENS", 8192) -CHAT_MAX_CONTINUATIONS = _int_env("CHAT_MAX_CONTINUATIONS", 3) +CHAT_MAX_CONTINUATIONS = _int_env("CHAT_MAX_CONTINUATIONS", 8) CHAT_CONTEXT_MAX_CHARS = 128_000 CHAT_SUMMARY_EXCERPT_MAX_CHARS = 8000 SUMMARY_RETENTION_DAYS = 90 diff --git a/manual_trading_hub/hub_ai/prompts.py b/manual_trading_hub/hub_ai/prompts.py index 5368bdc..49c6be0 100644 --- a/manual_trading_hub/hub_ai/prompts.py +++ b/manual_trading_hub/hub_ai/prompts.py @@ -50,7 +50,7 @@ CHAT_SYSTEM = """ - 若附带「今日总结摘要」,那是较早生成的缓存,**实盘持仓以【当前多账户快照】里的「实盘持仓总览」为准**,摘要里若提到持仓可能已过时。 - 若用户上传图片,可结合图中可见信息讨论,看不清的明确说看不清。 - **优先接住【用户现在说】和【此前对话】**:用户聊心态、悔单、某笔操作时,先顺着这个话题回应,不要每句都复述账户资金数字。 -- **接续对话**:有【此前对话】时须接着聊,不要重复开场白,回复写完整,不要说到一半戛然而止。 +- **接续对话**:有【此前对话】时须接着聊,不要重复开场白;整段回复必须写完,以句号/问号/感叹号收尾,不得停在半句话;编号列表每条单独一行。 - 快照里的盈亏/资金仅在需要核对事实时引用;用户口述与快照冲突时,以快照为准并口语说明。 """.strip()