fix: 修复通知理由字段被截断 (#1989)

* fix: preserve complete decision signal reasons

* fix(review-feedback-1989): 评审结论 . - 必要性 :通过。PR 针对 1969 中 DecisionSignal 理由被静默截断的问题提供了直接且有用户价值的修复

* fix(review-feedback-1989): 评审结论 - 是否可直接合入 :不可
This commit is contained in:
zhulinsen
2026-07-12 16:07:01 +08:00
committed by GitHub
parent 44371c2961
commit 5a0552f1f5
6 changed files with 163 additions and 30 deletions
+1
View File
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- [修复] 按股票代码删除历史记录时分批清理全部匹配项,并拒绝空白代码,避免超过 10000 条后残留记录或无筛选删除。
- [修复] 市场结构概念排行为空或超时时复用本轮负结果,避免批量个股分析重复请求同一概念排行数据源。
- [修复] Windows/macOS 桌面后端打包显式收集并校验 AkShare `file_fold/calendar.json`,避免发行包因缺少交易日历 package data 导致热点题材和选股日线增强降级。
- [修复] 邮件、Telegram 与报告共享的 DecisionSignal 摘要完整展示已脱敏的理由,避免固定 120 字符在句中无提示截断。
- [改进] 为 multi-agent DecisionAgent 增加内部低敏分歧摘要输入管线,作为 #1904 P1 解释输出的前置 plumbing;不改变 public API、dashboard schema 或最终解释字段。
- [修复] 推送报告、Jinja 报告与历史 Markdown 导出复用 Web/API 的评分-action 口径:高分但旧 `operation_advice` 仍为持有且无降级原因时,建议文案与三类统计展示为买入;有明确 guardrail reason 时继续保留持有/观望。
- [改进] GitHub Actions 每日分析工作流补齐 TickFlow 数据源环境变量映射,并收敛 README 数据源稳定性说明到完整指南。
+1
View File
@@ -171,6 +171,7 @@ Web 入口位于 `/decision-signals`:
- 没有 active 信号时,告警 worker 只创建最小 `source_type=alert/action=alert` 信号。
- 告警信号的 `trace_id=alert-rule-<hash>` 只用于同源重试的 best-effort 去重,不覆盖 active 信号本体。
- 通知只引用公开摘要字段:`action`、`horizon`、`reason`、`watch_conditions`、`risk_summary`、`source_report_id`。
- 通知中的 `reason` 在脱敏后完整展示,避免固定字符数在句中截断;`watch_conditions` 和 `risk_summary` 仍保持紧凑摘要上限。
- 通知不得输出 signal `metadata`、`evidence`、raw diagnostics、webhook URL、token 或 cookie。
- `GET /api/v1/portfolio/risk` 的 `decision_signal_risk` 只统计当前持仓中的 active `sell/reduce/alert` 信号,查询失败时 fail-open。
+84 -27
View File
@@ -84,12 +84,27 @@ class TelegramSender:
# Telegram 消息最大长度 4096 字符
max_length = 4096
if len(content) <= max_length:
telegram_content = self._convert_to_telegram_markdown(content)
if len(telegram_content) <= max_length:
# 单条消息发送
return self._send_telegram_message(api_url, chat_id, content, message_thread_id, timeout_seconds=timeout_seconds)
return self._send_telegram_message(
api_url,
chat_id,
content,
message_thread_id,
timeout_seconds=timeout_seconds,
)
else:
# 分段发送长消息
return self._send_telegram_chunked(api_url, chat_id, content, max_length, message_thread_id, timeout_seconds=timeout_seconds)
# 按 Markdown 转义后的最终 payload 分段,避免转义字符使请求超限
return self._send_telegram_chunked(
api_url,
chat_id,
telegram_content,
max_length,
message_thread_id,
timeout_seconds=timeout_seconds,
)
except Exception as e:
logger.error(f"发送 Telegram 消息失败: {e}")
@@ -105,10 +120,11 @@ class TelegramSender:
message_thread_id: Optional[str] = None,
*,
timeout_seconds: Optional[float] = None,
markdown_converted: bool = False,
) -> bool:
"""Send a single Telegram message with exponential backoff retry (Fixes #287)"""
# Convert Markdown to Telegram-compatible format
telegram_text = self._convert_to_telegram_markdown(text)
telegram_text = text if markdown_converted else self._convert_to_telegram_markdown(text)
payload = {
"chat_id": chat_id,
@@ -241,40 +257,81 @@ class TelegramSender:
*,
timeout_seconds: Optional[float] = None,
) -> bool:
"""分段发送长 Telegram 消息"""
"""按已转换的 Telegram Markdown payload 分段发送长消息。"""
# 按段落分割
sections = content.split("\n---\n")
delimiter = "\n---\n"
delimiter_length = len(delimiter)
current_chunk = []
current_length = 0
all_success = True
chunk_index = 1
for section in sections:
section_length = len(section) + 5 # +5 for "\n---\n"
def _flush_chunk() -> bool:
nonlocal current_chunk, current_length, chunk_index, all_success
if not current_chunk:
return all_success
if current_length + section_length > max_length:
# 发送当前块
if current_chunk:
chunk_content = "\n---\n".join(current_chunk)
logger.info(f"发送 Telegram 消息块 {chunk_index}...")
if not self._send_telegram_message(api_url, chat_id, chunk_content, message_thread_id, timeout_seconds=timeout_seconds):
all_success = False
chunk_index += 1
# 重置
current_chunk = [section]
current_length = section_length
else:
current_chunk.append(section)
current_length += section_length
# 发送最后一块
if current_chunk:
chunk_content = "\n---\n".join(current_chunk)
logger.info(f"发送 Telegram 消息块 {chunk_index}...")
if not self._send_telegram_message(api_url, chat_id, chunk_content, message_thread_id, timeout_seconds=timeout_seconds):
chunk_index += 1
current_chunk = []
current_length = 0
if not self._send_telegram_message(
api_url,
chat_id,
chunk_content,
message_thread_id,
timeout_seconds=timeout_seconds,
markdown_converted=True,
):
all_success = False
return all_success
def _split_long_section(section: str, limit: int) -> list[str]:
if len(section) <= limit:
return [section]
chunks: list[str] = []
for start in range(0, len(section), limit):
chunks.append(section[start:start + limit])
return chunks
for section in sections:
if len(section) > max_length:
# 单段超限时强制切片,避免依赖“\\n---\\n”边界导致的整段超长发送
if not _flush_chunk():
return False
for long_chunk in _split_long_section(section, max_length):
logger.info(f"发送 Telegram 消息块 {chunk_index}...")
chunk_index += 1
if not self._send_telegram_message(
api_url,
chat_id,
long_chunk,
message_thread_id,
timeout_seconds=timeout_seconds,
markdown_converted=True,
):
all_success = False
continue
additional_length = len(section)
if current_chunk:
additional_length += delimiter_length
if current_length + additional_length > max_length:
_flush_chunk()
current_chunk = [section]
current_length = len(section)
continue
current_chunk.append(section)
current_length += additional_length
# 发送最后一块
if not _flush_chunk():
return False
return all_success
+5 -3
View File
@@ -83,7 +83,8 @@ def format_decision_signal_excerpt(summary: Any, report_language: str = "zh") ->
if parts:
lines.append(" | ".join(parts))
for key in ("reason", "watch_conditions", "risk_summary"):
text = _public_text(summary.get(key), max_length=120)
max_length = None if key == "reason" else 120
text = _public_text(summary.get(key), max_length=max_length)
if text:
lines.append(f"- {labels[key]}: {text}")
return "\n".join(lines)
@@ -95,7 +96,7 @@ def _public_scalar(value: Any, *, max_length: int) -> str:
return sanitize_decision_signal_text(value)[:max_length]
def _public_text(value: Any, *, max_length: int) -> str:
def _public_text(value: Any, *, max_length: Optional[int]) -> str:
if value in (None, "", [], {}):
return ""
if isinstance(value, (list, tuple)):
@@ -108,4 +109,5 @@ def _public_text(value: Any, *, max_length: int) -> str:
)
else:
text = str(value).strip()
return sanitize_decision_signal_text(text)[:max_length]
sanitized = sanitize_decision_signal_text(text)
return sanitized if max_length is None else sanitized[:max_length]
+21
View File
@@ -95,6 +95,27 @@ def test_format_decision_signal_excerpt_formats_english_and_redacts_text() -> No
assert "- Risk: token=[REDACTED]" in excerpt
def test_format_decision_signal_excerpt_preserves_complete_sanitized_reason() -> None:
reason = (
"159516当前处于中期震荡、短期调整阶段。技术面上,价格缩量回踩MA5,乖离率仅1.29%,"
"具备洗盘结束的特征。然而,均线系统尚未形成标准多头排列,且大盘环境风险等级较高,"
"压制了反弹空间。建议投资者保持谨慎,关注MA5支撑的有效性,切勿盲目追高。"
)
excerpt = format_decision_signal_excerpt({
"reason": f"{reason} token=secret-value",
"watch_conditions": "观察" * 80,
"risk_summary": "风险" * 80,
})
assert f"- 理由: {reason} token=[REDACTED]" in excerpt
assert "切勿盲目追高。" in excerpt
watch_line = next(line for line in excerpt.splitlines() if line.startswith("- 观察条件: "))
risk_line = next(line for line in excerpt.splitlines() if line.startswith("- 风险: "))
assert len(watch_line.removeprefix("- 观察条件: ")) == 120
assert len(risk_line.removeprefix("- 风险: ")) == 120
def test_format_decision_signal_excerpt_returns_empty_for_invalid_input() -> None:
assert format_decision_signal_excerpt(None) == ""
assert format_decision_signal_excerpt({}) == ""
+51
View File
@@ -1650,6 +1650,57 @@ class TestTelegramSender(unittest.TestCase):
self.assertIn("[详情](https://example.com/report)", rendered)
self.assertNotIn("# 日报", rendered)
@mock.patch.object(TelegramSender, "_send_telegram_message", return_value=True)
def test_send_telegram_chunked_splits_large_reason_without_delimiter(self, mock_send_telegram_message):
cfg = _config(telegram_bot_token="BOT", telegram_chat_id="CHAT")
sender = TelegramSender(cfg)
long_reason = "原因过长需分段测试:" + ("A" * 5000)
content = f"**AI 决策信号**\n- 理由: {long_reason}\n"
result = sender._send_telegram_chunked(
"http://api.telegram.org",
"CHAT",
sender._convert_to_telegram_markdown(content),
max_length=4096,
timeout_seconds=3,
)
self.assertTrue(result)
self.assertGreaterEqual(len(mock_send_telegram_message.call_args_list), 2)
for call in mock_send_telegram_message.call_args_list:
sent_text = call.args[2]
self.assertLessEqual(len(sent_text), 4096)
@mock.patch.object(TelegramSender, "_send_telegram_message", return_value=True)
def test_send_to_telegram_chunked_path_with_long_reason_without_delimiter(self, mock_send_telegram_message):
cfg = _config(telegram_bot_token="BOT", telegram_chat_id="CHAT")
sender = TelegramSender(cfg)
long_reason = "原因过长需分段测试:" + ("B" * 5000)
content = f"**AI 决策信号**\n- 理由: {long_reason}\n"
result = sender.send_to_telegram(content)
self.assertTrue(result)
self.assertGreaterEqual(len(mock_send_telegram_message.call_args_list), 2)
for call in mock_send_telegram_message.call_args_list:
sent_text = call.args[2]
self.assertLessEqual(len(sent_text), 4096)
@mock.patch("src.notification_sender.telegram_sender.requests.post")
def test_send_chunks_by_final_markdown_payload_length(self, mock_post):
mock_post.return_value = _response(200, {"ok": True})
cfg = _config(telegram_bot_token="BOT", telegram_chat_id="CHAT")
sender = TelegramSender(cfg)
content = "(" * 4090
result = sender.send_to_telegram(content)
self.assertTrue(result)
self.assertGreaterEqual(mock_post.call_count, 2)
payload_texts = [call.kwargs["json"]["text"] for call in mock_post.call_args_list]
self.assertTrue(all(len(text) <= 4096 for text in payload_texts))
self.assertEqual("".join(payload_texts), sender._convert_to_telegram_markdown(content))
@mock.patch("src.notification_sender.telegram_sender.requests.post")
def test_send_plain_text_fallback_handles_non_json_200(self, mock_post):
markdown_error = _response(400)