fix: 统一报告动作展示口径 (#1982)

* fix: unify report action display

* fix(review-feedback-1982): add Korean action labels or fall back before returning display action

* fix(review-feedback-1982): Derive signal metadata from resolved actions

* fix(review-feedback-1982): Derive history signal metadata from resolved actions

* fix(review-feedback-1982): Derive renderer emojis from resolved actions

* fix(review-feedback-1982): src/notification.py:2838:build stock summary 先得到 display

* fix(review-feedback-1982): 将 PR 正文同步到最新 head,确保范围、验证结果与实际 diff 一致

* fix(review-feedback-1982): 将 GitHub PR 正文同步到当前 head,并修正 docs/CHANGELOG.md 中不准确的审查过程记录
This commit is contained in:
zhulinsen
2026-07-11 14:04:34 +08:00
committed by GitHub
parent d08374898c
commit 093057a11b
15 changed files with 661 additions and 79 deletions
+1
View File
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]
- [改进] 为 multi-agent DecisionAgent 增加内部低敏分歧摘要输入管线,作为 #1904 P1 解释输出的前置 plumbing;不改变 public API、dashboard schema 或最终解释字段。
- [修复] 推送报告、Jinja 报告与历史 Markdown 导出复用 Web/API 的评分-action 口径:高分但旧 `operation_advice` 仍为持有且无降级原因时,建议文案与三类统计展示为买入;有明确 guardrail reason 时继续保留持有/观望。
- [改进] GitHub Actions 每日分析工作流补齐 TickFlow 数据源环境变量映射,并收敛 README 数据源稳定性说明到完整指南。
- [修复] WebUI 启动时显式 `--host` / `--port` 不再被 `.env` 中的 `WEBUI_HOST` / `WEBUI_PORT` 覆盖,未传 CLI 参数时统一使用解析后的运行时配置。
- [改进] GitHub Actions: 每日分析工作流(`00-daily-analysis.yml`)新增钉钉通知环境变量映射,支持在云端定时任务中直接使用钉钉机器人。
+102 -41
View File
@@ -45,10 +45,14 @@ from src.report_language import (
get_chip_unavailable_reason,
is_chip_structure_unavailable,
localize_chip_health,
localize_operation_advice,
localize_trend_prediction,
normalize_report_language,
)
from src.schemas.decision_action import (
display_action_fields_for_result,
display_decision_type_for_result,
display_operation_advice_for_result,
)
from bot.models import BotMessage
from src.utils.sanitize import sanitize_diagnostic_text
from src.utils.data_processing import (
@@ -230,7 +234,7 @@ class NotificationService(
# 仅分析结果摘要(Issue #262):true 时只推送汇总,不含个股详情
self._report_summary_only = getattr(config, 'report_summary_only', False)
self._report_show_llm_model = getattr(config, 'report_show_llm_model', True)
self._history_compare_cache: Dict[Tuple[int, Tuple[Tuple[str, str], ...]], Dict[str, List[Dict[str, Any]]]] = {}
self._history_compare_cache: Dict[Tuple[int, str, Tuple[Tuple[str, str], ...]], Dict[str, List[Dict[str, Any]]]] = {}
# 初始化各渠道
AstrbotSender.__init__(self, config)
@@ -298,8 +302,11 @@ class NotificationService(
if history_compare_n <= 0 or not results:
return {"history_by_code": {}}
report_language = self._get_report_language(results)
cache_key = (
history_compare_n,
report_language,
tuple(sorted((r.code, getattr(r, 'query_id', '') or '') for r in results)),
)
if cache_key in self._history_compare_cache:
@@ -318,6 +325,7 @@ class NotificationService(
codes,
limit=history_compare_n,
exclude_query_ids=exclude_ids,
report_language=report_language,
)
except Exception as e:
logger.debug("History comparison skipped: %s", e)
@@ -830,10 +838,7 @@ class NotificationService(
reverse=True
)
# 统计信息 - 使用 decision_type 字段准确统计
buy_count = sum(1 for r in results if getattr(r, 'decision_type', '') == 'buy')
sell_count = sum(1 for r in results if getattr(r, 'decision_type', '') == 'sell')
hold_count = sum(1 for r in results if getattr(r, 'decision_type', '') in ('hold', ''))
buy_count, sell_count, hold_count = self._count_display_decisions(results, report_language)
avg_score = sum(r.sentiment_score for r in results) / len(results) if results else 0
report_lines.extend([
@@ -854,10 +859,10 @@ class NotificationService(
if self._report_summary_only:
report_lines.extend([f"## 📊 {labels['summary_heading']}", ""])
for r in sorted_results:
_, emoji, _ = self._get_signal_level(r)
signal_text, emoji, _ = self._get_signal_level(r)
report_lines.append(
f"{emoji} **{self._get_display_name(r, report_language)}({r.code})**: "
f"{localize_operation_advice(r.operation_advice, report_language)} | "
f"{signal_text} | "
f"{labels['score_label']} {r.sentiment_score} | "
f"{localize_trend_prediction(r.trend_prediction, report_language)}"
)
@@ -865,13 +870,13 @@ class NotificationService(
report_lines.extend([f"## 📈 {labels['report_title']}", ""])
# 逐个股票的详细分析
for result in sorted_results:
_, emoji, _ = self._get_signal_level(result)
signal_text, emoji, _ = self._get_signal_level(result)
confidence_stars = result.get_confidence_stars() if hasattr(result, 'get_confidence_stars') else '⭐⭐'
report_lines.extend([
f"### {emoji} {self._get_display_name(result, report_language)} ({result.code})",
"",
f"**{labels['action_advice_label']}:{localize_operation_advice(result.operation_advice, report_language)}** | "
f"**{labels['action_advice_label']}:{signal_text}** | "
f"**{labels['score_label']}:{result.sentiment_score}** | "
f"**{labels['trend_label']}:{localize_trend_prediction(result.trend_prediction, report_language)}** | "
f"**Confidence:{confidence_stars}**",
@@ -1096,12 +1101,57 @@ class NotificationService(
report_lines.append(f"- {limitation}")
report_lines.append("")
def _get_display_operation_advice(
self,
result: AnalysisResult,
report_language: Optional[str] = None,
) -> str:
return display_operation_advice_for_result(
result,
report_language=report_language or self._get_report_language(result),
)
def _count_display_decisions(
self,
results: List[AnalysisResult],
report_language: Optional[str] = None,
) -> Tuple[int, int, int]:
language = report_language or self._get_report_language(results)
buckets = [
display_decision_type_for_result(result, report_language=language)
for result in results
]
buy_count = sum(1 for bucket in buckets if bucket == "buy")
sell_count = sum(1 for bucket in buckets if bucket == "sell")
hold_count = len(buckets) - buy_count - sell_count
return buy_count, sell_count, hold_count
def _get_signal_level(self, result: AnalysisResult) -> tuple:
"""Get localized signal level and color based on operation advice."""
return get_signal_level(
result.operation_advice,
"""Get display text and signal metadata from the resolved action."""
report_language = self._get_report_language(result)
display_fields = display_action_fields_for_result(
result,
report_language=report_language,
)
signal_advice = {
"buy": "buy",
"add": "buy",
"hold": "hold",
"reduce": "reduce",
"sell": "sell",
"watch": "watch",
"avoid": "hold",
"alert": "sell",
}.get(display_fields["action"])
_, emoji, signal_tag = get_signal_level(
signal_advice or self._get_display_operation_advice(result, report_language),
result.sentiment_score,
self._get_report_language(result),
report_language,
)
return (
self._get_display_operation_advice(result, report_language),
emoji,
signal_tag,
)
def generate_dashboard_report(
@@ -1159,10 +1209,7 @@ class NotificationService(
# 按评分排序(高分在前)
sorted_results = sorted(results, key=lambda x: x.sentiment_score, reverse=True)
# 统计信息 - 使用 decision_type 字段准确统计
buy_count = sum(1 for r in results if getattr(r, 'decision_type', '') == 'buy')
sell_count = sum(1 for r in results if getattr(r, 'decision_type', '') == 'sell')
hold_count = sum(1 for r in results if getattr(r, 'decision_type', '') in ('hold', ''))
buy_count, sell_count, hold_count = self._count_display_decisions(results, report_language)
report_lines = [
f"# 🎯 {report_date} {labels['dashboard_title']}",
@@ -1179,11 +1226,11 @@ class NotificationService(
"",
])
for r in sorted_results:
_, signal_emoji, _ = self._get_signal_level(r)
signal_text, signal_emoji, _ = self._get_signal_level(r)
display_name = self._get_display_name(r, report_language)
report_lines.append(
f"{signal_emoji} **{display_name}({r.code})**: "
f"{localize_operation_advice(r.operation_advice, report_language)} | "
f"{signal_text} | "
f"{labels['score_label']} {r.sentiment_score} | "
f"{localize_trend_prediction(r.trend_prediction, report_language)}"
)
@@ -1264,7 +1311,7 @@ class NotificationService(
report_lines.extend([
f"| {labels['position_status_label']} | {labels['action_advice_label']} |",
"|---------|---------|",
f"| 🆕 **{labels['no_position_label']}** | {pos_advice.get('no_position', localize_operation_advice(result.operation_advice, report_language))} |",
f"| 🆕 **{labels['no_position_label']}** | {pos_advice.get('no_position', self._get_display_operation_advice(result, report_language))} |",
f"| 💼 **{labels['has_position_label']}** | {pos_advice.get('has_position', labels['continue_holding'])} |",
"",
])
@@ -1496,10 +1543,7 @@ class NotificationService(
# 按评分排序
sorted_results = sorted(results, key=lambda x: x.sentiment_score, reverse=True)
# 统计 - 使用 decision_type 字段准确统计
buy_count = sum(1 for r in results if getattr(r, 'decision_type', '') == 'buy')
sell_count = sum(1 for r in results if getattr(r, 'decision_type', '') == 'sell')
hold_count = sum(1 for r in results if getattr(r, 'decision_type', '') in ('hold', ''))
buy_count, sell_count, hold_count = self._count_display_decisions(results, report_language)
lines = [
f"## 🎯 {report_date} {labels['dashboard_title']}",
@@ -1514,11 +1558,11 @@ class NotificationService(
lines.append(f"**📊 {labels['summary_heading']}**")
lines.append("")
for r in sorted_results:
_, signal_emoji, _ = self._get_signal_level(r)
signal_text, signal_emoji, _ = self._get_signal_level(r)
stock_name = self._get_display_name(r, report_language)
lines.append(
f"{signal_emoji} **{stock_name}({r.code})**: "
f"{localize_operation_advice(r.operation_advice, report_language)} | "
f"{signal_text} | "
f"{labels['score_label']} {r.sentiment_score} | "
f"{localize_trend_prediction(r.trend_prediction, report_language)}"
)
@@ -1650,10 +1694,7 @@ class NotificationService(
# 按评分排序
sorted_results = sorted(results, key=lambda x: x.sentiment_score, reverse=True)
# 统计 - 使用 decision_type 字段准确统计
buy_count = sum(1 for r in results if getattr(r, 'decision_type', '') == 'buy')
sell_count = sum(1 for r in results if getattr(r, 'decision_type', '') == 'sell')
hold_count = sum(1 for r in results if getattr(r, 'decision_type', '') in ('hold', ''))
buy_count, sell_count, hold_count = self._count_display_decisions(results, report_language)
avg_score = sum(r.sentiment_score for r in results) / len(results) if results else 0
lines = [
@@ -1667,12 +1708,12 @@ class NotificationService(
# 每只股票精简信息(控制长度)
for result in sorted_results:
_, emoji, _ = self._get_signal_level(result)
signal_text, emoji, _ = self._get_signal_level(result)
# 核心信息行
lines.append(f"### {emoji} {self._get_display_name(result, report_language)}({result.code})")
lines.append(
f"**{localize_operation_advice(result.operation_advice, report_language)}** | "
f"**{signal_text}** | "
f"{labels['score_label']}:{result.sentiment_score} | "
f"{localize_trend_prediction(result.trend_prediction, report_language)}"
)
@@ -1743,9 +1784,7 @@ class NotificationService(
if not results:
return f"# {report_date} {labels['brief_title']}\n\n{labels['no_results']}"
sorted_results = sorted(results, key=lambda x: x.sentiment_score, reverse=True)
buy_count = sum(1 for r in results if getattr(r, 'decision_type', '') == 'buy')
sell_count = sum(1 for r in results if getattr(r, 'decision_type', '') == 'sell')
hold_count = sum(1 for r in results if getattr(r, 'decision_type', '') in ('hold', ''))
buy_count, sell_count, hold_count = self._count_display_decisions(results, report_language)
lines = [
f"# {report_date} {labels['brief_title']}",
"",
@@ -1753,14 +1792,14 @@ class NotificationService(
]
self._append_market_status_line(lines, results, report_language)
for r in sorted_results:
_, emoji, _ = self._get_signal_level(r)
signal_text, emoji, _ = self._get_signal_level(r)
name = self._get_display_name(r, report_language)
dash = r.dashboard or {}
core = dash.get('core_conclusion', {}) or {}
one = (core.get('one_sentence') or r.analysis_summary or '')[:60]
lines.append(
f"**{name}({r.code})** {emoji} "
f"{localize_operation_advice(r.operation_advice, report_language)} | "
f"{signal_text} | "
f"{labels['score_label']} {r.sentiment_score} | {one}"
)
lines.append("")
@@ -1913,7 +1952,7 @@ class NotificationService(
lines.extend([
f"### 💼 {labels['position_advice_heading']}",
"",
f"- 🆕 **{labels['no_position_label']}**: {pos_advice.get('no_position', localize_operation_advice(result.operation_advice, report_language))}",
f"- 🆕 **{labels['no_position_label']}**: {pos_advice.get('no_position', self._get_display_operation_advice(result, report_language))}",
f"- 💼 **{labels['has_position_label']}**: {pos_advice.get('has_position', labels['continue_holding'])}",
"",
])
@@ -2792,10 +2831,32 @@ class NotificationBuilder:
lines = [f"📊 **{labels['summary_heading']}**", ""]
for r in sorted(results, key=lambda x: x.sentiment_score, reverse=True):
_, emoji, _ = get_signal_level(r.operation_advice, r.sentiment_score, report_language)
display_action = display_action_fields_for_result(
r,
report_language=report_language,
)["action"]
signal_action = {
"buy": "buy",
"add": "buy",
"hold": "hold",
"reduce": "reduce",
"sell": "sell",
"watch": "watch",
"avoid": "hold",
"alert": "sell",
}.get(display_action)
display_advice = display_operation_advice_for_result(
r,
report_language=report_language,
)
signal_text, emoji, _ = get_signal_level(
signal_action or display_advice,
r.sentiment_score,
report_language,
)
name = get_localized_stock_name(r.name, r.code, report_language)
lines.append(
f"{emoji} {name}({r.code}): {localize_operation_advice(r.operation_advice, report_language)} | "
f"{emoji} {name}({r.code}): {display_advice} | "
f"{labels['score_label']} {r.sentiment_score}"
)
+123 -10
View File
@@ -11,8 +11,12 @@ from __future__ import annotations
import re
from typing import Any, Dict, Literal, Optional, TypedDict, get_args
from src.report_language import normalize_report_language
from src.schemas.decision_scale import action_for_score, score_action_conflicts_without_guardrail
from src.report_language import localize_operation_advice, normalize_report_language
from src.schemas.decision_scale import (
action_for_score,
extract_decision_guardrail_reason,
score_action_conflicts_without_guardrail,
)
DecisionAction = Literal["buy", "add", "hold", "reduce", "sell", "watch", "avoid", "alert"]
@@ -26,14 +30,14 @@ _ACTION_VALUES = set(get_args(DecisionAction))
_NON_STOCK_REPORT_TYPES = {"market_review"}
_ACTION_LABELS: Dict[str, Dict[str, str]] = {
"buy": {"zh": "买入", "en": "Buy"},
"add": {"zh": "加仓", "en": "Add"},
"hold": {"zh": "持有", "en": "Hold"},
"reduce": {"zh": "减仓", "en": "Reduce"},
"sell": {"zh": "卖出", "en": "Sell"},
"watch": {"zh": "观望", "en": "Watch"},
"avoid": {"zh": "回避", "en": "Avoid"},
"alert": {"zh": "预警", "en": "Alert"},
"buy": {"zh": "买入", "en": "Buy", "ko": "매수"},
"add": {"zh": "加仓", "en": "Add", "ko": "추가 매수"},
"hold": {"zh": "持有", "en": "Hold", "ko": "보유"},
"reduce": {"zh": "减仓", "en": "Reduce", "ko": "비중축소"},
"sell": {"zh": "卖出", "en": "Sell", "ko": "매도"},
"watch": {"zh": "观望", "en": "Watch", "ko": "관망"},
"avoid": {"zh": "回避", "en": "Avoid", "ko": "회피"},
"alert": {"zh": "预警", "en": "Alert", "ko": "경고"},
}
_EXPLICIT_ALIASES: Dict[str, DecisionAction] = {
@@ -395,3 +399,112 @@ def build_action_fields(
"action": action,
"action_label": localize_action_label(action, report_language) if action else None,
}
def _result_guardrail_reason(result: Any) -> Optional[str]:
return extract_decision_guardrail_reason(
{
"guardrail_reason": getattr(result, "guardrail_reason", None),
"downgrade_reason": getattr(result, "downgrade_reason", None),
"dashboard": getattr(result, "dashboard", None),
"metadata": getattr(result, "metadata", None),
}
)
def display_action_fields(
*,
operation_advice: Any = None,
explicit_action: Any = None,
action_label: Any = None,
report_type: Any = None,
report_language: Optional[str] = "zh",
sentiment_score: Any = None,
guardrail_reason: Any = None,
) -> DecisionActionFields:
"""Resolve one canonical action for every public display surface."""
action_source = explicit_action
if normalize_decision_action(action_source) is None and str(action_label or "").strip():
action_source = action_label
return build_action_fields(
operation_advice=operation_advice,
explicit_action=action_source,
report_type=report_type,
report_language=report_language,
sentiment_score=sentiment_score,
guardrail_reason=guardrail_reason,
align_with_score=True,
)
def _display_result_kwargs(
result: Any,
*,
report_language: Optional[str] = None,
report_type: Any = None,
) -> dict[str, Any]:
return {
"operation_advice": getattr(result, "operation_advice", None),
"explicit_action": getattr(result, "action", None),
"action_label": getattr(result, "action_label", None),
"report_type": report_type or getattr(result, "report_type", None),
"report_language": report_language or getattr(result, "report_language", "zh"),
"sentiment_score": getattr(result, "sentiment_score", None),
"guardrail_reason": _result_guardrail_reason(result),
}
def display_action_fields_for_result(
result: Any,
*,
report_language: Optional[str] = None,
report_type: Any = None,
) -> DecisionActionFields:
return display_action_fields(
**_display_result_kwargs(result, report_language=report_language, report_type=report_type)
)
def display_operation_advice_for_result(
result: Any,
*,
report_language: Optional[str] = None,
report_type: Any = None,
) -> str:
"""Return the same localized action label used by Web/API display fields."""
fields = display_action_fields_for_result(
result,
report_language=report_language,
report_type=report_type,
)
if fields["action_label"]:
return fields["action_label"]
language = report_language or getattr(result, "report_language", "zh")
return localize_operation_advice(getattr(result, "operation_advice", None), language)
def display_decision_type_for_result(
result: Any,
*,
report_language: Optional[str] = None,
report_type: Any = None,
) -> str:
"""Map the displayed eight-state action to the legacy three summary buckets."""
action = display_action_fields_for_result(
result,
report_language=report_language,
report_type=report_type,
)["action"]
if action in {"buy", "add"}:
return "buy"
if action in {"reduce", "sell"}:
return "sell"
if action is not None:
return "hold"
legacy = str(getattr(result, "decision_type", "") or "").strip().lower()
if legacy in {"buy", "hold", "sell"}:
return legacy
return "hold"
+45
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any, Optional
@@ -98,6 +99,50 @@ def score_band_metadata(value: Any) -> dict[str, Any]:
}
def extract_decision_guardrail_reason(payload: Any) -> Optional[str]:
"""Extract an applied score/action guardrail reason from a result payload."""
data = payload if isinstance(payload, Mapping) else {}
dashboard = data.get("dashboard") if isinstance(data.get("dashboard"), Mapping) else {}
calibration = (
dashboard.get("decision_score_calibration")
if isinstance(dashboard.get("decision_score_calibration"), Mapping)
else {}
)
stability = (
dashboard.get("decision_stability")
if isinstance(dashboard.get("decision_stability"), Mapping)
else {}
)
metadata = data.get("metadata") if isinstance(data.get("metadata"), Mapping) else {}
stability_applied = stability.get("applied")
include_stability_reason = stability_applied not in (False, 0, "0", "false", "False")
candidates = [
data.get("guardrail_reason"),
data.get("downgrade_reason"),
data.get("decision_score_guardrail_reason"),
metadata.get("guardrail_reason"),
metadata.get("downgrade_reason"),
calibration.get("guardrail_reason"),
calibration.get("downgrade_reason"),
]
if include_stability_reason:
candidates.extend(
[
stability.get("guardrail_reason"),
stability.get("downgrade_reason"),
stability.get("reason"),
]
)
for candidate in candidates:
text = str(candidate or "").strip()
if text:
return text
return None
def score_action_conflicts_without_guardrail(
*,
score: Any,
+39 -3
View File
@@ -12,18 +12,50 @@ import logging
from typing import Any, Dict, List, Optional
from src.storage import DatabaseManager
from src.report_language import normalize_report_language
from src.schemas.decision_action import display_action_fields
from src.schemas.decision_scale import extract_decision_guardrail_reason
from src.utils.data_processing import parse_json_field
logger = logging.getLogger(__name__)
def _record_to_signal(record: Any) -> Optional[Dict[str, Any]]:
def _record_to_signal(
record: Any,
*,
report_language: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Convert AnalysisHistory record to signal dict. Skip on parse error."""
raw_result = parse_json_field(getattr(record, "raw_result", None))
if not isinstance(raw_result, dict):
raw_result = {}
operation_advice = raw_result.get("operation_advice") or getattr(record, "operation_advice", None)
explicit_action = raw_result.get("action")
action_label = raw_result.get("action_label")
resolved_report_language = normalize_report_language(
report_language
or raw_result.get("report_language")
or getattr(record, "report_language", None)
)
action_fields = display_action_fields(
operation_advice=operation_advice,
explicit_action=explicit_action,
action_label=action_label,
report_type=getattr(record, "report_type", None),
report_language=resolved_report_language,
sentiment_score=getattr(record, "sentiment_score", None),
guardrail_reason=extract_decision_guardrail_reason(raw_result),
)
try:
return {
"created_at": record.created_at.isoformat() if record.created_at else None,
"query_id": record.query_id,
"sentiment_score": record.sentiment_score,
"operation_advice": record.operation_advice,
"action": action_fields["action"],
"action_label": action_fields["action_label"],
"trend_prediction": record.trend_prediction,
}
except Exception as e:
@@ -35,6 +67,8 @@ def get_signal_changes(
code: str,
limit: int = 5,
exclude_query_id: Optional[str] = None,
*,
report_language: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""
Get recent signal changes for a single stock.
@@ -56,7 +90,7 @@ def get_signal_changes(
)
out = []
for r in records:
sig = _record_to_signal(r)
sig = _record_to_signal(r, report_language=report_language)
if sig:
out.append(sig)
return out
@@ -66,6 +100,8 @@ def get_signal_changes_batch(
codes: List[str],
limit: int = 5,
exclude_query_ids: Optional[Dict[str, str]] = None,
*,
report_language: Optional[str] = None,
) -> Dict[str, List[Dict[str, Any]]]:
"""
Get recent signal changes for multiple stocks.
@@ -90,7 +126,7 @@ def get_signal_changes_batch(
exclude_query_id=exclude,
)
for r in records:
sig = _record_to_signal(r)
sig = _record_to_signal(r, report_language=report_language)
if sig:
result[code].append(sig)
return result
+49 -11
View File
@@ -26,7 +26,6 @@ from src.report_language import (
is_chip_structure_unavailable,
localize_bias_status,
localize_chip_health,
localize_operation_advice,
localize_trend_prediction,
normalize_report_language,
)
@@ -36,7 +35,12 @@ from src.market_phase_summary import (
extract_market_phase_summary,
rebuild_market_phase_summary_for_stock_code,
)
from src.schemas.decision_action import build_action_fields
from src.schemas.decision_action import (
display_action_fields,
display_action_fields_for_result,
display_operation_advice_for_result,
)
from src.schemas.decision_scale import extract_decision_guardrail_reason
from src.utils.sniper_points import find_sniper_points
from src.utils.data_processing import (
extract_realtime_detail_fields,
@@ -587,14 +591,14 @@ class HistoryService:
def _decision_action_fields_for_record(self, record, raw_result: Any) -> Dict[str, Any]:
raw = raw_result if isinstance(raw_result, dict) else {}
return build_action_fields(
return display_action_fields(
operation_advice=raw.get("operation_advice") or getattr(record, "operation_advice", None),
explicit_action=raw.get("action"),
action_label=raw.get("action_label"),
report_type=getattr(record, "report_type", None),
report_language=normalize_report_language(raw.get("report_language")),
sentiment_score=getattr(record, "sentiment_score", None),
guardrail_reason=raw.get("guardrail_reason") or raw.get("downgrade_reason"),
align_with_score=True,
guardrail_reason=extract_decision_guardrail_reason(raw),
)
def delete_history_records(self, record_ids: List[int]) -> int:
@@ -835,7 +839,7 @@ class HistoryService:
dashboard = raw_result.get("dashboard", {})
# Build AnalysisResult with available data
return AnalysisResult(
result = AnalysisResult(
code=raw_result.get("code", record.code),
name=raw_result.get("name", record.name),
sentiment_score=raw_result.get("sentiment_score", record.sentiment_score or 50),
@@ -873,6 +877,10 @@ class HistoryService:
change_pct=raw_result.get("change_pct"),
model_used=raw_result.get("model_used"),
)
guardrail_reason = extract_decision_guardrail_reason(raw_result)
if guardrail_reason:
setattr(result, "guardrail_reason", guardrail_reason)
return result
except Exception as e:
logger.error(f"Failed to rebuild AnalysisResult: {e}", exc_info=True)
return None
@@ -988,7 +996,7 @@ class HistoryService:
report_lines.extend([
f"| {labels['position_status_label']} | {labels['action_advice_label']} |",
"|---------|---------|",
f"| 🆕 **{labels['no_position_label']}** | {pos_advice.get('no_position', localize_operation_advice(result.operation_advice, report_language))} |",
f"| 🆕 **{labels['no_position_label']}** | {pos_advice.get('no_position', self._get_display_operation_advice(result, report_language))} |",
f"| 💼 **{labels['has_position_label']}** | {pos_advice.get('has_position', labels['continue_holding'])} |",
"",
])
@@ -1204,12 +1212,42 @@ class HistoryService:
return "N/A"
return text
def _get_display_operation_advice(
self,
result: AnalysisResult,
report_language: Optional[str] = None,
) -> str:
return display_operation_advice_for_result(
result,
report_language=report_language or getattr(result, "report_language", "zh"),
)
def _get_signal_level(self, result: AnalysisResult) -> Tuple[str, str, str]:
"""Get signal level based on sentiment score and decision type."""
return get_signal_level(
result.operation_advice,
"""Get display text and signal metadata from the resolved action."""
report_language = getattr(result, "report_language", "zh")
display_fields = display_action_fields_for_result(
result,
report_language=report_language,
)
signal_advice = {
"buy": "buy",
"add": "buy",
"hold": "hold",
"reduce": "reduce",
"sell": "sell",
"watch": "watch",
"avoid": "hold",
"alert": "sell",
}.get(display_fields["action"])
_, emoji, signal_tag = get_signal_level(
signal_advice or self._get_display_operation_advice(result, report_language),
result.sentiment_score,
getattr(result, "report_language", "zh"),
report_language,
)
return (
self._get_display_operation_advice(result, report_language),
emoji,
signal_tag,
)
@staticmethod
+35 -6
View File
@@ -28,6 +28,12 @@ from src.report_language import (
localize_trend_prediction,
normalize_report_language,
)
from src.schemas.decision_action import (
display_action_fields_for_result,
display_decision_type_for_result,
display_operation_advice_for_result,
localize_action_label,
)
from src.utils.data_processing import (
normalize_model_used,
signal_attribution_has_content,
@@ -126,20 +132,42 @@ def render(
sorted_results = sorted(results, key=lambda x: x.sentiment_score, reverse=True)
sorted_enriched = []
for r in sorted_results:
st, se, _ = get_signal_level(r.operation_advice, r.sentiment_score, report_language)
display_action = display_action_fields_for_result(
r,
report_language=report_language,
)["action"]
display_advice = display_operation_advice_for_result(
r,
report_language=report_language,
)
signal_action = {
"buy": "buy",
"add": "buy",
"hold": "hold",
"reduce": "reduce",
"sell": "sell",
"watch": "watch",
"avoid": "hold",
"alert": "sell",
}.get(display_action, display_action)
_, se, _ = get_signal_level(signal_action or display_advice, r.sentiment_score, report_language)
rn = get_localized_stock_name(r.name, r.code, report_language)
sorted_enriched.append({
"result": r,
"signal_text": st,
"signal_text": display_advice,
"signal_emoji": se,
"stock_name": _escape_md(rn),
"localized_operation_advice": localize_operation_advice(r.operation_advice, report_language),
"localized_operation_advice": display_advice,
"localized_trend_prediction": localize_trend_prediction(r.trend_prediction, report_language),
})
buy_count = sum(1 for r in results if getattr(r, "decision_type", "") == "buy")
sell_count = sum(1 for r in results if getattr(r, "decision_type", "") == "sell")
hold_count = sum(1 for r in results if getattr(r, "decision_type", "") in ("hold", ""))
display_buckets = [
display_decision_type_for_result(r, report_language=report_language)
for r in results
]
buy_count = sum(1 for bucket in display_buckets if bucket == "buy")
sell_count = sum(1 for bucket in display_buckets if bucket == "sell")
hold_count = len(display_buckets) - buy_count - sell_count
show_llm_model = bool(getattr(get_config(), "report_show_llm_model", True))
models_used: List[str] = []
if show_llm_model:
@@ -202,6 +230,7 @@ def render(
"get_chip_unavailable_reason": get_chip_unavailable_reason,
"is_chip_structure_unavailable": is_chip_structure_unavailable,
"localize_operation_advice": localize_operation_advice,
"localize_action_label": localize_action_label,
"localize_trend_prediction": localize_trend_prediction,
"localize_chip_health": localize_chip_health,
"signal_attribution_has_content": signal_attribution_has_content,
+1 -1
View File
@@ -9,7 +9,7 @@
{% set dash = e.result.dashboard or {} %}
{% set core = (dash.get('core_conclusion') or {}) if dash else {} %}
{% set one = (core.get('one_sentence') or e.result.analysis_summary or '')[:60] %}
**{{ e.stock_name }}({{ e.result.code }})** {{ e.signal_emoji }} {{ e.localized_operation_advice }} | {{ labels.score_label }} {{ e.result.sentiment_score }} | {{ one }}
**{{ e.stock_name }}({{ e.result.code }})** {{ e.signal_emoji }} {{ e.signal_text }} | {{ labels.score_label }} {{ e.result.sentiment_score }} | {{ one }}
{% endfor %}
*{{ report_timestamp }}*
+3 -3
View File
@@ -9,7 +9,7 @@
## 📊 {{ labels.summary_heading }}
{% for e in enriched %}
{{ e.signal_emoji }} **{{ e.stock_name }}({{ e.result.code }})**: {{ e.localized_operation_advice }} | {{ labels.score_label }} {{ e.result.sentiment_score }} | {{ e.localized_trend_prediction }}
{{ e.signal_emoji }} **{{ e.stock_name }}({{ e.result.code }})**: {{ e.signal_text }} | {{ labels.score_label }} {{ e.result.sentiment_score }} | {{ e.localized_trend_prediction }}
{% endfor %}
---
@@ -71,7 +71,7 @@
{% if pos_advice %}
| {{ labels.position_status_label }} | {{ labels.action_advice_label }} |
|---------|---------|
| 🆕 **{{ labels.no_position_label }}** | {{ pos_advice.get('no_position', localize_operation_advice(result.operation_advice, report_language)) }} |
| 🆕 **{{ labels.no_position_label }}** | {{ pos_advice.get('no_position', e.localized_operation_advice) }} |
| 💼 **{{ labels.has_position_label }}** | {{ pos_advice.get('has_position', labels.continue_holding) }} |
{% endif %}
@@ -215,7 +215,7 @@
| {{ labels.time_label }} | {{ labels.score_label }} | {{ labels.advice_label }} | {{ labels.trend_label }} |
|------|------|------|------|
{% for h in hist %}
| {{ h.created_at[:16] if h.created_at else 'N/A' }} | {{ h.sentiment_score or 'N/A' }} | {{ localize_operation_advice(h.operation_advice or 'N/A', report_language) }} | {{ localize_trend_prediction(h.trend_prediction or 'N/A', report_language) }} |
| {{ h.created_at[:16] if h.created_at else 'N/A' }} | {{ h.sentiment_score or 'N/A' }} | {{ h.action_label or localize_action_label(h.action, report_language) or localize_operation_advice(h.operation_advice or 'N/A', report_language) }} | {{ localize_trend_prediction(h.trend_prediction or 'N/A', report_language) }} |
{% endfor %}
{% endif %}
+1 -1
View File
@@ -8,7 +8,7 @@
{% if summary_only %}
**📊 {{ labels.summary_heading }}**
{% for e in enriched %}
{{ e.signal_emoji }} **{{ e.stock_name }}({{ e.result.code }})**: {{ e.localized_operation_advice }} | {{ labels.score_label }} {{ e.result.sentiment_score }} | {{ e.localized_trend_prediction }}
{{ e.signal_emoji }} **{{ e.stock_name }}({{ e.result.code }})**: {{ e.signal_text }} | {{ labels.score_label }} {{ e.result.sentiment_score }} | {{ e.localized_trend_prediction }}
{% endfor %}
{% else %}
{% for e in enriched %}
+21
View File
@@ -1595,6 +1595,27 @@ class AnalysisHistoryTestCase(unittest.TestCase):
self.assertIn("Unnamed Stock (AAPL)", markdown)
self.assertNotIn("核心结论", markdown)
def test_history_markdown_signal_metadata_uses_explicit_avoid_action(self) -> None:
result = AnalysisResult(
code="AAPL",
name="Apple",
sentiment_score=90,
trend_prediction="Bullish",
operation_advice="Hold",
analysis_summary="Risk remains elevated.",
report_language="en",
action="avoid",
action_label="Avoid",
)
markdown = HistoryService(self.db)._generate_single_stock_markdown(
result,
MagicMock(created_at=None),
)
self.assertIn("**🟡 Avoid** | Bullish", markdown)
self.assertNotIn("Strong Buy", markdown)
def test_history_markdown_returns_persisted_market_review_report(self) -> None:
"""Market review history should return the saved Markdown without rebuilding a stock report."""
result = AnalysisResult(
+65
View File
@@ -5,6 +5,9 @@ import pytest
from src.schemas.decision_action import (
build_action_fields,
display_action_fields_for_result,
display_decision_type_for_result,
display_operation_advice_for_result,
localize_action_label,
normalize_decision_action,
)
@@ -293,6 +296,23 @@ def test_localize_action_label_uses_report_language() -> None:
assert localize_action_label("avoid", "en") == "Avoid"
@pytest.mark.parametrize(
("action", "expected_label"),
[
("buy", "매수"),
("add", "추가 매수"),
("hold", "보유"),
("reduce", "비중축소"),
("sell", "매도"),
("watch", "관망"),
("avoid", "회피"),
("alert", "경고"),
],
)
def test_localize_action_label_supports_korean(action: str, expected_label: str) -> None:
assert localize_action_label(action, "ko") == expected_label
def test_build_action_fields_respects_market_review_exclusion() -> None:
fields = build_action_fields(
operation_advice="买入",
@@ -366,3 +386,48 @@ def test_build_action_fields_keeps_neutral_score_conflict_when_guardrail_is_expl
guardrail_reason="等待回踩确认",
align_with_score=True,
) == {"action": "watch", "action_label": "观望"}
def test_display_helpers_share_score_aligned_action_with_report_rows_and_counts() -> None:
result = type(
"Result",
(),
{
"operation_advice": "Hold",
"action": None,
"action_label": None,
"sentiment_score": 72,
"decision_type": "hold",
"report_language": "en",
"dashboard": {},
},
)()
assert display_action_fields_for_result(result) == {"action": "buy", "action_label": "Buy"}
assert display_operation_advice_for_result(result) == "Buy"
assert display_decision_type_for_result(result) == "buy"
def test_display_helpers_preserve_neutral_action_when_guardrail_was_applied() -> None:
result = type(
"Result",
(),
{
"operation_advice": "Hold",
"action": "hold",
"action_label": "Hold",
"sentiment_score": 72,
"decision_type": "hold",
"report_language": "en",
"dashboard": {
"decision_stability": {
"applied": True,
"reason": "Wait for confirmation",
}
},
},
)()
assert display_action_fields_for_result(result) == {"action": "hold", "action_label": "Hold"}
assert display_operation_advice_for_result(result) == "Hold"
assert display_decision_type_for_result(result) == "hold"
+41
View File
@@ -0,0 +1,41 @@
from datetime import datetime
from types import SimpleNamespace
from src.services.history_comparison_service import _record_to_signal
def _record(**overrides):
values = {
"created_at": datetime(2026, 7, 11, 9, 0),
"query_id": "q1",
"sentiment_score": 72,
"operation_advice": "Hold",
"trend_prediction": "Bullish",
"report_type": "stock",
"report_language": "en",
"raw_result": "{}",
}
values.update(overrides)
return SimpleNamespace(**values)
def test_history_signal_uses_score_aligned_display_action() -> None:
signal = _record_to_signal(_record(), report_language="en")
assert signal["action"] == "buy"
assert signal["action_label"] == "Buy"
def test_history_signal_preserves_applied_guardrail() -> None:
signal = _record_to_signal(
_record(
raw_result=(
'{"action":"hold","dashboard":{"decision_stability":'
'{"applied":true,"reason":"Wait for confirmation"}}}'
)
),
report_language="en",
)
assert signal["action"] == "hold"
assert signal["action_label"] == "Hold"
+90 -3
View File
@@ -30,7 +30,7 @@ for optional_module in ("litellm", "json_repair"):
sys.modules[optional_module] = mock.MagicMock()
from src.config import Config
from src.notification import NotificationService, NotificationChannel
from src.notification import NotificationBuilder, NotificationChannel, NotificationService
from src.notification_noise import reset_notification_noise_state
from src.analyzer import AnalysisResult
from bot.models import BotMessage, ChatType
@@ -662,6 +662,93 @@ class TestNotificationServiceSendToMethods(unittest.TestCase):
class TestNotificationServiceReportGeneration(unittest.TestCase):
"""报告生成与选路相关测试。"""
def test_signal_metadata_uses_resolved_eight_state_action(self):
service = NotificationService()
cases = [
("avoid", "Avoid", 90, ("Avoid", "🟡", "hold")),
("add", "Add", 50, ("Add", "🟢", "buy")),
("alert", "Alert", 85, ("Alert", "🔴", "sell")),
]
for action, action_label, score, expected in cases:
with self.subTest(action=action):
result = AnalysisResult(
code="AAPL",
name="Apple",
sentiment_score=score,
trend_prediction="Neutral",
operation_advice="Hold",
report_language="en",
action=action,
action_label=action_label,
)
self.assertEqual(service._get_signal_level(result), expected)
def test_build_stock_summary_uses_resolved_eight_state_action(self):
summary = NotificationBuilder.build_stock_summary(
[
AnalysisResult(
code="AVOID",
name="Avoid Corp",
sentiment_score=90,
trend_prediction="Neutral",
operation_advice="Avoid",
report_language="en",
action="avoid",
action_label="Avoid",
),
AnalysisResult(
code="ALERT",
name="Alert Corp",
sentiment_score=85,
trend_prediction="Neutral",
operation_advice="Alert",
report_language="en",
action="alert",
action_label="Alert",
),
AnalysisResult(
code="ADD",
name="Add Corp",
sentiment_score=50,
trend_prediction="Neutral",
operation_advice="Add",
report_language="en",
action="add",
action_label="Add",
),
]
)
self.assertIn("🟡 Avoid Corp(AVOID): Avoid | Score 90", summary)
self.assertIn("🔴 Alert Corp(ALERT): Alert | Score 85", summary)
self.assertIn("🟢 Add Corp(ADD): Add | Score 50", summary)
self.assertNotIn("Buy | Score 50", summary)
self.assertNotIn("Strong Buy", summary)
@mock.patch("src.notification.get_config")
def test_report_rows_and_summary_use_same_score_aligned_action(
self, mock_get_config: mock.MagicMock
):
mock_get_config.return_value = _make_config(report_renderer_enabled=False)
service = NotificationService()
result = AnalysisResult(
code="AAPL",
name="Apple",
sentiment_score=72,
trend_prediction="Bullish",
operation_advice="Hold",
decision_type="hold",
report_language="en",
)
out = service.generate_brief_report([result], report_date="2026-07-11")
self.assertIn("🟢1 🟡0 🔴0", out)
self.assertIn("Buy | Score 72", out)
self.assertNotIn("Hold | Score 72", out)
@mock.patch("src.notification.get_config")
def test_generate_aggregate_report_routes_by_report_type(self, mock_get_config: mock.MagicMock):
mock_get_config.return_value = _make_config()
@@ -1139,7 +1226,7 @@ class TestNotificationServiceReportGeneration(unittest.TestCase):
self.assertNotIn("消息面", out)
@mock.patch("src.notification.get_config")
def test_generate_single_stock_report_localizes_english_fallback(self, mock_get_config: mock.MagicMock):
def test_generate_single_stock_report_aligns_english_fallback_with_score(self, mock_get_config: mock.MagicMock):
mock_get_config.return_value = _make_config(report_renderer_enabled=False, report_language="en")
service = NotificationService()
result = AnalysisResult(
@@ -1166,7 +1253,7 @@ class TestNotificationServiceReportGeneration(unittest.TestCase):
self.assertIn("Core Conclusion", out)
self.assertIn("Action Levels", out)
self.assertIn("Hold", out)
self.assertIn("Buy", out)
def _make_fundamental_context(self) -> dict:
return {
+45
View File
@@ -79,7 +79,52 @@ class TestReportRenderer(unittest.TestCase):
self.assertIsNotNone(out)
self.assertIn("决策仪表盘", out)
self.assertIn("贵州茅台", out)
self.assertIn("买入", out)
self.assertIn("🟢买入:1", out)
def test_render_markdown_preserves_guardrailed_neutral_action(self) -> None:
r = _make_result(
dashboard={
"core_conclusion": {"one_sentence": "等待确认"},
"decision_stability": {"applied": True, "reason": "等待回踩确认"},
}
)
out = render("markdown", [r], summary_only=True)
self.assertIsNotNone(out)
self.assertIn("持有", out)
self.assertIn("🟡观望:1", out)
def test_render_markdown_uses_explicit_avoid_and_alert_text(self) -> None:
avoid = _make_result(
code="AVOID",
name="Avoid Corp",
sentiment_score=90,
operation_advice="Buy",
report_language="en",
)
avoid.action = "avoid"
avoid.action_label = "Avoid"
alert = _make_result(
code="ALERT",
name="Alert Corp",
sentiment_score=85,
operation_advice="Buy",
report_language="en",
)
alert.action = "alert"
alert.action_label = "Alert"
out = render("markdown", [avoid, alert], summary_only=True)
self.assertIsNotNone(out)
self.assertIn("🟡 **Avoid Corp(AVOID)**: Avoid | Score 90", out)
self.assertIn("🔴 **Alert Corp(ALERT)**: Alert | Score 85", out)
self.assertIn("**Avoid Corp(AVOID)**: Avoid | Score 90", out)
self.assertIn("**Alert Corp(ALERT)**: Alert | Score 85", out)
self.assertNotIn("**Avoid Corp(AVOID)**: Buy", out)
self.assertNotIn("**Alert Corp(ALERT)**: Buy", out)
def test_render_markdown_full(self) -> None:
"""Markdown platform renders full report."""