Initial commit

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-07-13 15:38:41 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
commit 71db82393a
180 changed files with 170640 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# era2 engines package
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,929 @@
"""
二期报告渲染引擎 (era2)
基于一期 report_renderer.py 拷贝,改了 import 路径
支持中英双语(lang="zh" / "en"
"""
import base64
import json
import os
import random
import string
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
import numpy as np
import sys
from jinja2 import Environment, FileSystemLoader
# era2 独立 config(不依赖 backend
sys.path.insert(0, str(Path(__file__).parent.parent))
from config_era2 import DIMENSION_FRAMEWORK, LEVEL_THRESHOLDS
# 项目根(用于添加 i18n 包到 sys.path
PROJECT_ROOT_FOR_I18N = Path(__file__).parent.parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT_FOR_I18N))
from i18n import get_translator, get_bundle # noqa: E402
# era2 模板目录(统一放在 report-admin/templates/era2
TEMPLATES_DIR = Path(__file__).parent.parent.parent.parent / "templates" / "era2"
OUTPUT_DIR = Path(__file__).parent.parent.parent.parent / "output" / "era2"
# 二期暂无 SCHOOL_TYPE_MAP176所学校没有逐校类型信息)
SCHOOL_TYPE_MAP = {}
# 水平描述从 config 导入
try:
from config_era2 import LEVEL_DESCRIPTIONS
except ImportError:
LEVEL_DESCRIPTIONS = {}
class ReportRendererEra2:
"""二期 HTML 报告渲染引擎"""
def __init__(self):
self.env = Environment(
loader=FileSystemLoader(str(TEMPLATES_DIR)),
autoescape=False,
)
def render(self, report_data: Dict, llm_sections: Dict[str, str],
enable_agent: bool = False, lang: str = "zh") -> str:
"""
渲染完整HTML报告
report_data: stats_engine.compute_school_report_data() 的输出
llm_sections: llm_engine.generate_report_segments() 的输出
enable_agent: 是否嵌入 AI 对话助手
lang: 语言代码("zh" / "en"
"""
template = self.env.get_template("base.html")
# 翻译器
t = get_translator(lang)
# 准备模板数据
school = report_data["school"]
district = report_data.get("district", "")
# 学校/区显示名(英文版区名翻译为 "Changning District" 等;学校名作为专有名词保留)
school_display = school
district_display = t.district(district) if district else district
# 用于模板/图表的两个翻译映射(中文 key → 当前 lang 显示)
bundle = get_bundle(lang)
dim_translation_map = bundle.get("DIMENSIONS", {})
sub_translation_map = bundle.get("SUB_DIMENSIONS", {})
# ECharts JS 用 i18n bundle
ec_i18n = dict(bundle.get("UI", {}))
# 子维度小节编号(中文:二、三..;英文:II. III..)
if lang == "en":
sub_section_labels = ["", "II.", "III.", "IV.", "V.", "VI.", "VII.", "VIII.", "IX."]
ag_section_labels = ["I.", "II.", "III.", "IV.", "V.", "VI.", "VII.", "VIII.", "IX."]
cn_subnums = sub_section_labels
cn_nums = ag_section_labels
html_lang = "en"
else:
sub_section_labels = ["", "二、", "三、", "四、", "五、", "六、", "七、", "八、", "九、"]
ag_section_labels = ["一、", "二、", "三、", "四、", "五、", "六、", "七、", "八、", "九、"]
cn_subnums = sub_section_labels
cn_nums = ag_section_labels
html_lang = "zh-CN"
# 生成日期(按语言)
generation_date = t.date(datetime.now())
context = {
"school": school,
"school_display": school_display,
"district": district,
"district_display": district_display,
"total_schools_in_district": report_data.get("total_schools_in_district", report_data["overall"].get("total_schools", 0)),
"school_info": report_data["school_info"],
"overall": self._to_namespace(report_data["overall"]),
"dimensions": {
name: self._to_namespace(data)
for name, data in report_data["dimensions"].items()
},
"sub_dimensions": {
name: self._to_namespace(data)
for name, data in report_data["sub_dimensions"].items()
},
"framework": {
name: self._to_namespace(info)
for name, info in DIMENSION_FRAMEWORK.items()
},
"llm_sections": llm_sections,
"level_descriptions": LEVEL_DESCRIPTIONS,
"level_descriptions_keys": list(LEVEL_DESCRIPTIONS.keys()),
"generation_date": generation_date,
# i18n 注入
"t": t,
"lang": lang,
"html_lang": html_lang,
"ec_i18n": ec_i18n,
"dim_translation_map": dim_translation_map,
"sub_translation_map": sub_translation_map,
"cn_subnums": cn_subnums,
"cn_nums": cn_nums,
"ag_section_labels": ag_section_labels,
# ECharts数据 — 传原始dict,由模板的tojson过滤器序列化一次
"radar_data": self._build_radar_data(report_data),
"sub_dim_chart_data": self._build_sub_dim_charts(report_data),
"score_compare_data": self._build_score_compare(report_data),
"cluster_radar_data": self._build_cluster_radar(report_data),
"correlation_data": self._build_correlation_heatmap(report_data),
"level_dist_data": self._build_level_distribution(report_data),
"school_ranking_data": self._build_school_ranking(report_data),
# 新增图表数据
"cluster_type_dist_data": self._build_cluster_type_distribution(report_data),
"cluster_line_compare_data": self._build_cluster_line_compare(report_data),
"dim_scatter_data": self._build_dim_scatter_charts(report_data),
"dim_score_bar_data": self._build_dim_score_bars(report_data),
"dim_sub_radar_data": self._build_dim_sub_radar_charts(report_data),
# 创新图表
"profile_card_data": self._build_profile_card(report_data),
"quadrant_data": self._build_quadrant_chart(report_data),
"thermometer_data": self._build_thermometer_data(report_data),
"waterfall_data": self._build_waterfall_chart(report_data),
}
# AI 对话助手(可选)
if enable_agent:
chat_config, report_json = self._build_chat_config(report_data)
context["chat_config"] = chat_config
context["report_data_json"] = report_json
else:
context["chat_config"] = None
context["report_data_json"] = "{}"
return template.render(**context)
def render_to_file(self, report_data: Dict, llm_sections: Dict[str, str],
output_path: Path = None, enable_agent: bool = False,
lang: str = "zh") -> Path:
"""渲染并保存到文件"""
html = self.render(report_data, llm_sections, enable_agent=enable_agent, lang=lang)
school = report_data["school"]
if output_path is None:
output_dir = OUTPUT_DIR
output_dir.mkdir(parents=True, exist_ok=True)
suffix = "_report_en.html" if lang == "en" else "_报告.html"
output_path = output_dir / f"{school}{suffix}"
output_path.write_text(html, encoding="utf-8")
return output_path
def _build_radar_data(self, report_data: Dict) -> Dict:
"""构建雷达图数据"""
school = report_data["school"]
dims = list(report_data["dimensions"].keys())
school_values = [report_data["dimensions"][d]["score"] for d in dims]
avg_values = [report_data["dimensions"][d]["district_avg"] for d in dims]
return {
"dimensions": dims,
"legend": [school, "区均值"],
"series": [
{"name": school, "values": school_values},
{"name": "区均值", "values": avg_values},
],
}
def _build_sub_dim_charts(self, report_data: Dict) -> Dict:
"""构建各维度的子维度柱状图数据"""
charts = {}
part_names = {
"课程领导力": "part3", "教学变革力": "part4", "学生发展指导力": "part5",
"教师发展支持力": "part6", "教育质量评估力": "part7",
"教育条件保障力": "part8", "数字化赋能力": "part9",
}
for dim_name, info in DIMENSION_FRAMEWORK.items():
part_id = part_names[dim_name]
sub_dims = info["sub_dimensions"]
categories = []
school_values = []
avg_values = []
for sd in sub_dims:
sd_data = report_data["sub_dimensions"].get(sd, {})
if sd_data:
categories.append(sd)
school_values.append(round(sd_data["score"], 2))
avg_values.append(round(sd_data["district_avg"], 2))
charts[part_id] = {
"categories": categories,
"school_values": school_values,
"avg_values": avg_values,
}
return charts
def _build_score_compare(self, report_data: Dict) -> Dict:
"""构建得分对比横向条形图数据:本校 vs 区均值 vs 同类学校均值"""
school = report_data["school"]
dims = list(report_data["dimensions"].keys())
return {
"categories": dims,
"school_values": [round(report_data["dimensions"][d]["score"], 2) for d in dims],
"district_avg": [round(report_data["dimensions"][d]["district_avg"], 2) for d in dims],
"same_type_avg": [round(report_data["dimensions"][d]["same_type_avg"], 2) for d in dims],
"school_name": school,
}
def _build_cluster_radar(self, report_data: Dict) -> Dict:
"""构建聚类类型特征对比雷达图(较好类 vs 待提升类)"""
all_dim_scores = report_data.get("all_schools_dim_scores", {})
if not all_dim_scores:
return {}
# 从 overall cluster info 中提取各学校的聚类标签
school = report_data["school"]
dims = list(report_data["dimensions"].keys())
# 计算各学校的总体得分来判断聚类
school_totals = {}
for s in list(list(all_dim_scores.values())[0].keys()):
total = 0
for d in dims:
total += all_dim_scores.get(d, {}).get(s, 50)
school_totals[s] = total / len(dims)
# 二分聚类(简单按总分中位数分)
median_score = sorted(school_totals.values())[len(school_totals) // 2]
good_schools = [s for s, v in school_totals.items() if v >= median_score]
weak_schools = [s for s, v in school_totals.items() if v < median_score]
good_avgs = []
weak_avgs = []
for d in dims:
dim_data = all_dim_scores.get(d, {})
good_avgs.append(round(sum(dim_data.get(s, 50) for s in good_schools) / max(len(good_schools), 1), 2))
weak_avgs.append(round(sum(dim_data.get(s, 50) for s in weak_schools) / max(len(weak_schools), 1), 2))
return {
"dimensions": dims,
"legend": ["课程实施较好类", "课程实施待提升类", school],
"series": [
{"name": "课程实施较好类", "values": good_avgs},
{"name": "课程实施待提升类", "values": weak_avgs},
{"name": school, "values": [round(report_data["dimensions"][d]["score"], 2) for d in dims]},
],
"good_count": len(good_schools),
"weak_count": len(weak_schools),
}
def _build_correlation_heatmap(self, report_data: Dict) -> Dict:
"""构建维度间相关性热力图"""
correlation = report_data.get("correlation", {})
if not correlation:
return {}
dims = list(correlation.keys())
# 构建二维数组 [x_index, y_index, value]
data = []
for i, d1 in enumerate(dims):
for j, d2 in enumerate(dims):
val = correlation.get(d1, {}).get(d2, 0)
data.append([i, j, round(val, 3) if val is not None else 0])
# 短名
short_names = [d.replace("", "").replace("教育", "") for d in dims]
return {
"dimensions": dims,
"short_names": short_names,
"data": data,
}
def _build_level_distribution(self, report_data: Dict) -> Dict:
"""构建各三级维度水平分布堆叠条形图"""
charts = {}
part_names = {
"课程领导力": "part3", "教学变革力": "part4", "学生发展指导力": "part5",
"教师发展支持力": "part6", "教育质量评估力": "part7",
"教育条件保障力": "part8", "数字化赋能力": "part9",
}
for dim_name, info in DIMENSION_FRAMEWORK.items():
part_id = part_names[dim_name]
sub_dims = info["sub_dimensions"]
categories = []
level1_pcts = []
level2_pcts = []
level3_pcts = []
level4_pcts = []
school_levels = []
for sd in sub_dims:
sd_data = report_data["sub_dimensions"].get(sd, {})
if not sd_data:
continue
dist = sd_data.get("level_distribution", {})
total = sum(dist.values())
if total == 0:
continue
categories.append(sd)
level1_pcts.append(round(dist.get("水平1", 0) / total * 100, 1))
level2_pcts.append(round(dist.get("水平2", 0) / total * 100, 1))
level3_pcts.append(round(dist.get("水平3", 0) / total * 100, 1))
level4_pcts.append(round(dist.get("水平4", 0) / total * 100, 1))
school_levels.append(sd_data.get("level", 0))
charts[part_id] = {
"categories": categories,
"level1": level1_pcts,
"level2": level2_pcts,
"level3": level3_pcts,
"level4": level4_pcts,
"school_levels": school_levels,
}
return charts
def _build_school_ranking(self, report_data: Dict) -> Dict:
"""构建区内各校维度排名对比图"""
school = report_data["school"]
all_dim_scores = report_data.get("all_schools_dim_scores", {})
dims = list(report_data["dimensions"].keys())
if not all_dim_scores:
return {}
# 获取所有学校名
first_dim = list(all_dim_scores.values())[0] if all_dim_scores else {}
all_schools = list(first_dim.keys())
# 计算每校总体得分并排序
school_totals = {}
for s in all_schools:
total = sum(all_dim_scores.get(d, {}).get(s, 50) for d in dims)
school_totals[s] = round(total / len(dims), 2)
sorted_schools = sorted(school_totals.items(), key=lambda x: x[1], reverse=True)
return {
"schools": [s[0] for s in sorted_schools],
"total_scores": [s[1] for s in sorted_schools],
"current_school": school,
"dimensions": dims,
"dim_scores": {
d: [round(all_dim_scores.get(d, {}).get(s[0], 50), 2) for s in sorted_schools]
for d in dims
},
}
def _build_cluster_type_distribution(self, report_data: Dict) -> Dict:
"""
构建课程实施类型分布饼/条形图数据(如参考报告图2-2)
展示 较好类 vs 待提升类 在区内各校的分布
"""
all_dim_scores = report_data.get("all_schools_dim_scores", {})
if not all_dim_scores:
return {}
school = report_data["school"]
dims = list(report_data["dimensions"].keys())
# 计算各学校总体得分并二分聚类
school_totals = {}
first_dim_data = list(all_dim_scores.values())[0] if all_dim_scores else {}
all_schools = list(first_dim_data.keys())
for s in all_schools:
total = sum(all_dim_scores.get(d, {}).get(s, 50) for d in dims)
school_totals[s] = total / len(dims)
median_score = sorted(school_totals.values())[len(school_totals) // 2]
good_schools = [s for s, v in school_totals.items() if v >= median_score]
weak_schools = [s for s, v in school_totals.items() if v < median_score]
school_cluster = "较好" if school in good_schools else "待提升"
return {
"good_count": len(good_schools),
"weak_count": len(weak_schools),
"total": len(all_schools),
"school_cluster": school_cluster,
"school_name": school,
"good_schools": good_schools,
"weak_schools": weak_schools,
}
def _build_cluster_line_compare(self, report_data: Dict) -> Dict:
"""
构建两类学校特征折线对比图(如参考报告图2-3)
两条折线:较好类 vs 待提升类在7个维度上的得分
"""
all_dim_scores = report_data.get("all_schools_dim_scores", {})
if not all_dim_scores:
return {}
school = report_data["school"]
dims = list(report_data["dimensions"].keys())
# 计算聚类
first_dim_data = list(all_dim_scores.values())[0] if all_dim_scores else {}
all_schools = list(first_dim_data.keys())
school_totals = {}
for s in all_schools:
total = sum(all_dim_scores.get(d, {}).get(s, 50) for d in dims)
school_totals[s] = total / len(dims)
median_score = sorted(school_totals.values())[len(school_totals) // 2]
good_schools = [s for s, v in school_totals.items() if v >= median_score]
weak_schools = [s for s, v in school_totals.items() if v < median_score]
good_avgs = []
weak_avgs = []
for d in dims:
dim_data = all_dim_scores.get(d, {})
good_avgs.append(round(sum(dim_data.get(s, 50) for s in good_schools) / max(len(good_schools), 1), 2))
weak_avgs.append(round(sum(dim_data.get(s, 50) for s in weak_schools) / max(len(weak_schools), 1), 2))
return {
"dimensions": dims,
"good_values": good_avgs,
"weak_values": weak_avgs,
"good_count": len(good_schools),
"weak_count": len(weak_schools),
}
def _build_dim_scatter_charts(self, report_data: Dict) -> Dict:
"""
构建各二级维度的聚类散点图数据(2D / 3D)
参考报告中每个维度都有一个散点图显示所有学校的聚类分布
对于有2个子维度的 → 2D散点图
对于有3个子维度的 → 3D散点图
对于有4个子维度的 → 取前2个主成分的2D散点图
"""
all_sub_scores = report_data.get("all_schools_sub_scores", {})
if not all_sub_scores:
return {}
school = report_data["school"]
charts = {}
part_names = {
"课程领导力": "part3", "教学变革力": "part4", "学生发展指导力": "part5",
"教师发展支持力": "part6", "教育质量评估力": "part7",
"教育条件保障力": "part8", "数字化赋能力": "part9",
}
first_sub = list(all_sub_scores.values())[0] if all_sub_scores else {}
all_schools = list(first_sub.keys())
for dim_name, info in DIMENSION_FRAMEWORK.items():
part_id = part_names[dim_name]
sub_dims = info["sub_dimensions"]
n_subs = len(sub_dims)
# 获取各学校在这些子维度上的得分
school_scores = {}
for s in all_schools:
scores = []
for sd in sub_dims:
val = all_sub_scores.get(sd, {}).get(s, 50)
scores.append(round(float(val), 2))
school_scores[s] = scores
# 简单二分聚类
totals = {s: sum(v) / len(v) for s, v in school_scores.items()}
med = sorted(totals.values())[len(totals) // 2]
clusters = {s: 0 if totals[s] >= med else 1 for s in all_schools}
if n_subs == 2:
# 2D散点图
data_good = []
data_weak = []
school_point = None
for s in all_schools:
point = school_scores[s]
if s == school:
school_point = point
elif clusters[s] == 0:
data_good.append(point)
else:
data_weak.append(point)
charts[part_id] = {
"type": "2d",
"axes": sub_dims,
"good_data": data_good,
"weak_data": data_weak,
"school_point": school_point,
"school_name": school,
"dim_name": dim_name,
}
elif n_subs == 3:
# 3D散点图
data_good = []
data_weak = []
school_point = None
for s in all_schools:
point = school_scores[s]
if s == school:
school_point = point
elif clusters[s] == 0:
data_good.append(point)
else:
data_weak.append(point)
charts[part_id] = {
"type": "3d",
"axes": sub_dims,
"good_data": data_good,
"weak_data": data_weak,
"school_point": school_point,
"school_name": school,
"dim_name": dim_name,
}
elif n_subs >= 4:
# 取前两个子维度做2D散点
axes = sub_dims[:2]
data_good = []
data_weak = []
school_point = None
for s in all_schools:
point = school_scores[s][:2]
if s == school:
school_point = point
elif clusters[s] == 0:
data_good.append(point)
else:
data_weak.append(point)
charts[part_id] = {
"type": "2d",
"axes": axes,
"good_data": data_good,
"weak_data": data_weak,
"school_point": school_point,
"school_name": school,
"dim_name": dim_name,
}
return charts
def _build_dim_score_bars(self, report_data: Dict) -> Dict:
"""
构建各维度独立得分柱状图数据(如参考报告图3-1、图4-1等)
展示本校 vs 区均值 vs 同类学校均值 的对比
"""
school = report_data["school"]
charts = {}
part_names = {
"课程领导力": "part3", "教学变革力": "part4", "学生发展指导力": "part5",
"教师发展支持力": "part6", "教育质量评估力": "part7",
"教育条件保障力": "part8", "数字化赋能力": "part9",
}
for dim_name, dim_data in report_data["dimensions"].items():
part_id = part_names.get(dim_name, "")
if not part_id:
continue
charts[part_id] = {
"dim_name": dim_name,
"school_name": school,
"school_score": round(dim_data["score"], 2),
"district_avg": round(dim_data["district_avg"], 2),
"same_type_avg": round(dim_data["same_type_avg"], 2),
}
return charts
def _build_dim_sub_radar_charts(self, report_data: Dict) -> Dict:
"""
构建各二级维度的子维度雷达图(如参考报告图3-2)
多条线对比: 本校 vs 区均值 vs 同类学校均值
"""
school = report_data["school"]
all_sub_scores = report_data.get("all_schools_sub_scores", {})
charts = {}
part_names = {
"课程领导力": "part3", "教学变革力": "part4", "学生发展指导力": "part5",
"教师发展支持力": "part6", "教育质量评估力": "part7",
"教育条件保障力": "part8", "数字化赋能力": "part9",
}
# Get same-type schools
school_info = report_data.get("school_info", {})
school_type = school_info.get("type", "")
all_schools = list(list(all_sub_scores.values())[0].keys()) if all_sub_scores else []
same_type_schools = [s for s in all_schools if SCHOOL_TYPE_MAP.get(s, {}).get("type") == school_type]
for dim_name, info in DIMENSION_FRAMEWORK.items():
part_id = part_names.get(dim_name, "")
if not part_id:
continue
sub_dims = info["sub_dimensions"]
school_values = []
district_avg = []
same_type_avg = []
for sd in sub_dims:
sd_data = report_data["sub_dimensions"].get(sd, {})
if sd_data:
school_values.append(round(sd_data["score"], 2))
district_avg.append(round(sd_data["district_avg"], 2))
# 计算同类学校均值
if same_type_schools and all_sub_scores:
st_vals = [all_sub_scores.get(sd, {}).get(s, 50) for s in same_type_schools]
same_type_avg.append(round(sum(float(v) for v in st_vals) / len(st_vals), 2))
else:
same_type_avg.append(district_avg[-1])
if len(sub_dims) >= 3:
charts[part_id] = {
"type": "radar",
"sub_dims": sub_dims,
"school_name": school,
"school_values": school_values,
"district_avg": district_avg,
"same_type_avg": same_type_avg,
}
else:
charts[part_id] = {
"type": "bar",
"sub_dims": sub_dims,
"school_name": school,
"school_values": school_values,
"district_avg": district_avg,
"same_type_avg": same_type_avg,
}
return charts
# ========== 创新图表数据构建 ==========
def _build_profile_card(self, report_data: Dict) -> Dict:
"""
A. 学校画像卡(综合仪表盘)
- 总体得分环形仪表盘
- 7个维度的红绿灯状态(基于各维度下子维度的最低水平)
- 20个三级维度的水平分布概览
"""
school = report_data["school"]
overall = report_data["overall"]
# 维度红绿灯:每个二级维度取其子维度的最低水平作为"短板"指示
dim_signals = []
for dim_name, dim_data in report_data["dimensions"].items():
sub_dims = DIMENSION_FRAMEWORK[dim_name]["sub_dimensions"]
levels = []
for sd in sub_dims:
sd_data = report_data["sub_dimensions"].get(sd, {})
if sd_data:
levels.append(sd_data.get("level", 0))
min_level = min(levels) if levels else 0
avg_level = round(sum(levels) / len(levels), 1) if levels else 0
dim_signals.append({
"name": dim_name,
"score": round(dim_data["score"], 2),
"min_level": min_level,
"avg_level": avg_level,
"rank": dim_data.get("rank_in_district", 0),
})
# 20个三级维度的水平分布统计
level_counts = {1: 0, 2: 0, 3: 0, 4: 0}
for sd_name, sd_data in report_data["sub_dimensions"].items():
lv = sd_data.get("level", 0)
if lv in level_counts:
level_counts[lv] += 1
return {
"school_name": school,
"school_type": report_data["school_info"].get("type", ""),
"total_score": overall["score"],
"district_avg": overall["district_avg"],
"rank": overall["rank_in_district"],
"total_schools": overall["total_schools"],
"cluster": overall.get("cluster", ""),
"dim_signals": dim_signals,
"level_counts": level_counts,
"total_sub_dims": sum(level_counts.values()),
}
def _build_quadrant_chart(self, report_data: Dict) -> Dict:
"""
B. 优势-短板象限图(Gap Analysis
X轴 = 得分, Y轴 = 与区均值的差值
四象限:右上=核心优势, 左下=急需改进, 右下=隐性风险, 左上=潜力项
"""
school = report_data["school"]
items = []
for sd_name, sd_data in report_data["sub_dimensions"].items():
parent_dim = sd_data.get("parent_dimension", "")
items.append({
"name": sd_name,
"parent": parent_dim,
"score": round(sd_data["score"], 2),
"diff": round(sd_data["diff_district"], 2),
"level": sd_data.get("level", 0),
})
return {
"school_name": school,
"items": items,
"x_center": 50, # 区均值(标准化后均值=50
"y_center": 0, # 差值=0 的参照线
}
def _build_thermometer_data(self, report_data: Dict) -> Dict:
"""
C. 维度温度计条形图数据
为每个三级维度构建横向温度计数据:
- 得分范围20-80
- 水平分界线
- 本校位置、区均值位置、同类学校均值位置
"""
school = report_data["school"]
school_type = report_data["school_info"].get("type", "")
all_sub_scores = report_data.get("all_schools_sub_scores", {})
same_type_schools = [s for s in SCHOOL_TYPE_MAP
if SCHOOL_TYPE_MAP[s].get("type") == school_type]
thermometers = {}
for sd_name, sd_data in report_data["sub_dimensions"].items():
# 同类学校均值
if same_type_schools and sd_name in all_sub_scores:
st_vals = [float(all_sub_scores[sd_name].get(s, 50)) for s in same_type_schools]
same_type_avg = round(sum(st_vals) / len(st_vals), 2)
else:
same_type_avg = round(sd_data["district_avg"], 2)
# 水平阈值
thresholds = LEVEL_THRESHOLDS.get(sd_name, {})
thermometers[sd_name] = {
"score": round(sd_data["score"], 2),
"district_avg": round(sd_data["district_avg"], 2),
"same_type_avg": same_type_avg,
"level": sd_data.get("level", 0),
"thresholds": {
"level4": thresholds.get("level4", 57),
"level3": thresholds.get("level3", 50),
"level2": thresholds.get("level2", 43),
},
}
return {
"school_name": school,
"data": thermometers,
}
def _build_waterfall_chart(self, report_data: Dict) -> Dict:
"""
D. 进步空间瀑布图
展示:如果每个低于水平3的子维度提升到水平3的阈值,总分增加多少
让校长看到"改哪几个点收益最大"
"""
school = report_data["school"]
current_total = report_data["overall"]["score"]
# 找出所有低于水平3的子维度
improvement_items = []
for sd_name, sd_data in report_data["sub_dimensions"].items():
level = sd_data.get("level", 0)
if level < 3:
current_score = sd_data["score"]
# 提升到水平3的阈值
target_score = LEVEL_THRESHOLDS.get(sd_name, {}).get("level3", 50)
gap = round(target_score - current_score, 2)
if gap > 0:
improvement_items.append({
"name": sd_name,
"parent": sd_data.get("parent_dimension", ""),
"current_score": round(current_score, 2),
"target_score": round(target_score, 2),
"gap": gap,
"current_level": level,
})
# 按收益从大到小排序
improvement_items.sort(key=lambda x: x["gap"], reverse=True)
# 估算总分提升(简化:假设20个子维度等权重影响总分)
total_sub_dims = len(report_data["sub_dimensions"])
cumulative = current_total
waterfall_steps = [{"name": "当前总分", "value": round(current_total, 2), "type": "current"}]
for item in improvement_items:
# 粗略估算:子维度提升gap分 → 总分提升 gap / total_sub_dims * 权重
# 实际PCA权重不同,此处用等权近似
estimated_gain = round(item["gap"] / total_sub_dims, 2)
cumulative += estimated_gain
waterfall_steps.append({
"name": item["name"],
"value": round(estimated_gain, 2),
"type": "gain",
"detail": f"从水平{item['current_level']}→水平3 (+{item['gap']}分)",
})
waterfall_steps.append({"name": "潜在总分", "value": round(cumulative, 2), "type": "potential"})
return {
"school_name": school,
"current_total": round(current_total, 2),
"potential_total": round(cumulative, 2),
"total_gain": round(cumulative - current_total, 2),
"steps": waterfall_steps,
"improvements": improvement_items,
}
@staticmethod
def _to_namespace(d: Dict) -> Dict:
"""将dict转为可用点号访问的对象(Jinja2兼容)"""
class Namespace(dict):
def __getattr__(self, key):
try:
return self[key]
except KeyError:
return None
return Namespace(d) if isinstance(d, dict) else d
# ========== AI 对话助手配置 ==========
@staticmethod
def _xor_encode(plaintext: str, xor_key: str) -> str:
"""XOR + Base64 编码(简单混淆,防止明文暴露)"""
xor_bytes = bytearray(len(plaintext))
for i, ch in enumerate(plaintext):
xor_bytes[i] = ord(ch) ^ ord(xor_key[i % len(xor_key)])
return base64.b64encode(xor_bytes).decode('ascii')
def _build_chat_config(self, report_data: Dict) -> tuple:
"""
构建 AI 对话助手的配置和上下文数据
Returns:
(chat_config dict, report_data_json string)
"""
# 从 backend/app/config.py 读取 LLM 设置(全项目唯一真相源)
_backend_path = str(Path(__file__).parent.parent.parent.parent / "backend")
if _backend_path not in sys.path:
sys.path.insert(0, _backend_path)
from app.config import LLM_BASE_URL, LLM_API_KEY, LLM_MODEL
# 生成随机 XOR key(每次渲染不同)
xor_key = ''.join(random.choices(string.ascii_letters + string.digits, k=16))
# XOR 编码 API Key
api_key_encoded = self._xor_encode(LLM_API_KEY, xor_key)
chat_config = {
"api_key_encoded": api_key_encoded,
"xor_key": xor_key,
"api_base_url": LLM_BASE_URL,
"model": LLM_MODEL,
"school_name": report_data["school"],
}
# 构建精简版 report_data JSON(去掉超大的 all_schools 数据以节省体积)
slim_data = {
"school": report_data.get("school"),
"school_info": report_data.get("school_info", {}),
"overall": report_data.get("overall", {}),
"dimensions": report_data.get("dimensions", {}),
"sub_dimensions": report_data.get("sub_dimensions", {}),
}
# 清理 numpy 类型
def clean(obj):
if isinstance(obj, dict):
return {k: clean(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [clean(v) for v in obj]
elif isinstance(obj, (np.integer,)):
return int(obj)
elif isinstance(obj, (np.floating,)):
return round(float(obj), 4)
elif isinstance(obj, np.ndarray):
return obj.tolist()
elif isinstance(obj, float):
return round(obj, 4)
return obj
report_data_json = json.dumps(clean(slim_data), ensure_ascii=False)
return chat_config, report_data_json
File diff suppressed because it is too large Load Diff
+374
View File
@@ -0,0 +1,374 @@
"""
二期统计引擎 (era2)
支持全市基准标准化 + 同类学校均值计算
"""
import pandas as pd
import numpy as np
from typing import Dict, List, Optional, Tuple
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
from scipy import stats
import logging
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from config_era2 import (
PCA_MEAN, PCA_STD, LEVEL_THRESHOLDS, DIMENSION_FRAMEWORK, SUBJECTS,
SCHOOL_TYPE_MAP,
)
try:
from config_era2 import CLUSTER_CONFIG
except ImportError:
CLUSTER_CONFIG = {}
try:
from config_era2 import LEVEL_DESCRIPTIONS
except ImportError:
LEVEL_DESCRIPTIONS = {}
logger = logging.getLogger(__name__)
class StatsEngineEra2:
"""二期统计引擎"""
def __init__(self):
self._dimension_scores: Optional[pd.DataFrame] = None
self._sub_dimension_scores: Optional[pd.DataFrame] = None
def standardize_scores(self, raw_scores: np.ndarray) -> np.ndarray:
if len(raw_scores) < 2:
return np.full_like(raw_scores, PCA_MEAN, dtype=float)
mean = np.nanmean(raw_scores)
std = np.nanstd(raw_scores, ddof=1)
if std == 0 or np.isnan(std):
return np.full_like(raw_scores, PCA_MEAN, dtype=float)
return (raw_scores - mean) / std * PCA_STD + PCA_MEAN
def pca_compose(self, data_matrix: pd.DataFrame) -> np.ndarray:
filled = data_matrix.fillna(data_matrix.mean())
if filled.shape[1] == 0:
return np.full(filled.shape[0], PCA_MEAN)
if filled.shape[1] == 1:
return self.standardize_scores(filled.iloc[:, 0].values)
scaler = StandardScaler()
scaled = scaler.fit_transform(filled)
n_components = min(1, filled.shape[1], filled.shape[0])
pca = PCA(n_components=n_components)
scores = pca.fit_transform(scaled)[:, 0]
loadings = pca.components_[0]
if np.sum(loadings) < 0:
scores = -scores
return self.standardize_scores(scores)
def determine_level(self, score: float, dimension: str) -> int:
thresholds = LEVEL_THRESHOLDS.get(dimension, {})
if not thresholds:
if score > 55: return 4
elif score > 50: return 3
elif score > 45: return 2
else: return 1
if score > thresholds["level4"]: return 4
elif score > thresholds["level3"]: return 3
elif score > thresholds["level2"]: return 2
else: return 1
def get_level_description(self, dimension: str, level: int) -> str:
descriptions = LEVEL_DESCRIPTIONS.get(dimension, {})
return descriptions.get(level, f"水平{level}")
def compute_dimension_scores(self, school_raw_scores: Dict[str, Dict[str, List[float]]]) -> pd.DataFrame:
"""原始赋分 → nanmean → z-score标准化(旧方法,兼容保留)"""
schools = list(school_raw_scores.keys())
all_sub_dims = []
for dim, info in DIMENSION_FRAMEWORK.items():
all_sub_dims.extend(info["sub_dimensions"])
raw_matrix = {}
for sub_dim in all_sub_dims:
values = []
for school in schools:
scores = school_raw_scores.get(school, {}).get(sub_dim, [])
values.append(np.nanmean(scores) if scores else np.nan)
raw_matrix[sub_dim] = values
raw_df = pd.DataFrame(raw_matrix, index=schools)
result = pd.DataFrame(index=schools)
for sub_dim in all_sub_dims:
if sub_dim in raw_df.columns:
result[sub_dim] = self.standardize_scores(raw_df[sub_dim].values)
else:
result[sub_dim] = PCA_MEAN
self._sub_dimension_scores = result
return result
def compute_dimension_scores_pca(self, pca_sub_scores: pd.DataFrame) -> pd.DataFrame:
"""
接受PCA引擎的输出(已经是子维度得分的DataFrame),直接使用。
PCA引擎内部已完成 Z标准化 → PCA → ×10+50 的全流程。
Args:
pca_sub_scores: PcaScoringEngineEra2.compute_all() 的输出
DataFrame, index=学校, columns=子维度名
Returns:
与 compute_dimension_scores 相同格式的 DataFrame
"""
all_sub_dims = []
for dim, info in DIMENSION_FRAMEWORK.items():
all_sub_dims.extend(info["sub_dimensions"])
result = pd.DataFrame(index=pca_sub_scores.index)
for sub_dim in all_sub_dims:
if sub_dim in pca_sub_scores.columns:
result[sub_dim] = pca_sub_scores[sub_dim]
else:
result[sub_dim] = PCA_MEAN
self._sub_dimension_scores = result
return result
def compute_dimension_aggregates(self, sub_scores: pd.DataFrame) -> pd.DataFrame:
result = pd.DataFrame(index=sub_scores.index)
for dim, info in DIMENSION_FRAMEWORK.items():
sub_dims = [s for s in info["sub_dimensions"] if s in sub_scores.columns]
if sub_dims:
result[dim] = sub_scores[sub_dims].mean(axis=1)
else:
result[dim] = PCA_MEAN
result["总体得分"] = result[list(DIMENSION_FRAMEWORK.keys())].mean(axis=1)
self._dimension_scores = result
return result
def compute_levels(self, sub_scores: pd.DataFrame) -> pd.DataFrame:
levels = pd.DataFrame(index=sub_scores.index)
for col in sub_scores.columns:
levels[col] = sub_scores[col].apply(lambda x: self.determine_level(x, col))
return levels
def cluster_analysis(self, scores: pd.DataFrame, n_clusters: int = 2,
dimension_name: Optional[str] = None) -> Dict:
"""
K-means 聚类分析。
Args:
scores: 学校×子维度 的得分 DataFrame
n_clusters: 聚类数(默认2,可通过 CLUSTER_CONFIG 覆盖)
dimension_name: 维度名称,用于从 CLUSTER_CONFIG 查询聚类数
"""
# 从配置覆盖聚类数
if dimension_name and dimension_name in CLUSTER_CONFIG:
n_clusters = CLUSTER_CONFIG[dimension_name]
scaler = StandardScaler()
scaled = scaler.fit_transform(scores.fillna(PCA_MEAN))
n_clusters = min(n_clusters, len(scores))
if n_clusters < 2:
return {"labels": [0] * len(scores), "centers": scores.values.tolist()}
kmeans = KMeans(n_clusters=n_clusters, random_state=42, n_init=10)
labels = kmeans.fit_predict(scaled)
cluster_means = {}
for c in range(n_clusters):
mask = labels == c
cluster_means[c] = scores[mask].mean().to_dict()
avg_per_cluster = {c: np.mean(list(v.values())) for c, v in cluster_means.items()}
sorted_clusters = sorted(avg_per_cluster.items(), key=lambda x: x[1], reverse=True)
# 命名策略:2类 → 较好/待提升,3类 → 较好/中等/待提升
cluster_names = {}
if n_clusters == 2:
name_list = ["较好", "待提升"]
elif n_clusters == 3:
name_list = ["较好", "中等", "待提升"]
else:
name_list = [f"{i+1}" for i in range(n_clusters)]
for rank, (c, _) in enumerate(sorted_clusters):
cluster_names[c] = name_list[rank] if rank < len(name_list) else f"{rank+1}"
return {
"labels": labels.tolist(),
"n_clusters": n_clusters,
"school_clusters": {
school: cluster_names[labels[i]]
for i, school in enumerate(scores.index)
},
"cluster_means": cluster_means,
"cluster_names": cluster_names,
}
def t_test_vs_mean(self, school_scores: np.ndarray, ref_mean: float) -> Dict:
scores = school_scores[~np.isnan(school_scores)]
if len(scores) < 2:
return {"t": np.nan, "p": np.nan, "significant": False, "n": len(scores)}
t_stat, p_value = stats.ttest_1samp(scores, ref_mean)
return {
"t": round(float(t_stat), 3),
"p": round(float(p_value), 4),
"significant": float(p_value) < 0.05,
"n": len(scores),
}
def correlation_analysis(self, dim_scores: pd.DataFrame) -> pd.DataFrame:
dim_cols = [c for c in dim_scores.columns if c in DIMENSION_FRAMEWORK]
return dim_scores[dim_cols].corr()
def compute_school_report_data(self, school: str,
sub_scores: pd.DataFrame,
dim_scores: pd.DataFrame,
district_schools: Optional[List[str]] = None) -> Dict:
"""
生成单校报告数据。
sub_scores / dim_scores: 全市所有学校的标准化分数(全市基准)
district_schools: 该学校所在区的学校列表(用于计算区均值和区内排名)
如果为None,则用sub_scores中所有学校
"""
all_schools = list(sub_scores.index)
if school not in all_schools:
raise ValueError(f"学校 '{school}' 不在数据中")
# 确定区内学校列表
if district_schools is None:
district_schools = all_schools
district_schools = [s for s in district_schools if s in all_schools]
# 区内分数切片
dist_sub = sub_scores.loc[district_schools]
dist_dim = dim_scores.loc[district_schools]
# 区均值
district_avg_sub = dist_sub.mean()
district_avg_dim = dist_dim.mean()
# 同类学校均值(从全市SCHOOL_TYPE_MAP中找同类型学校)
school_info_data = SCHOOL_TYPE_MAP.get(school, {})
school_type = school_info_data.get("type", "")
same_type_schools = [
s for s in all_schools
if SCHOOL_TYPE_MAP.get(s, {}).get("type") == school_type and school_type
]
if len(same_type_schools) >= 2:
same_type_avg_sub = sub_scores.loc[same_type_schools].mean()
same_type_avg_dim = dim_scores.loc[same_type_schools].mean()
else:
same_type_avg_sub = district_avg_sub
same_type_avg_dim = district_avg_dim
# 构建 school_info(从 SCHOOL_TYPE_MAP 获取)
school_info = {}
if school_info_data:
school_info = {
"type": school_info_data.get("type", ""),
"code": school_info_data.get("code", ""),
"nature": school_info_data.get("nature", ""),
"feature": school_info_data.get("area", ""), # 所处地区作为feature
}
levels = self.compute_levels(sub_scores)
# 聚类只在区内做
dim_cols = [c for c in dim_scores.columns if c in DIMENSION_FRAMEWORK]
overall_cluster = self.cluster_analysis(dist_dim[dim_cols], dimension_name="总体")
dim_clusters = {}
for dim, info in DIMENSION_FRAMEWORK.items():
sub_dims = [s for s in info["sub_dimensions"] if s in sub_scores.columns]
if sub_dims:
dim_clusters[dim] = self.cluster_analysis(dist_sub[sub_dims], dimension_name=dim)
correlation = self.correlation_analysis(dist_dim)
# 区内排名
school_score = float(dist_dim.loc[school, "总体得分"])
rank_in_district = int((dist_dim["总体得分"] >= school_score).sum())
# 全市排名(不分类型,所有参与监测的学校)
rank_in_city = int((dim_scores["总体得分"] >= school_score).sum())
total_schools_in_city = len(all_schools)
# 全市同类排名
if len(same_type_schools) >= 2:
st_dim = dim_scores.loc[same_type_schools]
rank_in_same_type = int((st_dim["总体得分"] >= school_score).sum())
total_same_type = len(same_type_schools)
else:
rank_in_same_type = rank_in_district
total_same_type = len(district_schools)
report = {
"school": school,
"school_info": school_info,
"overall": {
"score": round(school_score, 2),
"district_avg": round(float(district_avg_dim["总体得分"]), 2),
"same_type_avg": round(float(same_type_avg_dim.get("总体得分", PCA_MEAN)), 2),
"rank_in_district": rank_in_district,
"total_schools": len(district_schools),
"rank_in_city": rank_in_city,
"total_schools_in_city": total_schools_in_city,
"cluster": overall_cluster["school_clusters"].get(school, ""),
"school_type": school_type,
"same_type_count": total_same_type,
"rank_in_same_type": rank_in_same_type,
},
"dimensions": {},
"sub_dimensions": {},
"correlation": correlation.to_dict(),
"all_schools_dim_scores": dist_dim.to_dict(),
"all_schools_sub_scores": dist_sub.to_dict(),
}
for dim in DIMENSION_FRAMEWORK:
score = float(dist_dim.loc[school, dim])
d_avg = float(district_avg_dim[dim])
st_avg = float(same_type_avg_dim.get(dim, PCA_MEAN))
sub_dims = DIMENSION_FRAMEWORK[dim]["sub_dimensions"]
sub_vals = np.array([float(sub_scores.loc[school, s]) for s in sub_dims if s in sub_scores.columns])
t_test = self.t_test_vs_mean(sub_vals, d_avg)
report["dimensions"][dim] = {
"score": round(score, 2),
"district_avg": round(d_avg, 2),
"same_type_avg": round(st_avg, 2),
"diff_district": round(score - d_avg, 2),
"rank_in_district": int((dist_dim[dim] >= score).sum()),
"rank_in_city": int((dim_scores[dim] >= score).sum()),
"t_test_vs_district": t_test,
"cluster": dim_clusters.get(dim, {}).get("school_clusters", {}).get(school, ""),
}
for dim, info in DIMENSION_FRAMEWORK.items():
for sub_dim in info["sub_dimensions"]:
if sub_dim not in sub_scores.columns:
continue
score = float(sub_scores.loc[school, sub_dim])
d_avg = float(district_avg_sub[sub_dim])
level = int(levels.loc[school, sub_dim])
# 区内排名
rank = int((dist_sub[sub_dim] >= score).sum())
# 全市排名
rank_city = int((sub_scores[sub_dim] >= score).sum())
# 区内水平分布
dist_levels = levels.loc[district_schools]
dim_levels = dist_levels[sub_dim]
level_dist = {f"水平{i}": int((dim_levels == i).sum()) for i in range(1, 5)}
report["sub_dimensions"][sub_dim] = {
"parent_dimension": dim,
"score": round(score, 2),
"district_avg": round(d_avg, 2),
"diff_district": round(score - d_avg, 2),
"rank_in_district": rank,
"rank_in_city": rank_city,
"level": level,
"level_description": self.get_level_description(sub_dim, level),
"level_distribution": level_dist,
}
return report
+664
View File
@@ -0,0 +1,664 @@
"""
数据溯源引擎 — 生成单校从原始数据到最终得分的完整计算链路
设计原则:
- 不侵入现有PCA引擎,独立读取数据并重建中间过程
- 输出JSON结构,前端R3F组件直接消费
- 支持缓存(同一学校的trace数据不会频繁变化)
"""
import json
import hashlib
import logging
import numpy as np
import pandas as pd
from pathlib import Path
from typing import Dict, List, Optional, Any
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
import sys
sys.path.insert(0, str(Path(__file__).parent.parent))
from config_era2 import (
PCA_MEAN, PCA_STD, DIMENSION_FRAMEWORK, LEVEL_THRESHOLDS,
LEVEL_DESCRIPTIONS, SUBJECTS, SCHOOL_TYPE_MAP,
)
logger = logging.getLogger(__name__)
# 缓存目录
PROJECT_ROOT = Path(__file__).parent.parent.parent.parent
TRACE_CACHE_DIR = PROJECT_ROOT / "output" / "trace_cache"
class TraceEngine:
"""数据溯源引擎:为单校生成完整的6阶段计算链路"""
def __init__(self, data_engine, pca_engine, stats_engine,
sub_scores: pd.DataFrame, dim_scores: pd.DataFrame):
self.de = data_engine
self.pca = pca_engine
self.stats = stats_engine
self.sub_scores = sub_scores
self.dim_scores = dim_scores
def compute_trace(self, school: str, district_schools: List[str],
use_cache: bool = True) -> Dict:
"""
主入口:生成单校的完整计算链路
Returns:
{school, school_info, stages: {raw_data, scoring, pca_detail, standardized, levels, dimensions, overall}}
"""
# 缓存
if use_cache:
cached = self._load_cache(school)
if cached is not None:
return cached
logger.info(f"[Trace] 生成 {school} 的计算链路...")
all_schools = list(self.sub_scores.index)
dist_schools = [s for s in district_schools if s in all_schools]
# 阶段0:原始数据概览
stage_raw = self._trace_raw_data(school)
# 阶段1:赋分过程 + 阶段2: PCA细节
stage_scoring, stage_pca = self._trace_scoring_and_pca(school, all_schools)
# 阶段3:标准化后得分
stage_std = self._trace_standardized(school, dist_schools)
# 阶段4:水平判定
stage_levels = self._trace_levels(school)
# 阶段5:维度聚合 → 总分
stage_dims, stage_overall = self._trace_dimensions(school, dist_schools)
# 全市对比数据(用于标准化分布可视化)
all_schools_overall = {}
for s in dist_schools:
if s in self.dim_scores.index and "总体得分" in self.dim_scores.columns:
all_schools_overall[s] = round(float(self.dim_scores.loc[s, "总体得分"]), 2)
result = {
"school": school,
"school_info": SCHOOL_TYPE_MAP.get(school, {}),
"district_school_count": len(dist_schools),
"city_school_count": len(all_schools),
"stages": {
"raw_data": stage_raw,
"scoring": stage_scoring,
"pca_detail": stage_pca,
"standardized": stage_std,
"levels": stage_levels,
"dimensions": stage_dims,
"overall": stage_overall,
},
"all_schools_overall": all_schools_overall,
}
self._save_cache(school, result)
return result
# ====================================================================
# 阶段0:原始数据概览
# ====================================================================
def _trace_raw_data(self, school: str) -> Dict:
"""展示该校的原始数据概况"""
# B表数据
course_df = self._get_course(school)
b_fields = len(course_df)
b_sample = []
if len(course_df) > 0:
sample_rows = course_df.head(12)
for _, r in sample_rows.iterrows():
b_sample.append({
"name": str(r.get("字段名称", "")),
"value": _safe_value(r.get("字段取值", "")),
"type": _guess_type(r.get("字段取值", "")),
})
# C表数据
c_total = 0
c_subjects = []
for subj in SUBJECTS:
sub_df = self._get_subject(school, subj)
count = len(sub_df)
c_total += count
if count > 0:
c_subjects.append({"subject": subj, "field_count": count})
c_sample = []
# 取第一个有数据的学科的前几行
for subj in SUBJECTS:
sub_df = self._get_subject(school, subj)
if len(sub_df) > 0:
for _, r in sub_df.head(8).iterrows():
c_sample.append({
"subject": subj,
"name": str(r.get("字段名称", "")),
"value": _safe_value(r.get("字段取值", "")),
"type": _guess_type(r.get("字段取值", "")),
})
break
return {
"b_table": {
"field_count": b_fields,
"sample_fields": b_sample,
},
"c_table": {
"field_count": c_total,
"subject_count": len(c_subjects),
"subjects": c_subjects,
"sample_fields": c_sample,
},
"total_fields": b_fields + c_total,
}
# ====================================================================
# 阶段1 & 2:赋分 + PCA
# ====================================================================
def _trace_scoring_and_pca(self, school: str, all_schools: List[str]) -> tuple:
"""
为每个子维度重建赋分过程和PCA细节。
策略:对每个子维度,分别计算该子维度的赋分矩阵,
记录该校的具体输入值、赋分规则和PCA参数。
"""
scoring = {}
pca_detail = {}
# 按维度框架遍历每个子维度
sub_dim_methods = {
"国家标准遵循": self._trace_national_standard,
"课程结构建设": self._trace_course_structure,
"课程规范落实": self._trace_school_level_generic,
"教学方式变革": self._trace_subject_level_generic,
"作业设计与管理变革": self._trace_subject_level_generic,
"学科发展的个性化辅导": self._trace_subject_level_generic,
"学生生涯发展指导": self._trace_school_level_generic,
"培训支持": self._trace_subject_level_generic,
"教研支持": self._trace_subject_level_generic,
"项目支持": self._trace_subject_level_generic,
"科学评价观": self._trace_subject_level_generic,
"学业质量评估": self._trace_subject_level_generic,
"综合素质评估": self._trace_school_level_generic,
"实践活动评估": self._trace_subject_level_generic,
"区域推进": self._trace_school_level_generic,
"环境支持": self._trace_school_level_generic,
"资源支持": self._trace_school_level_generic,
"教学方式创新": self._trace_subject_level_generic,
"评价精准化与个性化": self._trace_school_level_generic,
"课程迭代优化": self._trace_school_level_generic,
}
for dim_name, info in DIMENSION_FRAMEWORK.items():
for sub_dim in info["sub_dimensions"]:
method = sub_dim_methods.get(sub_dim, self._trace_generic_fallback)
try:
s_info, p_info = method(sub_dim, school, all_schools)
except Exception as e:
logger.warning(f"[Trace] {sub_dim} trace failed: {e}")
s_info = {"method": "unknown", "error": str(e)}
p_info = {}
# 追加最终得分
final_score = float(self.sub_scores.loc[school, sub_dim]) if sub_dim in self.sub_scores.columns else None
s_info["final_score"] = round(final_score, 2) if final_score is not None else None
s_info["parent_dimension"] = dim_name
scoring[sub_dim] = s_info
pca_detail[sub_dim] = p_info
return scoring, pca_detail
def _trace_national_standard(self, sub_dim: str, school: str,
all_schools: List[str]) -> tuple:
"""国家标准遵循的特殊trace"""
BXIU_STD = {"语文": 8, "数学": 8, "英语": 6, "思想政治": 6, "历史": 4, "地理": 4,
"物理": 6, "化学": 4, "生命科学": 4, "体育": 12, "技术": 6, "艺术": 6}
MERGE_MAP = {
"信息技术": "技术", "通用技术": "技术", "劳动技术": "技术",
"音乐": "艺术", "美术": "艺术", "生物学": "生命科学", "体育与健康": "体育",
}
SPSS_12 = list(BXIU_STD.keys())
# 获取该校课时
hours_df = self.pca._get_weekly_hours(school)
merged = {s: {"必修": 0, "选必": 0, "选修": 0} for s in SPSS_12}
if len(hours_df) > 0:
for _, r in hours_df.iterrows():
subj = r.get("学科", "")
mapped = MERGE_MAP.get(subj, subj)
if mapped not in merged:
continue
field = r["字段名称"]
val = r["字段取值"]
if pd.isna(val):
continue
if "必修课周课时" in field and "选择性" not in field:
merged[mapped]["必修"] += val
elif "选择性必修" in field:
merged[mapped]["选必"] += val
elif "选修课周课时" in field:
merged[mapped]["选修"] += val
# 必修分档评分
inputs = []
for s in SPSS_12:
actual = merged[s]["必修"]
std = BXIU_STD[s]
if actual == 0:
score = 0.0
rule = "低于标准->0"
elif abs(actual - std) <= 1.0:
score = 2.0
rule = "一致->2"
elif actual > std:
score = 1.0
rule = "高于标准->1"
else:
score = 0.0
rule = "低于标准->0"
inputs.append({
"name": f"{s}必修课时",
"raw": actual,
"standard": std,
"score": score,
"rule": rule,
})
total_xb = sum(merged[s]["选必"] for s in SPSS_12)
total_xx = sum(merged[s]["选修"] for s in SPSS_12)
scoring_info = {
"method": "PCA(12学科必修分档) + Z(选必达标) + Z(选修达标) -> 均值",
"inputs": inputs,
"sub_factors": [
{"name": "必修PCA", "input_count": 12, "type": "PCA"},
{"name": "选必达标", "raw": total_xb, "threshold": 42,
"met": total_xb >= 42, "type": "Z-score"},
{"name": "选修达标", "raw": total_xx, "threshold": 14,
"met": total_xx >= 14, "type": "Z-score"},
],
}
# PCA细节:构建全市必修矩阵
bx_rows = {}
for s in all_schools:
h_df = self.pca._get_weekly_hours(s)
m = {subj: {"必修": 0} for subj in SPSS_12}
if len(h_df) > 0:
for _, r in h_df.iterrows():
subj = r.get("学科", "")
mapped = MERGE_MAP.get(subj, subj)
if mapped not in m:
continue
field = r["字段名称"]
val = r["字段取值"]
if pd.isna(val):
continue
if "必修课周课时" in field and "选择性" not in field:
m[mapped]["必修"] += val
row = {}
for subj in SPSS_12:
actual = m[subj]["必修"]
std_val = BXIU_STD[subj]
if actual == 0:
row[subj] = 0.0
elif abs(actual - std_val) <= 1.0:
row[subj] = 2.0
elif actual > std_val:
row[subj] = 1.0
else:
row[subj] = 0.0
bx_rows[s] = row
matrix = pd.DataFrame(bx_rows).T
pca_info = self._extract_pca_details(matrix, school, "必修课PCA")
return scoring_info, pca_info
def _trace_course_structure(self, sub_dim: str, school: str,
all_schools: List[str]) -> tuple:
"""课程结构建设的trace3组PCA"""
scoring_info = {
"method": "PCA(学科课程结构) + PCA(校本特色) + PCA(综合实践) -> 均值",
"sub_factors": [
{"name": "学科类课程结构PCA", "type": "PCA",
"description": "必修/选必/选修课时比例偏离度"},
{"name": "校本特色课程PCA", "type": "PCA",
"description": "选修课数量+时长"},
{"name": "综合实践PCA", "type": "PCA",
"description": "党团次数+社考个数+志愿时长+劳动"},
],
}
# 简化的PCA info
pca_info = {
"type": "multi_factor",
"factor_count": 3,
"school_count": len(all_schools),
"note": "三组因子各自做PCA后取均值",
}
return scoring_info, pca_info
def _trace_subject_level_generic(self, sub_dim: str, school: str,
all_schools: List[str]) -> tuple:
"""C表学科级子维度的通用trace"""
# 获取该校在该子维度的最终得分
scoring_info = {
"method": "学科级赋分 -> 全市Z标准化 -> PCA(第一主成分) -> x10+50 -> 学科均值 -> 学校均值",
"data_source": "C表(学科课程实施情况表)",
"subject_count": len(SUBJECTS),
}
# 尝试获取该学科的一些原始数据作为示例
sample_inputs = []
for subj in SUBJECTS[:3]: # 取前3个学科作示例
sub_df = self._get_subject(school, subj)
if len(sub_df) > 0:
for _, r in sub_df.head(3).iterrows():
sample_inputs.append({
"subject": subj,
"name": str(r.get("字段名称", "")),
"value": _safe_value(r.get("字段取值", "")),
})
scoring_info["sample_inputs"] = sample_inputs
pca_info = {
"type": "subject_level",
"school_count": len(all_schools),
"subject_count": len(SUBJECTS),
"pipeline": "题目赋分 -> Z标准化 -> PCA -> x10+50",
}
return scoring_info, pca_info
def _trace_school_level_generic(self, sub_dim: str, school: str,
all_schools: List[str]) -> tuple:
"""B表学校级子维度的通用trace"""
scoring_info = {
"method": "学校级赋分 -> 全市Z标准化 -> PCA(第一主成分) -> x10+50",
"data_source": "B表(课程实施情况表)",
}
# 取一些原始数据作示例
course_df = self._get_course(school)
sample_inputs = []
if len(course_df) > 0:
for _, r in course_df.head(6).iterrows():
sample_inputs.append({
"name": str(r.get("字段名称", "")),
"value": _safe_value(r.get("字段取值", "")),
})
scoring_info["sample_inputs"] = sample_inputs
pca_info = {
"type": "school_level",
"school_count": len(all_schools),
"pipeline": "赋分 -> Z标准化 -> PCA -> x10+50",
}
return scoring_info, pca_info
def _trace_generic_fallback(self, sub_dim: str, school: str,
all_schools: List[str]) -> tuple:
"""回退方法"""
return {"method": "unknown"}, {}
# ====================================================================
# PCA细节提取
# ====================================================================
def _extract_pca_details(self, matrix: pd.DataFrame, school: str,
label: str = "PCA") -> Dict:
"""从赋分矩阵中提取PCA的详细参数"""
matrix = matrix.dropna(axis=1, how="all")
if matrix.shape[1] == 0:
return {"label": label, "error": "empty_matrix"}
filled = matrix.copy().infer_objects(copy=False)
for col in filled.columns:
col_mean = filled[col].mean()
if np.isnan(col_mean):
col_mean = 0.0
filled[col] = filled[col].fillna(col_mean)
if filled.shape[1] == 1:
vals = filled.iloc[:, 0].values.astype(float)
school_idx = list(filled.index).index(school) if school in filled.index else -1
return {
"label": label,
"type": "single_variable_z",
"variable": str(filled.columns[0]),
"school_value": round(float(vals[school_idx]), 4) if school_idx >= 0 else None,
"mean": round(float(np.nanmean(vals)), 4),
"std": round(float(np.nanstd(vals, ddof=1)), 4),
"n_schools": len(vals),
}
# Z标准化
scaler = StandardScaler()
try:
scaled = scaler.fit_transform(filled.values.astype(float))
except ValueError:
return {"label": label, "error": "scaling_failed"}
# PCA
pca = PCA(n_components=min(1, filled.shape[1], filled.shape[0]))
scores = pca.fit_transform(scaled)[:, 0]
loadings = pca.components_[0]
if np.sum(loadings) < 0:
scores = -scores
loadings = -loadings
mean = np.mean(scores)
std = np.std(scores, ddof=1)
school_idx = list(filled.index).index(school) if school in filled.index else -1
school_raw_score = scores[school_idx] if school_idx >= 0 else None
school_std_score = ((school_raw_score - mean) / std * PCA_STD + PCA_MEAN) if (school_raw_score is not None and std > 0) else None
# Loadings详情
loading_details = []
for i, col in enumerate(filled.columns):
loading_details.append({
"variable": str(col),
"loading": round(float(loadings[i]), 4),
})
loading_details.sort(key=lambda x: abs(x["loading"]), reverse=True)
return {
"label": label,
"type": "pca",
"n_schools": int(filled.shape[0]),
"n_variables": int(filled.shape[1]),
"explained_variance_ratio": round(float(pca.explained_variance_ratio_[0]), 4),
"loadings": loading_details,
"school_pca_score": round(float(school_raw_score), 4) if school_raw_score is not None else None,
"school_standardized": round(float(school_std_score), 2) if school_std_score is not None else None,
"pca_mean": round(float(mean), 4),
"pca_std": round(float(std), 4),
}
# ====================================================================
# 阶段3:标准化后得分
# ====================================================================
def _trace_standardized(self, school: str, dist_schools: List[str]) -> Dict:
"""标准化后的20个子维度得分"""
result = {}
for dim_name, info in DIMENSION_FRAMEWORK.items():
for sub_dim in info["sub_dimensions"]:
if sub_dim not in self.sub_scores.columns:
continue
score = float(self.sub_scores.loc[school, sub_dim])
# 区内均值和全市均值
dist_vals = self.sub_scores.loc[
[s for s in dist_schools if s in self.sub_scores.index], sub_dim
]
all_vals = self.sub_scores[sub_dim]
result[sub_dim] = {
"score": round(score, 2),
"district_avg": round(float(dist_vals.mean()), 2),
"city_avg": round(float(all_vals.mean()), 2),
"city_std": round(float(all_vals.std()), 2),
"diff_district": round(score - float(dist_vals.mean()), 2),
"diff_city": round(score - float(all_vals.mean()), 2),
"parent_dimension": dim_name,
}
return result
# ====================================================================
# 阶段4:水平判定
# ====================================================================
def _trace_levels(self, school: str) -> Dict:
result = {}
for dim_name, info in DIMENSION_FRAMEWORK.items():
for sub_dim in info["sub_dimensions"]:
if sub_dim not in self.sub_scores.columns:
continue
score = float(self.sub_scores.loc[school, sub_dim])
thresholds = LEVEL_THRESHOLDS.get(sub_dim, {})
level = self.stats.determine_level(score, sub_dim)
desc = LEVEL_DESCRIPTIONS.get(sub_dim, {}).get(level, "")
result[sub_dim] = {
"score": round(score, 2),
"thresholds": {
"level2": thresholds.get("level2", 43),
"level3": thresholds.get("level3", 50),
"level4": thresholds.get("level4", 57),
},
"level": level,
"description": desc,
"parent_dimension": dim_name,
}
return result
# ====================================================================
# 阶段5:维度聚合 → 总分
# ====================================================================
def _trace_dimensions(self, school: str, dist_schools: List[str]) -> tuple:
dims = {}
for dim_name, info in DIMENSION_FRAMEWORK.items():
sub_dims = info["sub_dimensions"]
sub_scores = {}
for sd in sub_dims:
if sd in self.sub_scores.columns:
sub_scores[sd] = round(float(self.sub_scores.loc[school, sd]), 2)
dim_score = float(self.dim_scores.loc[school, dim_name]) if dim_name in self.dim_scores.columns else None
dist_dim_vals = self.dim_scores.loc[
[s for s in dist_schools if s in self.dim_scores.index], dim_name
] if dim_name in self.dim_scores.columns else pd.Series()
dims[dim_name] = {
"sub_scores": sub_scores,
"score": round(dim_score, 2) if dim_score is not None else None,
"method": "mean(子维度标准化分)",
"district_avg": round(float(dist_dim_vals.mean()), 2) if len(dist_dim_vals) > 0 else None,
}
# 总分
overall_score = float(self.dim_scores.loc[school, "总体得分"]) if "总体得分" in self.dim_scores.columns else None
dist_overall = self.dim_scores.loc[
[s for s in dist_schools if s in self.dim_scores.index], "总体得分"
] if "总体得分" in self.dim_scores.columns else pd.Series()
rank = int((dist_overall >= overall_score).sum()) if overall_score is not None and len(dist_overall) > 0 else None
overall = {
"score": round(overall_score, 2) if overall_score is not None else None,
"method": "mean(7个维度分)",
"district_avg": round(float(dist_overall.mean()), 2) if len(dist_overall) > 0 else None,
"rank": rank,
"total_schools": len(dist_schools),
}
return dims, overall
# ====================================================================
# 数据访问代理
# ====================================================================
def _get_course(self, school: str) -> pd.DataFrame:
return self.pca._get_course(school)
def _get_subject(self, school: str, subject: str) -> pd.DataFrame:
return self.pca._get_subject(school, subject)
# ====================================================================
# 缓存
# ====================================================================
def _load_cache(self, school: str) -> Optional[Dict]:
TRACE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
cache_file = TRACE_CACHE_DIR / f"{school}_trace.json"
if cache_file.exists():
try:
return json.loads(cache_file.read_text("utf-8"))
except Exception:
return None
return None
def _save_cache(self, school: str, data: Dict):
TRACE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
cache_file = TRACE_CACHE_DIR / f"{school}_trace.json"
try:
cache_file.write_text(json.dumps(data, ensure_ascii=False, indent=2, default=_json_default), "utf-8")
except Exception as e:
logger.warning(f"[Trace] Cache write failed for {school}: {e}")
# ====================================================================
# 工具函数
# ====================================================================
def _safe_value(v) -> Any:
"""将pandas值转为JSON安全类型"""
if pd.isna(v):
return None
if isinstance(v, (np.integer,)):
return int(v)
if isinstance(v, (np.floating,)):
return round(float(v), 4)
return str(v)
def _guess_type(v) -> str:
"""猜测字段类型"""
if pd.isna(v):
return "null"
s = str(v).strip()
try:
float(s)
return "number"
except ValueError:
pass
if s in ("0", "1", "", "", "", ""):
return "binary"
if s in ("已经建成并使用", "已经建成但未使用", "尚未建成", "已建成并使用", "已建成但未使用"):
return "ordinal"
return "text"
def _json_default(obj):
"""JSON序列化兜底"""
if isinstance(obj, (np.integer,)):
return int(obj)
if isinstance(obj, (np.floating,)):
return round(float(obj), 4)
if isinstance(obj, np.ndarray):
return obj.tolist()
if isinstance(obj, pd.Timestamp):
return str(obj)
if isinstance(obj, float) and (np.isnan(obj) or np.isinf(obj)):
return None
return str(obj)