fix: preserve screening post-analysis explanations

This commit is contained in:
ZhuLinsen
2026-08-30 21:50:04 +08:00
parent bffc793595
commit d4bfa269f1
3 changed files with 91 additions and 12 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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",