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
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""
测试脚本:验证数据引擎能正确读取和解析Excel数据
"""
import sys
from pathlib import Path
# 添加项目路径
sys.path.insert(0, str(Path(__file__).parent.parent / "backend"))
from app.engines.data_engine import DataEngine
from app.config import DIMENSION_FRAMEWORK, SCHOOL_TYPE_MAP
import json
def main():
print("=" * 80)
print("数据引擎测试")
print("=" * 80)
engine = DataEngine()
engine.load_all()
# 1. 基本信息
summary = engine.summary()
print(f"\n📊 数据摘要:")
print(f" 学校数量: {summary['school_count']}")
print(f" 学校列表: {summary['schools']}")
print(f" 基础信息行数: {summary['basic_info_rows']}")
print(f" 课程实施行数: {summary['course_impl_rows']}")
print(f" 学科课程行数: {summary['subject_impl_rows']}")
# 2. 测试单校数据
test_school = "延安中学"
print(f"\n🏫 {test_school} 基本信息:")
info = engine.get_school_basic_info(test_school)
for k, v in info.items():
print(f" {k}: {v}")
# 3. 测试课程数据
print(f"\n📚 {test_school} 课程实施数据:")
course_df = engine.get_school_course_data(test_school)
print(f" 总行数: {len(course_df)}")
print(f" 涉及学科: {sorted(course_df['学科'].unique().tolist())}")
print(f" 涉及题号: {sorted(course_df['题号'].unique().tolist())[:20]}...")
# 4. 测试学科数据
print(f"\n🔬 {test_school} 学科课程数据:")
subject_df = engine.get_school_subject_data(test_school)
print(f" 总行数: {len(subject_df)}")
print(f" 涉及学科: {sorted(subject_df['学科'].unique().tolist())}")
# 5. 测试周课时数据
print(f"\n{test_school} 周课时数据(前10行):")
hours = engine.get_weekly_hours(test_school)
if len(hours) > 0:
print(hours[["学科", "年级", "学期", "字段名称", "字段取值"]].head(10).to_string())
else:
print(" 无课时数据")
# 6. 测试课程规范数据
print(f"\n📋 {test_school} 课程规范数据(前5条):")
norms = engine.get_course_norms(test_school)
for n in norms[:5]:
print(f" {n['字段名称']}: {n['字段取值']}")
# 7. 所有学校类型
print(f"\n🏷️ 学校类型:")
for school in engine.schools:
info = SCHOOL_TYPE_MAP.get(school, {})
print(f" {school}: {info.get('type', '未知')} ({info.get('nature', '')}) [{info.get('feature', '')}]")
print("\n✅ 数据引擎测试完成!")
if __name__ == "__main__":
main()
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""
测试脚本:完整Pipeline - 数据加载→赋分→PCA→标准化→水平判定→统计分析
"""
import sys
from pathlib import Path
import json
sys.path.insert(0, str(Path(__file__).parent.parent / "backend"))
from app.engines.data_engine import DataEngine
from app.engines.scoring_engine import ScoringEngine
from app.engines.stats_engine import StatsEngine
from app.config import DIMENSION_FRAMEWORK, LEVEL_THRESHOLDS
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
def main():
print("=" * 80)
print("完整Pipeline测试:Excel → 赋分 → PCA → 标准化 → 水平判定")
print("=" * 80)
# Step 1: 加载数据
print("\n[Step 1] 加载Excel数据...")
data_engine = DataEngine()
data_engine.load_all()
# Step 2: 赋分
print("\n[Step 2] 对所有学校进行赋分...")
scoring_engine = ScoringEngine(data_engine)
raw_scores = scoring_engine.score_all_schools()
# 打印赋分结果概要
print("\n📊 赋分结果概要:")
for school in data_engine.schools:
scores = raw_scores[school]
print(f"\n 🏫 {school}:")
for dim, vals in scores.items():
avg = sum(vals) / len(vals) if vals else 0
print(f" {dim}: {len(vals)}个指标, 均值={avg:.2f}, 值={[round(v,1) for v in vals[:5]]}{'...' if len(vals) > 5 else ''}")
# Step 3: 统计分析
print("\n[Step 3] PCA合成 + 标准化...")
stats_engine = StatsEngine()
sub_scores = stats_engine.compute_dimension_scores(raw_scores)
print(f"\n三级维度标准化得分 (均值50, 标准差10):")
print(sub_scores.round(2).to_string())
# Step 4: 二级维度聚合
print("\n[Step 4] 二级维度聚合...")
dim_scores = stats_engine.compute_dimension_aggregates(sub_scores)
print(f"\n二级维度得分:")
print(dim_scores.round(2).to_string())
# Step 5: 水平判定
print("\n[Step 5] 水平判定...")
levels = stats_engine.compute_levels(sub_scores)
print(f"\n各学校各维度水平:")
print(levels.to_string())
# Step 6: 聚类分析
print("\n[Step 6] 聚类分析...")
dim_cols = [c for c in dim_scores.columns if c in DIMENSION_FRAMEWORK]
clusters = stats_engine.cluster_analysis(dim_scores[dim_cols])
print(f"\n学校聚类结果:")
for school, cluster in clusters["school_clusters"].items():
print(f" {school}: {cluster}")
# Step 7: 相关性
print("\n[Step 7] 维度间相关性:")
corr = stats_engine.correlation_analysis(dim_scores)
print(corr.round(3).to_string())
# Step 8: 生成一所学校的完整报告数据
test_school = "延安中学"
print(f"\n[Step 8] 生成 {test_school} 的完整报告数据包...")
report_data = stats_engine.compute_school_report_data(test_school, sub_scores, dim_scores)
print(f"\n📋 {test_school} 报告数据概要:")
print(f" 总体得分: {report_data['overall']['score']}")
print(f" 区内排名: {report_data['overall']['rank_in_district']}/{report_data['overall']['total_schools']}")
print(f" 聚类类型: {report_data['overall']['cluster']}")
print(f"\n 二级维度得分:")
for dim, data in report_data['dimensions'].items():
print(f" {dim}: {data['score']} (区均值{data['district_avg']}, 差异{data['diff_district']:+.2f})")
print(f"\n 三级维度水平:")
for sub_dim, data in report_data['sub_dimensions'].items():
print(f" {sub_dim}: 得分{data['score']}, 水平{data['level']}, {data['level_description'][:30]}")
# 保存报告数据为JSON
output_dir = Path(__file__).parent.parent / "output"
output_dir.mkdir(exist_ok=True)
# 清理numpy类型以便JSON序列化
def clean_for_json(obj):
import numpy as np
if isinstance(obj, dict):
return {k: clean_for_json(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [clean_for_json(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_clean = clean_for_json(report_data)
with open(output_dir / f"{test_school}_report_data.json", "w", encoding="utf-8") as f:
json.dump(report_clean, f, ensure_ascii=False, indent=2)
print(f"\n✅ 报告数据已保存到 output/{test_school}_report_data.json")
# 保存所有学校的得分汇总
all_scores = {
"sub_dimension_scores": clean_for_json(sub_scores.to_dict()),
"dimension_scores": clean_for_json(dim_scores.to_dict()),
"levels": clean_for_json(levels.to_dict()),
"clusters": clean_for_json(clusters),
}
with open(output_dir / "all_schools_scores.json", "w", encoding="utf-8") as f:
json.dump(all_scores, f, ensure_ascii=False, indent=2)
print(f"✅ 全校得分汇总已保存到 output/all_schools_scores.json")
print("\n" + "=" * 80)
print("✅ 完整Pipeline测试完成!")
print("=" * 80)
if __name__ == "__main__":
main()
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""
完整报告生成脚本:Excel → 赋分 → 统计 → LLM并行生成 → HTML报告
"""
import sys
import time
import argparse
import logging
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "backend"))
from app.engines.data_engine import DataEngine
from app.engines.scoring_engine import ScoringEngine
from app.engines.stats_engine import StatsEngine
from app.engines.llm_engine import LLMEngine
from app.engines.report_renderer import ReportRenderer
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger(__name__)
def progress_callback(completed, total, segment_id):
"""进度回调"""
pct = completed / total * 100
bar = "" * int(pct / 5) + "" * (20 - int(pct / 5))
print(f"\r [{bar}] {pct:.0f}% ({completed}/{total}) {segment_id:<40}", end="", flush=True)
def main():
parser = argparse.ArgumentParser(description="生成学校课程实施监测报告")
parser.add_argument("--school", type=str, default="延安中学", help="学校名称")
parser.add_argument("--no-cache", action="store_true", help="不使用LLM缓存")
parser.add_argument("--no-llm", action="store_true", help="跳过LLM生成(仅图表+数据)")
parser.add_argument("--list-schools", action="store_true", help="列出所有可用学校")
args = parser.parse_args()
start_time = time.time()
# ===== Step 1: 加载数据 =====
print("=" * 70)
print("📊 课程实施监测报告生成系统")
print("=" * 70)
logger.info("[1/5] 加载Excel数据...")
data_engine = DataEngine()
data_engine.load_all()
if args.list_schools:
print("\n可用学校:")
for s in data_engine.schools:
from app.config import SCHOOL_TYPE_MAP
info = SCHOOL_TYPE_MAP.get(s, {})
print(f" {s} ({info.get('type', '')})")
return
school = args.school
if school not in data_engine.schools:
print(f"\n❌ 学校 '{school}' 不在数据中。可用学校:")
for s in data_engine.schools:
print(f" - {s}")
return
print(f"\n🏫 目标学校: {school}")
# ===== Step 2: 赋分 =====
logger.info("[2/5] 全区赋分计算...")
scoring_engine = ScoringEngine(data_engine)
raw_scores = scoring_engine.score_all_schools()
# ===== Step 3: 统计分析 =====
logger.info("[3/5] PCA合成 + 标准化 + 统计分析...")
stats_engine = StatsEngine()
sub_scores = stats_engine.compute_dimension_scores(raw_scores)
dim_scores = stats_engine.compute_dimension_aggregates(sub_scores)
report_data = stats_engine.compute_school_report_data(school, sub_scores, dim_scores)
# ===== Step 4: LLM生成 =====
if args.no_llm:
logger.info("[4/5] 跳过LLM生成(--no-llm模式)")
llm_sections = {}
else:
logger.info("[4/5] LLM并行生成报告文字...")
llm_engine = LLMEngine()
llm_engine.set_progress_callback(progress_callback)
llm_sections = llm_engine.generate_report_segments(
report_data,
use_cache=not args.no_cache,
)
print() # 换行
# ===== Step 5: 渲染HTML =====
logger.info("[5/5] 渲染HTML报告...")
renderer = ReportRenderer()
output_dir = Path(__file__).parent.parent / "output"
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / f"{school}_报告.html"
renderer.render_to_file(report_data, llm_sections, output_path)
elapsed = time.time() - start_time
print(f"\n{'=' * 70}")
print(f"✅ 报告生成完成!")
print(f" 学校: {school}")
print(f" 总体得分: {report_data['overall']['score']}分 (区内第{report_data['overall']['rank_in_district']}名)")
print(f" LLM段落: {len(llm_sections)}")
print(f" 耗时: {elapsed:.1f}")
print(f" 输出: {output_path}")
print(f"{'=' * 70}")
if __name__ == "__main__":
main()
+207
View File
@@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""
批量报告生成脚本:为长宁区全部9所学校生成课程实施监测报告
数据加载和赋分/统计只做一次,LLM和渲染对每校独立执行
"""
import sys
import time
import argparse
import logging
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "backend"))
from app.engines.data_engine import DataEngine
from app.engines.scoring_engine import ScoringEngine
from app.engines.stats_engine import StatsEngine
from app.engines.llm_engine import LLMEngine
from app.engines.report_renderer import ReportRenderer
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger(__name__)
def progress_callback(completed, total, segment_id):
"""进度回调"""
pct = completed / total * 100
bar = "" * int(pct / 5) + "" * (20 - int(pct / 5))
print(f"\r [{bar}] {pct:.0f}% ({completed}/{total}) {segment_id:<40}", end="", flush=True)
def main():
parser = argparse.ArgumentParser(description="批量生成全部学校课程实施监测报告")
parser.add_argument("--no-cache", action="store_true", help="不使用LLM缓存")
parser.add_argument("--no-llm", action="store_true", help="跳过LLM生成(仅图表+数据)")
parser.add_argument("--schools", nargs="*", help="指定学校列表(默认全部9所)")
args = parser.parse_args()
total_start = time.time()
print("=" * 70)
print("📊 课程实施监测报告 — 批量生成系统")
print("=" * 70)
# ===== Step 1: 加载数据(只做一次) =====
logger.info("[全局 1/3] 加载Excel数据...")
data_engine = DataEngine()
data_engine.load_all()
# ===== Step 2: 全区赋分(只做一次) =====
logger.info("[全局 2/3] 全区赋分计算...")
scoring_engine = ScoringEngine(data_engine)
raw_scores = scoring_engine.score_all_schools()
# ===== Step 3: 统计分析(只做一次) =====
logger.info("[全局 3/3] PCA合成 + 标准化...")
stats_engine = StatsEngine()
sub_scores = stats_engine.compute_dimension_scores(raw_scores)
dim_scores = stats_engine.compute_dimension_aggregates(sub_scores)
# 确定生成哪些学校
all_schools = list(data_engine.schools)
if args.schools:
schools = []
for s in args.schools:
if s in all_schools:
schools.append(s)
else:
print(f" ⚠️ 学校 '{s}' 不在数据中,跳过")
if not schools:
print("❌ 没有有效的学校,退出")
return
else:
schools = all_schools
print(f"\n🏫 将为以下 {len(schools)} 所学校生成报告:")
for i, s in enumerate(schools, 1):
from app.config import SCHOOL_TYPE_MAP
info = SCHOOL_TYPE_MAP.get(s, {})
print(f" {i}. {s} ({info.get('type', '未知类型')})")
# ===== 初始化引擎 =====
renderer = ReportRenderer()
llm_engine = None
if not args.no_llm:
llm_engine = LLMEngine()
llm_engine.set_progress_callback(progress_callback)
output_dir = Path(__file__).parent.parent / "output"
output_dir.mkdir(parents=True, exist_ok=True)
# ===== 逐校生成 =====
results = []
for idx, school in enumerate(schools, 1):
school_start = time.time()
print(f"\n{'' * 70}")
print(f"🔄 [{idx}/{len(schools)}] 正在生成: {school}")
print(f"{'' * 70}")
try:
# 统计分析(学校专属数据包)
report_data = stats_engine.compute_school_report_data(school, sub_scores, dim_scores)
# LLM生成
if args.no_llm:
print(" ⏭️ 跳过LLM生成(--no-llm模式)")
llm_sections = {}
else:
print(" 🤖 LLM并行生成报告文字...")
llm_sections = llm_engine.generate_report_segments(
report_data,
use_cache=not args.no_cache,
)
print() # 换行(progress bar之后)
# 渲染HTML
output_path = output_dir / f"{school}_报告.html"
renderer.render_to_file(report_data, llm_sections, output_path)
# 保存数据JSON
import json
def _json_safe(obj):
"""处理numpy类型"""
import numpy as np
if isinstance(obj, (np.integer,)):
return int(obj)
if isinstance(obj, (np.floating,)):
return float(obj)
if isinstance(obj, np.ndarray):
return obj.tolist()
raise TypeError(f"Object of type {type(obj)} is not JSON serializable")
json_path = output_dir / f"{school}_report_data.json"
with open(json_path, "w", encoding="utf-8") as f:
json.dump(report_data, f, ensure_ascii=False, indent=2, default=_json_safe)
elapsed = time.time() - school_start
score = report_data["overall"]["score"]
rank = report_data["overall"]["rank_in_district"]
cluster = report_data["overall"]["cluster"]
llm_count = len(llm_sections)
results.append({
"school": school,
"status": "",
"score": score,
"rank": rank,
"cluster": cluster,
"llm_segments": llm_count,
"time": elapsed,
"output": str(output_path),
})
print(f" ✅ 完成! 得分={score}分 排名={rank}/9 类型={cluster} LLM={llm_count}段 耗时={elapsed:.1f}s")
except Exception as e:
elapsed = time.time() - school_start
logger.error(f" ❌ 生成失败: {e}", exc_info=True)
results.append({
"school": school,
"status": "",
"error": str(e),
"time": elapsed,
})
# ===== 汇总报告 =====
total_elapsed = time.time() - total_start
print(f"\n{'=' * 70}")
print(f"📋 批量生成结果汇总")
print(f"{'=' * 70}")
print(f"{'学校':<10} {'状态':<4} {'得分':<8} {'排名':<8} {'类型':<8} {'LLM段':<8} {'耗时':<8}")
print(f"{'' * 62}")
success_count = 0
for r in results:
if r["status"] == "":
success_count += 1
print(f"{r['school']:<10} {r['status']:<4} {r['score']:<8.2f} {r['rank']}/9{'':<5} {r['cluster']:<8} {r['llm_segments']:<8} {r['time']:.1f}s")
else:
print(f"{r['school']:<10} {r['status']:<4} {'失败: ' + r.get('error', '未知')}")
print(f"{'' * 62}")
print(f"成功: {success_count}/{len(schools)} | 总耗时: {total_elapsed:.1f}s")
print(f"输出目录: {output_dir}")
print(f"{'=' * 70}")
# 保存汇总JSON
import json
summary_path = output_dir / "batch_summary.json"
with open(summary_path, "w", encoding="utf-8") as f:
json.dump({
"generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
"total_schools": len(schools),
"success_count": success_count,
"total_time": round(total_elapsed, 1),
"results": results,
}, f, ensure_ascii=False, indent=2)
print(f"汇总文件: {summary_path}")
if __name__ == "__main__":
main()
+322
View File
@@ -0,0 +1,322 @@
#!/usr/bin/env python3
"""
二期数据 ETL 脚本
将原始平台导出格式(每题一个Sheet + 15个学科独立文件)
转换为与一期相同的扁平表格式,供后续赋分/分析引擎使用
输入:二期/[整理前] 二期_课程实施+教材使用情况表/
输出:report-admin/data/era2/ 下的3张扁平表
- era2_基础信息表.xlsx
- era2_课程实施情况表.xlsx
- era2_学科课程实施情况表.xlsx
一期目标格式:
基础信息表列: 题号, 题型, 题目ID, 题目内容, 字段ID, 字段名称, 字段值, 年级, 学期, 区, 学校名称, 办学性质, 学校类别, 学校等级, 地域类型, 特色类型, 等级B, 等级C
课程实施情况表列: 题号, 题型, 字段ID, 字段名称, 字段取值, 选项文字, 学科, 学期, 年级, 学校简称, 所在区, 学校类别, 学校性质, 所处地区, 学校类型, 学校类型编号
学科课程实施情况表列: 题号, 题型, 字段ID, 字段名称, 字段取值, 学科, 选项文字, 学校性质, 所在区, 学校类别, 学校特色, 学校简称, 所处地区, 学校类型, 学校类型编号
"""
import pandas as pd
import numpy as np
from pathlib import Path
from typing import Dict, List, Optional
import logging
import time
import glob
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger(__name__)
# ===== 路径配置 =====
PROJECT_ROOT = Path(__file__).parent.parent.parent # report-admin/
DATA_ROOT = PROJECT_ROOT.parent # 20260301-邱老师-校长画像/
ERA2_RAW_DIR = DATA_ROOT / "二期" / "[整理前] 二期_课程实施+教材使用情况表"
ERA2_OUTPUT_DIR = PROJECT_ROOT / "data" / "era2"
def load_raw_file(filepath: Path) -> Dict:
"""
加载一个二期原始 Excel 文件,返回:
{
'home': DataFrame, # 首页元信息
'data_sheets': {题号: DataFrame}, # 每题的数据
'field_map': DataFrame, # 字段映射关系
'dim_map': DataFrame, # 维度映射关系
'type_stats': DataFrame, # 题型统计
}
"""
logger.info(f" 加载: {filepath.name}")
xls = pd.ExcelFile(filepath)
result = {
'home': pd.read_excel(xls, sheet_name='首页'),
'data_sheets': {},
'field_map': None,
'dim_map': None,
'type_stats': None,
}
for sn in xls.sheet_names:
if sn == '首页':
continue
elif sn == '字段映射关系':
result['field_map'] = pd.read_excel(xls, sheet_name=sn)
elif sn == '维度映射关系':
result['dim_map'] = pd.read_excel(xls, sheet_name=sn)
elif sn == '题型统计':
result['type_stats'] = pd.read_excel(xls, sheet_name=sn)
elif sn.isdigit():
result['data_sheets'][int(sn)] = pd.read_excel(xls, sheet_name=sn)
xls.close()
total_rows = sum(len(df) for df in result['data_sheets'].values())
logger.info(f"{len(result['data_sheets'])}个题Sheet, {total_rows}行数据")
return result
def build_type_map(type_stats: pd.DataFrame) -> Dict[int, str]:
"""从题型统计构建 题号→题型 的映射"""
return dict(zip(type_stats['题号'].astype(int), type_stats['题型']))
def extract_school_short_name(full_name: str) -> str:
"""
从全称提取简称
'上海市延安中学''延安中学'
'华东政法大学附属中学''华政附中'
'华东师范大学附属天山学校''天山学校'
保留全称作为默认
"""
# 不做过度简化,保留全称让下游配置来处理映射
return full_name
def extract_district_name(region_str: str) -> str:
"""
从区域维度提取区名
'上海市长宁区教育学院''长宁区'
'上海市青浦区教师进修学院''青浦区'
"""
if pd.isna(region_str):
return ""
s = str(region_str)
# 提取 "XX区" 部分
for suffix in ['教育学院', '教师进修学院', '教育委员会']:
s = s.replace(suffix, '')
s = s.replace('上海市', '')
return s.strip()
# ===== 1. 基础信息表 ETL =====
def transform_basic_info(raw: Dict) -> pd.DataFrame:
"""
将二期基础信息表转为一期格式
一期列: 题号, 题型, 题目ID, 题目内容, 字段ID, 字段名称, 字段值, 年级, 学期, 区, 学校名称, ...
二期列: 问题id, 字段id, 字段名称, 字段取值, 维度id, 维度名称, 学科维度, 年级维度, 学期维度, 学校维度, 区域维度, 用户维度, 状态, 问卷提交时间
"""
logger.info("转换基础信息表...")
type_map = build_type_map(raw['type_stats'])
rows = []
for q_num, df in sorted(raw['data_sheets'].items()):
q_type = type_map.get(q_num, '未知')
for _, row in df.iterrows():
rows.append({
'题号': q_num,
'题型': q_type,
'题目ID': row.get('问题id', ''),
'题目内容': '', # 二期原始数据无此字段
'字段ID': row.get('字段id', ''),
'字段名称': row.get('字段名称', ''),
'字段值': row.get('字段取值', ''),
'年级': row.get('年级维度', '不分年级'),
'学期': row.get('学期维度', ''),
'': extract_district_name(row.get('区域维度', '')),
'学校名称': row.get('学校维度', ''),
# 以下字段二期原始数据无,留空后续由配置补充
'办学性质': '',
'学校类别': '',
'学校等级': '',
'地域类型': '',
'特色类型': '',
'等级B': '',
'等级C': '',
})
result = pd.DataFrame(rows)
# 强制所有列为字符串,避免混合类型导致 parquet 报错
result = result.astype(str)
logger.info(f" 基础信息表: {len(result)}")
return result
# ===== 2. 课程实施情况表 ETL =====
def transform_course_impl(raw: Dict) -> pd.DataFrame:
"""
将二期课程实施情况表转为一期格式
一期列: 题号, 题型, 字段ID, 字段名称, 字段取值, 选项文字, 学科, 学期, 年级, 学校简称, 所在区, ...
"""
logger.info("转换课程实施情况表...")
type_map = build_type_map(raw['type_stats'])
rows = []
for q_num, df in sorted(raw['data_sheets'].items()):
q_type = type_map.get(q_num, '未知')
for _, row in df.iterrows():
rows.append({
'题号': str(q_num),
'题型': q_type,
'字段ID': row.get('字段id', ''),
'字段名称': row.get('字段名称', ''),
'字段取值': row.get('字段取值', ''),
'选项文字': row.get('维度名称', ''), # 二期中维度名称对应选项文字
'学科': row.get('学科维度', '不分学科'),
'学期': row.get('学期维度', ''),
'年级': row.get('年级维度', '不分年级'),
'学校简称': row.get('学校维度', ''),
'所在区': extract_district_name(row.get('区域维度', '')),
# 以下字段二期原始数据无,留空
'学校类别': '',
'学校性质': '',
'所处地区': '',
'学校类型': '',
'学校类型编号': '',
})
result = pd.DataFrame(rows)
result = result.astype(str)
logger.info(f" 课程实施情况表: {len(result)}")
return result
# ===== 3. 学科课程实施情况表 ETL =====
def transform_subject_impl(raw_files: Dict[str, Dict]) -> pd.DataFrame:
"""
将二期的15个学科独立文件合并为一张表(一期格式)
一期列: 题号, 题型, 字段ID, 字段名称, 字段取值, 学科, 选项文字, 学校性质, 所在区, ...
"""
logger.info("转换学科课程实施情况表(合并15个学科文件)...")
all_rows = []
for subject, raw in sorted(raw_files.items()):
type_map = build_type_map(raw['type_stats'])
subject_rows = 0
for q_num, df in sorted(raw['data_sheets'].items()):
q_type = type_map.get(q_num, '未知')
for _, row in df.iterrows():
all_rows.append({
'题号': str(q_num),
'题型': q_type,
'字段ID': row.get('字段id', ''),
'字段名称': row.get('字段名称', ''),
'字段取值': row.get('字段取值', ''),
'学科': subject,
'选项文字': row.get('维度名称', ''),
'学校性质': '',
'所在区': extract_district_name(row.get('区域维度', '')),
'学校类别': '',
'学校特色': '',
'学校简称': row.get('学校维度', ''),
'所处地区': '',
'学校类型': '',
'学校类型编号': '',
})
subject_rows += 1
logger.info(f" {subject}: {subject_rows}")
result = pd.DataFrame(all_rows)
result = result.astype(str)
logger.info(f" 学科课程实施情况表合计: {len(result)}")
return result
# ===== 主流程 =====
def main():
start = time.time()
print("=" * 70)
print("📊 二期数据 ETL 转换")
print(f" 输入: {ERA2_RAW_DIR}")
print(f" 输出: {ERA2_OUTPUT_DIR}")
print("=" * 70)
# 检查输入目录
if not ERA2_RAW_DIR.exists():
logger.error(f"❌ 输入目录不存在: {ERA2_RAW_DIR}")
return
# 创建输出目录
ERA2_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
# ===== 1. 基础信息表 =====
print("\n[1/3] 基础信息表")
raw_basic = load_raw_file(ERA2_RAW_DIR / "第二期_学校基础信息表.xlsx")
df_basic = transform_basic_info(raw_basic)
out_basic = ERA2_OUTPUT_DIR / "era2_基础信息表.xlsx"
out_basic_pq = ERA2_OUTPUT_DIR / "era2_基础信息表.parquet"
df_basic.to_parquet(out_basic_pq, index=False)
logger.info(f" ✅ 保存 → {out_basic_pq}")
# ===== 2. 课程实施情况表 =====
print("\n[2/3] 课程实施情况表")
raw_course = load_raw_file(ERA2_RAW_DIR / "第二期_学校课程实施情况表.xlsx")
df_course = transform_course_impl(raw_course)
out_course_pq = ERA2_OUTPUT_DIR / "era2_课程实施情况表.parquet"
df_course.to_parquet(out_course_pq, index=False)
logger.info(f" ✅ 保存 → {out_course_pq}")
# ===== 3. 学科课程实施情况表 =====
print("\n[3/3] 学科课程实施情况表(15个学科文件)")
subject_files = sorted(ERA2_RAW_DIR.glob("第二期_*学科课程实施情况表.xlsx"))
raw_subjects = {}
for f in subject_files:
subject_name = f.name.replace("第二期_", "").replace("学科课程实施情况表.xlsx", "")
raw_subjects[subject_name] = load_raw_file(f)
df_subject = transform_subject_impl(raw_subjects)
# 学科表超过Excel行数上限(1,048,576),保存为parquet + 按区分片xlsx
out_subject_parquet = ERA2_OUTPUT_DIR / "era2_学科课程实施情况表.parquet"
df_subject.to_parquet(out_subject_parquet, index=False)
logger.info(f" ✅ 保存 (parquet) → {out_subject_parquet}")
# 注:如需xlsx格式可按区分片,但parquet格式已满足分析需求
# ===== 汇总 =====
elapsed = time.time() - start
print(f"\n{'=' * 70}")
print(f"✅ ETL 转换完成! 耗时 {elapsed:.1f}s")
print(f" 基础信息表: {len(df_basic):>8,}")
print(f" 课程实施情况表: {len(df_course):>8,}")
print(f" 学科课程实施情况表: {len(df_subject):>8,}")
print(f" 学校数量: {df_course['学校简称'].nunique()}")
print(f" 区域数量: {df_course['所在区'].nunique()}")
print(f" 学科数量: {df_subject['学科'].nunique()}")
print(f" 输出目录: {ERA2_OUTPUT_DIR}")
print(f"{'=' * 70}")
# 保存元信息
meta = {
'基础信息表行数': len(df_basic),
'课程实施情况表行数': len(df_course),
'学科课程实施情况表行数': len(df_subject),
'学校列表': sorted(df_course['学校简称'].unique().tolist()),
'区域列表': sorted(df_course['所在区'].unique().tolist()),
'学科列表': sorted(df_subject['学科'].unique().tolist()),
'课程实施题数': raw_course['type_stats']['题号'].max(),
'各学科题数': {s: r['type_stats']['题号'].max() for s, r in raw_subjects.items()},
}
import json
meta_path = ERA2_OUTPUT_DIR / "etl_meta.json"
with open(meta_path, 'w', encoding='utf-8') as f:
json.dump(meta, f, ensure_ascii=False, indent=2, default=str)
logger.info(f" 元信息 → {meta_path}")
if __name__ == "__main__":
main()
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""
二期数据验证脚本
检查 ETL 产出的 parquet 文件是否符合预期,
并与一期数据做结构和字段ID的对比
"""
import pandas as pd
import json
from pathlib import Path
PROJECT_ROOT = Path(__file__).parent.parent.parent # report-admin/
ERA1_DATA = PROJECT_ROOT / "data"
ERA2_DATA = PROJECT_ROOT / "data" / "era2"
def section(title):
print(f"\n{'=' * 70}")
print(f" {title}")
print(f"{'=' * 70}")
def main():
print("📊 二期数据验证 & 一期对比")
# ===== 加载二期数据 =====
section("1. 加载二期 ETL 输出")
df_basic = pd.read_parquet(ERA2_DATA / "era2_基础信息表.parquet")
df_course = pd.read_parquet(ERA2_DATA / "era2_课程实施情况表.parquet")
df_subject = pd.read_parquet(ERA2_DATA / "era2_学科课程实施情况表.parquet")
print(f" 基础信息表: {df_basic.shape}")
print(f" 课程实施情况表: {df_course.shape}")
print(f" 学科课程实施情况表: {df_subject.shape}")
# ===== 加载一期数据 =====
section("2. 加载一期数据")
df1_basic = pd.read_excel(ERA1_DATA / "长宁区_基础信息表.xlsx")
df1_course = pd.read_excel(ERA1_DATA / "长宁区_课程实施情况表.xlsx")
df1_subject = pd.read_excel(ERA1_DATA / "长宁区_学科课程实施情况表.xlsx")
print(f" 基础信息表: {df1_basic.shape}")
print(f" 课程实施情况表: {df1_course.shape}")
print(f" 学科课程实施情况表: {df1_subject.shape}")
# ===== 列名对比 =====
section("3. 列名对比")
for name, (d1, d2) in {
'基础信息表': (df1_basic, df_basic),
'课程实施情况表': (df1_course, df_course),
'学科课程实施情况表': (df1_subject, df_subject),
}.items():
c1 = set(d1.columns)
c2 = set(d2.columns)
print(f"\n [{name}]")
print(f" 一期列: {sorted(c1)}")
print(f" 二期列: {sorted(c2)}")
print(f" 一期有/二期无: {sorted(c1 - c2) or ''}")
print(f" 二期有/一期无: {sorted(c2 - c1) or ''}")
print(f" 共有列: {sorted(c1 & c2)}")
# ===== 字段ID对比(课程实施情况表) =====
section("4. 课程实施情况表 - 字段ID对比")
# 一期用 "字段ID",二期用 "字段ID"
id_col_1 = '字段ID' if '字段ID' in df1_course.columns else None
id_col_2 = '字段ID' if '字段ID' in df_course.columns else None
if id_col_1 and id_col_2:
ids1 = set(df1_course[id_col_1].dropna().unique())
ids2 = set(df_course[id_col_2].dropna().unique())
print(f" 一期字段ID数: {len(ids1)}")
print(f" 二期字段ID数: {len(ids2)}")
common = ids1 & ids2
print(f" 共有: {len(common)}")
print(f" 一期有/二期无: {len(ids1 - ids2)}")
if ids1 - ids2:
print(f" 缺失: {sorted(ids1 - ids2)[:20]}{'...' if len(ids1 - ids2) > 20 else ''}")
print(f" 二期有/一期无: {len(ids2 - ids1)}")
if ids2 - ids1:
print(f" 新增: {sorted(ids2 - ids1)[:20]}{'...' if len(ids2 - ids1) > 20 else ''}")
else:
print(f" ⚠️ 无法对比: 一期列={id_col_1}, 二期列={id_col_2}")
# 尝试用字段名称对比
names1 = set(df1_course['字段名称'].dropna().unique())
names2 = set(df_course['字段名称'].dropna().unique())
print(f" [改用字段名称对比]")
print(f" 一期字段名称数: {len(names1)}")
print(f" 二期字段名称数: {len(names2)}")
common = names1 & names2
print(f" 共有: {len(common)}")
only1 = sorted(names1 - names2)
only2 = sorted(names2 - names1)
print(f" 一期有/二期无: {len(only1)}")
if only1:
for n in only1[:30]:
print(f" - {n}")
print(f" 二期有/一期无: {len(only2)}")
if only2:
for n in only2[:30]:
print(f" + {n}")
# ===== 学科课程表 - 字段名称对比 =====
section("5. 学科课程实施情况表 - 字段名称对比")
names1 = set(df1_subject['字段名称'].dropna().unique())
names2 = set(df_subject['字段名称'].dropna().unique())
print(f" 一期字段名称数: {len(names1)}")
print(f" 二期字段名称数: {len(names2)}")
common = names1 & names2
print(f" 共有: {len(common)}")
only1 = sorted(names1 - names2)
only2 = sorted(names2 - names1)
print(f" 一期有/二期无: {len(only1)}")
if only1:
for n in only1[:20]:
print(f" - {n}")
if len(only1) > 20:
print(f" ... (共{len(only1)}个)")
print(f" 二期有/一期无: {len(only2)}")
if only2:
for n in only2[:20]:
print(f" + {n}")
if len(only2) > 20:
print(f" ... (共{len(only2)}个)")
# ===== 长宁区数据提取验证 =====
section("6. 长宁区数据提取")
cn_course = df_course[df_course['所在区'] == '长宁区']
cn_subject = df_subject[df_subject['所在区'] == '长宁区']
print(f" 课程实施情况表(长宁区): {len(cn_course)}")
print(f" 学科课程实施情况表(长宁区): {len(cn_subject)}")
print(f" 长宁区学校: {sorted(cn_course['学校简称'].unique())}")
print(f" 一期学校: {sorted(df1_course['学校简称'].unique()) if '学校简称' in df1_course.columns else sorted(df1_course['学校名称'].unique())}")
# ===== 数据质量 =====
section("7. 数据质量检查")
for name, df in [('课程实施情况表', df_course), ('学科课程实施情况表', df_subject)]:
total = len(df)
nulls = df['字段取值'].isna().sum() + (df['字段取值'] == '').sum() + (df['字段取值'] == 'nan').sum()
print(f" [{name}]")
print(f" 总行数: {total}")
print(f" 字段取值为空/nan: {nulls} ({nulls/total*100:.1f}%)")
print(f" 学校数: {df['学校简称'].nunique()}")
print(f"\n✅ 验证完成!")
if __name__ == "__main__":
main()
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""
二期 Pipeline 测试脚本
数据加载 → PCA赋分(SPSS对齐) → 标准化 → 水平判定 → 聚类 → 统计分析
不调用 LLM,不生成 HTML 报告
支持两种模式:
--district 长宁区 只分析长宁区8所学校(与一期对比验证)
--district all 分析全部13个区176所学校
"""
import sys
import time
import argparse
import json
import logging
from pathlib import Path
import numpy as np
# 添加当前目录到 path(加载 config_era2 和 data_engine_era2
sys.path.insert(0, str(Path(__file__).parent))
from data_engine_era2 import DataEngineEra2
from engines.pca_scoring_engine_era2 import PcaScoringEngineEra2
from engines.stats_engine_era2 import StatsEngineEra2 as StatsEngine
from config_era2 import DIMENSION_FRAMEWORK, LEVEL_THRESHOLDS
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger(__name__)
def clean_for_json(obj):
"""处理 numpy 类型"""
if isinstance(obj, dict):
return {k: clean_for_json(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [clean_for_json(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
def main():
parser = argparse.ArgumentParser(description="二期数据Pipeline测试")
parser.add_argument("--district", type=str, default="长宁区",
help="区域筛选,'all'表示全部区域,默认'长宁区'")
parser.add_argument("--school", type=str, default=None,
help="指定一所学校查看详情")
parser.add_argument("--list-schools", action="store_true",
help="列出所有可用学校")
args = parser.parse_args()
start_time = time.time()
print("=" * 70)
print("📊 二期数据 Pipeline 测试")
print("=" * 70)
# ===== Step 1: 加载数据 =====
district = None if args.district == "all" else args.district
logger.info(f"[1/5] 加载二期数据... (区域: {args.district})")
data_engine = DataEngineEra2(district_filter=district)
data_engine.load_all()
summary = data_engine.summary()
print(f"\n📋 数据摘要:")
print(f" 学校数: {summary['school_count']}")
print(f" 基础信息行数: {summary['basic_info_rows']}")
print(f" 课程实施行数: {summary['course_impl_rows']}")
print(f" 学科课程行数: {summary['subject_impl_rows']}")
if args.list_schools:
print(f"\n可用学校 ({len(data_engine.schools)}所):")
for i, s in enumerate(data_engine.schools, 1):
print(f" {i:3d}. {s}")
return
# ===== Step 2: PCA赋分(SPSS对齐) =====
logger.info(f"[2/5] PCA赋分计算 ({len(data_engine.schools)}所学校, SPSS对齐)...")
pca_engine = PcaScoringEngineEra2(data_engine)
pca_sub_scores = pca_engine.compute_all()
# 打印PCA赋分摘要
print(f"\n📊 PCA赋分结果摘要 (前5所学校):")
for school in list(data_engine.schools)[:5]:
if school in pca_sub_scores.index:
row = pca_sub_scores.loc[school]
vals = [f"{c}:{row[c]:.1f}" for c in row.index[:4] if not np.isnan(row[c])]
print(f" {school}: {', '.join(vals)}...")
# ===== Step 3: 标准化 =====
logger.info("[3/5] 全市基准标准化...")
stats_engine = StatsEngine()
sub_scores = stats_engine.compute_dimension_scores_pca(pca_sub_scores)
dim_scores = stats_engine.compute_dimension_aggregates(sub_scores)
print(f"\n📊 三级维度标准化得分 (均值50, 标准差10):")
print(f" 形状: {sub_scores.shape}")
print(f" 均值: {sub_scores.mean().mean():.2f}")
print(f" 标准差: {sub_scores.std().mean():.2f}")
print(f"\n📊 二级维度得分 (前5所学校):")
print(dim_scores.head().round(2).to_string())
# ===== Step 4: 水平判定 =====
logger.info("[4/5] 水平判定...")
levels = stats_engine.compute_levels(sub_scores)
print(f"\n📊 水平分布统计:")
for dim in levels.columns:
dist = levels[dim].value_counts().sort_index()
dist_str = " ".join([f"水平{k}:{v}" for k, v in dist.items()])
print(f" {dim}: {dist_str}")
# ===== Step 5: 聚类 + 相关性 =====
logger.info("[5/5] 聚类分析 + 相关性...")
dim_cols = [c for c in dim_scores.columns if c in DIMENSION_FRAMEWORK]
clusters = stats_engine.cluster_analysis(dim_scores[dim_cols])
cluster_dist = {}
for school, cluster in clusters["school_clusters"].items():
cluster_dist[cluster] = cluster_dist.get(cluster, 0) + 1
print(f"\n📊 聚类分布: {cluster_dist}")
corr = stats_engine.correlation_analysis(dim_scores)
print(f"\n📊 维度间相关性:")
print(corr.round(3).to_string())
# ===== 单校详情 =====
test_school = args.school or (data_engine.schools[0] if data_engine.schools else None)
if test_school and test_school in data_engine.schools:
print(f"\n{'' * 70}")
print(f"🏫 {test_school} 详细报告数据")
print(f"{'' * 70}")
report_data = stats_engine.compute_school_report_data(test_school, sub_scores, dim_scores)
print(f" 总体得分: {report_data['overall']['score']}")
print(f" 排名: {report_data['overall']['rank_in_district']}/{report_data['overall']['total_schools']}")
print(f" 聚类类型: {report_data['overall']['cluster']}")
print(f"\n 二级维度:")
for dim, data in report_data['dimensions'].items():
print(f" {dim}: {data['score']} (区均{data['district_avg']}, 差{data['diff_district']:+.2f}) [{data['cluster']}]")
print(f"\n 三级维度:")
for sub_dim, data in report_data['sub_dimensions'].items():
print(f" {sub_dim}: {data['score']} 水平{data['level']} (区均{data['district_avg']})")
# 保存报告数据
output_dir = Path(__file__).parent.parent.parent / "output" / "era2"
output_dir.mkdir(parents=True, exist_ok=True)
report_clean = clean_for_json(report_data)
json_path = output_dir / f"{test_school}_report_data.json"
with open(json_path, "w", encoding="utf-8") as f:
json.dump(report_clean, f, ensure_ascii=False, indent=2)
print(f"\n ✅ 报告数据 → {json_path}")
# ===== 保存全量得分 =====
output_dir = Path(__file__).parent.parent.parent / "output" / "era2"
output_dir.mkdir(parents=True, exist_ok=True)
all_data = {
"district_filter": args.district,
"school_count": len(data_engine.schools),
"sub_dimension_scores": clean_for_json(sub_scores.to_dict()),
"dimension_scores": clean_for_json(dim_scores.to_dict()),
"levels": clean_for_json(levels.to_dict()),
"clusters": clean_for_json(clusters),
}
scores_path = output_dir / f"all_scores_{args.district}.json"
with open(scores_path, "w", encoding="utf-8") as f:
json.dump(all_data, f, ensure_ascii=False, indent=2)
elapsed = time.time() - start_time
print(f"\n{'=' * 70}")
print(f"✅ Pipeline测试完成! 耗时 {elapsed:.1f}s")
print(f" 区域: {args.district}")
print(f" 学校数: {len(data_engine.schools)}")
print(f" 得分文件: {scores_path}")
print(f"{'=' * 70}")
if __name__ == "__main__":
main()
+212
View File
@@ -0,0 +1,212 @@
#!/usr/bin/env python3
"""
二期报告生成脚本框架
数据 → PCA赋分(SPSS对齐) → 统计 → [LLM并行生成] → HTML报告
⚠️ 默认 --no-llm 模式,不会调用 LLM(避免产生费用)
若需启用 LLM 生成,手动传 --enable-llm 参数
"""
import sys
import time
import argparse
import json
import logging
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).parent))
from data_engine_era2 import DataEngineEra2
from engines.pca_scoring_engine_era2 import PcaScoringEngineEra2
from engines.stats_engine_era2 import StatsEngineEra2 as StatsEngine
from config_era2 import DIMENSION_FRAMEWORK
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger(__name__)
def clean_for_json(obj):
if isinstance(obj, dict):
return {k: clean_for_json(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [clean_for_json(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
def progress_callback(completed, total, segment_id):
pct = completed / total * 100
bar = "" * int(pct / 5) + "" * (20 - int(pct / 5))
print(f"\r [{bar}] {pct:.0f}% ({completed}/{total}) {segment_id:<40}", end="", flush=True)
def main():
parser = argparse.ArgumentParser(description="二期报告生成")
parser.add_argument("--school", type=str, required=True,
help="学校名称(简称或全称均可)")
parser.add_argument("--district", type=str, default="长宁区",
help="区域筛选,'all'表示全部,默认'长宁区'")
parser.add_argument("--enable-llm", action="store_true",
help="启用LLM生成(会产生API调用费用!)")
parser.add_argument("--no-cache", action="store_true",
help="不使用LLM缓存")
parser.add_argument("--no-scoring-cache", action="store_true",
help="不使用赋分缓存(强制重新计算全市赋分)")
parser.add_argument("--list-schools", action="store_true",
help="列出所有可用学校")
parser.add_argument("--enable-agent", action="store_true",
help="在报告中嵌入AI对话助手(右下角浮动按钮)")
parser.add_argument("--lang", type=str, default="zh", choices=["zh", "en", "both"],
help="报告语言:zh(中文,默认)/ en(英文)/ both(同时生成两份)")
args = parser.parse_args()
start_time = time.time()
print("=" * 70)
print("📊 二期课程实施监测报告生成系统")
if not args.enable_llm:
print("⚠️ LLM 已禁用(--no-llm 模式),仅生成数据+图表")
if args.enable_agent:
print("🤖 AI 对话助手已启用")
print("=" * 70)
# ===== Step 1: 加载数据 =====
district = None if args.district == "all" else args.district
logger.info(f"[1/5] 加载二期数据... (区域: {args.district}, 全市基准)")
# 加载全市数据,按区筛选报告范围
data_engine = DataEngineEra2(district_filter=district)
data_engine.load_all()
district_schools = data_engine.schools # 本区学校列表
# 全市数据引擎(用于赋分全市学校)
if data_engine.use_city_data and district:
city_engine = DataEngineEra2(district_filter=None) # 不筛选区
city_engine.load_all()
all_schools_for_scoring = city_engine
logger.info(f" 全市基准: {len(city_engine.schools)}校, 本区: {len(district_schools)}")
else:
city_engine = None
all_schools_for_scoring = data_engine
if args.list_schools:
print(f"\n可用学校 ({len(district_schools)}所):")
for i, s in enumerate(district_schools, 1):
print(f" {i:3d}. {s}")
return
school = args.school
if school not in district_schools:
# 尝试从映射查找
from config_era2 import SCHOOL_NAME_SHORT_TO_FULL, SCHOOL_NAME_FULL_TO_SHORT
if school in SCHOOL_NAME_SHORT_TO_FULL:
pass
elif school in SCHOOL_NAME_FULL_TO_SHORT:
school = SCHOOL_NAME_FULL_TO_SHORT[school]
if school not in all_schools_for_scoring.schools:
print(f"\n❌ 学校 '{args.school}' 不在数据中。本区可用学校:")
for s in district_schools:
print(f" - {s}")
return
print(f"\n🏫 目标学校: {school}")
print(f"📍 数据范围: {args.district} ({len(district_schools)}所学校)")
if city_engine:
print(f"📊 标准化基准: 全市{len(city_engine.schools)}所学校")
# ===== Step 2: PCA赋分(SPSS对齐,全市所有学校) =====
logger.info(f"[2/5] PCA赋分计算 ({len(all_schools_for_scoring.schools)}校, SPSS对齐)...")
pca_engine = PcaScoringEngineEra2(all_schools_for_scoring)
pca_sub_scores = pca_engine.compute_all()
# ===== Step 3: 统计分析(全市基准标准化) =====
logger.info("[3/5] 全市基准标准化 + 统计分析...")
stats_engine = StatsEngine()
sub_scores = stats_engine.compute_dimension_scores_pca(pca_sub_scores)
dim_scores = stats_engine.compute_dimension_aggregates(sub_scores)
report_data = stats_engine.compute_school_report_data(
school, sub_scores, dim_scores,
district_schools=district_schools,
)
# 注入区域信息(模板需要)
report_data["district"] = args.district
report_data["total_schools_in_district"] = len(district_schools)
# 决定要生成的语言列表
langs = ["zh", "en"] if args.lang == "both" else [args.lang]
# ===== Step 4: LLM生成(按语言分别生成;缓存按 lang 隔离) =====
llm_sections_by_lang = {}
if args.enable_llm:
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "backend"))
from app.engines.llm_engine import LLMEngine
llm_engine = LLMEngine()
llm_engine.set_progress_callback(progress_callback)
for lg in langs:
logger.info(f"[4/5] LLM并行生成报告文字 (lang={lg}) ...")
llm_sections_by_lang[lg] = llm_engine.generate_report_segments(
report_data,
use_cache=not args.no_cache,
lang=lg,
)
print()
else:
logger.info("[4/5] 跳过LLM生成(--no-llm 模式)")
for lg in langs:
llm_sections_by_lang[lg] = {}
# ===== Step 5: 渲染HTML =====
from engines.report_renderer_era2 import ReportRendererEra2 as ReportRenderer
renderer = ReportRenderer()
output_dir = Path(__file__).parent.parent.parent / "output" / "era2" / args.district
output_dir.mkdir(parents=True, exist_ok=True)
output_paths = {}
for lg in langs:
logger.info(f"[5/5] 渲染HTML报告 (lang={lg}) ...")
suffix = "_report_en.html" if lg == "en" else "_报告.html"
output_path = output_dir / f"{school}{suffix}"
renderer.render_to_file(
report_data, llm_sections_by_lang[lg], output_path,
enable_agent=args.enable_agent, lang=lg,
)
output_paths[lg] = output_path
# 保存报告数据JSON(一份,与语言无关)
json_path = output_dir / f"{school}_report_data.json"
with open(json_path, "w", encoding="utf-8") as f:
json.dump(clean_for_json(report_data), f, ensure_ascii=False, indent=2)
elapsed = time.time() - start_time
print(f"\n{'=' * 70}")
print(f"✅ 报告生成完成!")
print(f" 学校: {school}")
print(f" 区域: {args.district} ({len(data_engine.schools)}所学校)")
print(f" 总体得分: {report_data['overall']['score']}分 (第{report_data['overall']['rank_in_district']}名)")
for lg in langs:
n_sec = len(llm_sections_by_lang.get(lg, {}))
print(f" LLM段落({lg}): {n_sec}{'(已禁用)' if not args.enable_llm else ''}")
print(f" AI助手: {'✅ 已嵌入' if args.enable_agent else '❌ 未启用'}")
print(f" 耗时: {elapsed:.1f}")
for lg, p in output_paths.items():
print(f" HTML({lg}): {p}")
print(f" JSON: {json_path}")
print(f"{'=' * 70}")
if __name__ == "__main__":
main()
+211
View File
@@ -0,0 +1,211 @@
#!/usr/bin/env python3
"""
二期批量报告生成脚本
数据加载/PCA赋分(SPSS对齐)/统计只做一次,LLM和渲染对每校独立执行
⚠️ 默认 --no-llm 模式,不调用 LLM
"""
import sys
import time
import argparse
import json
import logging
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).parent))
from data_engine_era2 import DataEngineEra2
from engines.pca_scoring_engine_era2 import PcaScoringEngineEra2
from engines.stats_engine_era2 import StatsEngineEra2 as StatsEngine
from config_era2 import DIMENSION_FRAMEWORK
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger(__name__)
def clean_for_json(obj):
if isinstance(obj, dict):
return {k: clean_for_json(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [clean_for_json(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
def main():
parser = argparse.ArgumentParser(description="二期批量报告生成")
parser.add_argument("--district", type=str, default="长宁区",
help="区域筛选,'all'表示全部")
parser.add_argument("--schools", nargs="*",
help="指定学校列表(默认该区全部)")
parser.add_argument("--enable-llm", action="store_true",
help="启用LLM(会产生API费用!)")
parser.add_argument("--no-cache", action="store_true",
help="不使用LLM缓存")
parser.add_argument("--enable-agent", action="store_true",
help="在报告中嵌入AI对话助手")
args = parser.parse_args()
total_start = time.time()
print("=" * 70)
print("📊 二期课程实施监测报告 — 批量生成")
if not args.enable_llm:
print("⚠️ LLM 已禁用,仅生成数据+图表报告")
if args.enable_agent:
print("🤖 AI 对话助手已启用")
print("=" * 70)
# ===== 全局步骤(只做一次) =====
district = None if args.district == "all" else args.district
logger.info(f"[全局 1/3] 加载二期数据... (区域: {args.district}, 全市基准)")
data_engine = DataEngineEra2(district_filter=district)
data_engine.load_all()
district_schools = data_engine.schools
# 全市数据引擎(用于赋分全市学校做基准)
if data_engine.use_city_data and district:
city_engine = DataEngineEra2(district_filter=None)
city_engine.load_all()
all_schools_for_scoring = city_engine
logger.info(f" 全市基准: {len(city_engine.schools)}校, 本区: {len(district_schools)}")
else:
city_engine = None
all_schools_for_scoring = data_engine
logger.info(f"[全局 2/3] PCA赋分 ({len(all_schools_for_scoring.schools)}所学校, SPSS对齐)...")
pca_engine = PcaScoringEngineEra2(all_schools_for_scoring)
pca_sub_scores = pca_engine.compute_all()
logger.info("[全局 3/3] 全市基准标准化...")
stats_engine = StatsEngine()
sub_scores = stats_engine.compute_dimension_scores_pca(pca_sub_scores)
dim_scores = stats_engine.compute_dimension_aggregates(sub_scores)
# 确定学校列表(本区)
all_schools = district_schools
if args.schools:
schools = [s for s in args.schools if s in all_schools]
skipped = [s for s in args.schools if s not in all_schools]
for s in skipped:
print(f" ⚠️ 学校 '{s}' 不在数据中,跳过")
else:
schools = all_schools
print(f"\n🏫 将为 {len(schools)} 所学校生成报告:")
for i, s in enumerate(schools, 1):
print(f" {i}. {s}")
# 初始化引擎
from engines.report_renderer_era2 import ReportRendererEra2 as ReportRenderer
renderer = ReportRenderer()
llm_engine = None
if args.enable_llm:
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "backend"))
from app.engines.llm_engine import LLMEngine
llm_engine = LLMEngine()
output_dir = Path(__file__).parent.parent.parent / "output" / "era2" / args.district
output_dir.mkdir(parents=True, exist_ok=True)
# ===== 逐校生成 =====
results = []
for idx, school in enumerate(schools, 1):
school_start = time.time()
print(f"\n{'' * 70}")
print(f"🔄 [{idx}/{len(schools)}] {school}")
try:
report_data = stats_engine.compute_school_report_data(
school, sub_scores, dim_scores,
district_schools=district_schools,
)
# 注入区域信息(模板需要)
report_data["district"] = args.district
report_data["total_schools_in_district"] = len(district_schools)
if args.enable_llm and llm_engine:
llm_sections = llm_engine.generate_report_segments(
report_data, use_cache=not args.no_cache)
else:
llm_sections = {}
# 渲染
html_path = output_dir / f"{school}_报告.html"
renderer.render_to_file(report_data, llm_sections, html_path,
enable_agent=args.enable_agent)
# 保存JSON
json_path = output_dir / f"{school}_report_data.json"
with open(json_path, "w", encoding="utf-8") as f:
json.dump(clean_for_json(report_data), f, ensure_ascii=False, indent=2)
elapsed = time.time() - school_start
score = report_data["overall"]["score"]
rank = report_data["overall"]["rank_in_district"]
total = report_data["overall"]["total_schools"]
results.append({
"school": school, "status": "",
"score": score, "rank": rank, "total": total,
"time": elapsed,
})
print(f" ✅ 得分={score} 排名={rank}/{total} 耗时={elapsed:.1f}s")
except Exception as e:
elapsed = time.time() - school_start
logger.error(f" ❌ 失败: {e}", exc_info=True)
results.append({"school": school, "status": "", "error": str(e), "time": elapsed})
# ===== 汇总 =====
total_elapsed = time.time() - total_start
print(f"\n{'=' * 70}")
print(f"📋 批量生成汇总")
print(f"{'=' * 70}")
print(f"{'学校':<15} {'状态':<4} {'得分':<8} {'排名':<10} {'耗时':<8}")
print(f"{'' * 50}")
success = 0
for r in results:
if r["status"] == "":
success += 1
print(f"{r['school']:<15} {r['status']:<4} {r['score']:<8.2f} {r['rank']}/{r['total']:<7} {r['time']:.1f}s")
else:
print(f"{r['school']:<15} {r['status']:<4} 失败: {r.get('error', '')[:30]}")
print(f"{'' * 50}")
print(f"成功: {success}/{len(schools)} | 总耗时: {total_elapsed:.1f}s")
print(f"输出目录: {output_dir}")
# 保存汇总
summary = {
"generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
"era": 2,
"district": args.district,
"total_schools": len(schools),
"success": success,
"total_time": round(total_elapsed, 1),
"llm_enabled": args.enable_llm,
"results": results,
}
with open(output_dir / "batch_summary.json", "w", encoding="utf-8") as f:
json.dump(summary, f, ensure_ascii=False, indent=2)
if __name__ == "__main__":
main()
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""
覆盖率审计脚本
检查:
1. 二期ETL输出的字段名称,赋分引擎实际用到了多少
2. 赋分引擎里按关键词匹配的字段,在二期数据中能否命中
3. 每个维度的赋分数据源覆盖情况
"""
import sys
import re
import inspect
from pathlib import Path
from collections import defaultdict
import pandas as pd
import numpy as np
sys.path.insert(0, str(Path(__file__).parent))
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "backend"))
from data_engine_era2 import DataEngineEra2
from app.engines.scoring_engine import ScoringEngine
from config_era2 import DIMENSION_FRAMEWORK, SUBJECTS
def section(title):
print(f"\n{'=' * 80}")
print(f" {title}")
print(f"{'=' * 80}")
def main():
# ===== 加载二期全量数据(不分区,看全貌) =====
section("1. 加载二期全量数据")
engine = DataEngineEra2(district_filter=None)
engine.load_all()
print(f" 学校数: {len(engine.schools)}")
# 获取全量字段名称
course_df = engine._course_impl
subject_df = engine._subject_impl
basic_df = engine._basic_info
course_fields = sorted(course_df["字段名称"].dropna().unique())
subject_fields = sorted(subject_df["字段名称"].dropna().unique())
basic_fields = sorted(basic_df["字段名称"].dropna().unique())
print(f" 课程实施表字段名称数: {len(course_fields)}")
print(f" 学科课程表字段名称数: {len(subject_fields)}")
print(f" 基础信息表字段名称数: {len(basic_fields)}")
# ===== 2. 提取赋分引擎中所有用到的字段名称和关键词 =====
section("2. 赋分引擎字段引用分析")
# 从 ScoringEngine 源码中提取所有字符串常量(字段名称/关键词)
src = inspect.getsource(ScoringEngine)
# 提取所有中文字符串(字段名称)
# 匹配双引号和单引号中的中文字符串
field_refs = set()
keyword_refs = set()
# 精确字段引用:df["字段名称"] == "xxx" 或 字段名称 == field
exact_patterns = re.findall(r'["\']([^"\']*[\u4e00-\u9fff][^"\']*)["\']', src)
for p in exact_patterns:
# 跳过注释性文字
if len(p) > 30 or '' in p or '' in p or '赋分' in p:
continue
field_refs.add(p)
# 关键词引用:str.contains("xxx")
contains_patterns = re.findall(r'\.str\.contains\(["\']([^"\']+)["\']', src)
for p in contains_patterns:
# 这些是用 | 分隔的关键词
for kw in p.split('|'):
keyword_refs.add(kw.strip())
# 其他关键词引用(in循环中的列表)
keyword_lists = re.findall(r'for (?:keyword|field|hw_type|resource) in \[([^\]]+)\]', src)
for kl in keyword_lists:
items = re.findall(r'["\']([^"\']+)["\']', kl)
for item in items:
if any('\u4e00' <= c <= '\u9fff' for c in item):
field_refs.add(item)
print(f"\n 赋分引擎中精确引用的字段名称: {len(field_refs)}")
print(f" 赋分引擎中关键词引用: {len(keyword_refs)}")
# ===== 3. 逐一检查精确字段在二期数据中的命中情况 =====
section("3. 精确字段匹配检查")
all_data_fields = set(course_fields) | set(subject_fields) | set(basic_fields)
matched = []
missing = []
for f in sorted(field_refs):
if f in all_data_fields:
matched.append(f)
else:
missing.append(f)
print(f"\n ✅ 命中: {len(matched)}/{len(field_refs)}")
print(f" ❌ 未命中: {len(missing)}/{len(field_refs)}")
if missing:
print(f"\n 未命中的字段(赋分引擎引用但二期数据中不存在):")
for f in missing:
# 尝试模糊匹配
fuzzy = [df for df in all_data_fields if f.replace('_', '') in df.replace('_', '') or df.replace('_', '') in f.replace('_', '')]
if fuzzy:
print(f"{f}")
print(f" → 可能对应: {fuzzy[:3]}")
else:
print(f"{f} (无近似匹配)")
# ===== 4. 关键词匹配检查 =====
section("4. 关键词匹配检查")
for kw in sorted(keyword_refs):
course_hits = course_df[course_df["字段名称"].str.contains(kw, na=False)]["字段名称"].unique()
subject_hits = subject_df[subject_df["字段名称"].str.contains(kw, na=False)]["字段名称"].unique()
total = len(course_hits) + len(subject_hits)
status = "" if total > 0 else ""
print(f" {status} '{kw}': 课程表{len(course_hits)}个, 学科表{len(subject_hits)}")
if total == 0:
# 看看有没有相近的
all_names = list(course_fields) + list(subject_fields)
similar = [n for n in all_names if kw[:2] in n][:3]
if similar:
print(f" → 近似: {similar}")
# ===== 5. 按维度逐一检查赋分数据覆盖 =====
section("5. 按维度检查赋分数据覆盖(抽样一所学校)")
# 取一所数据较完整的学校
test_school = engine.schools[0]
print(f" 测试学校: {test_school}")
scoring = ScoringEngine(engine)
scores = scoring._score_school(test_school)
print(f"\n {'维度':<20} {'赋分项数':>8} {'均值':>8} {'是否有效':>8}")
print(f" {'' * 50}")
for dim, vals in scores.items():
n = len(vals)
avg = np.mean(vals) if vals else 0
# 判断是否有效:是否全是默认值
is_default = (n <= 1 and abs(avg - 1.0) < 0.01) or (n <= 1 and abs(avg - 1.5) < 0.01) or (n <= 1 and abs(avg - 2.0) < 0.01) or (n <= 1 and abs(avg - 0.5) < 0.01)
status = "⚠️ 默认值" if is_default else ""
print(f" {dim:<20} {n:>8} {avg:>8.2f} {status:>8}")
# ===== 6. 多校抽样统计 =====
section("6. 多校统计:各维度赋分项数分布")
# 取前20所学校统计
sample_schools = engine.schools[:20]
dim_stats = defaultdict(list)
for school in sample_schools:
s = scoring._score_school(school)
for dim, vals in s.items():
dim_stats[dim].append(len(vals))
print(f"\n 抽样学校数: {len(sample_schools)}")
print(f"\n {'维度':<20} {'最小':>6} {'最大':>6} {'均值':>6} {'全为1':>8}")
print(f" {'' * 50}")
for dim in DIMENSION_FRAMEWORK:
for sub in DIMENSION_FRAMEWORK[dim]["sub_dimensions"]:
vals = dim_stats.get(sub, [0])
min_v = min(vals)
max_v = max(vals)
avg_v = np.mean(vals)
all_one = sum(1 for v in vals if v <= 1)
warn = "⚠️" if all_one > len(vals) * 0.5 else ""
print(f" {sub:<20} {min_v:>6} {max_v:>6} {avg_v:>6.1f} {all_one:>4}/{len(vals)} {warn}")
# ===== 7. 二期新增但赋分引擎未使用的字段 =====
section("7. 二期数据中存在但赋分引擎未引用的高频字段(Top 30)")
# 统计二期中每个字段名称出现的学校数
course_field_school_count = course_df.groupby("字段名称")["学校名称"].nunique().sort_values(ascending=False)
subject_field_school_count = subject_df.groupby("字段名称")["学校名称"].nunique().sort_values(ascending=False)
# 过滤掉已被赋分引擎引用的
unused_course = course_field_school_count[~course_field_school_count.index.isin(field_refs)]
unused_subject = subject_field_school_count[~subject_field_school_count.index.isin(field_refs)]
# 进一步过滤:去掉被关键词匹配可能命中的
def is_keyword_matched(field_name):
for kw in keyword_refs:
if kw in str(field_name):
return True
return False
unused_course_strict = unused_course[~unused_course.index.map(is_keyword_matched)]
unused_subject_strict = unused_subject[~unused_subject.index.map(is_keyword_matched)]
print(f"\n [课程实施表] 未被引用的字段 (按学校覆盖率排序, Top 20):")
for field, cnt in unused_course_strict.head(20).items():
print(f" {field}: {cnt}所学校有数据")
print(f"\n [学科课程表] 未被引用的字段 (Top 20):")
for field, cnt in unused_subject_strict.head(20).items():
print(f" {field}: {cnt}所学校有数据")
print(f"\n✅ 覆盖率审计完成!")
if __name__ == "__main__":
main()
+378
View File
@@ -0,0 +1,378 @@
#!/usr/bin/env python3
"""
深度字段审计:逐方法 × 逐字段,精确检查赋分引擎每个赋分函数的数据覆盖。
"""
import sys
from pathlib import Path
from collections import defaultdict
import pandas as pd
import numpy as np
sys.path.insert(0, str(Path(__file__).parent))
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "backend"))
from data_engine_era2 import DataEngineEra2
from config_era2 import SUBJECTS
def section(title):
print(f"\n{'=' * 80}")
print(f" {title}")
print(f"{'=' * 80}")
def check_exact_field(df, field_name, label=""):
"""检查精确字段名是否存在"""
hits = df[df["字段名称"] == field_name]
n_schools = hits["学校名称"].nunique() if len(hits) > 0 else 0
status = f"{n_schools}" if n_schools > 0 else "❌ 不存在"
return n_schools, status
def check_contains_field(df, keyword, label=""):
"""检查关键词匹配的字段"""
hits = df[df["字段名称"].str.contains(keyword, na=False)]
n_fields = hits["字段名称"].nunique() if len(hits) > 0 else 0
n_schools = hits["学校名称"].nunique() if len(hits) > 0 else 0
return n_fields, n_schools
def find_similar(all_fields, keyword, top=5):
"""模糊搜索"""
results = []
for f in all_fields:
if keyword in str(f):
results.append(f)
return results[:top]
def main():
engine = DataEngineEra2(district_filter=None)
engine.load_all()
course_df = engine._course_impl
subject_df = engine._subject_impl
all_course_fields = sorted(course_df["字段名称"].dropna().unique())
all_subject_fields = sorted(subject_df["字段名称"].dropna().unique())
all_fields = set(all_course_fields) | set(all_subject_fields)
total_schools = len(engine.schools)
print(f"总学校数: {total_schools}")
# ===== 按赋分方法逐个检查 =====
results = {} # method -> {field: (status, detail)}
def audit_method(method_name, dim_name, checks):
"""
checks: list of (source, type, field_or_keyword, description)
source: 'course' or 'subject'
type: 'exact' or 'contains'
"""
results[method_name] = {"dim": dim_name, "fields": []}
df_map = {"course": course_df, "subject": subject_df}
for source, check_type, field, desc in checks:
df = df_map[source]
if check_type == "exact":
n_schools, status = check_exact_field(df, field)
similar = []
if n_schools == 0:
similar = find_similar(all_course_fields if source == "course" else all_subject_fields, field[:4] if len(field) > 4 else field)
results[method_name]["fields"].append({
"field": field, "source": source, "type": check_type,
"desc": desc, "n_schools": n_schools, "status": status,
"similar": similar,
})
else: # contains
n_fields, n_schools = check_contains_field(df, field)
results[method_name]["fields"].append({
"field": field, "source": source, "type": check_type,
"desc": desc, "n_schools": n_schools, "n_fields": n_fields,
"status": f"{n_fields}字段/{n_schools}" if n_fields > 0 else "",
})
# ===== 1. 国家标准遵循 =====
audit_method("_score_national_standard", "国家标准遵循", [
("subject", "exact", "学科必修课周课时", "必修课周课时"),
("subject", "exact", "学科选择性必修课周课时", "选必课周课时"),
("subject", "exact", "学科类选修课周课时", "选修课周课时"),
])
# ===== 2. 课程结构建设 =====
audit_method("_score_course_structure", "课程结构建设", [
("course", "exact", "跨学科选修课门数", "跨学科选修课"),
("course", "exact", "综合主题选修课门数", "综合主题选修课"),
("course", "exact", "综合实践选修课门数", "综合实践选修课"),
("course", "exact", "三年应完成的研究性学习数量", "研究性学习数量"),
("course", "exact", "三年社会考察个数", "社会考察"),
("course", "exact", "三年志愿服务时长", "志愿服务"),
])
# ===== 3. 课程规范落实 =====
audit_method("_score_course_norms", "课程规范落实", [
("course", "contains", "建设规范文本", "规范文本"),
("course", "contains", "档案", "档案规范"),
("course", "contains", "已经建成并使用", "已建成使用"),
("course", "contains", "已经建成尚未使用", "已建成未使用"),
("course", "contains", "尚未建成", "尚未建成"),
])
# ===== 4. 教学方式变革 =====
audit_method("_score_teaching_reform", "教学方式变革", [
("subject", "contains", "认识程度", "认识程度"),
("subject", "contains", "落实程度", "落实程度"),
("subject", "contains", "体现形式", "实施方式体现形式"),
])
# ===== 5. 作业设计与管理变革 =====
audit_method("_score_homework_reform", "作业设计与管理变革", [
("subject", "exact", "实践类作业_有布置", "实践类作业"),
("subject", "exact", "表现类作业_有布置", "表现类作业"),
("subject", "exact", "跨学科作业_有布置", "跨学科作业"),
("subject", "exact", "团队合作类作业_有布置", "团队合作类作业"),
("subject", "contains", "作业属性标注", "作业属性标注"),
("subject", "exact", "回家作业时长控制_学校控制", "时长控制-学校"),
("subject", "exact", "回家作业时长控制_教研组负责", "时长控制-教研组"),
("subject", "exact", "作业批改范围_全部批改", "全部批改"),
("subject", "exact", "作业批改范围_部分练习", "部分批改"),
])
# ===== 6. 个性化辅导 =====
audit_method("_score_personalized_tutoring", "学科发展的个性化辅导", [
("subject", "exact", "个别辅导时长_每周>2h", "辅导>2h"),
("subject", "exact", "个别辅导时长_每周1~2h", "辅导1-2h"),
("subject", "exact", "个别辅导时长_每周<1h", "辅导<1h"),
("subject", "exact", "个别辅导时长_几乎无", "辅导-几乎无"),
("subject", "exact", "个别辅导实施方式_分散辅导", "分散辅导"),
("subject", "exact", "个别辅导实施方式_分组统一辅导", "分组辅导"),
("subject", "exact", "个别辅导实施方式_班级统一辅导", "班级辅导"),
])
# ===== 7. 生涯发展指导 =====
audit_method("_score_career_guidance", "学生生涯发展指导", [
("course", "exact", "生涯指导实施方式_专设课程", "专设课程"),
("course", "exact", "生涯指导实施方式_社会考察和志愿服务", "社考志愿"),
("course", "exact", "完成生涯指导的学生占比_90%+", "覆盖率90%+"),
("course", "exact", "生涯指导教师构成_本校和外聘结合", "师资-结合"),
("course", "exact", "生涯指导教师构成_本校为主", "师资-本校为主"),
("course", "exact", "生涯指导的校外资源支持程度", "校外资源"),
("course", "exact", "生涯指导的校内资源支持程度", "校内资源"),
])
# ===== 8. 培训支持 =====
audit_method("_score_training_support", "培训支持", [
("subject", "exact", "提供外校培训的教师人数", "外校培训人数"),
("subject", "exact", "区域培训指导人数", "区域培训人数"),
])
# ===== 9. 教研支持 =====
audit_method("_score_research_support", "教研支持", [
("subject", "exact", "学科教研组每学期活动次数", "教研活动次数"),
("subject", "exact", "学科教研组平均每次活动时长", "教研活动时长"),
("subject", "exact", "学科教研工作计划_有", "教研计划"),
("subject", "exact", "学科教研组校级展示_有", "校级展示"),
("subject", "exact", "学科教研组区域展示_有", "区域展示"),
("subject", "exact", "学科教研组成果发表_有", "成果发表"),
])
# ===== 10. 项目支持 =====
audit_method("_score_project_support", "项目支持", [
("subject", "exact", "学科承担的市级教改项目个数", "学科市级项目"),
("subject", "exact", "学科承担的区级教改项目个数", "学科区级项目"),
("subject", "exact", "学科承担的校级教改项目个数", "学科校级项目"),
("course", "exact", "学校负责的市级教改项目个数", "学校负责市级"),
("course", "exact", "学校参与的市级教改项目个数", "学校参与市级"),
("course", "exact", "学校负责的区级教改项目个数", "学校负责区级"),
("course", "exact", "学校参与的区级教改项目个数", "学校参与区级"),
("course", "exact", "校级教改项目个数", "校级项目"),
])
# ===== 11. 科学评价观 =====
audit_method("_score_scientific_evaluation", "科学评价观", [
("subject", "contains", "作业评价关注点", "作业评价"),
("subject", "contains", "课堂表现评价关注点", "课堂评价"),
("subject", "contains", "学科实践活动评价关注点", "实践评价"),
])
# ===== 12. 学业质量评估 =====
audit_method("_score_academic_evaluation", "学业质量评估", [
("subject", "exact", "作业评价工具_已经建成并使用", "作业工具-建成使用"),
("subject", "exact", "课堂表现评价工具_已经建成并使用", "课堂工具-建成使用"),
("subject", "exact", "作业评价工具_已经建成尚未使用", "作业工具-建成未用"),
("subject", "exact", "作业评价工具_尚未建成和使用", "作业工具-未建成"),
("subject", "exact", "学期考试分析_执行分析并存档", "考试分析-存档"),
("subject", "exact", "学期考试分析_执行分析,不要求存档", "考试分析-不存档"),
("subject", "exact", "学期考试分析_教师自己决定", "考试分析-自定"),
("subject", "exact", "表现性评价应用程度_经常", "表现性-经常"),
("subject", "exact", "表现性评价应用程度_有时", "表现性-有时"),
("subject", "exact", "表现性评价应用程度_总是", "表现性-总是"),
("subject", "exact", "表现性评价应用程度_从不", "表现性-从不"),
])
# ===== 13. 综合素质评估 =====
audit_method("_score_comprehensive_evaluation", "综合素质评估", [
("course", "exact", "综评评价体系_已经建成并使用", "综评-建成使用"),
("course", "exact", "综评评价体系_已经建成尚未使用", "综评-建成未用"),
("course", "exact", "综评评价体系_未建成", "综评-未建成"),
("course", "exact", "综评评价体系_不准备建设", "综评-不建设"),
("course", "exact", "综评信息化实现_自建平台支持", "综评IT-自建"),
("course", "exact", "综评信息化实现_借助第三方平台支持", "综评IT-三方"),
("course", "contains", "综评结果使用", "综评结果使用"),
("course", "contains", "综评应用", "综评应用"),
])
# ===== 14. 实践活动评估 =====
audit_method("_score_practice_evaluation", "实践活动评估", [
("course", "exact", "研究性学习评价工具_已经建成并使用", "研学评价-建成使用"),
("course", "exact", "研究性学习评价工具_尚未建成和使用", "研学评价-未建成"),
("course", "exact", "社会考察评价工具_已经建成并使用", "社考评价-建成使用"),
("course", "exact", "社会考察评价工具_尚未建成和使用", "社考评价-未建成"),
("subject", "exact", "学科实践活动工具_已经建成并使用", "学科实践-建成使用"),
("subject", "exact", "学科实践活动工具_已经建成尚未使用", "学科实践-建成未用"),
("subject", "exact", "学科实践活动工具_尚未建成和使用", "学科实践-未建成"),
])
# ===== 15. 区域推进 =====
audit_method("_score_regional_promotion", "区域推进", [
("course", "exact", "区域工作会参与_一月4次以上", "工作会-月4+"),
("course", "exact", "区域工作会参与_一月1次", "工作会-月1"),
("course", "exact", "区域工作会参与_二月1次", "工作会-两月1"),
("course", "exact", "区域工作会参与_三月1次", "工作会-季1"),
("course", "contains", "区域推进措施", "推进措施"),
])
# ===== 16. 环境支持 =====
audit_method("_score_environment_support", "环境支持", [
("course", "exact", "场馆供给_能满足需要", "场馆-满足"),
("course", "exact", "场馆供给_基本满足需要", "场馆-基本满足"),
("course", "exact", "场馆供给_难以满足需要", "场馆-难满足"),
("course", "exact", "专用教室供给_能满足需要", "专用教室-满足"),
("course", "exact", "专用教室供给_基本满足需要", "专用教室-基本满足"),
("course", "contains", "信息化平台功能", "信息化平台功能"),
])
# ===== 17. 资源支持 =====
audit_method("_score_resource_support", "资源支持", [
("subject", "exact", "必修课校内资源支持程度", "必修校内资源"),
("subject", "exact", "选择性必修课校内资源支持程度", "选必校内资源"),
("subject", "exact", "必修课校外资源支持程度", "必修校外资源"),
("subject", "exact", "选择性必修课校外资源支持程度", "选必校外资源"),
("subject", "exact", "学科教研组总人数", "教研组总人数"),
])
# ===== 18. 教学方式创新 =====
audit_method("_score_digital_teaching", "教学方式创新", [
("subject", "exact", "信息技术与教学融合的认识_所有人可做到", "IT融合-所有人"),
("subject", "exact", "信息技术与教学融合的认识_个别人可做到", "IT融合-个别人"),
("subject", "exact", "信息化终端使用比例_80%+", "终端-80%+"),
("subject", "exact", "信息化终端使用比例_60~79%", "终端-60~79%"),
("subject", "exact", "信息化终端使用比例_30~59%", "终端-30~59%"),
("subject", "exact", "信息化终端使用比例_30%-", "终端-30%-"),
])
# ===== 19. 评价精准化 =====
audit_method("_score_digital_evaluation", "评价精准化与个性化", [
("subject", "exact", "学业评价信息化实现_自建平台支持", "学评IT-自建"),
("subject", "exact", "学业评价信息化实现_借助第三方平台支持", "学评IT-三方"),
("subject", "exact", "学业评价信息化实现_没有平台支持", "学评IT-无"),
("course", "exact", "学校信息系统对选课支持程度", "选课系统"),
("course", "exact", "学校信息系统对排课支持程度", "排课系统"),
])
# ===== 20. 课程迭代优化 =====
audit_method("_score_digital_curriculum", "课程迭代优化", [
("course", "exact", "数据连通_有数据能互通", "数据互通"),
("course", "exact", "数据连通_有数据不互通", "数据不互通"),
("course", "exact", "管理业务的信息化应用_绝大部分", "管理IT-绝大部分"),
("course", "exact", "教学业务的信息化应用_绝大部分", "教学IT-绝大部分"),
("course", "exact", "已完成的网络课程门数", "网络课程数"),
])
# ===== 打印报告 =====
section("完整字段覆盖审计报告")
total_fields = 0
total_ok = 0
total_missing = 0
dim_summary = {}
for method, info in results.items():
dim = info["dim"]
ok = sum(1 for f in info["fields"] if f["n_schools"] > 0)
fail = sum(1 for f in info["fields"] if f["n_schools"] == 0)
total = len(info["fields"])
total_fields += total
total_ok += ok
total_missing += fail
pct = ok / total * 100 if total > 0 else 0
bar = "" * int(pct / 5) + "" * (20 - int(pct / 5))
print(f"\n{'' * 80}")
print(f" {dim} ({method})")
print(f" [{bar}] {pct:.0f}% ({ok}/{total})")
print(f"{'' * 80}")
for f in info["fields"]:
src_label = "课程表" if f["source"] == "course" else "学科表"
type_label = "精确" if f["type"] == "exact" else "关键词"
print(f" {f['status']:<16} [{src_label}/{type_label}] {f['field']}")
if f["n_schools"] == 0 and f.get("similar"):
print(f" → 近似: {f['similar'][:3]}")
dim_summary[dim] = {"ok": ok, "fail": fail, "total": total, "pct": pct}
# ===== 汇总 =====
section("维度覆盖率汇总")
print(f"\n{'维度':<20} {'覆盖':>5} {'缺失':>5} {'总数':>5} {'覆盖率':>8}")
print(f"{'' * 50}")
for dim, s in dim_summary.items():
marker = "⚠️" if s["pct"] < 60 else "" if s["pct"] >= 80 else ""
print(f"{dim:<20} {s['ok']:>5} {s['fail']:>5} {s['total']:>5} {s['pct']:>6.0f}% {marker}")
print(f"{'' * 50}")
print(f"{'合计':<20} {total_ok:>5} {total_missing:>5} {total_fields:>5} {total_ok/total_fields*100:>6.0f}%")
# ===== ETL层面检查:一期有但二期没有的字段名模式 =====
section("ETL字段名差异分析")
# 检查常见差异模式
patterns_to_check = [
("个别辅导时长", "辅导时长字段命名"),
("信息技术与教学融合", "IT融合字段"),
("信息化终端使用比例", "终端使用比例"),
("信息化平台功能", "平台功能"),
("教学业务的信息化", "教学信息化"),
("数据连通", "数据连通"),
("已完成的网络课程", "网络课程"),
("作业批改范围", "作业批改"),
("实践类作业", "实践作业"),
("表现类作业", "表现作业"),
("跨学科作业", "跨学科作业"),
("团队合作类作业", "团队合作作业"),
]
for keyword, label in patterns_to_check:
course_hits = [f for f in all_course_fields if keyword in str(f)]
subject_hits = [f for f in all_subject_fields if keyword in str(f)]
if course_hits or subject_hits:
print(f"\n'{keyword}' ({label}):")
for h in course_hits:
print(f" 课程表: {h}")
for h in subject_hits:
print(f" 学科表: {h}")
else:
# 更宽松的搜索
kw_short = keyword[:4]
c2 = [f for f in all_course_fields if kw_short in str(f)]
s2 = [f for f in all_subject_fields if kw_short in str(f)]
print(f"\n'{keyword}' ({label}): 不存在")
if c2 or s2:
print(f" 近似(课程表): {c2[:5]}")
print(f" 近似(学科表): {s2[:5]}")
print(f"\n✅ 深度审计完成!")
if __name__ == "__main__":
main()
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""
全市数据ETL:将全市3个xlsx转为parquet
输入:二期/A全市数据_基础信息表_sh_basic2.xlsx
二期/B全市数据_学校课程实施情况表_sh_sch6.xlsx
二期/C全市数据_学科课程实施情况表_sh_sub7(全)(1).xlsx
输出:data/era2/city_基础信息表.parquet
data/era2/city_课程实施情况表.parquet
data/era2/city_学科课程实施情况表.parquet
data/era2/city_school_meta.parquet (学校元数据:类型、性质等)
"""
import pandas as pd
import json
import time
import logging
from pathlib import Path
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", datefmt="%H:%M:%S")
logger = logging.getLogger(__name__)
PROJECT_ROOT = Path(__file__).parent.parent.parent # report-admin/
RAW_DIR = PROJECT_ROOT.parent / "二期"
OUTPUT_DIR = PROJECT_ROOT / "data" / "era2"
# 输入文件
FILE_A = RAW_DIR / "A全市数据_基础信息表_sh_basic2.xlsx"
FILE_B = RAW_DIR / "B全市数据_学校课程实施情况表_sh_sch6.xlsx"
FILE_C = RAW_DIR / "C全市数据_学科课程实施情况表_sh_sub7(全)(1).xlsx"
def etl_basic_info():
"""A表:基础信息表"""
logger.info("加载A表(基础信息)...")
df = pd.read_excel(FILE_A)
logger.info(f" 原始行数: {len(df)}, 学校数: {df['s_name'].nunique()}")
# 列名映射:与当前parquet格式对齐
# A表列: item_no, item_type, item_id, alternative, field_id, field_name, field_value,
# grade, sem, district, s_name, ownership, category, level, area, tese, levelb, levelc
# 当前parquet列: 字段名称, 字段值, 学校名称, 区, ...
df = df.rename(columns={
"s_name": "学校名称",
"district": "",
"field_name": "字段名称",
"field_value": "字段值",
"ownership": "学校性质",
"category": "学校类别",
"level": "学校类型_原始",
"area": "所处地区",
"tese": "学校特色",
"levelb": "学校类型",
"levelc": "学校类型编号",
"grade": "年级",
"sem": "学期",
})
out = OUTPUT_DIR / "city_基础信息表.parquet"
df.to_parquet(out, index=False)
logger.info(f"{out} ({len(df)}行, {df['学校名称'].nunique()}校)")
return df
def etl_course_impl():
"""B表:课程实施情况表"""
logger.info("加载B表(课程实施)...")
df = pd.read_excel(FILE_B)
logger.info(f" 原始行数: {len(df)}")
# 过滤表头行
df = df[df["所在区"] != "district"].copy()
logger.info(f" 过滤后行数: {len(df)}, 学校数: {df['学校简称'].nunique()}")
# 列名已与parquet一致,无需改
out = OUTPUT_DIR / "city_课程实施情况表.parquet"
df.to_parquet(out, index=False)
logger.info(f"{out} ({len(df)}行, {df['学校简称'].nunique()}校)")
return df
def etl_subject_impl():
"""C表:学科课程实施情况表"""
logger.info("加载C表(学科课程,约44MB,需要30-60秒)...")
df = pd.read_excel(FILE_C)
logger.info(f" 原始行数: {len(df)}")
# 过滤表头行
df = df[df["所在区"] != "district"].copy()
logger.info(f" 过滤后行数: {len(df)}, 学校数: {df['学校简称'].nunique()}")
out = OUTPUT_DIR / "city_学科课程实施情况表.parquet"
df.to_parquet(out, index=False)
logger.info(f"{out} ({len(df)}行, {df['学校简称'].nunique()}校)")
return df
def build_school_meta(df_b: pd.DataFrame):
"""从B表提取学校元数据(类型、性质等)"""
logger.info("构建学校元数据表...")
meta = df_b.drop_duplicates("学校简称")[
["学校简称", "所在区", "学校类别", "学校性质", "所处地区", "学校类型", "学校类型编号"]
].copy()
meta = meta.sort_values(["所在区", "学校简称"]).reset_index(drop=True)
# 清理学校类型编号中的换行符
meta["学校类型编号"] = meta["学校类型编号"].str.replace(r"\n", "", regex=True)
out = OUTPUT_DIR / "city_school_meta.parquet"
meta.to_parquet(out, index=False)
logger.info(f"{out} ({len(meta)}校)")
# 同时输出JSON便于查看
meta_json = OUTPUT_DIR / "city_school_meta.json"
meta.to_json(meta_json, orient="records", force_ascii=False, indent=2)
logger.info(f"{meta_json}")
# 统计
print(f"\n{'='*60}")
print(f"学校元数据统计")
print(f"{'='*60}")
print(f"总学校数: {len(meta)}")
print(f"区域数: {meta['所在区'].nunique()}")
print(f"\n各区学校数:")
for d in sorted(meta["所在区"].unique()):
n = len(meta[meta["所在区"] == d])
print(f" {d}: {n}")
print(f"\n学校类型分布:")
print(meta["学校类型"].value_counts().to_string())
print(f"\n学校性质分布:")
print(meta["学校性质"].value_counts().to_string())
return meta
def main():
total_start = time.time()
print("=" * 60)
print("📊 全市数据ETL — xlsx → parquet")
print("=" * 60)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
# A表
t0 = time.time()
df_a = etl_basic_info()
logger.info(f" A表耗时: {time.time()-t0:.1f}s")
# B表
t0 = time.time()
df_b = etl_course_impl()
logger.info(f" B表耗时: {time.time()-t0:.1f}s")
# C表
t0 = time.time()
df_c = etl_subject_impl()
logger.info(f" C表耗时: {time.time()-t0:.1f}s")
# 学校元数据
meta = build_school_meta(df_b)
total = time.time() - total_start
print(f"\n{'='*60}")
print(f"✅ ETL完成! 总耗时: {total:.1f}s")
print(f" A表: {len(df_a)}行 → city_基础信息表.parquet")
print(f" B表: {len(df_b)}行 → city_课程实施情况表.parquet")
print(f" C表: {len(df_c)}行 → city_学科课程实施情况表.parquet")
print(f" 元数据: {len(meta)}校 → city_school_meta.parquet/json")
print(f"{'='*60}")
if __name__ == "__main__":
main()
+103
View File
@@ -0,0 +1,103 @@
# 二期数据处理脚本
## 数据概况
| 项目 | 一期 | 二期 |
|------|------|------|
| 区域 | 仅长宁区 | **13个区** |
| 学校数 | 9所 | **176~177所** |
| 基础信息表 | 277行 | 5,557行 |
| 课程实施情况表 | 5,506行 / 128题 | 145,484行 / **103题** |
| 学科课程实施情况表 | 25,006行 / 131题 | **1,348,513行** / 87~131题 |
### 一期 vs 二期 关键差异
1. **格式不同**:一期是3张扁平Excel,二期是原始平台导出(每题一个Sheet + 15个学科独立文件)
2. **题目数量**:课程实施表少了25题(128→103),学科表按学科不同题数也不同
3. **学校名称**:一期用简称("延安中学"),二期用全称("上海市延安中学")
4. **长宁区**:二期只有8所(缺少"市三女中"),一期有9所
5. **字段ID覆盖**
- 课程实施表:共有275个,一期独有74个,二期独有62个
- 学科表字段名称:共有713个,一期独有150个,二期独有53个
## 脚本说明
### `01_etl_transform.py` — ETL转换
将二期原始数据转换为与一期相同列结构的3张扁平表(parquet格式)。
```bash
cd report-admin
python3 scripts/era2/01_etl_transform.py
```
**输入**`二期/[整理前] 二期_课程实施+教材使用情况表/` 下的17个xlsx文件
**输出**`data/era2/` 下的3个parquet文件 + etl_meta.json
- `era2_基础信息表.parquet` — 列与一期完全一致
- `era2_课程实施情况表.parquet` — 列与一期完全一致
- `era2_学科课程实施情况表.parquet` — 列与一期完全一致
- `etl_meta.json` — 元信息(学校列表、区域列表等)
> ⚠️ 学科表有135万行,超过Excel行数上限(1,048,576),因此使用parquet格式
### `02_verify_data.py` — 数据验证
对比一期和二期的列名、字段ID、学校名称等。
```bash
cd report-admin
python3 scripts/era2/02_verify_data.py
```
## 完整脚本列表
| 脚本 | 功能 | 用法 |
|------|------|------|
| `01_etl_transform.py` | 原始格式 → 扁平表 parquet | `python3 scripts/era2/01_etl_transform.py` |
| `02_verify_data.py` | 验证 ETL + 一期对比 | `python3 scripts/era2/02_verify_data.py` |
| `03_test_pipeline.py` | 完整 Pipeline 测试(不调 LLM | `python3 scripts/era2/03_test_pipeline.py --district 长宁区` |
| `04_generate_report.py` | 单校报告生成 | `python3 scripts/era2/04_generate_report.py --school 延安中学` |
| `05_batch_generate.py` | 批量报告生成 | `python3 scripts/era2/05_batch_generate.py --district 长宁区` |
| `config_era2.py` | 二期配置(路径、学校映射等) | 被其他脚本导入 |
| `data_engine_era2.py` | 二期数据引擎(兼容一期接口) | 被其他脚本导入 |
### 关键参数
- `--district 长宁区` — 只分析长宁区8所学校(默认)
- `--district all` — 分析全部13个区176所学校
- `--enable-llm` — 启用 LLM 文字生成(**会产生 API 费用**)
- `--school 延安中学` — 指定学校(支持简称)
- `--list-schools` — 列出可用学校
### 运行示例
```bash
cd report-admin
# 1. ETL(只需运行一次)
python3 scripts/era2/01_etl_transform.py
# 2. 测试 pipeline(长宁区)
python3 scripts/era2/03_test_pipeline.py --district 长宁区
# 3. 全市 pipeline
python3 scripts/era2/03_test_pipeline.py --district all
# 4. 生成延安中学报告(不调LLM)
python3 scripts/era2/04_generate_report.py --school 延安中学
# 5. 批量生成长宁区全部报告(不调LLM)
python3 scripts/era2/05_batch_generate.py --district 长宁区
# 6. 启用LLM生成(⚠️ 会产生费用)
python3 scripts/era2/04_generate_report.py --school 延安中学 --enable-llm
```
## 后续优化方向
1. **学校名称映射扩展**:目前只配了长宁区8所学校的全称→简称映射,全市176所需补充
2. **学校类型信息补充**:办学性质、学校等级、特色类型等(一期在 config 手工配了9所)
3. **题号映射精细核对**:确认二期103题与一期128题的对应关系,优化赋分覆盖率
4. **赋分规则适配**:基于字段ID的赋分规则大部分可复用,但部分新增/缺失字段需处理
5. **全市对照基准**:二期有176所学校,PCA和标准化终于有统计意义,可做真正的全市对照
+153
View File
@@ -0,0 +1,153 @@
"""
二期配置文件
学校名称映射、学校类型信息等
"""
import pandas as pd
from pathlib import Path
# ===== 路径 =====
PROJECT_ROOT = Path(__file__).parent.parent.parent # report-admin/
DATA_DIR = PROJECT_ROOT / "data" / "era2"
ERA1_DATA_DIR = PROJECT_ROOT / "data"
# --- 区级数据(原始ETL产出,用于兼容旧流程) ---
PARQUET_BASIC_INFO = DATA_DIR / "era2_基础信息表.parquet"
PARQUET_COURSE_IMPL = DATA_DIR / "era2_课程实施情况表.parquet"
PARQUET_SUBJECT_IMPL = DATA_DIR / "era2_学科课程实施情况表.parquet"
EXCEL_SCORING_RULES = ERA1_DATA_DIR / "赋分整理表.xlsx"
# --- 全市数据(08_etl_city_data.py 产出) ---
CITY_PARQUET_BASIC_INFO = DATA_DIR / "city_基础信息表.parquet"
CITY_PARQUET_COURSE_IMPL = DATA_DIR / "city_课程实施情况表.parquet"
CITY_PARQUET_SUBJECT_IMPL = DATA_DIR / "city_学科课程实施情况表.parquet"
CITY_SCHOOL_META = DATA_DIR / "city_school_meta.parquet"
# ===== PCA参数(与一期一致) =====
PCA_MEAN = 50
PCA_STD = 10
# ===== 学校全称→简称 映射(保留,兼容旧的区级数据) =====
SCHOOL_NAME_FULL_TO_SHORT = {
"上海市延安中学": "延安中学",
"上海市复旦中学": "复旦中学",
"上海市仙霞高级中学": "仙霞高中",
"上海市建青实验学校": "建青实验",
"华东政法大学附属中学": "华政附中",
"上海市民办新虹桥中学": "民办新虹桥",
"华东师范大学附属天山学校": "天山学校",
"上海市西郊学校": "西郊学校",
}
SCHOOL_NAME_SHORT_TO_FULL = {v: k for k, v in SCHOOL_NAME_FULL_TO_SHORT.items()}
# ===== 全市学校类型映射(从 city_school_meta.parquet 自动加载) =====
def _load_school_type_map() -> dict:
"""从全市元数据parquet加载266校的类型信息"""
if not CITY_SCHOOL_META.exists():
return {}
df = pd.read_parquet(CITY_SCHOOL_META)
result = {}
for _, row in df.iterrows():
result[str(row["学校简称"]).strip()] = {
"type": row.get("学校类型", ""),
"code": row.get("学校类型编号", ""),
"nature": row.get("学校性质", ""),
"category": row.get("学校类别", ""),
"area": row.get("所处地区", ""),
"district": row.get("所在区", ""),
}
return result
SCHOOL_TYPE_MAP = _load_school_type_map()
# ===== 15个学科(与一期一致) =====
SUBJECTS = [
"语文", "数学", "英语", "物理", "化学", "生物学",
"历史", "地理", "思想政治", "体育与健康",
"信息技术", "通用技术", "艺术", "音乐", "美术"
]
# ===== 七大维度体系(与一期完全一致) =====
DIMENSION_FRAMEWORK = {
"课程领导力": {
"sub_dimensions": ["国家标准遵循", "课程结构建设", "课程规范落实"],
},
"教学变革力": {
"sub_dimensions": ["教学方式变革", "作业设计与管理变革"],
},
"学生发展指导力": {
"sub_dimensions": ["学科发展的个性化辅导", "学生生涯发展指导"],
},
"教师发展支持力": {
"sub_dimensions": ["培训支持", "教研支持", "项目支持"],
},
"教育质量评估力": {
"sub_dimensions": ["科学评价观", "学业质量评估", "综合素质评估", "实践活动评估"],
},
"教育条件保障力": {
"sub_dimensions": ["区域推进", "环境支持", "资源支持"],
},
"数字化赋能力": {
"sub_dimensions": ["教学方式创新", "评价精准化与个性化", "课程迭代优化"],
},
}
# ===== 聚类数配置(对齐SPSS =====
# SPSS中学生发展指导力/教师发展支持力/教育质量评估力使用3类聚类,其余使用2类
CLUSTER_CONFIG = {
"课程领导力": 2,
"教学变革力": 2,
"学生发展指导力": 3, # SPSS: 78:122:56
"教师发展支持力": 3, # SPSS: 1:162:79
"教育条件保障力": 2,
"教育质量评估力": 3, # SPSS: 110:109:38
"数字化赋能力": 2,
"总体": 2,
}
# 水平阈值(与一期完全一致,来自赋分整理表)
LEVEL_THRESHOLDS = {
"国家标准遵循": {"level4": 57, "level3": 53.5, "level2": 43},
"课程结构建设": {"level4": 55.5, "level3": 49, "level2": 45},
"课程规范落实": {"level4": 58, "level3": 50, "level2": 44},
"教学方式变革": {"level4": 54, "level3": 49, "level2": 45},
"作业设计与管理变革": {"level4": 54, "level3": 49, "level2": 45},
"学科发展的个性化辅导": {"level4": 60, "level3": 50, "level2": 46},
"学生生涯发展指导": {"level4": 56, "level3": 50, "level2": 42},
"培训支持": {"level4": 51, "level3": 49, "level2": 47.5},
"教研支持": {"level4": 55, "level3": 50, "level2": 47},
"项目支持": {"level4": 56.5, "level3": 50, "level2": 44},
"科学评价观": {"level4": 57, "level3": 50, "level2": 43},
"学业质量评估": {"level4": 54, "level3": 46, "level2": 41},
"综合素质评估": {"level4": 58.5, "level3": 50, "level2": 39},
"实践活动评估": {"level4": 50, "level3": 46.5, "level2": 43},
"区域推进": {"level4": 59, "level3": 52, "level2": 40},
"环境支持": {"level4": 56, "level3": 49.5, "level2": 41},
"资源支持": {"level4": 57.5, "level3": 49, "level2": 42},
"教学方式创新": {"level4": 54, "level3": 45, "level2": 40},
"评价精准化与个性化": {"level4": 60, "level3": 50, "level2": 40},
"课程迭代优化": {"level4": 55, "level3": 50, "level2": 45},
}
# 水平质性描述(与一期完全一致,完整版)
LEVEL_DESCRIPTIONS = {
"国家标准遵循": {4: "所有科目必修课程开齐开足,选必和选修满足要求", 3: "考试类科目必修开齐,选必和选修满足要求", 2: "必修未能开齐开足,选必和选修有一个满足要求", 1: "必修未能开齐开足,选必和选修均不满足要求"},
"课程结构建设": {4: "学科类必修课程离差总和在30%以内,总体三类课程在150%以下,校本课程和综合实践较好", 3: "总体三类课程离差总和在250%以下,校本课程和综合实践高于平均水平", 2: "总体三类课程离差总和在300%以下,校本课程和综合实践较差", 1: "总体三类课程的离差和很高,校本课程和综合实践在末尾25%"},
"课程规范落实": {4: "都有建设规范文本和档案记录", 3: "都有建设规范文本,但过程性档案记录已经全部建成,部分有尚未使用", 2: "部分有建设规范文本,档案大部分已经建成但未使用", 1: "基本没有建设规范文本,档案尚未建成"},
"教学方式变革": {4: "所有老师有共识和研究,并能够系统设计、有效实施,至少有3种常态化落实形式", 3: "大部分老师有共识和研究,并能够落实教材中的相关要求,至少有2种常态化落实形式", 2: "个别老师有研究,并能够偶尔引导学生开展学习,至少有1种常态化落实形式", 1: "基本没有教学方法等相关研究,基本不组织相关学习,没有或仅有1种落实形式"},
"作业设计与管理变革": {4: "在每类创新性作业中至少掌握3种类型,作业时长有统一控制管理,定期批改评价,有多元化属性标注", 3: "在每类创新性作业中至少掌握2种类型,偶有时长控制管理,面批为主,有至少两种属性标注", 2: "至少掌握1种类型或擅长某1-2种创新作业,学生自己控制时长,很少反馈,有属性标注", 1: "擅长某种创新作业,学生自己控制时长,几乎不反馈,无属性标注"},
"学科发展的个性化辅导": {4: "各学科平均辅导时长每周2小时以上,教师根据学情确定内容,采取个别辅导方式", 3: "各学科平均辅导时长每周1-2小时,教师根据学情确定内容,主要个别分散辅导", 2: "各学科平均辅导时长每周1小时以内,学生提出需求后教师辅导,分组统一或分散辅导", 1: "辅导时长每周1小时以内或不辅导,学生提出需求后教师辅导,班级统一辅导"},
"学生生涯发展指导": {4: "专设生涯指导课程,三年覆盖90%+学生,本校+外聘教师队伍,校内外资源足够支持", 3: "外请讲座实施,三年覆盖70-90%学生,外聘教师队伍,校内外资源有一些支持", 2: "与社会考察/志愿服务结合实施,三年覆盖50-70%学生,外聘教师,几乎无资源支持", 1: "与社会考察/志愿服务结合实施,三年覆盖50%以下,未形成稳定队伍,几乎无资源支持"},
"培训支持": {4: "各学科教师平均外出培训人数≥2.5人", 3: "各学科教师平均外出培训人数≥1.5人", 2: "各学科教师平均外出培训人数≥1人", 1: "各学科教师几乎不进行外出培训"},
"教研支持": {4: "教研的数量和质量均较高", 3: "有教研活动,质量较好", 2: "有教研活动但质量一般", 1: "活动数量和质量均较低"},
"项目支持": {4: "各学科均有负责的校级以上项目至少一个", 3: "有部分学科负责校级以上项目至少一个", 2: "各学科没有负责的校级以上项目,部分学科有校级项目至少一个", 1: "各学科校级及校级以上的项目均没有"},
"科学评价观": {4: "在所有方面均能至少关注两项素养发展", 3: "至少有三个方面关注两项素养发展", 2: "能较多关注学生发展", 1: "较少关注学生发展"},
"学业质量评估": {4: "校本化评价工具已研制并使用,学期考试质量分析并标注属性较为全面", 3: "校本化评价工具已研制并使用,学期考试质量分析不做硬性要求", 2: "至少已研制一项校本化评价工具,学期考试质量分析不做硬性要求", 1: "未能重视学业质量评估,校本化评价工具均欠缺"},
"综合素质评估": {4: "校本化综合素质评价体系均有建设和使用,有平台支持且评价结果表达科学", 3: "校本化综合素质评价体系均有建设和使用,支持平台和评价结果使用有待提高", 2: "校本化综合素质评价方案建成,但具体评价工具有待开发", 1: "未能重视校本化综合素质评价,方案、工具和结果使用等均欠缺"},
"实践活动评估": {4: "研究性学习、社会考察和学科实践活动的校本化评价工具已研制并使用", 3: "学科实践活动的校本化评价工具已研制并使用,研究性学习/社会考察至少一项已研制但尚未使用", 2: "学科实践活动的校本化评价工具已研制", 1: "研究性学习、社会考察和学科实践活动的校本化评价工具均尚未研制和使用"},
"区域推进": {4: "召开会议平均一个月2次及以上,区域管理文件数量和措施均为最大值", 3: "召开会议平均一个月1次及以上,区域管理文件3个以上,措施达3项", 2: "召开会议、文件和措施至少有一项建设较好", 1: "召开会议、文件和措施三项均建设较差"},
"环境支持": {4: "信息化支持和硬件支持均处于较高水平", 3: "信息化支持和硬件在平均水平附近", 2: "信息化支持和硬件支持至少有一项建设较好", 1: "信息化支持和硬件支持均建设较差"},
"资源支持": {4: "校内外资源和师资水平均处于较高水平", 3: "校内外资源至少有一项供给较好,师资水平较好", 2: "校内外资源至少有一项仅略低于平均水平,师资水平略低于平均水平", 1: "校内外资源和师资水平三项均建设较差"},
"教学方式创新": {4: "信息技术与教学融合程度高,教师能常态化使用信息技术", 3: "信息技术与教学融合程度较高,教师能使用信息技术", 2: "信息技术与教学融合程度一般,学校有信息化平台建设", 1: "信息技术与教学融合程度较差,教师基本不使用信息技术"},
"评价精准化与个性化": {4: "有自建信息技术平台支持学科诊断与综合素质评价", 3: "借助第三方平台支持学科诊断与综合素质评价", 2: "有信息技术平台支持学业评价", 1: "没有信息技术平台支持评价"},
"课程迭代优化": {4: "学校对促进教学数字化转型有专项研修计划并开展相关活动,业务绝大部分使用信息化系统", 3: "学校尚未制定专项研修计划,业务绝大部分使用信息化系统", 2: "学校较少开展数字化转型活动,少数业务使用信息化系统", 1: "学校几乎不开展数字化转型活动,尚未建成信息化管理系统"},
}
+271
View File
@@ -0,0 +1,271 @@
"""
二期数据引擎
支持两种数据源:
1. 全市数据(city_*.parquet — 默认,266所学校,16个区
2. 区级数据(era2_*.parquet — 旧流程兼容,176所学校,13个区
"""
import pandas as pd
import numpy as np
from pathlib import Path
from typing import Dict, List, Optional
import logging
from config_era2 import (
PARQUET_BASIC_INFO, PARQUET_COURSE_IMPL, PARQUET_SUBJECT_IMPL,
CITY_PARQUET_BASIC_INFO, CITY_PARQUET_COURSE_IMPL, CITY_PARQUET_SUBJECT_IMPL,
SCHOOL_NAME_FULL_TO_SHORT, SUBJECTS,
)
logger = logging.getLogger(__name__)
class DataEngineEra2:
"""
二期数据引擎
默认使用全市数据(city_*.parquet),通过 district_filter 筛选特定区。
同时提供全市基准数据接口供 StatsEngine 使用。
"""
def __init__(self, district_filter: Optional[str] = None, use_city_data: bool = True):
"""
Args:
district_filter: 可选,只加载某个区的数据(如 "杨浦区"
None 表示加载全部区域
use_city_data: 是否使用全市数据(默认True)。False则用旧的区级parquet
"""
self.district_filter = district_filter
self.use_city_data = use_city_data and CITY_PARQUET_COURSE_IMPL.exists()
self._basic_info: Optional[pd.DataFrame] = None
self._course_impl: Optional[pd.DataFrame] = None
self._subject_impl: Optional[pd.DataFrame] = None
# 全市数据(不受 district_filter 限制,用于全市基准)
self._city_course_impl: Optional[pd.DataFrame] = None
self._city_subject_impl: Optional[pd.DataFrame] = None
def load_all(self) -> None:
"""加载所有数据"""
src = "全市" if self.use_city_data else "区级"
logger.info(f"开始加载二期parquet数据({src}数据源)...")
if self.use_city_data:
self._load_city_data()
else:
self._load_legacy_data()
logger.info(
f"数据加载完成: 基础信息={len(self._basic_info)}行, "
f"课程实施={len(self._course_impl)}行, "
f"学科课程={len(self._subject_impl)}行, "
f"当前区学校数={len(self.schools)}"
)
if self._city_course_impl is not None:
logger.info(f"全市基准: {self._city_course_impl['学校名称'].nunique()}")
@staticmethod
def _strip_str_columns(df: pd.DataFrame, columns: List[str]) -> pd.DataFrame:
"""对指定列做 strip(),去除空格和换行符"""
for col in columns:
if col in df.columns:
df[col] = df[col].astype(str).str.strip()
return df
def _load_city_data(self) -> None:
"""从全市parquet加载(默认路径)"""
# 全市课程实施(全量,用于基准)
df_course_all = pd.read_parquet(CITY_PARQUET_COURSE_IMPL)
df_course_all = df_course_all.rename(columns={"学校简称": "学校名称"})
df_course_all = self._strip_str_columns(df_course_all, ["学校名称", "字段名称", "所在区"])
self._city_course_impl = df_course_all
df_subject_all = pd.read_parquet(CITY_PARQUET_SUBJECT_IMPL)
df_subject_all = df_subject_all.rename(columns={"学校简称": "学校名称"})
df_subject_all = self._strip_str_columns(df_subject_all, ["学校名称", "字段名称", "所在区", "学科"])
self._city_subject_impl = df_subject_all
# 按区筛选的数据(用于赋分和报告生成)
if self.district_filter:
self._course_impl = df_course_all[df_course_all["所在区"] == self.district_filter].copy()
self._subject_impl = df_subject_all[df_subject_all["所在区"] == self.district_filter].copy()
else:
self._course_impl = df_course_all.copy()
self._subject_impl = df_subject_all.copy()
# 基础信息表
df_basic = pd.read_parquet(CITY_PARQUET_BASIC_INFO)
df_basic = self._strip_str_columns(df_basic, ["学校名称", "字段名称", ""])
if self.district_filter:
self._basic_info = df_basic[df_basic[""] == self.district_filter].copy()
else:
self._basic_info = df_basic
def _load_legacy_data(self) -> None:
"""从旧的区级parquet加载(兼容)"""
self._basic_info = pd.read_parquet(PARQUET_BASIC_INFO)
self._basic_info = self._strip_str_columns(self._basic_info, ["学校名称", "字段名称", ""])
if self.district_filter:
self._basic_info = self._basic_info[self._basic_info[""] == self.district_filter].copy()
self._basic_info = self._standardize_school_name(self._basic_info, "学校名称")
df = pd.read_parquet(PARQUET_COURSE_IMPL)
df = self._strip_str_columns(df, ["学校简称", "字段名称", "所在区"])
if self.district_filter:
df = df[df["所在区"] == self.district_filter].copy()
df = self._standardize_school_name(df, "学校简称")
df = df.rename(columns={"学校简称": "学校名称"})
self._course_impl = df
df = pd.read_parquet(PARQUET_SUBJECT_IMPL)
df = self._strip_str_columns(df, ["学校简称", "字段名称", "所在区", "学科"])
if self.district_filter:
df = df[df["所在区"] == self.district_filter].copy()
df = self._standardize_school_name(df, "学校简称")
df = df.rename(columns={"学校简称": "学校名称"})
self._subject_impl = df
def _standardize_school_name(self, df: pd.DataFrame, col: str) -> pd.DataFrame:
"""将学校全称映射为简称(旧数据兼容用)"""
df[col] = df[col].map(lambda x: SCHOOL_NAME_FULL_TO_SHORT.get(x, x))
return df
@property
def city_schools(self) -> List[str]:
"""获取全市学校列表(用于全市基准计算)"""
if self._city_course_impl is not None:
return sorted(self._city_course_impl["学校名称"].unique().tolist())
return self.schools
@property
def city_course_data(self) -> Optional[pd.DataFrame]:
"""全市课程实施数据(不受district_filter限制)"""
return self._city_course_impl
@property
def city_subject_data(self) -> Optional[pd.DataFrame]:
"""全市学科课程数据(不受district_filter限制)"""
return self._city_subject_impl
# ===== 与一期 DataEngine 完全一致的接口 =====
@property
def schools(self) -> List[str]:
"""获取所有学校名称列表"""
if self._course_impl is None:
self.load_all()
return sorted(self._course_impl["学校名称"].unique().tolist())
def get_school_basic_info(self, school: str) -> Dict:
"""获取学校基本信息"""
if self._basic_info is None:
self.load_all()
df = self._basic_info[self._basic_info["学校名称"] == school]
def _get_field(field_name):
rows = df[df["字段名称"] == field_name]
if len(rows) > 0:
return rows.iloc[0]["字段值"]
return None
return {
"school_name": school,
"建校年份": _get_field("建校年份"),
"占地面积": _get_field("占地面积"),
"建筑面积": _get_field("建筑面积"),
}
def get_school_course_data(self, school: str) -> pd.DataFrame:
"""获取某学校的课程实施情况数据"""
if self._course_impl is None:
self.load_all()
return self._course_impl[self._course_impl["学校名称"] == school].copy()
def get_school_subject_data(self, school: str, subject: Optional[str] = None) -> pd.DataFrame:
"""获取某学校的学科课程实施数据"""
if self._subject_impl is None:
self.load_all()
df = self._subject_impl[self._subject_impl["学校名称"] == school].copy()
if subject:
df = df[df["学科"] == subject]
return df
# ===== 课程领导力相关数据提取(与一期一致) =====
def get_weekly_hours(self, school: str) -> pd.DataFrame:
"""获取学校各学科各年级各学期的周课时数据"""
df = self.get_school_course_data(school)
hours_fields = ["学科必修课周课时", "学科选择性必修课周课时", "学科类选修课周课时"]
result = df[df["字段名称"].isin(hours_fields)].copy()
result["字段取值"] = pd.to_numeric(result["字段取值"], errors="coerce")
return result
def get_course_norms(self, school: str) -> List[Dict]:
"""获取学校课程规范落实相关数据"""
df = self.get_school_course_data(school)
norm_keywords = ["建设规范文本", "档案", "已经建成并使用", "尚未建成"]
mask = df["字段名称"].apply(
lambda x: any(k in str(x) for k in norm_keywords) if pd.notna(x) else False
)
return df[mask][["字段名称", "字段取值"]].to_dict("records")
# ===== 教学变革力相关(与一期一致) =====
def get_teaching_reform_data(self, school: str) -> pd.DataFrame:
df = self.get_school_subject_data(school)
reform_keywords = [
"认识程度", "落实程度", "认识", "落实",
"理解式学习", "自主性学习", "实践性学习", "跨学科学习",
"信息技术与教学融合", "信息融入教学",
]
mask = df["字段名称"].apply(
lambda x: any(k in str(x) for k in reform_keywords) if pd.notna(x) else False
)
return df[mask]
def get_homework_data(self, school: str) -> pd.DataFrame:
df = self.get_school_subject_data(school)
hw_keywords = [
"作业", "实践类", "表现类", "跨学科", "团队合作",
"批改", "评价", "属性标注", "时长控制",
]
mask = df["字段名称"].apply(
lambda x: any(k in str(x) for k in hw_keywords) if pd.notna(x) else False
)
return df[mask]
# ===== 通用方法 =====
def get_field_value(self, school: str, source: str, field_name: str,
subject: Optional[str] = None) -> Optional[str]:
if source == "basic":
df = self._basic_info[self._basic_info["学校名称"] == school]
col = "字段名称"
val_col = "字段值"
elif source == "course":
df = self.get_school_course_data(school)
col = "字段名称"
val_col = "字段取值"
elif source == "subject":
df = self.get_school_subject_data(school, subject)
col = "字段名称"
val_col = "字段取值"
else:
return None
rows = df[df[col] == field_name]
if len(rows) > 0:
return rows.iloc[0][val_col]
return None
def summary(self) -> Dict:
if self._basic_info is None:
self.load_all()
return {
"era": 2,
"district_filter": self.district_filter,
"schools": self.schools,
"school_count": len(self.schools),
"basic_info_rows": len(self._basic_info),
"course_impl_rows": len(self._course_impl),
"subject_impl_rows": len(self._subject_impl),
"subjects": SUBJECTS,
"districts": sorted(self._course_impl["所在区"].unique().tolist()) if "所在区" in self._course_impl.columns else [],
}
+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)
+199
View File
@@ -0,0 +1,199 @@
# 二期(Era2)全市数据升级 — 关键点说明
> 更新日期:2026-03-18
>
> 本文档用于向领导解释:为什么需要接入全市数据、接入后能带来什么价值、技术上需要注意哪些关键点。
---
## 一、背景:当前方案的局限
二期报告生成系统目前使用的数据来自**原始平台导出**(17个Excel分学科文件,经ETL合并),覆盖 **13个区、176所学校**。但存在三个核心局限:
### 1. "区均值恒等于50分"问题
当前系统对每个区**独立做标准化**(PCA + Z-score → 均值50,标准差10),导致:
- 任何一个区的"区均值"永远精确等于**50.00分**
- 校长看到的"高于区均值X分"只能反映**区内相对位置**,无法回答"我们区在全市什么水平?"
- 强区和弱区的50分含金量完全不同,但报告无法体现
### 2. 缺失学校类型信息
原始导出数据中**没有**学校类型字段(市实验性示范性高中、区实验性示范性高中、公办普通高中、民办高中、特色高中等),导致:
- 报告中"同类学校均值"被迫等于区均值,丧失了对标参考价值
- LLM分析文字无法给出针对性的定位建议(如"作为市实验性示范性高中,应在全市发挥引领作用")
- 缺少A/B/C/D/T类学校的分类对比
### 3. 缺失3个区的数据
原始导出覆盖13个区、176所学校,缺少**嘉定区(11校)、普陀区(13校)、浦东新区(57校)**的数据,合计缺失约81所学校。
---
## 二、全市数据带来的价值
邱老师提供的全市数据(3个Excel文件)覆盖 **16个区、266所学校**,与当前数据相比:
### 价值1:全市统一基准线 ⭐⭐⭐
用全市266所学校做标准化基准后:
| 对比项 | 当前方案 | 升级后 |
|--------|----------|--------|
| 标准化基准 | 区内自己 → 区均值恒=50 | 全市266校 → 区均值有差异 |
| 杨浦区均值 | 50.00(数学定义) | 可能 52-55(反映杨浦在全市偏上) |
| 报告价值 | 只能看区内排名 | 能看到全市坐标、区域定位 |
| 校长体感 | "高于均值8分"——均值是什么? | "高于全市均值8分"——清晰有力 |
### 价值2:学校类型对标 ⭐⭐⭐
全市数据自带每所学校的完整元数据:
| 字段 | 示例 | 报告中的用途 |
|------|------|-------------|
| 学校类型 | 市实验性示范性高中 | "同类学校均值"计算、LLM定位建议 |
| 学校类型编号 | A类/B类/C类/D类/T类 | 五类学校分组对比 |
| 学校性质 | 公办/民办 | 办学性质维度分析 |
| 所处地区 | 城区/城市郊区 | 城郊差异分析 |
| 学校特色 | 科技类/艺术类/人文类 | 特色学校的个性化建议 |
**升级后的报告示例**
> 贵校课程实施总体得分58.75分,高于**杨浦区均值(53.2分)** 5.55分,高于**全市同类学校(市实验性示范性高中,74所)均值(54.8分)** 3.95分,在杨浦区14所学校中排名第1位。
### 价值3:覆盖完整16区 ⭐⭐
| 对比项 | 当前方案 | 升级后 |
|--------|----------|--------|
| 覆盖区域 | 13个区 | **16个区**+嘉定、普陀、浦东新区) |
| 学校数量 | 176所 | **266所** |
| 浦东新区 | ❌ 缺失 | ✅ 57所(最大区) |
### 价值4:恢复被削弱的维度 ⭐⭐
全市数据比当前parquet **多出102个字段**,其中包括之前因原始导出格式问题被标记为"二期删除"的关键字段:
| 恢复的字段 | 归属维度 | 影响 |
|-----------|---------|------|
| `信息化平台功能_*`(18个) | 环境支持 | 该子维度从"降级评分"恢复为完整评分 |
| `管理业务的信息化应用_*`(2个) | 课程迭代优化 | 该子维度从仅1个评分项恢复到3+个 |
| `教学业务的信息化应用_*`(4个) | 课程迭代优化 | 同上 |
| `已完成的网络课程门数`(1个) | 课程迭代优化 | 同上 |
| 德育、劳动周、选课走班等(约75个) | 多个维度 | 增加评分区分度 |
> ⚠️ 唯一仍缺失的字段:`信息技术与教学融合的认识_*`(教学方式创新维度),已用`教学信息化应用程度_*`替代,不受影响。
---
## 三、技术实施方案
### 架构决策:在现有era2代码上扩展,不复制新分支
**原因**:赋分引擎、渲染引擎、模板、LLM引擎完全复用,只改数据加载层和统计基准层。复制会导致两套几乎相同的代码,后续维护成本翻倍。
### 改动范围
| 文件 | 改动内容 | 影响 |
|------|---------|------|
| `08_etl_city_data.py` | **新建** — 将全市3个xlsx转为parquet | 一次性ETL |
| `config_era2.py` | 新增全市数据路径、自动构建`SCHOOL_TYPE_MAP`266校) | 配置扩展 |
| `data_engine_era2.py` | 支持加载全市parquet;提供全市基准数据接口 | 接口扩展 |
| `stats_engine_era2.py` | 标准化基准改为全市;同类学校均值从全市同类型算 | 核心逻辑变化 |
| `04_generate_report.py` | 新增`--city-baseline`参数(默认开启) | 参数扩展 |
| `05_batch_generate.py` | 同上 | 参数扩展 |
赋分引擎、渲染引擎、LLM引擎、模板 → **零改动**
---
## 四、需要注意的关键点
### 关键点1:学校名称映射
全市数据使用**简称**(如"同济一附"),当前系统使用**全称**(如"同济大学第一附属中学")。
| 来源 | 杨浦区示例 |
|------|-----------|
| 全市数据 | 同济一附、复旦附中、交大附中、控江中学 |
| 当前系统 | 同济大学第一附属中学、复旦大学附属中学… |
**处理方案**
- 统一使用全市数据的**简称**作为系统内部标识
- 报告标题使用全称(从A表或映射表获取),正文用简称
- 需构建266所学校的 简称↔全称 映射表
### 关键点2:学校数量差异
| 数据源 | 学校数 | 说明 |
|--------|:------:|------|
| A表(基础信息) | 270 | 含4所无课程数据的学校 |
| B表(课程实施) | 266 | 以此为准 |
| C表(学科课程) | 待确认 | 理论上应与B表一致 |
| 当前parquet | 176 | 原始导出不完整 |
差异原因:当前parquet来自原始平台导出(17个分学科文件),部分区的数据未被导出。全市数据是整合后的完整版。
> **建议**:以全市B表的266所学校为准。A表多出的4所(宝山世外、协和高中、金瑞学校、青浦协和)无课程数据,不参与赋分。
### 关键点3:字段差异处理
| 类别 | 数量 | 说明 |
|------|:----:|------|
| 全市与parquet共有字段 | 331 | 赋分引擎已适配 |
| 全市多出的字段 | 102 | 其中约25个可提升赋分精度,其余为新增题目 |
| parquet独有的字段 | 5 | 其中`nan`为脏数据,其余4个为极少数学校的特殊选项 |
**原则**:赋分引擎基于字段名称匹配,多出的字段不会报错(被忽略),但可以主动利用恢复的字段提升评分精度。
### 关键点4B表中的表头行污染
全市B表的`所在区`列中混有一行值为`district`的表头行,加载时需过滤:
```python
df = df[df['所在区'] != 'district']
```
### 关键点5:标准化基准变化的影响
使用全市基准后,**所有已生成的报告得分都会变化**:
| 影响 | 说明 |
|------|------|
| 分数值变化 | 同一学校的得分可能从58.75→55.2或→62.3(取决于全市分布) |
| 排名逻辑不变 | 区内排名仍按区内学校比较 |
| 水平划分 | 阈值不变(仍用赋分整理表中的固定阈值),但学校可能跨水平 |
| LLM缓存 | 所有旧缓存失效,需重新生成(约50秒/校) |
> **建议**:全市基准上线后,需一次性重新生成所有已交付区域的报告。
### 关键点6C表文件较大
`C全市数据_学科课程实施情况表_sh_sub7(全)(1).xlsx` 约44MB,包含全市15个学科×266校的数据。ETL转parquet后约10-15MB,后续读取速度不受影响。
---
## 五、实施步骤(预估)
| 步骤 | 工作量 | 说明 |
|------|--------|------|
| 1. 全市数据ETLxlsx→parquet) | 30分钟 | 新建脚本,处理名称映射和表头清洗 |
| 2. 构建全量SCHOOL_TYPE_MAP | 15分钟 | 从A/B表提取266校元数据 |
| 3. 改造data_engine支持全市数据 | 30分钟 | 加载全市parquet,提供基准接口 |
| 4. 改造stats_engine标准化逻辑 | 45分钟 | 全市基准 + 同类学校均值 |
| 5. 恢复C类削弱字段的赋分 | 30分钟 | 环境支持、课程迭代优化维度 |
| 6. 测试验证 | 30分钟 | 对比前后得分变化,确认逻辑正确 |
| 7. 重新生成已交付区域报告 | ~60分钟 | 杨浦14校+宝山19校+长宁9校 |
**合计:约 3-4 小时**
---
## 六、预期效果对比
### 报告第一段(Before
> 贵校课程实施总体得分为58.75分,高于**长宁区**均值(**50.00分**)8.75分,同时高于同类学校均值(**50.00分**)8.75分…
### 报告第一段(After
> 贵校课程实施总体得分为XX.XX分,高于**杨浦区**均值(**XX.XX分**)X.XX分,高于**全市同类学校(市实验性示范性高中,74所)** 均值(**XX.XX分**)X.XX分。在杨浦区14所学校中排名第1位,在全市同类学校中排名第X位…
信息密度和说服力显著提升。
+31
View File
@@ -0,0 +1,31 @@
#!/bin/bash
# 批量生成7所学校报告
cd "$(dirname "$0")"
schools=(
"同济一附:杨浦区"
"上师二附:金山区"
"嘉一实高:嘉定区"
"上师闵分:闵行区"
"上师大附中:浦东新区"
"交大附中:杨浦区"
"市西中学:静安区"
)
echo "=============================="
echo "批量生成 ${#schools[@]} 所学校报告"
echo "=============================="
for item in "${schools[@]}"; do
school="${item%%:*}"
district="${item##*:}"
echo ""
echo ">>> 开始生成: ${school} (${district})"
python3 04_generate_report.py --school "$school" --district "$district" --enable-llm 2>&1 | grep -E "✅|❌|总体得分|LLM段落|耗时|生成完成|ERROR|失败"
echo "<<< 完成: ${school}"
done
echo ""
echo "=============================="
echo "全部完成!"
echo "=============================="
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env python3
"""
报告本地服务器 — 自带 CORS 代理
功能:
1. 以 HTTP 方式提供 output/era2/ 下的报告 HTML(解决 file:// CORS 问题)
2. /proxy/chat/completions → 转发到真实 LLM API(支持流式 SSE)
用法:
python3 serve_report.py # 默认端口 9380
python3 serve_report.py --port 8080 # 指定端口
然后浏览器打开 http://localhost:9380/复旦大学附属中学_报告.html
"""
import argparse
import http.server
import json
import sys
import threading
import urllib.request
import urllib.error
import webbrowser
from pathlib import Path
from functools import partial
# 自动定位 output/era2 目录
SCRIPT_DIR = Path(__file__).parent
OUTPUT_DIR = SCRIPT_DIR.parent.parent / "output" / "era2"
# LLM API 配置 — 统一从 backend/app/config.py 读取(唯一真相源)
sys.path.insert(0, str(SCRIPT_DIR))
from config_era2 import * # noqa: F401,F403 era2 本地配置(路径/学校映射等)
_BACKEND_PATH = str(SCRIPT_DIR.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 # noqa: E402
class ReportHandler(http.server.SimpleHTTPRequestHandler):
"""扩展 SimpleHTTPRequestHandler,增加 CORS 代理路由"""
def __init__(self, *args, llm_base_url=None, llm_api_key=None, **kwargs):
self.llm_base_url = llm_base_url or LLM_BASE_URL
self.llm_api_key = llm_api_key or LLM_API_KEY
super().__init__(*args, **kwargs)
def end_headers(self):
"""所有响应都加 CORS 头"""
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
super().end_headers()
def do_OPTIONS(self):
"""处理 CORS 预检请求"""
self.send_response(204)
self.end_headers()
def do_POST(self):
"""代理 POST 请求到 LLM API"""
if self.path == "/proxy/chat/completions":
self._proxy_chat()
else:
self.send_error(404, "Not Found")
def _proxy_chat(self):
"""转发聊天请求到 LLM API,支持流式 SSE"""
try:
# 读取请求体
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length)
# 构造转发请求
api_url = self.llm_base_url.rstrip("/") + "/chat/completions"
req = urllib.request.Request(
api_url,
data=body,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {self.llm_api_key}",
},
method="POST",
)
# 检查是否为流式请求
try:
req_json = json.loads(body)
is_stream = req_json.get("stream", False)
except (json.JSONDecodeError, UnicodeDecodeError):
is_stream = False
# 转发请求
resp = urllib.request.urlopen(req, timeout=120)
# 发送响应头
self.send_response(resp.status)
# 传递关键响应头
for header in ["Content-Type"]:
val = resp.getheader(header)
if val:
self.send_header(header, val)
if is_stream:
self.send_header("Cache-Control", "no-cache")
self.send_header("X-Accel-Buffering", "no")
self.end_headers()
# 流式转发
if is_stream:
while True:
chunk = resp.read(1024)
if not chunk:
break
self.wfile.write(chunk)
self.wfile.flush()
else:
self.wfile.write(resp.read())
except urllib.error.HTTPError as e:
error_body = e.read().decode("utf-8", errors="replace")
self.send_response(e.code)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({
"error": {"message": f"LLM API error: {e.code}", "detail": error_body}
}).encode())
except Exception as e:
self.send_response(502)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({
"error": {"message": f"Proxy error: {str(e)}"}
}).encode())
def log_message(self, format, *args):
"""美化日志"""
msg = format % args
if "/proxy/" in msg:
sys.stderr.write(f" 🔄 PROXY {msg}\n")
elif ".html" in msg:
sys.stderr.write(f" 📄 {msg}\n")
# 静默其他请求(JS/CSS/图片等)
def main():
parser = argparse.ArgumentParser(description="报告本地服务器(带CORS代理)")
parser.add_argument("--port", type=int, default=9380, help="端口号(默认9380")
parser.add_argument("--no-open", action="store_true", help="不自动打开浏览器")
parser.add_argument("--dir", type=str, default=str(OUTPUT_DIR),
help=f"报告目录(默认 {OUTPUT_DIR}")
args = parser.parse_args()
serve_dir = Path(args.dir)
if not serve_dir.exists():
print(f"❌ 目录不存在: {serve_dir}")
sys.exit(1)
# 列出可用报告
reports = sorted(serve_dir.glob("*_报告.html"))
print(f"{'=' * 60}")
print(f"🌐 报告本地服务器")
print(f"{'=' * 60}")
print(f" 目录: {serve_dir}")
print(f" 地址: http://localhost:{args.port}")
print(f" 代理: /proxy/chat/completions → {LLM_BASE_URL}")
print(f" 报告: {len(reports)}")
for r in reports:
url = f"http://localhost:{args.port}/{r.name}"
print(f" 📊 {url}")
print(f"{'=' * 60}")
print(f" 按 Ctrl+C 停止\n")
# 创建 handler,绑定到报告目录
handler = partial(
ReportHandler,
directory=str(serve_dir),
llm_base_url=LLM_BASE_URL,
llm_api_key=LLM_API_KEY,
)
server = http.server.HTTPServer(("0.0.0.0", args.port), handler)
# 自动打开第一份报告
if not args.no_open and reports:
url = f"http://localhost:{args.port}/{reports[0].name}"
threading.Timer(0.5, lambda: webbrowser.open(url)).start()
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n\n👋 服务器已停止")
server.server_close()
if __name__ == "__main__":
main()