Files
gaokao-volunteer-system/data/crowd_db/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

375 lines
13 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(plan, user_score, province, loader=None) -> list[dict]
将 crowd_detector.detect_crowd_risk 返回的 RiskFinding 列表
转换为 templates/audit_report.html 模板所需的 crowd_risks 字典格式,
每条带 🔴/🟡/🟢 三色 emoji 标识high/medium/low
模板期望字段(来自 templates/audit_report.html 第 191-202 行):
{
"school": str,
"major": str,
"frequency": int,
"predicted_increase": int,
"risk_level": "high" | "medium" | "low",
"risk_level_label": str, # 中文标签 ""/""/""
"risk_emoji": str, # 🔴/🟡/🟢
"platforms": list[str],
"alternatives": [
{"school": str, "score": int|str},
...
],
}
辅助能力:
- group_by_risk(findings) — 按风险等级分组返回 dict[level, list[dict]]
- format_risk_summary(crowd_risks) — 给报告顶部用的单行汇总
(如"🔴1 🟡2 🟢3"
- render_risk_table(crowd_risks) — 给不支持 Jinja 的命令行场景用的
简易纯文本表格
"""
from __future__ import annotations
from typing import Any, Dict, Iterable, List, Optional, Protocol
SOURCE_TYPE_DISPLAY_META: Dict[str, Dict[str, str]] = {
"official_release": {"icon": "", "label": "来源", "category": "official"},
"manual_summary": {"icon": "⚠️", "label": "报告", "category": "report"},
"platform_scrape": {"icon": "⚠️", "label": "报告", "category": "report"},
"derived": {"icon": "📊", "label": "估算", "category": "estimated"},
}
PUBLIC_SOURCE_TYPE_DISPLAY_META: Dict[str, Dict[str, str]] = {
"official": {"icon": "", "label": "来源", "category": "official"},
"report": {"icon": "⚠️", "label": "报告", "category": "report"},
"estimated": {"icon": "📊", "label": "估算", "category": "estimated"},
}
class _LoaderProtocol(Protocol):
"""loader 必须提供的方法duck-typing避免对 CrowdDBLoader 强依赖)。"""
def find_recommendations(
self, province: str, score: int
) -> List[Dict[str, Any]]: ...
def load_metadata(self, province: str) -> Optional[Dict[str, Any]]: ...
LoaderLike = _LoaderProtocol
from data.crowd_db.crowd_detector import ( # noqa: E402
PlanEntry,
RiskFinding,
detect_crowd_risk,
plan_entry as _plan_entry,
)
# 风险等级 → emoji / 中文标签 映射
RISK_LEVEL_META: Dict[str, Dict[str, str]] = {
"high": {"emoji": "🔴", "label": "", "zh": "高风险"},
"medium": {"emoji": "🟡", "label": "", "zh": "中风险"},
"low": {"emoji": "🟢", "label": "", "zh": "低风险"},
}
def _alternative_to_template(alt: Dict[str, Any]) -> Dict[str, Any]:
"""把 crowd_db 中的 alternatives 项转为模板需要的 school/score 形态。
crowd_db 原始:{"name": str, "major": str, "score": int}
模板需要的字段名school / score
"""
school = alt.get("name") or alt.get("school") or ""
score = alt.get("score", 0)
try:
score = int(score)
except (TypeError, ValueError):
score = 0
return {"school": school, "score": score, "major": alt.get("major", "")}
def _classify_score_bands(score_ranges: List[Dict[str, Any]]) -> set:
"""分类分数带覆盖情况(用于 high 门槛检查)。
返回集合可能包含:{'high', 'mid', 'low'}
- high: 分数带中值 >= 580
- mid: 480 <= 分数带中值 < 580
- low: 分数带中值 < 480
"""
bands = set()
for sr in score_ranges:
rng = sr.get("range", [0, 0])
if not rng or len(rng) < 2:
continue
mid = (rng[0] + rng[1]) / 2
if mid >= 580:
bands.add("high")
elif mid >= 480:
bands.add("mid")
else:
bands.add("low")
return bands
def _compute_quality_level(metadata: Optional[Dict[str, Any]]) -> tuple[str, str]:
"""综合判定质量等级(防止静默升级)。
门槛来源docs/plans/2026-06-23-national-high-trust-crowd-db-plan.md §4
Returns:
(quality_level, quality_label)
"""
if not metadata:
return "unknown", "未知"
confidence = metadata.get("confidence")
try:
confidence = float(confidence) if confidence is not None else None
except (TypeError, ValueError):
confidence = None
if confidence is None:
return "unknown", "未知"
score_ranges = metadata.get("score_ranges", [])
# 统计 recs / alts
recs = sum(len(r.get("recommendations", [])) for r in score_ranges)
alts = sum(
len(rec.get("alternatives", []))
for r in score_ranges
for rec in r.get("recommendations", [])
)
# 统计分数带覆盖
bands = _classify_score_bands(score_ranges)
# high 门槛plan §4.3
if (
confidence >= 0.80
and len(score_ranges) >= 8
and recs >= 40
and alts >= 60
and len(bands) >= 3
):
return "high", "A级高置信"
# usable 门槛plan §4.2
if confidence >= 0.65 and len(score_ranges) >= 6 and recs >= 24 and alts >= 24:
return "usable", "B级可用"
# low: 已脱离 skeleton 但未达 usable
if confidence >= 0.5:
return "low", "D级建设中"
# skeletonplan §4.1
return "skeleton", "C级骨架"
def _normalize_provenance(
metadata: Optional[Dict[str, Any]], full_data: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
metadata = metadata or {}
public_source_type = metadata.get("source_type") or "estimated"
raw_source_type = (
metadata.get("raw_source_type") or metadata.get("source_type") or "derived"
)
display = SOURCE_TYPE_DISPLAY_META.get(raw_source_type)
if display is None:
display = PUBLIC_SOURCE_TYPE_DISPLAY_META.get(
public_source_type,
PUBLIC_SOURCE_TYPE_DISPLAY_META["estimated"],
)
# 使用综合判定;优先用 full_data含 score_ranges否则退到 metadata
quality_data = full_data or metadata
quality_level, quality_label = _compute_quality_level(quality_data)
confidence = metadata.get("confidence")
try:
confidence = float(confidence) if confidence is not None else None
except (TypeError, ValueError):
confidence = None
data_year = metadata.get("data_year")
try:
data_year = int(data_year) if data_year is not None else None
except (TypeError, ValueError):
data_year = None
return {
"source_type": display["category"],
"raw_source_type": raw_source_type,
"source_type_display": display["category"],
"source_type_label": display["label"],
"source_type_icon": display["icon"],
"source": metadata.get("source", ""),
"source_url": metadata.get("source_url", ""),
"confidence": confidence,
"quality_level": quality_level,
"quality_label": quality_label,
"last_updated": metadata.get("last_updated", ""),
"data_year": data_year,
}
def _load_provenance_metadata(
loader: Optional[LoaderLike], province: str
) -> Dict[str, Any]:
if loader is None:
from data.crowd_db.loader import CrowdDBLoader
loader = CrowdDBLoader() # type: ignore[assignment]
# 优先尝试加载完整数据(含 score_ranges用于综合质量判定
load_province = getattr(loader, "load_province", None)
if callable(load_province):
full_data = load_province(province)
if isinstance(full_data, dict):
return _normalize_provenance(full_data, full_data=full_data)
# 退化路径:只加载 metadata
load_metadata = getattr(loader, "load_metadata", None)
if callable(load_metadata):
metadata = load_metadata(province)
if isinstance(metadata, dict) or metadata is None:
return _normalize_provenance(metadata)
return _normalize_provenance(None)
def finding_to_risk_dict(
finding: RiskFinding, provenance: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""RiskFinding → 模板所需的 crowd_risks 单条字典。
若 risk_level 不在 RISK_LEVEL_META 中crowd_detector 不会返回 none
因为 frequency=0 已被跳过fallback 到 low + 🟢。
provenance 已是 _normalize_provenance 的输出(含 quality_level
直接合并即可,不再二次规范化。
"""
meta = RISK_LEVEL_META.get(finding.risk_level, RISK_LEVEL_META["low"])
risk = {
"school": finding.school,
"major": finding.major or "",
"frequency": int(finding.frequency),
"predicted_increase": int(finding.predicted_increase),
"risk_level": finding.risk_level,
"risk_level_label": meta["label"],
"risk_emoji": meta["emoji"],
"platforms": list(finding.platforms),
"alternatives": [_alternative_to_template(a) for a in finding.alternatives],
}
if provenance:
risk.update(provenance)
return risk
def build_crowd_risks(
plan: Iterable[PlanEntry],
user_score: int,
province: str,
loader: Optional[LoaderLike] = None,
) -> List[Dict[str, Any]]:
"""构建 crowd_risks 列表(直接喂给 audit_report.html 模板)。
Args:
plan: 用户志愿方案dict / dataclass / tuple 的可迭代对象)
user_score: 用户分数
province: 招生省份
loader: 可选注入的 CrowdDBLoader
Returns:
模板所需字典列表(已按 frequency 降序)。
frequency=0 / 省份无数据 / 方案为空 → 返回空列表。
"""
findings = detect_crowd_risk(plan, user_score, province, loader=loader) # type: ignore[arg-type]
provenance = _load_provenance_metadata(loader, province)
return [finding_to_risk_dict(f, provenance=provenance) for f in findings]
def group_by_risk(
crowd_risks: Iterable[Dict[str, Any]],
) -> Dict[str, List[Dict[str, Any]]]:
"""把 crowd_risks 按风险等级分组。
返回值总是包含 high/medium/low 三个 key缺位为空列表
方便模板 / CLI 直接读取而不必判 None。
"""
grouped: Dict[str, List[Dict[str, Any]]] = {"high": [], "medium": [], "low": []}
for r in crowd_risks:
level = r.get("risk_level", "low")
if level not in grouped:
grouped[level] = []
grouped[level].append(r)
return grouped
def format_risk_summary(crowd_risks: Iterable[Dict[str, Any]]) -> str:
"""生成报告顶部用的单行风险汇总。
例:空 → "无扎堆风险"
1 高 2 中 3 低 → "🔴 1 个高风险、🟡 2 个中风险、🟢 3 个低风险"
"""
grouped = group_by_risk(crowd_risks)
counts = {level: len(grouped.get(level, [])) for level in ("high", "medium", "low")}
if sum(counts.values()) == 0:
return "无扎堆风险"
parts = []
for level in ("high", "medium", "low"):
n = counts[level]
if n > 0:
meta = RISK_LEVEL_META[level]
unit = "" + meta["zh"]
parts.append(f"{meta['emoji']} {n} {unit}")
return "".join(parts)
def render_risk_table(crowd_risks: Iterable[Dict[str, Any]]) -> str:
"""生成纯文本版扎堆风险表格CLI / 微信消息场景)。
emoji 院校 专业 频次 预测 等级
"""
risks = list(crowd_risks)
if not risks:
return "(无扎堆风险记录)"
lines = ["扎堆风险报告:", ""]
header = (
f"{'等级':<4} {'院校':<14} {'专业':<12} {'频次':<4} {'预测上涨':<8} 平台"
)
lines.append(header)
lines.append("-" * len(header))
for r in risks:
lines.append(
f"{r.get('risk_emoji', '🟢'):<4} "
f"{r.get('school', ''):<14} "
f"{r.get('major', ''):<12} "
f"{str(r.get('frequency', 0)) + '/4':<4} "
f"+{r.get('predicted_increase', 0)}".ljust(8)
+ " "
f"{','.join(r.get('platforms', []))}"
)
alts = r.get("alternatives") or []
if alts:
for a in alts:
school = a.get("school", "")
score = a.get("score", "")
lines.append(f" └ 替代: {school}{score} 分)")
return "\n".join(lines)
# ---------- 命令行测试入口 ----------
if __name__ == "__main__":
sample_plan = [
_plan_entry("长沙理工大学", "计算机科学与技术"),
_plan_entry("湖南科技大学", "机械设计制造及其自动化"),
_plan_entry("某某野鸡大学", "考古学"),
]
risks = build_crowd_risks(sample_plan, user_score=575, province="湖南")
print(f"📊 575分湖南 方案扎堆报告:{format_risk_summary(risks)}")
print()
print(render_risk_table(risks))