Merge pull request #240 from yushenglin01/fix/ai-analysis-focus

修复 AI 分析关注重点不突出及静默失效问题
This commit is contained in:
wshy
2026-09-04 12:47:39 +08:00
committed by GitHub
7 changed files with 290 additions and 42 deletions
+109 -21
View File
@@ -72,8 +72,8 @@ _ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
# ----------------------------------------------------------------
# 用户 focus 输入净化 — 防止通过"特别关注"绕过红线诱导 AI 给出买卖建议
# 命中任一敏感词时,整个 focus 被丢弃(返回空串),由各 analyzer 据此跳过注入
# 用户 focus 输入规范化。交易建议类表达不会被静默丢弃,而是由统一提示词
# 转换成客观价位、风险和情景分析,避免历史报告显示了 focus、模型却没有收到
# ----------------------------------------------------------------
_FOCUS_BLOCKLIST = re.compile(
r"买入|卖出|加仓|减仓|轻仓|重仓|半仓|全仓|仓位|止损|止盈|"
@@ -87,19 +87,36 @@ _FOCUS_BLOCKLIST = re.compile(
def sanitize_focus(focus: str) -> str:
"""净化用户输入的 focus 文本。
命中交易指令/投资建议类敏感词时返回空串,阻止其注入 AI 提示词。
这是对系统提示词红线的兜底:即便用户试图通过 focus 绕过,也不会生效。
"""
"""规范化 focus 中的首尾空白与连续换行。"""
if not focus:
return ""
text = focus.strip()
text = re.sub(r"\s+", " ", focus).strip()
return text
def build_focus_instruction(focus: str, *, report_name: str = "分析报告") -> str:
"""构建所有报告共用的关注重点指令。
有关注点时要求模型在固定报告结构之前先直接回应。若原问题涉及交易
建议,保留问题语义但要求转换成中立的数据分析,不再无提示地整段丢弃。
"""
text = sanitize_focus(focus)
if not text:
return ""
lines = [
"## 用户关注重点(必须优先回应)",
f"用户关注: {text}",
f"请在完整{report_name}最前面先输出 `### 0. 🔎 关注重点回应`,"
"用 2-4 条带具体数据的结论直接回应;随后继续完成既定报告结构,"
"并在相关章节加深分析。不要只复述问题。",
]
if _FOCUS_BLOCKLIST.search(text):
return ""
return text
lines.append(
"该关注点含有买卖、仓位、目标价或预测类表达。不得给出相应操作结论;"
"请将其转换为客观的技术/财务状态、关键价位、风险因素和条件情景后回应。"
)
return "\n".join(lines)
def current_ai_provider() -> str:
@@ -309,11 +326,13 @@ async def stream_ai_text(
temperature: float | None = 0.5,
max_tokens: int | None = 4000,
timeout: float = 180.0,
prefer_final_answer: bool = False,
) -> AsyncIterator[str]:
"""Yield text deltas from the configured provider.
Codex CLI only exposes the final assistant message for this use case, so it
yields one complete chunk after the command exits.
yields one complete chunk after the command exits. ``prefer_final_answer``
lets compatible providers prioritize visible content over hidden reasoning.
max_tokens=None 表示不限制输出(同 generate_ai_text 的说明)。
"""
@@ -328,6 +347,7 @@ async def stream_ai_text(
temperature=temperature,
max_tokens=max_tokens,
timeout=timeout,
prefer_final_answer=prefer_final_answer,
):
yield chunk
@@ -374,6 +394,7 @@ async def _stream_openai(
temperature: float | None,
max_tokens: int | None,
timeout: float,
prefer_final_answer: bool,
) -> AsyncIterator[str]:
ai_key = secrets_store.get_ai_key()
if not ai_key:
@@ -381,15 +402,16 @@ async def _stream_openai(
client = _openai_client(ai_key, timeout)
model = current_ai_model()
base_url = secrets_store.get_ai_config("ai_base_url", settings.ai_base_url)
req_messages = list(messages)
async def _iter(stream):
async for chunk in stream:
delta = chunk.choices[0].delta if chunk.choices else None
if delta and delta.content:
yield delta.content
kwargs = _openai_kwargs(temperature=temperature, max_tokens=max_tokens)
kwargs = _openai_kwargs(
temperature=temperature,
max_tokens=max_tokens,
model=model,
base_url=base_url,
prefer_final_answer=prefer_final_answer,
)
while True:
try:
stream = await client.chat.completions.create(
@@ -410,7 +432,7 @@ async def _stream_openai(
raise
try:
async for piece in _iter(stream):
async for piece in _iter_openai_text(stream):
yield piece
except Exception as exc:
if _is_openai_transport_error(exc):
@@ -418,6 +440,53 @@ async def _stream_openai(
raise
_LENGTH_FINISH_REASONS = {"length", "max_tokens", "max_output_tokens"}
async def _iter_openai_text(stream) -> AsyncIterator[str]:
"""Normalize an OpenAI-compatible stream into complete text deltas.
Reasoning models may spend the entire completion budget on
``reasoning_content`` and finish with HTTP 200 but no user-visible text.
Treat that response, and any length-truncated partial response, as a
terminal generation error instead of silently reporting success.
"""
content_seen = False
reasoning_seen = False
finish_reason = ""
async for chunk in stream:
choices = getattr(chunk, "choices", None) or []
if not choices:
continue
choice = choices[0]
reason = getattr(choice, "finish_reason", None)
if reason:
finish_reason = str(reason)
delta = getattr(choice, "delta", None)
if delta is None:
continue
if getattr(delta, "reasoning_content", None):
reasoning_seen = True
content = getattr(delta, "content", None)
if content:
content_seen = True
yield content
if finish_reason in _LENGTH_FINISH_REASONS:
if reasoning_seen and not content_seen:
raise RuntimeError(
"AI 推理达到输出长度上限, 未生成正文; 请提高输出 Token 上限或改用非推理模型"
)
raise RuntimeError("AI 输出达到长度上限, 内容不完整; 请提高输出 Token 上限后重试")
if not content_seen:
if reasoning_seen:
raise RuntimeError("AI 仅返回推理内容, 未生成正文; 请检查模型配置或改用非推理模型")
raise RuntimeError("AI 服务未返回正文内容; 请检查模型配置或稍后重试")
def _openai_client(api_key: str, timeout: float):
from openai import AsyncOpenAI
@@ -479,8 +548,18 @@ def _openai_retry_kwargs(exc: Exception, kwargs: dict) -> dict | None:
return None
def _openai_kwargs(*, temperature: float | None, max_tokens: int | None) -> dict:
"""Build OpenAI create() kwargs; optional parameters are omitted when empty.
_DEEPSEEK_V4_MODELS = {"deepseek-v4-flash", "deepseek-v4-pro"}
def _openai_kwargs(
*,
temperature: float | None,
max_tokens: int | None,
model: str = "",
base_url: str = "",
prefer_final_answer: bool = False,
) -> dict:
"""Build OpenAI create() kwargs and map supported provider capabilities.
max_tokens=None 时不传 — 由服务端默认上限管理(推理模型的思考 token 也
计入该参数预算, 限制会挤占正文, 见 stream_ai_text 文档)。
@@ -494,6 +573,15 @@ def _openai_kwargs(*, temperature: float | None, max_tokens: int | None) -> dict
reasoning_effort = current_openai_reasoning_effort()
if reasoning_effort:
kwargs["reasoning_effort"] = reasoning_effort
if (
prefer_final_answer
and model.strip().lower() in _DEEPSEEK_V4_MODELS
and urlsplit(base_url.strip()).hostname == "api.deepseek.com"
):
# DeepSeek V4 defaults to thinking mode. For report-style tasks the
# hidden reasoning shares max_tokens with the final answer and can
# exhaust the budget before any visible content is emitted.
kwargs["extra_body"] = {"thinking": {"type": "disabled"}}
return kwargs
@@ -278,10 +278,10 @@ def _build_user_prompt(signals: dict, overview: dict, days: int, dates: list[str
_build_signal_block("🎰 游资特征 (排名波动大)", signals.get("hot_money", [])),
]
from app.services.ai_provider import sanitize_focus
safe_focus = sanitize_focus(focus)
if safe_focus:
parts.extend(["", f"本次分析请特别关注: {safe_focus}"])
from app.services.ai_provider import build_focus_instruction
focus_instruction = build_focus_instruction(focus, report_name=f"{dim}轮动分析报告")
if focus_instruction:
parts.extend(["", focus_instruction])
return "\n".join(parts)
@@ -374,6 +374,7 @@ async def analyze_rotation_stream(
temperature=0.5,
# 不限制输出(推理模型思考 token 计入预算, 见 ai_provider.stream_ai_text)
max_tokens=None,
prefer_final_answer=True,
):
got_content = True
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
+5 -7
View File
@@ -136,13 +136,10 @@ def _build_user_prompt(fins: dict[str, list[dict]], symbol: str, focus: str) ->
data_json,
"```",
]
from app.services.ai_provider import sanitize_focus
safe_focus = sanitize_focus(focus)
if safe_focus:
lines.extend([
"",
f"本次分析请特别关注: {safe_focus}",
])
from app.services.ai_provider import build_focus_instruction
focus_instruction = build_focus_instruction(focus, report_name="财务分析报告")
if focus_instruction:
lines.extend(["", focus_instruction])
return "\n".join(lines)
@@ -187,6 +184,7 @@ async def analyze_financials_stream(
temperature=0.4,
# 不限制输出(推理模型思考 token 计入预算, 见 ai_provider.stream_ai_text)
max_tokens=None,
prefer_final_answer=True,
):
got_content = True
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
+5 -4
View File
@@ -237,10 +237,10 @@ def _build_user_prompt(overview: dict, news: list[dict], focus: str, lhb_context
"消息催化一节请直接从量价异动给出可能的催化逻辑结论,不要编造具体消息,也不要复述本说明。)",
])
from app.services.ai_provider import sanitize_focus
safe_focus = sanitize_focus(focus)
if safe_focus:
parts.extend(["", f"本次复盘请特别关注: {safe_focus}"])
from app.services.ai_provider import build_focus_instruction
focus_instruction = build_focus_instruction(focus, report_name="大盘复盘报告")
if focus_instruction:
parts.extend(["", focus_instruction])
return "\n".join(parts)
@@ -336,6 +336,7 @@ async def recap_market_stream(
temperature=0.5,
# 不限制输出(推理模型思考 token 计入预算, 见 ai_provider.stream_ai_text)
max_tokens=None,
prefer_final_answer=True,
):
got_content = True
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
+7 -6
View File
@@ -16,8 +16,8 @@ from __future__ import annotations
import json
import logging
from collections.abc import AsyncIterator
from pathlib import Path
from typing import AsyncIterator
import polars as pl
@@ -238,10 +238,10 @@ def _build_user_prompt(
"请按系统提示词第 4 节的说明,在基本面/财务面维度给出\"接入中\"的友好提示,不要编造数据。)",
])
from app.services.ai_provider import sanitize_focus
safe_focus = sanitize_focus(focus)
if safe_focus:
parts.extend(["", f"本次分析请特别关注: {safe_focus}"])
from app.services.ai_provider import build_focus_instruction
focus_instruction = build_focus_instruction(focus, report_name="个股分析报告")
if focus_instruction:
parts.extend(["", focus_instruction])
return "\n".join(parts)
@@ -325,11 +325,12 @@ async def analyze_stock_stream(
# 不限制输出: 推理模型(deepseek reasoner 系)思考 token 计入 max_tokens
# 预算, 固定上限会把正文挤光(实测 4500 全被推理吃掉 → 正文 0 字)。
max_tokens=None,
prefer_final_answer=True,
):
got_content = True
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
except Exception as e: # noqa: BLE001
except Exception as e:
logger.exception("AI stock analysis failed for %s: %s", symbol, e)
yield json.dumps({"type": "error", "message": f"AI 分析失败: {e}"}, ensure_ascii=False)
return
+62
View File
@@ -0,0 +1,62 @@
"""AI 分析关注重点的统一 Prompt 契约测试。"""
from app.services.ai_provider import build_focus_instruction, sanitize_focus
from app.services.concept_rotation_analyzer import (
_build_user_prompt as build_rotation_user_prompt,
)
from app.services.financial_analyzer import (
_build_user_prompt as build_financial_user_prompt,
)
from app.services.market_recap import (
_build_user_prompt as build_recap_user_prompt,
)
from app.services.stock_analyzer import (
_build_user_prompt as build_stock_user_prompt,
)
def test_focus_whitespace_is_normalized() -> None:
assert sanitize_focus(" 支撑位\n 多少 ") == "支撑位 多少"
def test_trade_wording_is_reframed_instead_of_silently_dropped() -> None:
instruction = build_focus_instruction("现在能买吗,目标价多少", report_name="个股分析报告")
assert "用户关注: 现在能买吗,目标价多少" in instruction
assert "关注重点回应" in instruction
assert "不得给出相应操作结论" in instruction
assert "关键价位" in instruction
def test_safe_focus_requires_a_direct_answer_without_extra_warning() -> None:
instruction = build_focus_instruction("支撑位多少", report_name="个股分析报告")
assert "用户关注: 支撑位多少" in instruction
assert "用 2-4 条带具体数据的结论直接回应" in instruction
assert "不得给出相应操作结论" not in instruction
def test_all_focus_enabled_analyzers_share_the_priority_contract() -> None:
focus = "支撑位多少"
prompts = [
build_stock_user_prompt([], {}, {}, 10.0, "600000.SH", focus),
build_financial_user_prompt({}, "600000.SH", focus),
build_recap_user_prompt({}, [], focus),
build_rotation_user_prompt({}, {}, 12, [], focus),
]
for prompt in prompts:
assert "## 用户关注重点(必须优先回应)" in prompt
assert f"用户关注: {focus}" in prompt
assert "### 0. 🔎 关注重点回应" in prompt
def test_empty_focus_does_not_add_focus_section() -> None:
prompts = [
build_stock_user_prompt([], {}, {}, 10.0, "600000.SH", ""),
build_financial_user_prompt({}, "600000.SH", ""),
build_recap_user_prompt({}, [], ""),
build_rotation_user_prompt({}, {}, 12, [], ""),
]
assert all("用户关注重点" not in prompt for prompt in prompts)
+97
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import tomllib
from types import SimpleNamespace
import httpx
import openai
@@ -48,6 +49,41 @@ def test_normalize_openai_base_url_strips_trailing_slash():
assert normalize_openai_base_url("https://open.bigmodel.cn/api/paas/v4/") == "https://open.bigmodel.cn/api/paas/v4"
def test_openai_kwargs_prefers_final_answer_for_official_deepseek_v4():
kwargs = ai_provider._openai_kwargs(
temperature=0.5,
max_tokens=8192,
model="deepseek-v4-pro",
base_url="https://api.deepseek.com/v1",
prefer_final_answer=True,
)
assert kwargs["extra_body"] == {"thinking": {"type": "disabled"}}
def test_openai_kwargs_does_not_send_deepseek_option_to_other_providers():
kwargs = ai_provider._openai_kwargs(
temperature=0.5,
max_tokens=8192,
model="gpt-5.5",
base_url="https://api.openai.com/v1",
prefer_final_answer=True,
)
assert "extra_body" not in kwargs
def test_openai_kwargs_keeps_deepseek_default_without_final_answer_preference():
kwargs = ai_provider._openai_kwargs(
temperature=0.5,
max_tokens=8192,
model="deepseek-v4-pro",
base_url="https://api.deepseek.com/v1",
)
assert "extra_body" not in kwargs
def test_format_openai_error_hides_html_gateway_body():
response = httpx.Response(
504,
@@ -400,6 +436,67 @@ def test_save_ai_settings_rejects_non_positive(monkeypatch):
settings_api.save_ai_settings(req2)
async def _fake_openai_stream(*chunks):
for chunk in chunks:
yield chunk
def _stream_chunk(*, content=None, reasoning_content=None, finish_reason=None):
delta = SimpleNamespace(content=content, reasoning_content=reasoning_content)
choice = SimpleNamespace(delta=delta, finish_reason=finish_reason)
return SimpleNamespace(choices=[choice])
@pytest.mark.asyncio
async def test_iter_openai_text_rejects_reasoning_only_length_exhaustion():
stream = _fake_openai_stream(
_stream_chunk(reasoning_content="内部推理"),
_stream_chunk(finish_reason="length"),
)
with pytest.raises(RuntimeError, match="推理达到输出长度上限"):
async for _ in ai_provider._iter_openai_text(stream):
pass
@pytest.mark.asyncio
async def test_iter_openai_text_rejects_truncated_partial_content():
stream = _fake_openai_stream(
_stream_chunk(content="未完成正文"),
_stream_chunk(finish_reason="length"),
)
pieces = []
with pytest.raises(RuntimeError, match="输出达到长度上限"):
async for piece in ai_provider._iter_openai_text(stream):
pieces.append(piece)
assert pieces == ["未完成正文"]
@pytest.mark.asyncio
async def test_iter_openai_text_yields_complete_content_and_ignores_reasoning():
stream = _fake_openai_stream(
_stream_chunk(reasoning_content="内部推理"),
_stream_chunk(content="完整"),
_stream_chunk(content="正文"),
_stream_chunk(finish_reason="stop"),
)
pieces = [piece async for piece in ai_provider._iter_openai_text(stream)]
assert pieces == ["完整", "正文"]
@pytest.mark.asyncio
async def test_iter_openai_text_rejects_stream_without_content():
stream = _fake_openai_stream(_stream_chunk(finish_reason="stop"))
with pytest.raises(RuntimeError, match="未返回正文内容"):
async for _ in ai_provider._iter_openai_text(stream):
pass
def test_codex_process_env_excludes_application_secrets(monkeypatch, tmp_path):
monkeypatch.setenv("PATH", "test-path")
monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example")