Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
930 lines
37 KiB
Python
930 lines
37 KiB
Python
"""
|
||
二期报告渲染引擎 (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_MAP(176所学校没有逐校类型信息)
|
||
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
|