""" 报告渲染引擎:将数据+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