mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
fix: preserve screening post-analysis explanations
This commit is contained in:
@@ -16,7 +16,7 @@
|
||||
|
||||
## Why Selected
|
||||
|
||||
确定性本地解释优先使用 screening reason 和当前策略实际参与评分的因子;零权重或未配置的因子既不会进入缺省 `selection_reason`,也不会被写成“核心因子”,两处展示顺序都按“因子分数 × 策略权重”的真实贡献排列。`risk_summary` / `risk_level` 始终保留在独立风险展示,不会在缺少 reason 时提升为 `selection_reason`;行业标签也不会单独冒充入选依据。缺少 reason 和可核验加权因子时只确认“已进入当前选股候选结果”,不会把可能经过 LLM 排序、组合约束或后处理调整的最终名次误写成“确定性筛选排名”。来自 `post_analysis_summaries` 的 DSA/外部 analyzer 摘要保留 `post_analyzer:<name>` 来源并标记为 inferred,不冒充本地 observed;纯本地确定性 `scorecard` 摘要保持 observed,但只要 scorecard 消费了 `llm_confidence`、`llm_catalysts` 或 `llm_risks`,其解释质量就保持 inferred。即使 LLM 未配置、超时或返回无效结构,候选仍至少返回入选结果说明;LLM 不是本地解释的前置条件。
|
||||
确定性本地解释优先使用 screening reason 和当前策略实际参与评分的因子;零权重或未配置的因子既不会进入缺省 `selection_reason`,也不会被写成“核心因子”,两处展示顺序都按“因子分数 × 策略权重”的真实贡献排列。`risk_summary` / `risk_level` 始终保留在独立风险展示,不会在缺少 reason 时提升为 `selection_reason`;行业标签也不会单独冒充入选依据。缺少 reason 和可核验加权因子时只确认“已进入当前选股候选结果”,不会把可能经过 LLM 排序、组合约束或后处理调整的最终名次误写成“确定性筛选排名”。来自 `post_analysis_summaries` 的 DSA/外部 analyzer 摘要保留 `post_analyzer:<name>` 来源并标记为 inferred,不冒充本地 observed;即使候选同时已有显式 `reason` / `ranking_reason`,不同内容的后分析摘要也会作为 `post_analysis_summary` 一并返回,同文案只展示一次。纯本地确定性 `scorecard` 摘要保持 observed,但只要 scorecard 消费了 `llm_confidence`、`llm_catalysts` 或 `llm_risks`,其解释质量就保持 inferred。即使 LLM 未配置、超时或返回无效结构,候选仍至少返回入选结果说明;LLM 不是本地解释的前置条件。
|
||||
|
||||
## Why Now
|
||||
|
||||
|
||||
@@ -3917,6 +3917,28 @@ def _attach_candidate_explanations(
|
||||
_explanation_item("top_factors", f"核心因子:{text}", source="screening", quality="observed")
|
||||
)
|
||||
|
||||
summaries = candidate.get("post_analysis_summaries")
|
||||
if isinstance(summaries, dict):
|
||||
existing_texts = {
|
||||
str(item.get("text") or "").strip()
|
||||
for item in why_selected
|
||||
if str(item.get("text") or "").strip()
|
||||
}
|
||||
for analyzer, value in summaries.items():
|
||||
summary = str(value or "").strip()
|
||||
if not summary or summary in existing_texts:
|
||||
continue
|
||||
analyzer_name = str(analyzer).strip() or "unknown"
|
||||
why_selected.append(
|
||||
_explanation_item(
|
||||
"post_analysis_summary",
|
||||
summary,
|
||||
source=f"post_analyzer:{analyzer_name}",
|
||||
quality=_post_analysis_summary_quality(candidate, analyzer_name),
|
||||
)
|
||||
)
|
||||
existing_texts.add(summary)
|
||||
|
||||
if not any(item.get("quality") == "observed" for item in why_selected):
|
||||
why_selected.append(
|
||||
_explanation_item(
|
||||
@@ -4156,18 +4178,12 @@ def _build_candidate_reason(
|
||||
if isinstance(summaries, dict):
|
||||
for analyzer, value in summaries.items():
|
||||
if value:
|
||||
analyzer_name = str(analyzer).strip().lower()
|
||||
scorecard_uses_llm = analyzer_name == "scorecard" and (
|
||||
item.get("llm_confidence") is not None
|
||||
or bool(item.get("llm_catalysts"))
|
||||
or bool(item.get("llm_risks"))
|
||||
analyzer_name = str(analyzer).strip() or "unknown"
|
||||
return (
|
||||
str(value),
|
||||
f"post_analyzer:{analyzer_name}",
|
||||
_post_analysis_summary_quality(item, analyzer_name),
|
||||
)
|
||||
quality = (
|
||||
"observed"
|
||||
if analyzer_name == "scorecard" and not scorecard_uses_llm
|
||||
else "inferred"
|
||||
)
|
||||
return str(value), f"post_analyzer:{analyzer}", quality
|
||||
|
||||
factors = item.get("factor_scores")
|
||||
parts: List[str] = []
|
||||
@@ -4194,6 +4210,18 @@ def _build_candidate_reason(
|
||||
return (reason, "screening", "observed") if reason else ("", "", "")
|
||||
|
||||
|
||||
def _post_analysis_summary_quality(item: Dict[str, Any], analyzer: str) -> str:
|
||||
analyzer_name = analyzer.strip().lower()
|
||||
scorecard_uses_llm = analyzer_name == "scorecard" and (
|
||||
item.get("llm_confidence") is not None
|
||||
or bool(item.get("llm_catalysts"))
|
||||
or bool(item.get("llm_risks"))
|
||||
)
|
||||
if analyzer_name == "scorecard" and not scorecard_uses_llm:
|
||||
return "observed"
|
||||
return "inferred"
|
||||
|
||||
|
||||
def _to_plain(value: Any) -> Any:
|
||||
if is_dataclass(value):
|
||||
return asdict(value)
|
||||
|
||||
@@ -296,6 +296,57 @@ def test_scorecard_using_llm_fields_keeps_inferred_provenance() -> None:
|
||||
assert result["explanation_quality"]["why_selected"] == "partial"
|
||||
|
||||
|
||||
def test_explicit_reason_keeps_distinct_post_analysis_summaries() -> None:
|
||||
candidate = _normalize_candidate({
|
||||
"code": "600519",
|
||||
"ranking_reason": "量价和质量因子排名靠前",
|
||||
"post_analysis_summaries": {
|
||||
"scorecard": "本地因子计分摘要",
|
||||
"dsa": "模型补充的新闻风险摘要",
|
||||
},
|
||||
"factor_scores": {},
|
||||
}, 1)
|
||||
|
||||
result = _attach_candidate_explanations(candidate)
|
||||
|
||||
assert [item["code"] for item in result["why_selected"]] == [
|
||||
"selection_reason",
|
||||
"post_analysis_summary",
|
||||
"post_analysis_summary",
|
||||
]
|
||||
assert result["why_selected"][0]["text"] == "量价和质量因子排名靠前"
|
||||
assert result["why_selected"][1] == {
|
||||
"code": "post_analysis_summary",
|
||||
"text": "本地因子计分摘要",
|
||||
"source": "post_analyzer:scorecard",
|
||||
"quality": "observed",
|
||||
}
|
||||
assert result["why_selected"][2] == {
|
||||
"code": "post_analysis_summary",
|
||||
"text": "模型补充的新闻风险摘要",
|
||||
"source": "post_analyzer:dsa",
|
||||
"quality": "inferred",
|
||||
}
|
||||
assert result["explanation_quality"]["why_selected"] == "partial"
|
||||
|
||||
|
||||
def test_post_analysis_summary_matching_explicit_reason_is_not_duplicated() -> None:
|
||||
candidate = _normalize_candidate({
|
||||
"code": "600519",
|
||||
"reason": "同一条后分析摘要",
|
||||
"post_analysis_summaries": {
|
||||
"scorecard": "同一条后分析摘要",
|
||||
},
|
||||
"factor_scores": {},
|
||||
}, 1)
|
||||
|
||||
result = _attach_candidate_explanations(candidate)
|
||||
|
||||
assert [item["text"] for item in result["why_selected"]] == [
|
||||
"同一条后分析摘要",
|
||||
]
|
||||
|
||||
|
||||
def test_risk_level_is_not_promoted_to_selection_reason() -> None:
|
||||
candidate = _normalize_candidate({
|
||||
"code": "600519",
|
||||
|
||||
Reference in New Issue
Block a user