Files
gaokao-volunteer-system/data/crowd_db/crowd_detector.py
coder 1ce861166f feat(crowd-db): T2.5 扎堆检测单元测试 (覆盖率 93% ≥80%)
PRD 用例: 高风险识别 / 替代方案 / 跨省份 / 异常处理
- 新增 20 个 test_use_case_* 用例锚定 PRD 4 项要求
- 显式覆盖 freq=4 高风险完整字段、alt score 0-100、广东 vs 湖南跨省隔离
- 11 个异常处理用例覆盖 name=None/非 dict 元素/frequency 异常/loader 抛错

根因加固: detector 主循环加入 3 层防御
- isinstance(rec, dict) 防止 TypeError
- isinstance(rec_name, str) 防止 AttributeError on .strip()
- try/except frequency 防止 ValueError
- _normalize_entry 对 truthy 非 str school 走 str() 兜底

质量门禁
- crowd_detector.py 覆盖率 93% (101 stmts / 7 missed 仅 __main__ 块)
- test_crowd_detector 55/55 passed
- data/crowd_db/ 全量 125/125
- ruff check data/crowd_db/ All checks passed
- 仓库全量 249 passed (排除既有 orders/masking 失败)

Refs: docs/IMPLEMENTATION_PLAN_v2.md T2.5
2026-06-12 16:46:42 +08:00

228 lines
7.6 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.3)
核心入口detect_crowd_risk(plan, user_score, province) -> list[RiskFinding]
算法步骤:
1. 加载省份对应的 crowd_db使用 CrowdDBLoader
2. 遍历用户方案plan每条志愿
3. 在 crowd_db 的对应分数段中查找匹配
4. 风险等级根据 frequency 划分:
- frequency >= 4: high
- frequency 2-3 : medium
- frequency 1 : low
- frequency 0 : 跳过(不构成扎堆)
5. 返回 RiskFinding 列表(按 frequency 降序)+ 替代方案
"""
from __future__ import annotations
from dataclasses import dataclass, field, asdict
from typing import Any, Dict, Iterable, List, Optional, Union
from data.crowd_db.loader import CrowdDBLoader, CrowdRecommendation
# 兼容多种 plan 条目形态dict / dataclass / tuple
PlanEntry = Union[Dict[str, Any], CrowdRecommendation, tuple, list]
def plan_entry(school: str, major: Optional[str] = None) -> Dict[str, Any]:
"""构造一条 plan 条目dict 形态)。供调用方与测试使用。"""
return {"school": school, "major": major}
@dataclass
class RiskFinding:
"""扎堆检测结果中的一条风险记录"""
school: str
major: Optional[str]
frequency: int
risk_level: str
platforms: List[str] = field(default_factory=list)
predicted_increase: int = 0
alternatives: List[Dict[str, Any]] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return asdict(self)
def _normalize_entry(entry: PlanEntry) -> Dict[str, Any]:
"""把各种形态的 plan 条目统一为 dict。
支持:
- dict必须有 school可选 major
- CrowdRecommendationname -> school, major 保留)
- tuple / list[school, major] 或 [school]
- 其他形态str() 兜底
"""
if isinstance(entry, dict):
school_val = entry.get("school") or entry.get("name") or ""
# 防御: school 是非字符串(如 0/None时降级为 str
if not isinstance(school_val, str):
school_val = str(school_val) if school_val else ""
return {"school": school_val, "major": entry.get("major")}
if isinstance(entry, CrowdRecommendation):
return {"school": entry.name, "major": entry.major}
if isinstance(entry, (tuple, list)):
if len(entry) >= 2:
school_val = entry[0]
return {
"school": school_val
if isinstance(school_val, str)
else str(school_val),
"major": entry[1],
}
if len(entry) == 1:
school_val = entry[0]
return {
"school": school_val
if isinstance(school_val, str)
else str(school_val),
"major": None,
}
# 兜底: 任意对象 str() 化
return {"school": str(entry), "major": None}
def _risk_level_from_frequency(frequency: int) -> str:
"""根据推荐频次计算风险等级。"""
if frequency >= 4:
return "high"
if frequency >= 2:
return "medium"
if frequency >= 1:
return "low"
return "none" # 0 不构成风险
def _school_matches(school_a: str, school_b: str) -> bool:
"""院校名匹配。
规则:
- 完全相等:命中
- 简称/全称包含:仅当较短一方长度 >= 4 时命中
这样既保留“长沙民政” -> “长沙民政职业技术学院”的常用简称匹配,
也避免“大学”“学院”“湖南”这类过短泛词产生系统性误报。
"""
school_a = school_a.strip()
school_b = school_b.strip()
if not school_a or not school_b:
return False
if school_a == school_b:
return True
shorter_len = min(len(school_a), len(school_b))
return shorter_len >= 4 and (school_a in school_b or school_b in school_a)
def _major_matches(plan_major: Optional[str], rec_major: str) -> bool:
"""专业匹配规则:
- 计划未指定专业None / 空):按院校命中即可
- 计划指定专业:完全相等视为匹配
"""
if not plan_major:
return True
if not rec_major:
return False
return plan_major.strip() == rec_major.strip()
def detect_crowd_risk(
plan: Iterable[PlanEntry],
user_score: int,
province: str,
loader: Optional[CrowdDBLoader] = None,
) -> List[RiskFinding]:
"""检测方案的扎堆风险。
Args:
plan: 用户志愿方案dict / dataclass / tuple 的可迭代对象)
user_score: 用户分数
province: 招生省份
loader: 可选注入的 CrowdDBLoader便于测试
Returns:
RiskFinding 列表,按 frequency 降序排序。
- 方案为空 / 省份无数据 / 全部不命中:返回空列表
- frequency=0 的记录会被跳过(不构成扎堆)
"""
if not plan:
return []
if loader is None:
loader = CrowdDBLoader()
# 1) 取该分数段内的所有 crowd_db 记录
recs = loader.find_recommendations(province, user_score)
if not recs:
return []
findings: List[RiskFinding] = []
# 2) 遍历 plan
for raw_entry in plan:
entry = _normalize_entry(raw_entry)
school = entry["school"]
major = entry["major"]
if not school:
continue
# 3) 在该分数段 recs 中查找匹配
for rec in recs:
# 防御: loader 可能混入非 dict 元素(如 None / 字符串 / 数字)
if not isinstance(rec, dict):
continue
rec_name = rec.get("name")
# 防御: name 缺失或非字符串时跳过(避免 .strip() 抛错)
if not isinstance(rec_name, str) or not rec_name.strip():
continue
if not _school_matches(school, rec_name):
continue
if not _major_matches(major, rec.get("major", "")):
continue
freq_raw = rec.get("frequency", 0)
# 防御: frequency 字段异常(非数值)时按 0 处理
try:
freq = int(freq_raw)
except (TypeError, ValueError):
freq = 0
if freq <= 0:
continue
findings.append(
RiskFinding(
school=rec_name,
major=rec.get("major") or major,
frequency=freq,
risk_level=_risk_level_from_frequency(freq),
platforms=list(rec.get("platforms") or []),
predicted_increase=int(rec.get("predicted_increase") or 0),
alternatives=list(rec.get("alternatives") or []),
)
)
break # 一条 plan entry 命中一次即可
# 4) 按风险等级排序frequency 降序 → 等级高→低)
findings.sort(key=lambda f: f.frequency, reverse=True)
return findings
# ---------- 命令行测试入口 ----------
if __name__ == "__main__":
# 演示用575分湖南示例
sample_plan = [
plan_entry("长沙理工大学", "会计学"),
plan_entry("湖南文理学院", "汉语言文学"),
plan_entry("某某野鸡大学", "考古学"),
]
findings = detect_crowd_risk(sample_plan, user_score=575, province="湖南")
print(f"📊 575分湖南 方案扎堆检测:{len(findings)} 条风险")
for f in findings:
print(
f" - {f.school} {f.major or ''} "
f"(频次:{f.frequency}, 风险:{f.risk_level}, "
f"+{f.predicted_increase}分, 平台:{','.join(f.platforms)})"
)
for a in f.alternatives:
print(f" └ 替代: {a.get('name')} {a.get('major', '')}")