2026-06-12 11:00:03 +08:00
|
|
|
|
"""扎堆检测算法 (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)
|
|
|
|
|
|
- CrowdRecommendation(name -> school, major 保留)
|
|
|
|
|
|
- tuple / list([school, major] 或 [school])
|
2026-06-12 16:46:42 +08:00
|
|
|
|
- 其他形态:str() 兜底
|
2026-06-12 11:00:03 +08:00
|
|
|
|
"""
|
|
|
|
|
|
if isinstance(entry, dict):
|
2026-06-12 16:46:42 +08:00
|
|
|
|
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")}
|
2026-06-12 11:00:03 +08:00
|
|
|
|
if isinstance(entry, CrowdRecommendation):
|
|
|
|
|
|
return {"school": entry.name, "major": entry.major}
|
|
|
|
|
|
if isinstance(entry, (tuple, list)):
|
|
|
|
|
|
if len(entry) >= 2:
|
2026-06-12 16:46:42 +08:00
|
|
|
|
school_val = entry[0]
|
|
|
|
|
|
return {
|
|
|
|
|
|
"school": school_val
|
|
|
|
|
|
if isinstance(school_val, str)
|
|
|
|
|
|
else str(school_val),
|
|
|
|
|
|
"major": entry[1],
|
|
|
|
|
|
}
|
2026-06-12 11:00:03 +08:00
|
|
|
|
if len(entry) == 1:
|
2026-06-12 16:46:42 +08:00
|
|
|
|
school_val = entry[0]
|
|
|
|
|
|
return {
|
|
|
|
|
|
"school": school_val
|
|
|
|
|
|
if isinstance(school_val, str)
|
|
|
|
|
|
else str(school_val),
|
|
|
|
|
|
"major": None,
|
|
|
|
|
|
}
|
|
|
|
|
|
# 兜底: 任意对象 str() 化
|
2026-06-12 11:00:03 +08:00
|
|
|
|
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:
|
2026-06-12 16:46:42 +08:00
|
|
|
|
"""院校名匹配。
|
|
|
|
|
|
|
|
|
|
|
|
规则:
|
|
|
|
|
|
- 完全相等:命中
|
|
|
|
|
|
- 简称/全称包含:仅当较短一方长度 >= 4 时命中
|
|
|
|
|
|
|
|
|
|
|
|
这样既保留“长沙民政” -> “长沙民政职业技术学院”的常用简称匹配,
|
|
|
|
|
|
也避免“大学”“学院”“湖南”这类过短泛词产生系统性误报。
|
|
|
|
|
|
"""
|
|
|
|
|
|
school_a = school_a.strip()
|
|
|
|
|
|
school_b = school_b.strip()
|
2026-06-12 11:00:03 +08:00
|
|
|
|
if not school_a or not school_b:
|
|
|
|
|
|
return False
|
2026-06-12 16:46:42 +08:00
|
|
|
|
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)
|
2026-06-12 11:00:03 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _major_matches(plan_major: Optional[str], rec_major: str) -> bool:
|
|
|
|
|
|
"""专业匹配规则:
|
|
|
|
|
|
- 计划未指定专业(None / 空):按院校命中即可
|
|
|
|
|
|
- 计划指定专业:完全相等视为匹配
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not plan_major:
|
|
|
|
|
|
return True
|
|
|
|
|
|
if not rec_major:
|
2026-06-12 16:46:42 +08:00
|
|
|
|
return False
|
2026-06-12 11:00:03 +08:00
|
|
|
|
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:
|
2026-06-12 16:46:42 +08:00
|
|
|
|
# 防御: 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):
|
2026-06-12 11:00:03 +08:00
|
|
|
|
continue
|
|
|
|
|
|
if not _major_matches(major, rec.get("major", "")):
|
|
|
|
|
|
continue
|
2026-06-12 16:46:42 +08:00
|
|
|
|
freq_raw = rec.get("frequency", 0)
|
|
|
|
|
|
# 防御: frequency 字段异常(非数值)时按 0 处理
|
|
|
|
|
|
try:
|
|
|
|
|
|
freq = int(freq_raw)
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
freq = 0
|
2026-06-12 11:00:03 +08:00
|
|
|
|
if freq <= 0:
|
|
|
|
|
|
continue
|
|
|
|
|
|
findings.append(
|
|
|
|
|
|
RiskFinding(
|
2026-06-12 16:46:42 +08:00
|
|
|
|
school=rec_name,
|
2026-06-12 11:00:03 +08:00
|
|
|
|
major=rec.get("major") or major,
|
|
|
|
|
|
frequency=freq,
|
|
|
|
|
|
risk_level=_risk_level_from_frequency(freq),
|
2026-06-12 16:46:42 +08:00
|
|
|
|
platforms=list(rec.get("platforms") or []),
|
|
|
|
|
|
predicted_increase=int(rec.get("predicted_increase") or 0),
|
|
|
|
|
|
alternatives=list(rec.get("alternatives") or []),
|
2026-06-12 11:00:03 +08:00
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
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', '')}")
|