fix: restore stock bar summary badges (#1848)

This commit is contained in:
zhulinsen
2026-06-29 21:18:02 +08:00
committed by GitHub
parent a62fc56a74
commit a448886f67
5 changed files with 107 additions and 19 deletions

View File

@@ -10,7 +10,7 @@
"""
import logging
from typing import Optional
from typing import Any, Optional
from fastapi import APIRouter, HTTPException, Query, Depends, Body
@@ -71,6 +71,47 @@ def _normalize_code_for_grouping(code: str) -> str:
return normalize_stock_code(code or "")
def _raw_result_value(raw_result: Any, key: str) -> Any:
if not isinstance(raw_result, dict):
return None
value = raw_result.get(key)
if value is not None and value != "":
return value
for container_key in ("summary", "dashboard"):
container = raw_result.get(container_key)
if isinstance(container, dict):
nested_value = container.get(key)
if nested_value is not None and nested_value != "":
return nested_value
return None
def _coalesce_text(*values: Any) -> Optional[str]:
for value in values:
if value is None:
continue
text = str(value).strip()
if text:
return text
return None
def _coalesce_int(*values: Any) -> Optional[int]:
for value in values:
if value is None or isinstance(value, bool):
continue
if isinstance(value, str) and not value.strip():
continue
try:
return int(float(value))
except (TypeError, ValueError):
continue
return None
@router.get(
"",
response_model=HistoryListResponse,
@@ -285,15 +326,20 @@ def get_stock_bar(
record = seen[norm_code]
raw_result = parse_json_field(getattr(record, "raw_result", None))
model_used = raw_result.get("model_used") if isinstance(raw_result, dict) else None
sentiment_score = _coalesce_int(
record.sentiment_score,
_raw_result_value(raw_result, "sentiment_score"),
)
operation_advice = _coalesce_text(
record.operation_advice,
_raw_result_value(raw_result, "operation_advice"),
)
action_fields = build_action_fields(
operation_advice=(
raw_result.get("operation_advice") if isinstance(raw_result, dict) else None
)
or record.operation_advice,
explicit_action=raw_result.get("action") if isinstance(raw_result, dict) else None,
operation_advice=operation_advice,
explicit_action=_raw_result_value(raw_result, "action"),
report_type=record.report_type,
report_language=normalize_report_language(
raw_result.get("report_language") if isinstance(raw_result, dict) else None
_raw_result_value(raw_result, "report_language")
),
)
@@ -308,8 +354,8 @@ def get_stock_bar(
stock_code=display_stock_code,
stock_name=record.name,
report_type=record.report_type,
sentiment_score=record.sentiment_score,
operation_advice=record.operation_advice,
sentiment_score=sentiment_score,
operation_advice=operation_advice,
action=action_fields["action"],
action_label=action_fields["action_label"],
analysis_count=analysis_count,

View File

@@ -26,14 +26,15 @@ export const StockBarItemComponent: React.FC<StockBarItemProps> = ({
isMarketReview = false,
}) => {
const { language, t } = useUiLanguage();
const sentimentColor = item.sentimentScore !== undefined ? getSentimentColor(item.sentimentScore) : null;
const sentimentScore = typeof item.sentimentScore === 'number' ? item.sentimentScore : null;
const sentimentColor = sentimentScore !== null ? getSentimentColor(sentimentScore) : null;
const stockName = item.stockName || item.stockCode;
const actionLabels = buildDecisionActionLabelMap(t);
const operationLabel = getDecisionActionLabel(
item.action,
item.actionLabel,
item.operationAdvice,
null,
t('history.sentiment'),
actionLabels,
);
const phaseLabel = getMarketPhaseSummaryLabel(item.marketPhaseSummary, language)
@@ -85,7 +86,7 @@ export const StockBarItemComponent: React.FC<StockBarItemProps> = ({
>
{t('stockBar.market')}
</Badge>
) : operationLabel && sentimentColor ? (
) : sentimentColor ? (
<Badge
variant="default"
size="sm"
@@ -96,7 +97,7 @@ export const StockBarItemComponent: React.FC<StockBarItemProps> = ({
backgroundColor: `${sentimentColor}10`,
}}
>
{operationLabel} {item.sentimentScore}
{operationLabel} {sentimentScore}
</Badge>
) : null}
{onDelete && (

View File

@@ -121,7 +121,7 @@ describe('StockBarItemComponent', () => {
const actions = screen.getByTestId('history-card-actions');
expect(within(actions).queryByText('buy 28')).not.toBeInTheDocument();
expect(within(actions).queryByText(/28/)).not.toBeInTheDocument();
expect(within(actions).getByText(/28/)).toBeInTheDocument();
});
it('does not render financial compound English advice as an action badge', () => {
@@ -141,7 +141,7 @@ describe('StockBarItemComponent', () => {
let actions = screen.getByTestId('history-card-actions');
expect(within(actions).queryByText('持有 28')).not.toBeInTheDocument();
expect(within(actions).queryByText(/28/)).not.toBeInTheDocument();
expect(within(actions).getByText(/28/)).toBeInTheDocument();
rerender(
<StockBarItemComponent
@@ -159,7 +159,7 @@ describe('StockBarItemComponent', () => {
actions = screen.getByTestId('history-card-actions');
expect(within(actions).queryByText('卖出 31')).not.toBeInTheDocument();
expect(within(actions).queryByText(/31/)).not.toBeInTheDocument();
expect(within(actions).getByText(/31/)).toBeInTheDocument();
});
it('does not render Chinese financial context legacy advice as an action badge', () => {
@@ -179,7 +179,7 @@ describe('StockBarItemComponent', () => {
let actions = screen.getByTestId('history-card-actions');
expect(within(actions).queryByText('买入 32')).not.toBeInTheDocument();
expect(within(actions).queryByText(/32/)).not.toBeInTheDocument();
expect(within(actions).getByText(/32/)).toBeInTheDocument();
rerender(
<StockBarItemComponent
@@ -197,7 +197,7 @@ describe('StockBarItemComponent', () => {
actions = screen.getByTestId('history-card-actions');
expect(within(actions).queryByText('卖出 34')).not.toBeInTheDocument();
expect(within(actions).queryByText(/34/)).not.toBeInTheDocument();
expect(within(actions).getByText(/34/)).toBeInTheDocument();
});
it('does not render multi-guard legacy advice as an action badge', () => {
@@ -218,6 +218,6 @@ describe('StockBarItemComponent', () => {
const actions = screen.getByTestId('history-card-actions');
expect(within(actions).queryByText('回避 28')).not.toBeInTheDocument();
expect(within(actions).queryByText('预警 28')).not.toBeInTheDocument();
expect(within(actions).queryByText(/28/)).not.toBeInTheDocument();
expect(within(actions).getByText(/28/)).toBeInTheDocument();
});
});

View File

@@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
<!-- 新条目格式:- [类型] 描述(类型取值:新功能/改进/修复/文档/测试/chore-->
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->
- [修复] 修复 Web 首页个股栏在 stock-bar 摘要字段缺失或动作建议无法归类时隐藏情绪分与建议标识的问题。
## [3.24.1] - 2026-06-28
### 修复

View File

@@ -755,6 +755,45 @@ class AnalysisHistoryTestCase(unittest.TestCase):
self.assertEqual(response.items[0].action, "avoid")
self.assertEqual(response.items[0].action_label, "回避")
def test_stock_bar_item_falls_back_to_raw_result_summary_fields(self) -> None:
if get_stock_bar is None:
self.skipTest("fastapi is not installed in this test environment")
result = self._build_result()
result.operation_advice = "Hold"
result.report_language = "en"
saved = self.db.save_analysis_history(
result=result,
query_id="query_stock_bar_raw_fallback",
report_type="detailed",
news_content="stock report",
context_snapshot=None,
save_snapshot=False,
)
self.assertGreater(saved, 0)
with self.db.session_scope() as session:
row = session.query(AnalysisHistory).filter(
AnalysisHistory.query_id == "query_stock_bar_raw_fallback"
).first()
self.assertIsNotNone(row)
row.sentiment_score = None
row.operation_advice = None
response = get_stock_bar(
start_date=None,
end_date=None,
limit=10,
db_manager=self.db,
)
self.assertEqual(len(response.items), 1)
self.assertEqual(response.items[0].sentiment_score, 78)
self.assertEqual(response.items[0].operation_advice, "Hold")
self.assertEqual(response.items[0].action, "hold")
self.assertEqual(response.items[0].action_label, "Hold")
def test_history_detail_uses_service_resolved_action_fields(self) -> None:
if get_history_detail is None:
self.skipTest("fastapi is not installed in this test environment")