"""
报告渲染引擎:将数据+LLM文字+模板组装成最终HTML报告
"""
from datetime import datetime
from pathlib import Path
from typing import Dict, List
import numpy as np
from jinja2 import Environment, FileSystemLoader
from ..config import (
TEMPLATES_DIR, DIMENSION_FRAMEWORK, LEVEL_DESCRIPTIONS,
OUTPUT_DIR, SCHOOL_TYPE_MAP,
)
class ReportRenderer:
"""HTML报告渲染引擎"""
def __init__(self):
self.env = Environment(
loader=FileSystemLoader(str(TEMPLATES_DIR)),
autoescape=False, # 允许HTML直接渲染
)
def render(self, report_data: Dict, llm_sections: Dict[str, str]) -> str:
"""
渲染完整HTML报告
report_data: stats_engine.compute_school_report_data() 的输出
llm_sections: llm_engine.generate_report_segments() 的输出
"""
template = self.env.get_template("base.html")
# 准备模板数据
school = report_data["school"]
context = {
"school": school,
"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,
"generation_date": datetime.now().strftime("%Y年%m月%d日"),
# 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),
}
return template.render(**context)
def render_to_file(self, report_data: Dict, llm_sections: Dict[str, str],
output_path: Path = None) -> Path:
"""渲染并保存到文件"""
html = self.render(report_data, llm_sections)
school = report_data["school"]
if output_path is None:
output_dir = OUTPUT_DIR
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / f"{school}_报告.html"
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)
# 水平阈值
from ..config import LEVEL_THRESHOLDS
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的阈值,总分增加多少
让校长看到"改哪几个点收益最大"
"""
from ..config import LEVEL_THRESHOLDS
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