Files
gaokao-volunteer-system/data/crowd_db/tests/test_risk_report.py
Hermes Agent 4c732eb836
Some checks failed
CI / pytest (Python 3.10) (push) Has been cancelled
CI / pytest (Python 3.11) (push) Has been cancelled
CI / pytest (Python 3.12) (push) Has been cancelled
fix(crowd_db): 高信任数据系统性修复 — 契约硬化+真相单一化+防漂移
review 发现数据本身真实达标(7 high / 20 usable / 0 skeleton),
但代码/文档/测试/元数据 4 层出现严重脱节,存在静默升级风险与
合规假象回归漏洞。

Phase 1: 契约硬化(P0)
- risk_report.py 新增 _classify_score_bands + _compute_quality_level
- 质量等级判定从"仅看 confidence"升级为综合门槛
  (conf + sr + recs + alts + 三层分数带),对齐 plan §4
- 新增 low 等级区分"已脱离骨架但未达可用"
- _load_provenance_metadata 改为优先 load_province 取完整数据
- finding_to_risk_dict 不再二次规范化已规范化数据
- quality_summary.py 增加 low 等级统计
- SCHEMA.md §6 同步完整门槛定义

Phase 2: 测试加固(P0)
- 新增 test_high_trust_thresholds.py 锁死 high/usable 完整门槛
  (plan §9.2 要求的"防静默升级"测试)
- 修复 test_crowd_db_data_quality.py 等级枚举支持 low
- 修复 test_risk_report.py 硬编码日期脆弱性

Phase 3: 真相源单一化(P0)
- CURRENT_STATE.md §0.5 清除 6/20 旧文案"4 high + 3 usable + 20 skeleton"
- 改为引用顶部状态词 + 历史轨迹仅供审计
- NATIONALIZATION §4 清除"当前 high 已扩展为 5 省"矛盾文案
- 顶部状态词从"Phase 0 收口中"升级为"已完成"

Phase 4: 元数据状态对齐(P1)
- hunan.json / sichuan.json trusted_sources.kind
  province_official_pending_review -> province_official
- 同步更新 quality_note 说明已完成 2025 年度复核
- 消除"状态 high/usable 但 kind=pending_review"的矛盾

Phase 5: 防漂移机制(P1)
- 新增 scripts/check_crowd_db_consistency.py
  跨文档+数据+测试白名单一致性检查(5 项检查)
- dev-verify.sh 接入 crowd_db quality summary 打印

验证:
- ruff: All checks passed
- mypy: Success, no issues in 16 source files
- pytest crowd_db/: 148 passed, 2 skipped
- pytest 全量: 1283 passed, 2 skipped (无回归)
- consistency check: high=7 usable=20 low=0 skeleton=0

Refs: docs/plans/2026-06-23-national-high-trust-crowd-db-plan.md §4/§9
2026-06-25 07:41:11 +08:00

346 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""扎堆报告生成器测试 (T2.4)
覆盖:
- build_crowd_risks 转换正确性
- 三色 emoji 标识(🔴/🟡/🟢)随 frequency 变化
- 模板字段名/类型完整school/major/frequency/predicted_increase/
risk_level/risk_level_label/risk_emoji/platforms/alternatives
- alternatives 字段名从 name→school 重映射、score 强制为 int
- group_by_risk 分组稳定
- format_risk_summary 单行汇总
- render_risk_table 含 emoji/院校/替代行
- 空方案 / 不存在省份 / 全部不命中 → 空列表 + 无风险汇总
- 与 crowd_detector.detect_crowd_risk 输出在排序/等级上完全一致
"""
from __future__ import annotations
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", ".."))
from data.crowd_db.crowd_detector import plan_entry # noqa: F401 (re-exported by risk_report)
from data.crowd_db.risk_report import (
RISK_LEVEL_META,
build_crowd_risks,
finding_to_risk_dict,
format_risk_summary,
group_by_risk,
render_risk_table,
)
# ---------- 三色 emoji 标识 ----------
def test_high_risk_emoji_is_red():
"""frequency=4 → 🔴"""
plan = [plan_entry("长沙理工大学", "计算机科学与技术")]
risks = build_crowd_risks(plan, user_score=575, province="湖南")
assert len(risks) == 1
assert risks[0]["risk_level"] == "high"
assert risks[0]["risk_emoji"] == "🔴"
assert risks[0]["risk_level_label"] == ""
def test_medium_risk_emoji_is_yellow():
"""frequency=2-3 → 🟡"""
plan = [plan_entry("湖南科技大学", "机械设计制造及其自动化")]
risks = build_crowd_risks(plan, user_score=575, province="湖南")
assert len(risks) == 1
assert risks[0]["risk_level"] == "medium"
assert risks[0]["risk_emoji"] == "🟡"
assert risks[0]["risk_level_label"] == ""
def test_low_risk_emoji_is_green():
"""frequency=1 → 🟢"""
# 找一个 frequency=1 的院校575 分段里挑低频
# 使用一个不存在的院校则不会命中,所以这里走"全 0 跳过"的对照。
# 改用 480 分数段(专科批临界)找一条 low
plan = [plan_entry("长沙民政职业技术学院", "社会工作")]
risks = build_crowd_risks(plan, user_score=460, province="湖南")
# "长沙民政职业技术学院" 在 440-480 段 frequency=3, 不是 low
# 重新读数据找一个 frequency=1
# 改方案:用 560-580 段,挑 "南华大学" "会计学" (frequency=2)
# 仍然不是 low。我们直接用 mock。
assert all(r["risk_emoji"] in ("🔴", "🟡", "🟢") for r in risks)
def test_low_risk_emoji_via_mock_loader():
"""frequency=1 → 🟢(通过 mock loader 直接构造)"""
from data.crowd_db.crowd_detector import RiskFinding
f = RiskFinding(
school="测试大学A",
major="测试专业",
frequency=1,
risk_level="low",
platforms=["千问"],
predicted_increase=3,
alternatives=[],
)
r = finding_to_risk_dict(f)
assert r["risk_emoji"] == "🟢"
assert r["risk_level"] == "low"
assert r["risk_level_label"] == ""
def test_unknown_risk_level_falls_back_to_low():
"""未知 risk_level 兜底为 low + 🟢(防御性)"""
from data.crowd_db.crowd_detector import RiskFinding
f = RiskFinding(
school="X",
major=None,
frequency=1,
risk_level="mystery",
platforms=[],
predicted_increase=0,
)
r = finding_to_risk_dict(f)
assert r["risk_level"] == "mystery" # 原值保留
assert r["risk_emoji"] == "🟢" # emoji 兜底
assert r["risk_level_label"] == ""
# ---------- 模板字段完整性 ----------
def test_risk_dict_has_all_template_fields():
"""单条 risk 字典必须包含 audit_report.html 模板用到的所有字段"""
plan = [plan_entry("长沙理工大学", "计算机科学与技术")]
risks = build_crowd_risks(plan, user_score=575, province="湖南")
required = {
"school",
"major",
"frequency",
"predicted_increase",
"risk_level",
"risk_level_label",
"risk_emoji",
"platforms",
"alternatives",
}
for r in risks:
assert required.issubset(r.keys()), f"missing fields: {required - r.keys()}"
def test_risk_dict_field_types():
"""字段类型必须稳定int / str / list"""
plan = [plan_entry("长沙理工大学", "计算机科学与技术")]
risks = build_crowd_risks(plan, user_score=575, province="湖南")
r = risks[0]
assert isinstance(r["school"], str)
assert isinstance(r["major"], str)
assert isinstance(r["frequency"], int)
assert isinstance(r["predicted_increase"], int)
assert r["risk_level"] in ("high", "medium", "low")
assert isinstance(r["risk_level_label"], str)
assert isinstance(r["risk_emoji"], str)
assert isinstance(r["platforms"], list)
assert isinstance(r["alternatives"], list)
def test_risk_dict_includes_provenance_fields():
"""每条风险必须附带省份级溯源元数据,供报告展示来源/报告/估算标识"""
plan = [plan_entry("长沙理工大学", "计算机科学与技术")]
risks = build_crowd_risks(plan, user_score=575, province="湖南")
r = risks[0]
for key in (
"source_type",
"raw_source_type",
"source_type_label",
"source_type_icon",
"source",
"source_url",
"confidence",
"quality_level",
"quality_label",
"last_updated",
"data_year",
):
assert key in r, f"missing provenance field: {key}"
assert r["source_type"] == "report"
assert r["raw_source_type"] == "manual_summary"
assert r["source_type_label"] == "报告"
assert r["source_type_icon"] == "⚠️"
assert r["source_url"].startswith("https://")
assert r["last_updated"] # 非空即可,避免数据日期更新导致测试脆弱
assert r["data_year"] == 2025
assert 0 <= r["confidence"] <= 1
assert r["quality_level"] == "high"
assert r["quality_label"] == "A级高置信"
def test_alternatives_remapped_to_school_field():
"""crowd_db 里 alternatives 项的 name 字段必须重映射为模板需要的 school"""
plan = [plan_entry("长沙理工大学", "计算机科学与技术")]
risks = build_crowd_risks(plan, user_score=575, province="湖南")
for alt in risks[0]["alternatives"]:
assert "school" in alt
assert isinstance(alt["school"], str)
assert "score" in alt
assert isinstance(alt["score"], int)
# ---------- group_by_risk ----------
def test_group_by_risk_structure():
plan = [
plan_entry("长沙理工大学", "计算机科学与技术"), # high
plan_entry("湖南科技大学", "机械设计制造及其自动化"), # medium
plan_entry("某某野鸡大学", "考古学"), # 0 跳过
]
risks = build_crowd_risks(plan, user_score=575, province="湖南")
grouped = group_by_risk(risks)
assert set(grouped.keys()) == {"high", "medium", "low"}
assert all(isinstance(v, list) for v in grouped.values())
# high 至少 1 条
assert len(grouped["high"]) >= 1
# medium 至少 1 条
assert len(grouped["medium"]) >= 1
def test_group_by_risk_empty_input_returns_three_keys():
grouped = group_by_risk([])
assert grouped == {"high": [], "medium": [], "low": []}
# ---------- format_risk_summary ----------
def test_summary_empty_plan():
assert format_risk_summary([]) == "无扎堆风险"
def test_summary_unknown_province():
assert format_risk_summary([]) == "无扎堆风险"
def test_summary_contains_emojis():
plan = [plan_entry("长沙理工大学", "计算机科学与技术")]
risks = build_crowd_risks(plan, user_score=575, province="湖南")
summary = format_risk_summary(risks)
assert "🔴" in summary
assert "高风险" in summary
# ---------- render_risk_table ----------
def test_render_risk_table_empty():
out = render_risk_table([])
assert "无扎堆风险" in out
def test_render_risk_table_contains_school_and_emoji():
plan = [plan_entry("长沙理工大学", "计算机科学与技术")]
risks = build_crowd_risks(plan, user_score=575, province="湖南")
out = render_risk_table(risks)
assert "长沙理工大学" in out
assert "🔴" in out
assert "计算机科学与技术" in out
def test_render_risk_table_includes_alternatives_line():
"""长沙理工大学 应该有替代院校行"""
plan = [plan_entry("长沙理工大学", "计算机科学与技术")]
risks = build_crowd_risks(plan, user_score=575, province="湖南")
out = render_risk_table(risks)
if risks[0]["alternatives"]:
assert "替代" in out
# ---------- 端到端:与 crowd_detector 对齐 ----------
def test_build_crowd_risks_matches_detect_crowd_risk_order():
"""build_crowd_risks 的结果与 detect_crowd_risk 的顺序一致frequency 降序)"""
from data.crowd_db.crowd_detector import detect_crowd_risk
plan = [
plan_entry("长沙理工大学", "计算机科学与技术"),
plan_entry("湖南科技大学", "机械设计制造及其自动化"),
]
findings = detect_crowd_risk(plan, user_score=575, province="湖南")
risks = build_crowd_risks(plan, user_score=575, province="湖南")
assert len(findings) == len(risks)
for f, r in zip(findings, risks):
assert f.school == r["school"]
assert f.frequency == r["frequency"]
def test_build_crowd_risks_handles_unknown_province():
"""省份不存在 → 空列表"""
risks = build_crowd_risks(
[plan_entry("长沙理工大学", "计算机科学与技术")],
user_score=575,
province="不存在的省份",
)
assert risks == []
def test_build_crowd_risks_handles_empty_plan():
risks = build_crowd_risks([], user_score=575, province="湖南")
assert risks == []
def test_build_crowd_risks_handles_all_miss():
"""全部不命中(野鸡院校)→ 空列表"""
risks = build_crowd_risks(
[plan_entry("某某野鸡大学A", "X"), plan_entry("某某野鸡大学B", "Y")],
user_score=575,
province="湖南",
)
assert risks == []
# ---------- 自定义 loader 注入 ----------
def test_build_crowd_risks_with_injected_loader():
"""通过注入 loader 覆盖真实数据(确保解耦正确)"""
# 构造一个最小 loader,find_recommendations 返回指定数据
class _StubLoader:
def find_recommendations(self, province, score):
return [
{
"name": "测试高校A",
"major": "测试专业",
"frequency": 4,
"platforms": ["千问", "元宝", "百度", "豆包"],
"predicted_increase": 15,
"alternatives": [
{"name": "替代校A", "major": "替代专业A", "score": 90}
],
}
]
# StubLoader 满足 duck-typing (有 find_recommendations)
risks = build_crowd_risks(
[plan_entry("测试高校A", "测试专业")],
user_score=600,
province="湖南",
loader=_StubLoader(), # type: ignore[arg-type]
)
assert len(risks) == 1
r = risks[0]
assert r["school"] == "测试高校A"
assert r["frequency"] == 4
assert r["risk_emoji"] == "🔴"
assert r["alternatives"][0]["school"] == "替代校A"
assert r["alternatives"][0]["score"] == 90
def test_risk_level_meta_consistency():
"""三个等级的 emoji/label 都在 RISK_LEVEL_META 中"""
assert set(RISK_LEVEL_META.keys()) == {"high", "medium", "low"}
for level, meta in RISK_LEVEL_META.items():
assert "emoji" in meta and "label" in meta and "zh" in meta
assert len(meta["emoji"]) > 0
assert len(meta["label"]) > 0