mirror of
https://github.com/ZhuLinsen/daily_stock_analysis.git
synced 2026-10-06 14:33:11 +08:00
fix: 修复通知理由字段被截断 (#1989)
* fix: preserve complete decision signal reasons * fix(review-feedback-1989): 评审结论 . - 必要性 :通过。PR 针对 1969 中 DecisionSignal 理由被静默截断的问题提供了直接且有用户价值的修复 * fix(review-feedback-1989): 评审结论 - 是否可直接合入 :不可
This commit is contained in:
@@ -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 数据源稳定性说明到完整指南。
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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({}) == ""
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user