Initial commit

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-07-13 15:38:41 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
commit 71db82393a
180 changed files with 170640 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+872
View File
@@ -0,0 +1,872 @@
"""
Era2 API 路由: 全市266校报告生成系统
区选择 → 学校选择 → 报告生成 → 历史查看 → LLM助理
"""
import asyncio
import json
import logging
import time
from pathlib import Path
from typing import Optional
import numpy as np
from fastapi import APIRouter, HTTPException, BackgroundTasks
from fastapi.responses import HTMLResponse, StreamingResponse, FileResponse
from pydantic import BaseModel
from .era2_state import era2_state, OUTPUT_DIR
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/era2")
# ==================== Models ====================
class GenerateRequest(BaseModel):
school: str
district: str
use_cache: bool = True
skip_llm: bool = False
enable_agent: bool = False # AI助理已统一由管理平台前端ChatFab+后端/era2/chat提供,静态HTML不再内嵌
lang: str = "zh" # "zh" | "en" | "both"
class BatchGenerateRequest(BaseModel):
district: str
schools: Optional[list[str]] = None
use_cache: bool = True
skip_llm: bool = False
enable_agent: bool = False # AI助理已统一由管理平台前端ChatFab+后端/era2/chat提供,静态HTML不再内嵌
lang: str = "zh" # "zh" | "en" | "both"
class ChatRequest(BaseModel):
message: str
school: Optional[str] = None
district: Optional[str] = None
history: list[dict] = []
lang: str = "zh" # "zh" | "en"
# ==================== Helpers ====================
def _report_filename(school: str, lang: str) -> str:
"""根据 lang 返回报告 HTML 文件名(与 04_generate_report.py 一致)"""
if lang == "en":
return f"{school}_report_en.html"
return f"{school}_报告.html"
def _normalize_langs(lang: str) -> list[str]:
"""'zh' / 'en' / 'both' 标准化成 ['zh'] / ['en'] / ['zh','en']"""
if lang == "both":
return ["zh", "en"]
if lang == "en":
return ["en"]
return ["zh"]
# ==================== 区和学校 ====================
@router.get("/districts")
async def list_districts():
"""获取所有区的摘要"""
return {
"districts": era2_state.get_districts_summary(),
"total": len(era2_state.districts),
}
@router.get("/districts/{district}/schools")
async def list_schools_in_district(district: str):
"""获取某区的学校列表"""
if district not in era2_state.districts:
raise HTTPException(404, f"'{district}' 不存在")
schools = era2_state.get_schools_in_district(district)
return {
"district": district,
"schools": schools,
"total": len(schools),
}
# ==================== 报告生成 ====================
_generation_tasks = {}
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):
if np.isnan(obj) or np.isinf(obj):
return None
return round(obj, 4)
return obj
@router.post("/reports/generate")
async def generate_report(req: GenerateRequest, background_tasks: BackgroundTasks):
"""异步生成单校报告"""
school = req.school
district = req.district
if district not in era2_state.districts:
raise HTTPException(404, f"'{district}' 不存在")
district_schools = era2_state.district_schools.get(district, [])
if school not in district_schools:
raise HTTPException(404, f"学校 '{school}' 不在 {district}")
langs = _normalize_langs(req.lang)
task_id = f"era2_{school}_{int(time.time())}"
_generation_tasks[task_id] = {
"status": "pending",
"school": school,
"district": district,
"lang": req.lang,
"langs": langs,
"progress": 0,
"total": 29 * len(langs),
"current_segment": "",
"current_lang": langs[0] if langs else "zh",
"started_at": time.time(),
}
background_tasks.add_task(
_do_generate, task_id, school, district,
req.use_cache, req.skip_llm, req.enable_agent, langs
)
return {
"task_id": task_id, "school": school, "district": district,
"status": "started", "lang": req.lang, "langs": langs,
}
def _do_generate(task_id: str, school: str, district: str,
use_cache: bool, skip_llm: bool, enable_agent: bool,
langs: list[str]):
"""后台执行era2报告生成(支持中/英/双语)"""
import sys
ERA2_SCRIPTS = Path(__file__).parent.parent.parent.parent / "scripts" / "era2"
sys.path.insert(0, str(ERA2_SCRIPTS))
status = _generation_tasks[task_id]
status["status"] = "running"
try:
start = time.time()
# 1. 获取报告数据(与语言无关)
report_data = era2_state.get_report_data(school, district)
from engines.report_renderer_era2 import ReportRendererEra2
renderer = ReportRendererEra2()
output_dir = OUTPUT_DIR / district
output_dir.mkdir(parents=True, exist_ok=True)
# 单语段数(用于多语言进度合并)
per_lang_total = 29
outputs = {}
# 2. 按语言依次生成
for lang_idx, lang in enumerate(langs):
status["current_lang"] = lang
if skip_llm:
llm_sections = {}
# 进度推到该语言段末尾
status["progress"] = (lang_idx + 1) * per_lang_total
status["total"] = len(langs) * per_lang_total
else:
from app.engines.llm_engine import LLMEngine
llm_engine = LLMEngine()
def progress_cb(completed, total, segment_id, _lang_idx=lang_idx, _lang=lang):
# 累计进度 = 之前语言已完成段数 + 当前段数
status["progress"] = _lang_idx * total + completed
status["total"] = len(langs) * total
status["current_segment"] = segment_id
status["current_lang"] = _lang
llm_engine.set_progress_callback(progress_cb)
llm_sections = llm_engine.generate_report_segments(
report_data, use_cache=use_cache, lang=lang,
)
# 3. 渲染HTML(按语言区分文件名)
output_path = output_dir / _report_filename(school, lang)
renderer.render_to_file(report_data, llm_sections, output_path,
enable_agent=enable_agent, lang=lang)
outputs[lang] = str(output_path)
# 4. 保存 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
status["status"] = "completed"
status["elapsed"] = round(elapsed, 1)
status["score"] = report_data["overall"]["score"]
status["rank"] = report_data["overall"]["rank_in_district"]
status["outputs"] = outputs
logger.info(f"✅ [Era2] {district}/{school} 报告生成完成 ({','.join(langs)}), {elapsed:.1f}s")
except Exception as e:
status["status"] = "failed"
status["error"] = str(e)
logger.error(f"❌ [Era2] {district}/{school} 报告生成失败: {e}", exc_info=True)
@router.get("/reports/generate/{task_id}/status")
async def get_task_status(task_id: str):
"""查询生成状态"""
if task_id not in _generation_tasks:
raise HTTPException(404, f"任务 '{task_id}' 不存在")
return _generation_tasks[task_id]
@router.get("/reports/generate/{task_id}/stream")
async def stream_task_progress(task_id: str):
"""SSE 实时进度"""
if task_id not in _generation_tasks:
raise HTTPException(404, f"任务 '{task_id}' 不存在")
async def event_generator():
while True:
status = _generation_tasks.get(task_id, {})
data = json.dumps(status, ensure_ascii=False, default=str)
yield f"data: {data}\n\n"
if status.get("status") in ("completed", "failed"):
break
await asyncio.sleep(0.5)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# ==================== 批量生成 ====================
_batch_tasks = {}
@router.post("/reports/batch")
async def batch_generate(req: BatchGenerateRequest, background_tasks: BackgroundTasks):
"""批量生成某区报告"""
district = req.district
if district not in era2_state.districts:
raise HTTPException(404, f"'{district}' 不存在")
all_in_d = era2_state.district_schools.get(district, [])
schools = req.schools or all_in_d
invalid = [s for s in schools if s not in all_in_d]
if invalid:
raise HTTPException(400, f"无效学校: {invalid}")
langs = _normalize_langs(req.lang)
batch_id = f"era2_batch_{int(time.time())}"
_batch_tasks[batch_id] = {
"status": "pending",
"district": district,
"schools": schools,
"lang": req.lang,
"langs": langs,
"total": len(schools),
"completed": 0,
"current_school": None,
"current_lang": langs[0] if langs else "zh",
"results": [],
"started_at": time.time(),
}
background_tasks.add_task(
_do_batch, batch_id, district, schools,
req.use_cache, req.skip_llm, req.enable_agent, langs
)
return {
"batch_id": batch_id, "district": district,
"total": len(schools), "lang": req.lang, "langs": langs,
}
def _do_batch(batch_id: str, district: str, schools: list,
use_cache: bool, skip_llm: bool, enable_agent: bool,
langs: list[str]):
"""后台批量生成(支持中/英/双语)"""
import sys
ERA2_SCRIPTS = Path(__file__).parent.parent.parent.parent / "scripts" / "era2"
sys.path.insert(0, str(ERA2_SCRIPTS))
status = _batch_tasks[batch_id]
status["status"] = "running"
llm_engine = None
if not skip_llm:
from app.engines.llm_engine import LLMEngine
llm_engine = LLMEngine()
from engines.report_renderer_era2 import ReportRendererEra2
renderer = ReportRendererEra2()
output_dir = OUTPUT_DIR / district
output_dir.mkdir(parents=True, exist_ok=True)
for idx, school in enumerate(schools):
school_start = time.time()
status["current_school"] = school
try:
report_data = era2_state.get_report_data(school, district)
outputs = {}
for lang in langs:
status["current_lang"] = lang
if skip_llm or llm_engine is None:
llm_sections = {}
else:
llm_sections = llm_engine.generate_report_segments(
report_data, use_cache=use_cache, lang=lang,
)
output_path = output_dir / _report_filename(school, lang)
renderer.render_to_file(report_data, llm_sections, output_path,
enable_agent=enable_agent, lang=lang)
outputs[lang] = str(output_path)
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
status["results"].append({
"school": school,
"status": "success",
"score": report_data["overall"]["score"],
"rank": report_data["overall"]["rank_in_district"],
"langs": langs,
"outputs": outputs,
"time": round(elapsed, 1),
})
except Exception as e:
elapsed = time.time() - school_start
status["results"].append({
"school": school,
"status": "failed",
"error": str(e),
"time": round(elapsed, 1),
})
logger.error(f"❌ [Era2] 批量 {district}/{school} 失败: {e}", exc_info=True)
status["completed"] = idx + 1
status["status"] = "completed"
status["elapsed"] = round(time.time() - status["started_at"], 1)
status["current_school"] = None
@router.get("/reports/batch/{batch_id}/status")
async def get_batch_status(batch_id: str):
if batch_id not in _batch_tasks:
raise HTTPException(404, f"批次 '{batch_id}' 不存在")
return _batch_tasks[batch_id]
# ==================== 报告预览/下载/历史 ====================
@router.get("/reports/{district}/{school}/preview")
async def preview_report(district: str, school: str, lang: str = "zh"):
"""预览HTML报告(支持 ?lang=zh|en"""
path = OUTPUT_DIR / district / _report_filename(school, lang)
if not path.exists():
raise HTTPException(404, f"报告不存在: {district}/{school} (lang={lang})")
return HTMLResponse(path.read_text("utf-8"))
@router.get("/reports/{district}/{school}/download")
async def download_report(district: str, school: str, lang: str = "zh"):
"""下载HTML报告(支持 ?lang=zh|en"""
path = OUTPUT_DIR / district / _report_filename(school, lang)
if not path.exists():
raise HTTPException(404, f"报告不存在: {district}/{school} (lang={lang})")
if lang == "en":
download_name = f"{school}_Curriculum_Implementation_Monitoring_Report.html"
else:
download_name = f"{school}_课程实施监测报告.html"
return FileResponse(
str(path),
filename=download_name,
media_type="text/html; charset=utf-8",
)
@router.get("/reports/{district}/{school}/data")
async def get_report_json(district: str, school: str):
"""获取报告JSON数据"""
path = OUTPUT_DIR / district / f"{school}_report_data.json"
if not path.exists():
raise HTTPException(404, f"报告数据不存在: {district}/{school}")
return json.loads(path.read_text("utf-8"))
@router.get("/reports/history")
async def get_report_history():
"""获取所有已生成报告的历史"""
return {
"reports": era2_state.get_report_history(),
}
# ==================== LLM 聊天助理 ====================
def _build_chat_system_prompt(school: str = None, district: str = None, lang: str = "zh") -> str:
"""
构建AI聊天助理的系统prompt,注入完整报告数据上下文。
对齐原 chat_widget.html 内嵌版的上下文丰富度:
- 总体得分、区均值、排名、聚类
- 七大维度:得分、区均值、差值、排名、聚类
- 二十个三级维度:得分、区均值、差值、水平、排名
- 回答规范
支持 lang='en' 输出英文 system prompt。
"""
if lang == "en":
return _build_chat_system_prompt_en(school, district)
ctx = f"你是上海市高中课程实施监测数据分析助理。\n"
ctx += f"当前系统覆盖上海市{len(era2_state.districts)}个区、{len(era2_state.schools)}所高中。\n\n"
if not school or not district:
ctx += ("你可以回答关于学校课程实施监测的各种问题,"
"包括七大维度(课程领导力、教学变革力、学生发展指导力、教师发展支持力、"
"教育质量评估力、教育条件保障力、数字化赋能力)的解读、对比分析和改进建议。\n")
return ctx
# 尝试加载完整报告数据
data = None
try:
json_path = OUTPUT_DIR / district / f"{school}_report_data.json"
if json_path.exists():
data = json.loads(json_path.read_text("utf-8"))
except Exception as e:
logger.warning(f"加载学校JSON失败: {e}")
# JSON不存在时实时计算
if data is None:
try:
data = era2_state.get_report_data(school, district)
except Exception as e:
logger.warning(f"实时计算报告数据失败: {e}")
ctx += f"当前上下文学校: {school}{district}),数据加载失败。\n"
return ctx
o = data.get("overall", {})
dims = data.get("dimensions", {})
sub_dims = data.get("sub_dimensions", {})
ctx += f"## 报告核心数据\n\n"
ctx += f"### 总体表现\n"
ctx += f"- 当前学校: {school}{district}\n"
ctx += f"- 总体得分: {o.get('score')}\n"
ctx += f"- 区均值: {o.get('district_avg')}\n"
ctx += f"- 区内排名: 第{o.get('rank_in_district')}/{o.get('total_schools')}\n"
if o.get('rank_in_city') is not None and o.get('total_schools_in_city'):
ctx += f"- 全市排名: 第{o.get('rank_in_city')}/{o.get('total_schools_in_city')}\n"
ctx += f"- 聚类类型: 课程实施{o.get('cluster', '')}\n"
ctx += f"- 学校类型: {o.get('school_type', '')}\n\n"
total_city = o.get('total_schools_in_city')
# 七大维度表格
ctx += "### 七大维度得分\n"
if total_city:
ctx += "| 维度 | 得分 | 区均值 | 差值 | 区排名 | 全市排名 | 聚类 |\n"
ctx += "|------|------|--------|------|--------|----------|------|\n"
else:
ctx += "| 维度 | 得分 | 区均值 | 差值 | 区排名 | 聚类 |\n"
ctx += "|------|------|--------|------|--------|------|\n"
for dim_name, d in dims.items():
score = d.get('score')
davg = d.get('district_avg')
diff = d.get('diff_district')
rank = d.get('rank_in_district')
rank_city = d.get('rank_in_city')
cluster = d.get('cluster', '')
score_s = f"{score:.2f}" if isinstance(score, (int, float)) else str(score)
davg_s = f"{davg:.2f}" if isinstance(davg, (int, float)) else str(davg)
if isinstance(diff, (int, float)):
diff_s = f"+{diff:.2f}" if diff >= 0 else f"{diff:.2f}"
else:
diff_s = str(diff)
if total_city:
rc_s = f"{rank_city}/{total_city}" if rank_city is not None else "-"
ctx += f"| {dim_name} | {score_s} | {davg_s} | {diff_s} | {rank}/{o.get('total_schools')} | {rc_s} | {cluster} |\n"
else:
ctx += f"| {dim_name} | {score_s} | {davg_s} | {diff_s} | {rank}/{o.get('total_schools')} | {cluster} |\n"
ctx += "\n"
# 二十个三级维度表格
ctx += "### 二十个三级维度详情\n"
if total_city:
ctx += "| 三级维度 | 得分 | 区均值 | 差值 | 水平 | 区排名 | 全市排名 |\n"
ctx += "|----------|------|--------|------|------|--------|----------|\n"
else:
ctx += "| 三级维度 | 得分 | 区均值 | 差值 | 水平 | 区排名 |\n"
ctx += "|----------|------|--------|------|------|--------|\n"
for sd_name, sd in sub_dims.items():
score = sd.get('score')
davg = sd.get('district_avg')
diff = sd.get('diff_district')
level = sd.get('level')
rank = sd.get('rank_in_district')
rank_city = sd.get('rank_in_city')
score_s = f"{score:.2f}" if isinstance(score, (int, float)) else "N/A"
davg_s = f"{davg:.2f}" if isinstance(davg, (int, float)) else "N/A"
if isinstance(diff, (int, float)):
diff_s = f"+{diff:.2f}" if diff >= 0 else f"{diff:.2f}"
else:
diff_s = "N/A"
if total_city:
rc_s = f"{rank_city}/{total_city}" if rank_city is not None else "-"
ctx += f"| {sd_name} | {score_s} | {davg_s} | {diff_s} | 水平{level} | {rank}/{o.get('total_schools')} | {rc_s} |\n"
else:
ctx += f"| {sd_name} | {score_s} | {davg_s} | {diff_s} | 水平{level} | {rank}/{o.get('total_schools')} |\n"
ctx += "\n"
# 回答规范
ctx += "## 回答规范\n"
ctx += "1. 始终基于上述数据回答,引用具体数值\n"
ctx += '2. 称呼被分析学校为"贵校"\n'
ctx += "3. 语言风格:专业、客观、平实\n"
ctx += '4. 使用"高于/低于XX均值X.XX分"句式进行对比\n'
ctx += "5. 给出改进建议时要具体可操作\n"
ctx += "6. 如果用户问的内容不在数据范围内,诚实告知\n"
ctx += "7. 回答控制在200-500字以内,避免冗长\n"
return ctx
# 中→英 维度名映射(用于 chat 上下文)
_DIM_EN = {
"课程领导力": "Curriculum Leadership",
"教学变革力": "Instructional Reform Capacity",
"学生发展指导力": "Student Development Guidance",
"教师发展支持力": "Teacher Development Support",
"教育质量评估力": "Educational Quality Assessment",
"教育条件保障力": "Educational Conditions and Resources",
"数字化赋能力": "Digital Empowerment",
}
_SUB_EN = {
"国家标准遵循": "National Standards Compliance",
"课程结构建设": "Curriculum Structure Design",
"课程规范落实": "Curriculum Governance Implementation",
"教学方式变革": "Pedagogical Reform",
"作业设计与管理变革": "Homework Design and Management",
"学科发展的个性化辅导": "Personalized Subject Tutoring",
"学生生涯发展指导": "Student Career Development Guidance",
"培训支持": "Professional Training Support",
"教研支持": "Teaching Research Support",
"项目支持": "Research Project Support",
"科学评价观": "Scientific Assessment Perspective",
"学业质量评估": "Academic Quality Assessment",
"综合素质评估": "Holistic Competency Assessment",
"实践活动评估": "Practice-Based Activity Assessment",
"区域推进": "District-Level Implementation Drive",
"环境支持": "Environmental Support",
"资源支持": "Resource Support",
"教学方式创新": "Innovative Instructional Methods",
"评价精准化与个性化": "Precise and Personalized Assessment",
"课程迭代优化": "Iterative Curriculum Optimization",
}
_CLUSTER_EN = {
"较好": "High-Performing",
"中等": "Mid-Tier",
"待提升": "Improvement-Needed",
}
_DISTRICT_EN = {
"长宁区": "Changning District",
"杨浦区": "Yangpu District",
"闵行区": "Minhang District",
"浦东新区": "Pudong New Area",
"嘉定区": "Jiading District",
"宝山区": "Baoshan District",
"金山区": "Jinshan District",
"静安区": "Jing'an District",
"奉贤区": "Fengxian District",
"普陀区": "Putuo District",
"徐汇区": "Xuhui District",
}
def _build_chat_system_prompt_en(school: str = None, district: str = None) -> str:
"""English version of chat system prompt (OECD/PISA register)."""
ctx = f"You are a data-analysis assistant for the Shanghai Senior Secondary School Curriculum Implementation Monitoring system.\n"
ctx += f"The system covers {len(era2_state.districts)} districts and {len(era2_state.schools)} senior secondary schools across Shanghai.\n\n"
if not school or not district:
ctx += (
"You may respond to questions on curriculum-implementation monitoring, "
"including interpretation, comparison, and improvement recommendations across the seven dimensions: "
"Curriculum Leadership, Instructional Reform Capacity, Student Development Guidance, "
"Teacher Development Support, Educational Quality Assessment, "
"Educational Conditions and Resources, and Digital Empowerment.\n"
)
return ctx
# 加载完整报告数据
data = None
try:
json_path = OUTPUT_DIR / district / f"{school}_report_data.json"
if json_path.exists():
data = json.loads(json_path.read_text("utf-8"))
except Exception as e:
logger.warning(f"加载学校JSON失败: {e}")
if data is None:
try:
data = era2_state.get_report_data(school, district)
except Exception as e:
logger.warning(f"实时计算报告数据失败: {e}")
ctx += f"Current school context: {school} ({_DISTRICT_EN.get(district, district)}); data load failed.\n"
return ctx
o = data.get("overall", {})
dims = data.get("dimensions", {})
sub_dims = data.get("sub_dimensions", {})
district_en = _DISTRICT_EN.get(district, district)
cluster_en = _CLUSTER_EN.get(o.get("cluster", ""), o.get("cluster", ""))
ctx += "## Core Report Data\n\n"
ctx += "### Overall Performance\n"
ctx += f"- School (analysis target): {school} ({district_en})\n"
ctx += f"- Overall score: {o.get('score')}\n"
ctx += f"- District average: {o.get('district_avg')}\n"
ctx += f"- District rank: {o.get('rank_in_district')} of {o.get('total_schools')}\n"
if o.get("rank_in_city") is not None and o.get("total_schools_in_city"):
ctx += f"- Municipal rank: {o.get('rank_in_city')} of {o.get('total_schools_in_city')}\n"
if cluster_en:
ctx += f"- Implementation cluster: {cluster_en}\n"
ctx += f"- School type: {o.get('school_type', '')}\n\n"
total_city = o.get("total_schools_in_city")
# 七大维度
ctx += "### Scores Across Seven Dimensions\n"
if total_city:
ctx += "| Dimension | Score | District Avg. | Δ | District Rank | Municipal Rank | Cluster |\n"
ctx += "|-----------|-------|---------------|---|----------------|-----------------|---------|\n"
else:
ctx += "| Dimension | Score | District Avg. | Δ | District Rank | Cluster |\n"
ctx += "|-----------|-------|---------------|---|----------------|---------|\n"
for dim_name, d in dims.items():
score = d.get("score")
davg = d.get("district_avg")
diff = d.get("diff_district")
rank = d.get("rank_in_district")
rank_city = d.get("rank_in_city")
cluster = _CLUSTER_EN.get(d.get("cluster", ""), d.get("cluster", ""))
score_s = f"{score:.2f}" if isinstance(score, (int, float)) else str(score)
davg_s = f"{davg:.2f}" if isinstance(davg, (int, float)) else str(davg)
diff_s = (f"+{diff:.2f}" if diff >= 0 else f"{diff:.2f}") if isinstance(diff, (int, float)) else str(diff)
dim_en = _DIM_EN.get(dim_name, dim_name)
if total_city:
rc_s = f"{rank_city}/{total_city}" if rank_city is not None else "-"
ctx += f"| {dim_en} | {score_s} | {davg_s} | {diff_s} | {rank}/{o.get('total_schools')} | {rc_s} | {cluster} |\n"
else:
ctx += f"| {dim_en} | {score_s} | {davg_s} | {diff_s} | {rank}/{o.get('total_schools')} | {cluster} |\n"
ctx += "\n"
# 二十个三级维度
ctx += "### Twenty Sub-Dimensions\n"
if total_city:
ctx += "| Sub-dimension | Score | District Avg. | Δ | Level | District Rank | Municipal Rank |\n"
ctx += "|---------------|-------|---------------|---|-------|----------------|-----------------|\n"
else:
ctx += "| Sub-dimension | Score | District Avg. | Δ | Level | District Rank |\n"
ctx += "|---------------|-------|---------------|---|-------|----------------|\n"
for sd_name, sd in sub_dims.items():
score = sd.get("score")
davg = sd.get("district_avg")
diff = sd.get("diff_district")
level = sd.get("level")
rank = sd.get("rank_in_district")
rank_city = sd.get("rank_in_city")
score_s = f"{score:.2f}" if isinstance(score, (int, float)) else "N/A"
davg_s = f"{davg:.2f}" if isinstance(davg, (int, float)) else "N/A"
diff_s = (f"+{diff:.2f}" if diff >= 0 else f"{diff:.2f}") if isinstance(diff, (int, float)) else "N/A"
sd_en = _SUB_EN.get(sd_name, sd_name)
if total_city:
rc_s = f"{rank_city}/{total_city}" if rank_city is not None else "-"
ctx += f"| {sd_en} | {score_s} | {davg_s} | {diff_s} | Level {level} | {rank}/{o.get('total_schools')} | {rc_s} |\n"
else:
ctx += f"| {sd_en} | {score_s} | {davg_s} | {diff_s} | Level {level} | {rank}/{o.get('total_schools')} |\n"
ctx += "\n"
ctx += "## Response Conventions\n"
ctx += "1. Ground every observation in the data above; cite numerical values explicitly.\n"
ctx += '2. Refer to the analysed school as "your school".\n'
ctx += "3. Maintain a formal, evidence-based academic register (OECD/PISA style).\n"
ctx += '4. Use phrasing such as "X.XX points above/below the district average" for comparisons.\n'
ctx += "5. Improvement recommendations must be specific and actionable.\n"
ctx += "6. If a question lies outside the supplied data, say so honestly.\n"
ctx += "7. Keep responses concise — typically 150 to 350 words. Output strictly in formal English; do NOT use Chinese characters.\n"
return ctx
@router.post("/chat")
async def chat_with_assistant(req: ChatRequest):
"""
LLM 聊天助理:真正的异步流式响应
如果指定了 school+district,会注入该校的数据上下文
修复说明(2024-03):
- 旧版使用同步 OpenAI client,在 async generator 中阻塞事件循环,
导致:(1) 所有 chunk 攒到最后才发出 (2) 长时间阻塞触发超时
- 新版使用 AsyncOpenAI,真正 async for 逐 chunk yield
"""
from openai import AsyncOpenAI
from ..config import LLM_BASE_URL, LLM_API_KEY, LLM_MODEL
lang = (req.lang or "zh").lower()
if lang not in ("zh", "en"):
lang = "zh"
# 构建系统prompt(对齐内嵌chat_widget版本的完整上下文)
system_prompt = _build_chat_system_prompt(req.school, req.district, lang=lang)
# 构建消息
# NOTE: cdr.digiman.live 等 API 代理会丢弃 system 角色消息,
# 所以将系统prompt伪装成 user+assistant 对话对来注入上下文。
school_name = req.school or ("the school" if lang == "en" else "该校")
if lang == "en":
sys_inject = (
f"[SYSTEM INSTRUCTIONS] {system_prompt}\n\n"
"Please confirm that you have read and understood the data and conventions above; "
"subsequent answers must be grounded in this data."
)
ack = (
f"I have reviewed the full curriculum-implementation monitoring data for your school "
f"({school_name}), including the overall score, the seven dimensions, and the twenty sub-dimensions. "
"I will respond strictly on the basis of this data, in a formal academic register. "
"What would you like to know?"
)
else:
sys_inject = f"[系统指令] {system_prompt}\n\n请确认你已了解以上数据和规范,后续将基于这些数据回答问题。"
ack = (
f"我已了解贵校({school_name})课程实施监测的全部数据,"
"包括总体得分、七大维度和二十个三级维度的详细数据。"
"我将严格基于这些数据,以专业、客观的风格回答您的问题。请问有什么想了解的?"
)
messages = [
{"role": "user", "content": sys_inject},
{"role": "assistant", "content": ack},
]
for h in req.history[-10:]: # 最多保留10轮历史
messages.append({"role": h.get("role", "user"), "content": h.get("content", "")})
user_msg = req.message
if lang == "en":
# 进一步追加输出语言指示,防止模型回中文
user_msg = (
user_msg
+ "\n\n(Please answer strictly in formal English following the OECD/PISA register; "
"do not include Chinese characters.)"
)
messages.append({"role": "user", "content": user_msg})
# 异步流式调用(不阻塞事件循环)
client = AsyncOpenAI(
base_url=LLM_BASE_URL,
api_key=LLM_API_KEY,
timeout=120.0, # 连接+读取总超时 120s
)
async def stream_response():
try:
response = await client.chat.completions.create(
model=LLM_MODEL,
messages=messages,
stream=True,
max_tokens=2000,
)
async for chunk in response:
if chunk.choices and chunk.choices[0].delta.content:
content = chunk.choices[0].delta.content
yield f"data: {json.dumps({'content': content}, ensure_ascii=False)}\n\n"
yield "data: [DONE]\n\n"
except Exception as e:
logger.error(f"❌ Chat stream error: {e}")
yield f"data: {json.dumps({'error': str(e)}, ensure_ascii=False)}\n\n"
finally:
await client.close()
return StreamingResponse(
stream_response(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", # Nginx 禁用代理缓冲
"Content-Type": "text/event-stream",
},
)
# ==================== 配置 ====================
@router.get("/config/framework")
async def get_framework():
"""测评框架"""
return {
"dimensions": {
name: {"sub_dimensions": info["sub_dimensions"]}
for name, info in DIMENSION_FRAMEWORK.items()
},
"level_descriptions": LEVEL_DESCRIPTIONS,
}
# ==================== 数据溯源 ====================
@router.get("/trace/{district}/{school}")
async def get_trace(district: str, school: str):
"""
获取单校的完整计算链路溯源数据(6阶段)
供前端 React Three Fiber 数据溯源可视化消费
"""
district_schools = era2_state.district_schools.get(district, [])
if not district_schools:
raise HTTPException(404, f"区域不存在: {district}")
if school not in era2_state.schools:
raise HTTPException(404, f"学校不存在: {school}")
import sys as _sys
_era2_path = str(Path(__file__).parent.parent.parent.parent / "scripts" / "era2")
if _era2_path not in _sys.path:
_sys.path.insert(0, _era2_path)
from engines.trace_engine import TraceEngine
trace_engine = TraceEngine(
data_engine=era2_state.data_engine,
pca_engine=era2_state.pca_engine,
stats_engine=era2_state.stats_engine,
sub_scores=era2_state.sub_scores,
dim_scores=era2_state.dim_scores,
)
result = trace_engine.compute_trace(school, district_schools)
return result
+246
View File
@@ -0,0 +1,246 @@
"""
Era2 全局状态:全市266校 数据引擎 + PCA赋分引擎 + 统计引擎
启动时一次性加载,后续API直接使用
"""
import logging
import time
import sys
from pathlib import Path
from typing import Dict, List, Optional
from datetime import datetime
import pandas as pd
import numpy as np
# era2 引擎路径
ERA2_SCRIPTS = Path(__file__).parent.parent.parent.parent / "scripts" / "era2"
sys.path.insert(0, str(ERA2_SCRIPTS))
from data_engine_era2 import DataEngineEra2
from engines.pca_scoring_engine_era2 import PcaScoringEngineEra2
from engines.stats_engine_era2 import StatsEngineEra2
from config_era2 import (
SCHOOL_TYPE_MAP, DIMENSION_FRAMEWORK, LEVEL_DESCRIPTIONS,
CLUSTER_CONFIG,
)
logger = logging.getLogger(__name__)
# 输出目录
OUTPUT_DIR = Path(__file__).parent.parent.parent.parent / "output" / "era2"
class Era2State:
"""Era2 全市数据的全局单例状态"""
def __init__(self):
self._initialized = False
self.data_engine: Optional[DataEngineEra2] = None
self.pca_engine: Optional[PcaScoringEngineEra2] = None
self.stats_engine: Optional[StatsEngineEra2] = None
self.sub_scores: Optional[pd.DataFrame] = None
self.dim_scores: Optional[pd.DataFrame] = None
self.schools: List[str] = []
self.districts: List[str] = []
self.district_schools: Dict[str, List[str]] = {}
self.school_meta: Dict[str, dict] = {}
def initialize(self):
"""启动时加载全市数据并计算分数(耗时约40-60秒)"""
if self._initialized:
return
start = time.time()
logger.info("🚀 [Era2] 加载全市数据...")
# 1. 加载全市数据
self.data_engine = DataEngineEra2(use_city_data=True)
self.data_engine.load_all()
self.schools = self.data_engine.schools
# 2. 构建区→学校映射
self.school_meta = SCHOOL_TYPE_MAP
self.district_schools = {}
for school, info in SCHOOL_TYPE_MAP.items():
district = info.get("district", "未知")
if district not in self.district_schools:
self.district_schools[district] = []
if school in self.schools:
self.district_schools[district].append(school)
self.districts = sorted(self.district_schools.keys())
# 3. PCA赋分(全市)
logger.info(f"🔢 [Era2] PCA赋分 ({len(self.schools)}校)...")
self.pca_engine = PcaScoringEngineEra2(self.data_engine)
pca_sub_scores = self.pca_engine.compute_all()
# 4. 统计引擎
self.stats_engine = StatsEngineEra2()
self.sub_scores = self.stats_engine.compute_dimension_scores_pca(pca_sub_scores)
self.dim_scores = self.stats_engine.compute_dimension_aggregates(self.sub_scores)
self._initialized = True
elapsed = time.time() - start
logger.info(f"✅ [Era2] 初始化完成: {len(self.schools)}校, {len(self.districts)}区, 耗时{elapsed:.1f}s")
def get_districts_summary(self) -> List[dict]:
"""返回所有区的摘要(中英两份报告分别计数)"""
result = []
for district in self.districts:
schools_in_d = self.district_schools.get(district, [])
# 区内平均分
if schools_in_d and self.dim_scores is not None:
valid = [s for s in schools_in_d if s in self.dim_scores.index]
avg = float(self.dim_scores.loc[valid, "总体得分"].mean()) if valid else 50.0
else:
avg = 50.0
# 已生成报告数(中文 / 英文)
district_dir = OUTPUT_DIR / district
if district_dir.exists():
report_count_zh = len(list(district_dir.glob("*_报告.html")))
report_count_en = len(list(district_dir.glob("*_report_en.html")))
else:
report_count_zh = 0
report_count_en = 0
# 任意一种语言已有视为 has_report,用于 UI 总数显示
report_count = report_count_zh
result.append({
"district": district,
"school_count": len(schools_in_d),
"avg_score": round(avg, 2),
"report_count": report_count,
"report_count_zh": report_count_zh,
"report_count_en": report_count_en,
})
return result
def get_schools_in_district(self, district: str) -> List[dict]:
"""返回某区所有学校的详细信息"""
schools_in_d = self.district_schools.get(district, [])
if not schools_in_d:
return []
# 排名(全市排名 + 区内排名)
sorted_all = self.dim_scores["总体得分"].sort_values(ascending=False)
dist_scores = self.dim_scores.loc[
[s for s in schools_in_d if s in self.dim_scores.index], "总体得分"
].sort_values(ascending=False)
result = []
for dist_rank, (school, score) in enumerate(dist_scores.items(), 1):
info = self.school_meta.get(school, {})
city_rank = int((sorted_all >= score).sum())
# 报告状态(中文 + 英文)
report_path_zh = OUTPUT_DIR / district / f"{school}_报告.html"
report_path_en = OUTPUT_DIR / district / f"{school}_report_en.html"
has_report_zh = report_path_zh.exists()
has_report_en = report_path_en.exists()
# 兼容老字段
has_report = has_report_zh or has_report_en
report_generated_at_zh = ""
report_size_zh = 0
report_generated_at_en = ""
report_size_en = 0
if has_report_zh:
stat = report_path_zh.stat()
report_size_zh = stat.st_size
report_generated_at_zh = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M")
if has_report_en:
stat = report_path_en.stat()
report_size_en = stat.st_size
report_generated_at_en = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M")
# 兼容老字段:优先用中文版,没有则用英文版
report_size = report_size_zh or report_size_en
report_generated_at = report_generated_at_zh or report_generated_at_en
# 聚类
dim_cols = [c for c in self.dim_scores.columns if c in DIMENSION_FRAMEWORK]
valid_schools = [s for s in schools_in_d if s in self.dim_scores.index]
cluster_result = self.stats_engine.cluster_analysis(
self.dim_scores.loc[valid_schools, dim_cols], dimension_name="总体"
)
cluster = cluster_result["school_clusters"].get(school, "")
result.append({
"name": school,
"district": district,
"type": info.get("type", ""),
"nature": info.get("nature", ""),
"area": info.get("area", ""),
"score": round(float(score), 2),
"district_rank": dist_rank,
"city_rank": city_rank,
"total_in_district": len(dist_scores),
"total_in_city": len(sorted_all),
"cluster": cluster,
# 兼容老字段
"has_report": has_report,
"report_size": report_size,
"report_generated_at": report_generated_at,
# 中英分开
"has_report_zh": has_report_zh,
"has_report_en": has_report_en,
"report_size_zh": report_size_zh,
"report_size_en": report_size_en,
"report_generated_at_zh": report_generated_at_zh,
"report_generated_at_en": report_generated_at_en,
})
return result
def get_report_data(self, school: str, district: str) -> dict:
"""生成单校报告数据包"""
district_schools = self.district_schools.get(district, [])
report_data = self.stats_engine.compute_school_report_data(
school, self.sub_scores, self.dim_scores,
district_schools=district_schools,
)
report_data["district"] = district
report_data["total_schools_in_district"] = len(district_schools)
return report_data
def get_report_history(self) -> List[dict]:
"""扫描所有已生成的报告,返回历史列表(合并中英两份,每校最多两条)"""
history = []
if not OUTPUT_DIR.exists():
return history
for district_dir in sorted(OUTPUT_DIR.iterdir()):
if not district_dir.is_dir():
continue
district = district_dir.name
# 同时收集中文 (_报告.html) 和英文 (_report_en.html) 报告
collected = []
for html_file in sorted(district_dir.glob("*_报告.html")):
school = html_file.stem.replace("_报告", "")
collected.append((school, html_file, "zh"))
for html_file in sorted(district_dir.glob("*_report_en.html")):
school = html_file.stem.replace("_report_en", "")
collected.append((school, html_file, "en"))
for school, html_file, lang in collected:
stat = html_file.stat()
json_path = district_dir / f"{school}_report_data.json"
score = None
if json_path.exists():
try:
import json
data = json.loads(json_path.read_text("utf-8"))
score = data.get("overall", {}).get("score")
except Exception:
pass
history.append({
"school": school,
"district": district,
"type": self.school_meta.get(school, {}).get("type", ""),
"score": score,
"file_size": stat.st_size,
"generated_at": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
"file_name": html_file.name,
"lang": lang,
})
return history
# 全局单例
era2_state = Era2State()
+412
View File
@@ -0,0 +1,412 @@
"""
API 路由:所有 REST 端点
"""
import asyncio
import json
import logging
import time
from pathlib import Path
from typing import Optional
import numpy as np
from fastapi import APIRouter, HTTPException, BackgroundTasks, Query
from fastapi.responses import HTMLResponse, StreamingResponse, FileResponse
from pydantic import BaseModel
from .state import app_state
from ..config import OUTPUT_DIR, DIMENSION_FRAMEWORK, LEVEL_DESCRIPTIONS
from ..engines.llm_engine import LLMEngine
from ..engines.report_renderer import ReportRenderer
logger = logging.getLogger(__name__)
router = APIRouter()
# ==================== Pydantic Models ====================
class GenerateRequest(BaseModel):
school: str
use_cache: bool = True
skip_llm: bool = False
class BatchGenerateRequest(BaseModel):
schools: Optional[list[str]] = None # None = 全部
use_cache: bool = True
skip_llm: bool = False
# ==================== 学校相关 ====================
@router.get("/schools")
async def list_schools():
"""获取所有学校的摘要列表"""
return {
"schools": app_state.get_all_schools_summary(),
"total": len(app_state.schools),
}
@router.get("/schools/{school_name}")
async def get_school_detail(school_name: str):
"""获取单个学校的详细数据"""
if school_name not in app_state.schools:
raise HTTPException(404, f"学校 '{school_name}' 不存在")
report_data = app_state.get_report_data(school_name)
# JSON 安全序列化
def _safe(obj):
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"Type {type(obj)} not serializable")
# 转为 JSON 安全格式
safe_data = json.loads(json.dumps(report_data, default=_safe, ensure_ascii=False))
return safe_data
@router.get("/schools/{school_name}/dimensions")
async def get_school_dimensions(school_name: str):
"""获取学校的维度得分摘要"""
if school_name not in app_state.schools:
raise HTTPException(404, f"学校 '{school_name}' 不存在")
report_data = app_state.get_report_data(school_name)
return {
"school": school_name,
"overall": report_data["overall"],
"dimensions": report_data["dimensions"],
"sub_dimensions": report_data["sub_dimensions"],
}
# ==================== 报告生成 ====================
# 全局生成状态追踪
_generation_status = {}
@router.post("/reports/generate")
async def generate_report(req: GenerateRequest, background_tasks: BackgroundTasks):
"""
生成单校报告(异步后台任务)
返回任务 ID,前端通过 SSE 监听进度
"""
school = req.school
if school not in app_state.schools:
raise HTTPException(404, f"学校 '{school}' 不存在")
task_id = f"gen_{school}_{int(time.time())}"
_generation_status[task_id] = {
"status": "pending",
"school": school,
"progress": 0,
"total": 29,
"current_segment": "",
"started_at": time.time(),
}
background_tasks.add_task(
_do_generate, task_id, school, req.use_cache, req.skip_llm
)
return {"task_id": task_id, "school": school, "status": "started"}
def _do_generate(task_id: str, school: str, use_cache: bool, skip_llm: bool):
"""后台执行报告生成"""
status = _generation_status[task_id]
status["status"] = "running"
try:
start = time.time()
# 获取报告数据
report_data = app_state.get_report_data(school)
# LLM 生成
if skip_llm:
llm_sections = {}
status["progress"] = status["total"]
else:
llm_engine = LLMEngine()
def progress_cb(completed, total, segment_id):
status["progress"] = completed
status["total"] = total
status["current_segment"] = segment_id
llm_engine.set_progress_callback(progress_cb)
llm_sections = llm_engine.generate_report_segments(
report_data, use_cache=use_cache
)
# 渲染 HTML
renderer = ReportRenderer()
output_path = OUTPUT_DIR / f"{school}_报告.html"
renderer.render_to_file(report_data, llm_sections, output_path)
# 保存数据 JSON
def _safe(obj):
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"Type {type(obj)} not 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=_safe)
elapsed = time.time() - start
status["status"] = "completed"
status["elapsed"] = round(elapsed, 1)
status["output_path"] = str(output_path)
status["report_size"] = output_path.stat().st_size
status["score"] = report_data["overall"]["score"]
status["rank"] = report_data["overall"]["rank_in_district"]
logger.info(f"{school} 报告生成完成,耗时 {elapsed:.1f}s")
except Exception as e:
status["status"] = "failed"
status["error"] = str(e)
logger.error(f"{school} 报告生成失败: {e}", exc_info=True)
@router.get("/reports/generate/{task_id}/status")
async def get_generation_status(task_id: str):
"""查询生成任务状态"""
if task_id not in _generation_status:
raise HTTPException(404, f"任务 '{task_id}' 不存在")
return _generation_status[task_id]
@router.get("/reports/generate/{task_id}/stream")
async def stream_generation_progress(task_id: str):
"""SSE 实时进度流"""
if task_id not in _generation_status:
raise HTTPException(404, f"任务 '{task_id}' 不存在")
async def event_generator():
while True:
status = _generation_status.get(task_id, {})
data = json.dumps(status, ensure_ascii=False, default=str)
yield f"data: {data}\n\n"
if status.get("status") in ("completed", "failed"):
break
await asyncio.sleep(0.5)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# ==================== 批量生成 ====================
_batch_status = {}
@router.post("/reports/batch")
async def batch_generate(req: BatchGenerateRequest, background_tasks: BackgroundTasks):
"""批量生成所有学校报告"""
schools = req.schools or app_state.schools
invalid = [s for s in schools if s not in app_state.schools]
if invalid:
raise HTTPException(400, f"无效学校: {invalid}")
batch_id = f"batch_{int(time.time())}"
_batch_status[batch_id] = {
"status": "pending",
"schools": schools,
"total": len(schools),
"completed": 0,
"results": [],
"started_at": time.time(),
}
background_tasks.add_task(
_do_batch_generate, batch_id, schools, req.use_cache, req.skip_llm
)
return {"batch_id": batch_id, "schools": schools, "total": len(schools)}
def _do_batch_generate(batch_id: str, schools: list, use_cache: bool, skip_llm: bool):
"""后台执行批量生成"""
status = _batch_status[batch_id]
status["status"] = "running"
renderer = ReportRenderer()
llm_engine = None if skip_llm else LLMEngine()
for idx, school in enumerate(schools):
school_start = time.time()
status["current_school"] = school
status["current_index"] = idx
try:
report_data = app_state.get_report_data(school)
if skip_llm:
llm_sections = {}
else:
llm_sections = llm_engine.generate_report_segments(
report_data, use_cache=use_cache
)
output_path = OUTPUT_DIR / f"{school}_报告.html"
renderer.render_to_file(report_data, llm_sections, output_path)
# 保存数据 JSON
def _safe(obj):
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"Type {type(obj)} not 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=_safe)
elapsed = time.time() - school_start
status["results"].append({
"school": school,
"status": "success",
"score": report_data["overall"]["score"],
"rank": report_data["overall"]["rank_in_district"],
"cluster": report_data["overall"]["cluster"],
"time": round(elapsed, 1),
})
except Exception as e:
elapsed = time.time() - school_start
status["results"].append({
"school": school,
"status": "failed",
"error": str(e),
"time": round(elapsed, 1),
})
logger.error(f"❌ 批量生成 {school} 失败: {e}", exc_info=True)
status["completed"] = idx + 1
status["status"] = "completed"
status["elapsed"] = round(time.time() - status["started_at"], 1)
# 保存批量汇总 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": sum(1 for r in status["results"] if r["status"] == "success"),
"total_time": status["elapsed"],
"results": status["results"],
}, f, ensure_ascii=False, indent=2)
@router.get("/reports/batch/{batch_id}/status")
async def get_batch_status(batch_id: str):
"""查询批量生成状态"""
if batch_id not in _batch_status:
raise HTTPException(404, f"批次 '{batch_id}' 不存在")
return _batch_status[batch_id]
@router.get("/reports/batch/{batch_id}/stream")
async def stream_batch_progress(batch_id: str):
"""批量生成 SSE 进度流"""
if batch_id not in _batch_status:
raise HTTPException(404, f"批次 '{batch_id}' 不存在")
async def event_generator():
while True:
status = _batch_status.get(batch_id, {})
data = json.dumps(status, ensure_ascii=False, default=str)
yield f"data: {data}\n\n"
if status.get("status") in ("completed", "failed"):
break
await asyncio.sleep(1)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# ==================== 报告预览/下载 ====================
@router.get("/reports/{school_name}/preview")
async def preview_report(school_name: str):
"""预览 HTML 报告(返回 HTML 内容)"""
report_path = OUTPUT_DIR / f"{school_name}_报告.html"
if not report_path.exists():
raise HTTPException(404, f"学校 '{school_name}' 的报告尚未生成")
return HTMLResponse(
content=report_path.read_text(encoding="utf-8"),
media_type="text/html; charset=utf-8",
)
@router.get("/reports/{school_name}/download")
async def download_report(school_name: str):
"""下载 HTML 报告"""
report_path = OUTPUT_DIR / f"{school_name}_报告.html"
if not report_path.exists():
raise HTTPException(404, f"学校 '{school_name}' 的报告尚未生成")
return FileResponse(
path=str(report_path),
filename=f"{school_name}_课程实施监测报告.html",
media_type="text/html; charset=utf-8",
)
@router.get("/reports/{school_name}/data")
async def get_report_data(school_name: str):
"""获取报告的 JSON 数据"""
json_path = OUTPUT_DIR / f"{school_name}_report_data.json"
if not json_path.exists():
raise HTTPException(404, f"学校 '{school_name}' 的报告数据不存在")
data = json.loads(json_path.read_text(encoding="utf-8"))
return data
# ==================== 系统配置 ====================
@router.get("/config/framework")
async def get_framework():
"""获取测评框架配置"""
return {
"dimensions": {
name: {
"sub_dimensions": info["sub_dimensions"],
}
for name, info in DIMENSION_FRAMEWORK.items()
},
"level_descriptions": LEVEL_DESCRIPTIONS,
}
@router.get("/reports/summary")
async def get_batch_summary():
"""获取最近一次批量生成的汇总"""
summary_path = OUTPUT_DIR / "batch_summary.json"
if not summary_path.exists():
return {"message": "尚无批量生成记录"}
return json.loads(summary_path.read_text(encoding="utf-8"))
+132
View File
@@ -0,0 +1,132 @@
"""
应用全局状态:管理数据引擎、赋分引擎、统计引擎的单例
避免每次请求重新加载 Excel 数据
"""
import logging
import time
from datetime import datetime
from typing import Dict, List, Optional
import pandas as pd
from ..engines.data_engine import DataEngine
from ..engines.scoring_engine import ScoringEngine
from ..engines.stats_engine import StatsEngine
from ..config import SCHOOL_TYPE_MAP, DIMENSION_FRAMEWORK
logger = logging.getLogger(__name__)
class AppState:
"""应用全局状态"""
def __init__(self):
self.data_engine: Optional[DataEngine] = None
self.scoring_engine: Optional[ScoringEngine] = None
self.stats_engine: Optional[StatsEngine] = None
self.raw_scores: Optional[Dict] = None
self.sub_scores: Optional[pd.DataFrame] = None
self.dim_scores: Optional[pd.DataFrame] = None
self.schools: List[str] = []
self._initialized = False
def initialize(self):
"""初始化所有引擎(只在应用启动时调用一次)"""
if self._initialized:
return
start = time.time()
# 1. 加载数据
self.data_engine = DataEngine()
self.data_engine.load_all()
self.schools = self.data_engine.schools
# 2. 赋分
self.scoring_engine = ScoringEngine(self.data_engine)
self.raw_scores = self.scoring_engine.score_all_schools()
# 3. 统计
self.stats_engine = StatsEngine()
self.sub_scores = self.stats_engine.compute_dimension_scores(self.raw_scores)
self.dim_scores = self.stats_engine.compute_dimension_aggregates(self.sub_scores)
self._initialized = True
elapsed = time.time() - start
logger.info(f"全局状态初始化完成,耗时 {elapsed:.1f}s")
def get_report_data(self, school: str) -> Dict:
"""获取某学校的报告数据包"""
if not self._initialized:
self.initialize()
return self.stats_engine.compute_school_report_data(
school, self.sub_scores, self.dim_scores
)
def get_school_info(self, school: str) -> Dict:
"""获取学校基本信息"""
info = SCHOOL_TYPE_MAP.get(school, {})
if not self.dim_scores is None and school in self.dim_scores.index:
score = round(float(self.dim_scores.loc[school, "总体得分"]), 2)
rank = int((self.dim_scores["总体得分"] >= self.dim_scores.loc[school, "总体得分"]).sum())
else:
score = 0
rank = 0
return {
"name": school,
"type": info.get("type", ""),
"code": info.get("code", ""),
"nature": info.get("nature", ""),
"feature": info.get("feature", ""),
"score": score,
"rank": rank,
"total_schools": len(self.schools),
}
def get_all_schools_summary(self) -> List[Dict]:
"""获取所有学校的摘要信息"""
if not self._initialized:
self.initialize()
summaries = []
# 排名
sorted_schools = self.dim_scores["总体得分"].sort_values(ascending=False)
for rank, (school, score) in enumerate(sorted_schools.items(), 1):
if school == "总体得分":
continue
info = SCHOOL_TYPE_MAP.get(school, {})
# 聚类
dim_cols = [c for c in self.dim_scores.columns if c in DIMENSION_FRAMEWORK]
cluster_result = self.stats_engine.cluster_analysis(self.dim_scores[dim_cols])
cluster = cluster_result["school_clusters"].get(school, "")
# 检查已生成的报告
from ..config import OUTPUT_DIR
report_path = OUTPUT_DIR / f"{school}_报告.html"
has_report = report_path.exists()
report_size = report_path.stat().st_size if has_report else 0
report_generated_at = ""
if has_report:
mtime = report_path.stat().st_mtime
report_generated_at = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
summaries.append({
"name": school,
"type": info.get("type", ""),
"code": info.get("code", ""),
"nature": info.get("nature", ""),
"score": round(float(score), 2),
"rank": rank,
"cluster": cluster,
"has_report": has_report,
"report_size": report_size,
"report_generated_at": report_generated_at,
})
return summaries
# 全局单例
app_state = AppState()
+151
View File
@@ -0,0 +1,151 @@
"""
认证模块:JWT Token + 密码哈希
部署到外网时的安全认证层
"""
import hashlib
import os
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import Depends, HTTPException, status, Request, Response
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import JWTError, jwt
from pydantic import BaseModel
# ==================== 配置 ====================
# JWT 密钥:优先从环境变量读取,否则生成随机密钥(每次重启失效)
SECRET_KEY = os.environ.get("AUTH_SECRET_KEY", secrets.token_urlsafe(32))
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_HOURS = int(os.environ.get("AUTH_TOKEN_EXPIRE_HOURS", "24"))
# 默认用户(可通过环境变量覆盖)
DEFAULT_USERNAME = os.environ.get("AUTH_USERNAME", "admin")
DEFAULT_PASSWORD = os.environ.get("AUTH_PASSWORD", "pswd4admin")
# Bearer token 提取器
security = HTTPBearer(auto_error=False)
# ==================== 密码哈希 (SHA-256 + salt) ====================
def _hash_password(password: str, salt: str | None = None) -> str:
"""SHA-256 加盐哈希"""
if salt is None:
salt = secrets.token_hex(16)
hashed = hashlib.sha256(f"{salt}:{password}".encode()).hexdigest()
return f"{salt}${hashed}"
def _verify_password(plain_password: str, stored_hash: str) -> bool:
"""验证密码"""
if "$" not in stored_hash:
return False
salt, _ = stored_hash.split("$", 1)
return _hash_password(plain_password, salt) == stored_hash
# ==================== 模型 ====================
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
expires_in: int # 秒
class UserInfo(BaseModel):
username: str
# ==================== 用户存储(简单内存版) ====================
# 启动时对默认密码做哈希
_users_db: dict[str, str] = {
DEFAULT_USERNAME: _hash_password(DEFAULT_PASSWORD),
}
def authenticate_user(username: str, password: str) -> Optional[str]:
"""验证用户,成功返回用户名,失败返回 None"""
hashed = _users_db.get(username)
if not hashed:
return None
if not _verify_password(password, hashed):
return None
return username
# ==================== JWT 签发/验证 ====================
def create_access_token(username: str) -> tuple[str, int]:
"""创建 JWT token,返回 (token, expires_in_seconds)"""
expires_delta = timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
expire = datetime.now(timezone.utc) + expires_delta
payload = {
"sub": username,
"exp": expire,
"iat": datetime.now(timezone.utc),
}
token = jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
return token, int(expires_delta.total_seconds())
def verify_token(token: str) -> Optional[str]:
"""验证 JWT token,成功返回用户名,失败返回 None"""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
return None
# 检查用户是否仍然存在
if username not in _users_db:
return None
return username
except JWTError:
return None
# ==================== FastAPI 依赖 ====================
async def get_current_user(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
) -> str:
"""
从请求中提取并验证 JWT token
支持两种方式:
1. Authorization: Bearer <token> (标准方式)
2. Cookie: access_token=<token> (浏览器便捷方式)
"""
token = None
# 方式1: Authorization header
if credentials and credentials.credentials:
token = credentials.credentials
# 方式2: Cookie fallback
if not token:
token = request.cookies.get("access_token")
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="未登录,请先登录",
headers={"WWW-Authenticate": "Bearer"},
)
username = verify_token(token)
if not username:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="登录已过期,请重新登录",
headers={"WWW-Authenticate": "Bearer"},
)
return username
+248
View File
@@ -0,0 +1,248 @@
"""项目配置"""
import os
from pathlib import Path
from dotenv import load_dotenv
# 项目根目录
PROJECT_ROOT = Path(__file__).parent.parent.parent
# 加载项目根目录下的 .env(若存在),使 LLM 等配置可外部覆盖
load_dotenv(PROJECT_ROOT / ".env")
DATA_DIR = PROJECT_ROOT / "data"
TEMPLATES_DIR = PROJECT_ROOT / "templates" / "era1"
STATIC_DIR = PROJECT_ROOT / "backend" / "static"
OUTPUT_DIR = PROJECT_ROOT / "output"
# 数据文件路径
EXCEL_BASIC_INFO = DATA_DIR / "长宁区_基础信息表.xlsx"
EXCEL_COURSE_IMPL = DATA_DIR / "长宁区_课程实施情况表.xlsx"
EXCEL_SUBJECT_IMPL = DATA_DIR / "长宁区_学科课程实施情况表.xlsx"
EXCEL_SCORING_RULES = DATA_DIR / "赋分整理表.xlsx"
# =====================================================================
# LLM 配置 —— 全项目唯一真相源 (Single Source of Truth)
# 任何模块(backend / scripts / templates context)都应从此处读取,
# 不要在其他文件中重复硬编码 LLM_BASE_URL / LLM_API_KEY / LLM_MODEL。
# 敏感信息(尤其 API Key)不写入代码,仅从环境变量 / 项目根目录 .env 读取。
# =====================================================================
LLM_BASE_URL = os.environ.get("LLM_BASE_URL", "")
LLM_API_KEY = os.environ.get("LLM_API_KEY", "")
LLM_MODEL = os.environ.get("LLM_MODEL", "")
LLM_MAX_CONCURRENCY = int(os.environ.get("LLM_MAX_CONCURRENCY", "5"))
if not (LLM_BASE_URL and LLM_API_KEY and LLM_MODEL):
raise RuntimeError(
"缺少 LLM 配置:请在项目根目录 .env 中设置 "
"LLM_BASE_URL / LLM_API_KEY / LLM_MODEL(参考 .env.example"
)
# PCA标准化参数
PCA_MEAN = 50
PCA_STD = 10
# 学校名称标准化映射(确保各表一致)
SCHOOL_NAME_MAP = {
"市三女中": "市三女中",
"复旦中学": "复旦中学",
"仙霞高中": "仙霞高中",
"建青实验": "建青实验",
"延安中学": "延安中学",
"华政附中": "华政附中",
"民办新虹桥": "民办新虹桥",
"天山学校": "天山学校",
"西郊学校": "西郊学校",
}
# 学校类型
SCHOOL_TYPE_MAP = {
"延安中学": {"type": "市实验性示范性高中", "code": "A类学校", "nature": "公办", "feature": "非特色高中"},
"复旦中学": {"type": "市实验性示范性高中", "code": "A类学校", "nature": "公办", "feature": "非特色高中"},
"市三女中": {"type": "区实验性示范性高中", "code": "B类学校", "nature": "公办", "feature": "非特色高中"},
"建青实验": {"type": "区实验性示范性高中", "code": "B类学校", "nature": "公办", "feature": "非特色高中"},
"华政附中": {"type": "特色高中", "code": "B类学校", "nature": "公办", "feature": "人文类特色高中"},
"仙霞高中": {"type": "公办普通高中", "code": "C类学校", "nature": "公办", "feature": "非特色高中"},
"天山学校": {"type": "公办普通高中", "code": "C类学校", "nature": "公办", "feature": "非特色高中"},
"西郊学校": {"type": "公办普通高中", "code": "C类学校", "nature": "公办", "feature": "非特色高中"},
"民办新虹桥": {"type": "民办高中", "code": "C类学校", "nature": "民办", "feature": "非特色高中"},
}
# 15个学科
SUBJECTS = [
"语文", "数学", "英语", "物理", "化学", "生物学",
"历史", "地理", "思想政治", "体育与健康",
"信息技术", "通用技术", "艺术", "音乐", "美术"
]
# 七大维度体系
DIMENSION_FRAMEWORK = {
"课程领导力": {
"sub_dimensions": ["国家标准遵循", "课程结构建设", "课程规范落实"],
},
"教学变革力": {
"sub_dimensions": ["教学方式变革", "作业设计与管理变革"],
},
"学生发展指导力": {
"sub_dimensions": ["学科发展的个性化辅导", "学生生涯发展指导"],
},
"教师发展支持力": {
"sub_dimensions": ["培训支持", "教研支持", "项目支持"],
},
"教育质量评估力": {
"sub_dimensions": ["科学评价观", "学业质量评估", "综合素质评估", "实践活动评估"],
},
"教育条件保障力": {
"sub_dimensions": ["区域推进", "环境支持", "资源支持"],
},
"数字化赋能力": {
"sub_dimensions": ["教学方式创新", "评价精准化与个性化", "课程迭代优化"],
},
}
# 三级维度水平划分阈值 (从赋分整理表和报告中提取)
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},
}
# 水平的质性描述(从报告表1-2提取)
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: "学校几乎不开展数字化转型活动,尚未建成信息化管理系统",
},
}
+1
View File
@@ -0,0 +1 @@
+255
View File
@@ -0,0 +1,255 @@
"""
数据引擎:Excel解析 + 数据清洗 + 赋分计算
负责将原始Excel数据转换为每所学校的结构化得分数据
"""
import pandas as pd
import numpy as np
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import logging
from ..config import (
EXCEL_BASIC_INFO, EXCEL_COURSE_IMPL, EXCEL_SUBJECT_IMPL,
SCHOOL_NAME_MAP, SUBJECTS, SCHOOL_TYPE_MAP,
)
logger = logging.getLogger(__name__)
class DataEngine:
"""数据引擎:读取Excel、清洗、结构化"""
def __init__(self, data_dir: Optional[Path] = None):
self.data_dir = data_dir
self._basic_info: Optional[pd.DataFrame] = None
self._course_impl: Optional[pd.DataFrame] = None
self._subject_impl: Optional[pd.DataFrame] = None
def load_all(self) -> None:
"""加载所有Excel数据"""
logger.info("开始加载Excel数据...")
self._basic_info = self._load_basic_info()
self._course_impl = self._load_course_impl()
self._subject_impl = self._load_subject_impl()
logger.info(
f"数据加载完成: 基础信息={len(self._basic_info)}行, "
f"课程实施={len(self._course_impl)}行, "
f"学科课程={len(self._subject_impl)}"
)
def _standardize_school_name(self, df: pd.DataFrame, col: str) -> pd.DataFrame:
"""标准化学校名称"""
df[col] = df[col].map(lambda x: SCHOOL_NAME_MAP.get(x, x))
return df
def _load_basic_info(self) -> pd.DataFrame:
"""加载基础信息表"""
df = pd.read_excel(EXCEL_BASIC_INFO)
df = self._standardize_school_name(df, "学校名称")
return df
def _load_course_impl(self) -> pd.DataFrame:
"""加载课程实施情况表"""
df = pd.read_excel(EXCEL_COURSE_IMPL)
df = self._standardize_school_name(df, "学校简称")
# 统一列名
df = df.rename(columns={"学校简称": "学校名称"})
return df
def _load_subject_impl(self) -> pd.DataFrame:
"""加载学科课程实施情况表"""
df = pd.read_excel(EXCEL_SUBJECT_IMPL)
df = self._standardize_school_name(df, "学校简称")
df = df.rename(columns={"学校简称": "学校名称"})
return df
@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]
info = SCHOOL_TYPE_MAP.get(school, {})
# 提取关键字段
def _get_field(field_name):
rows = df[df["字段名称"] == field_name]
if len(rows) > 0:
return rows.iloc[0]["字段值"]
return None
info.update({
"school_name": school,
"建校年份": _get_field("建校年份"),
"教师总数": _get_field("学校教师总数"),
"占地面积": _get_field("占地面积"),
"建筑面积": _get_field("建筑面积"),
})
return info
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) -> 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) -> Dict:
"""获取教学方式变革相关数据(认识程度、落实程度、实施方式)"""
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) -> Dict:
"""获取作业设计与管理数据"""
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 get_field_by_id(self, school: str, source: str, field_id: str,
subject: Optional[str] = None) -> List[Dict]:
"""通过字段ID获取数据"""
if source == "basic":
df = self._basic_info[self._basic_info["学校名称"] == school]
elif source == "course":
df = self.get_school_course_data(school)
elif source == "subject":
df = self.get_school_subject_data(school, subject)
else:
return []
rows = df[df["字段ID"] == field_id]
return rows.to_dict("records")
def build_score_matrix(self) -> pd.DataFrame:
"""
构建所有学校×所有字段的得分矩阵
这是赋分引擎的输入
"""
if self._course_impl is None:
self.load_all()
records = []
for school in self.schools:
# 课程实施表数据
course_df = self.get_school_course_data(school)
for _, row in course_df.iterrows():
records.append({
"学校": school,
"来源": "course",
"题号": row.get("题号"),
"字段ID": row.get("字段ID"),
"字段名称": row.get("字段名称"),
"字段取值": row.get("字段取值"),
"学科": row.get("学科", "不分学科"),
"年级": row.get("年级", "不分年级"),
"学期": row.get("学期", ""),
})
# 学科课程表数据
subject_df = self.get_school_subject_data(school)
for _, row in subject_df.iterrows():
records.append({
"学校": school,
"来源": "subject",
"题号": row.get("题号"),
"字段ID": row.get("字段ID"),
"字段名称": row.get("字段名称"),
"字段取值": row.get("字段取值"),
"学科": row.get("学科", "不分学科"),
"年级": "",
"学期": "",
})
matrix = pd.DataFrame(records)
logger.info(f"得分矩阵构建完成: {len(matrix)}行, {len(self.schools)}所学校")
return matrix
def summary(self) -> Dict:
"""数据摘要"""
if self._basic_info is None:
self.load_all()
return {
"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,
"school_types": {s: SCHOOL_TYPE_MAP[s]["type"] for s in self.schools},
}
File diff suppressed because it is too large Load Diff
+777
View File
@@ -0,0 +1,777 @@
"""
报告渲染引擎:将数据+LLM文字+模板组装成最终HTML报告
"""
from datetime import datetime
from pathlib import Path
from typing import Dict, List
import numpy as np
from jinja2 import Environment, FileSystemLoader
from ..config import (
TEMPLATES_DIR, DIMENSION_FRAMEWORK, LEVEL_DESCRIPTIONS,
OUTPUT_DIR, SCHOOL_TYPE_MAP,
)
class ReportRenderer:
"""HTML报告渲染引擎"""
def __init__(self):
self.env = Environment(
loader=FileSystemLoader(str(TEMPLATES_DIR)),
autoescape=False, # 允许HTML直接渲染
)
def render(self, report_data: Dict, llm_sections: Dict[str, str]) -> str:
"""
渲染完整HTML报告
report_data: stats_engine.compute_school_report_data() 的输出
llm_sections: llm_engine.generate_report_segments() 的输出
"""
template = self.env.get_template("base.html")
# 准备模板数据
school = report_data["school"]
context = {
"school": school,
"school_info": report_data["school_info"],
"overall": self._to_namespace(report_data["overall"]),
"dimensions": {
name: self._to_namespace(data)
for name, data in report_data["dimensions"].items()
},
"sub_dimensions": {
name: self._to_namespace(data)
for name, data in report_data["sub_dimensions"].items()
},
"framework": {
name: self._to_namespace(info)
for name, info in DIMENSION_FRAMEWORK.items()
},
"llm_sections": llm_sections,
"level_descriptions": LEVEL_DESCRIPTIONS,
"generation_date": datetime.now().strftime("%Y年%m月%d"),
# ECharts数据 — 传原始dict,由模板的tojson过滤器序列化一次
"radar_data": self._build_radar_data(report_data),
"sub_dim_chart_data": self._build_sub_dim_charts(report_data),
"score_compare_data": self._build_score_compare(report_data),
"cluster_radar_data": self._build_cluster_radar(report_data),
"correlation_data": self._build_correlation_heatmap(report_data),
"level_dist_data": self._build_level_distribution(report_data),
"school_ranking_data": self._build_school_ranking(report_data),
# 新增图表数据
"cluster_type_dist_data": self._build_cluster_type_distribution(report_data),
"cluster_line_compare_data": self._build_cluster_line_compare(report_data),
"dim_scatter_data": self._build_dim_scatter_charts(report_data),
"dim_score_bar_data": self._build_dim_score_bars(report_data),
"dim_sub_radar_data": self._build_dim_sub_radar_charts(report_data),
# 创新图表
"profile_card_data": self._build_profile_card(report_data),
"quadrant_data": self._build_quadrant_chart(report_data),
"thermometer_data": self._build_thermometer_data(report_data),
"waterfall_data": self._build_waterfall_chart(report_data),
}
return template.render(**context)
def render_to_file(self, report_data: Dict, llm_sections: Dict[str, str],
output_path: Path = None) -> Path:
"""渲染并保存到文件"""
html = self.render(report_data, llm_sections)
school = report_data["school"]
if output_path is None:
output_dir = OUTPUT_DIR
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / f"{school}_报告.html"
output_path.write_text(html, encoding="utf-8")
return output_path
def _build_radar_data(self, report_data: Dict) -> Dict:
"""构建雷达图数据"""
school = report_data["school"]
dims = list(report_data["dimensions"].keys())
school_values = [report_data["dimensions"][d]["score"] for d in dims]
avg_values = [report_data["dimensions"][d]["district_avg"] for d in dims]
return {
"dimensions": dims,
"legend": [school, "区均值"],
"series": [
{"name": school, "values": school_values},
{"name": "区均值", "values": avg_values},
],
}
def _build_sub_dim_charts(self, report_data: Dict) -> Dict:
"""构建各维度的子维度柱状图数据"""
charts = {}
part_names = {
"课程领导力": "part3", "教学变革力": "part4", "学生发展指导力": "part5",
"教师发展支持力": "part6", "教育质量评估力": "part7",
"教育条件保障力": "part8", "数字化赋能力": "part9",
}
for dim_name, info in DIMENSION_FRAMEWORK.items():
part_id = part_names[dim_name]
sub_dims = info["sub_dimensions"]
categories = []
school_values = []
avg_values = []
for sd in sub_dims:
sd_data = report_data["sub_dimensions"].get(sd, {})
if sd_data:
categories.append(sd)
school_values.append(round(sd_data["score"], 2))
avg_values.append(round(sd_data["district_avg"], 2))
charts[part_id] = {
"categories": categories,
"school_values": school_values,
"avg_values": avg_values,
}
return charts
def _build_score_compare(self, report_data: Dict) -> Dict:
"""构建得分对比横向条形图数据:本校 vs 区均值 vs 同类学校均值"""
school = report_data["school"]
dims = list(report_data["dimensions"].keys())
return {
"categories": dims,
"school_values": [round(report_data["dimensions"][d]["score"], 2) for d in dims],
"district_avg": [round(report_data["dimensions"][d]["district_avg"], 2) for d in dims],
"same_type_avg": [round(report_data["dimensions"][d]["same_type_avg"], 2) for d in dims],
"school_name": school,
}
def _build_cluster_radar(self, report_data: Dict) -> Dict:
"""构建聚类类型特征对比雷达图(较好类 vs 待提升类)"""
all_dim_scores = report_data.get("all_schools_dim_scores", {})
if not all_dim_scores:
return {}
# 从 overall cluster info 中提取各学校的聚类标签
school = report_data["school"]
dims = list(report_data["dimensions"].keys())
# 计算各学校的总体得分来判断聚类
school_totals = {}
for s in list(list(all_dim_scores.values())[0].keys()):
total = 0
for d in dims:
total += all_dim_scores.get(d, {}).get(s, 50)
school_totals[s] = total / len(dims)
# 二分聚类(简单按总分中位数分)
median_score = sorted(school_totals.values())[len(school_totals) // 2]
good_schools = [s for s, v in school_totals.items() if v >= median_score]
weak_schools = [s for s, v in school_totals.items() if v < median_score]
good_avgs = []
weak_avgs = []
for d in dims:
dim_data = all_dim_scores.get(d, {})
good_avgs.append(round(sum(dim_data.get(s, 50) for s in good_schools) / max(len(good_schools), 1), 2))
weak_avgs.append(round(sum(dim_data.get(s, 50) for s in weak_schools) / max(len(weak_schools), 1), 2))
return {
"dimensions": dims,
"legend": ["课程实施较好类", "课程实施待提升类", school],
"series": [
{"name": "课程实施较好类", "values": good_avgs},
{"name": "课程实施待提升类", "values": weak_avgs},
{"name": school, "values": [round(report_data["dimensions"][d]["score"], 2) for d in dims]},
],
"good_count": len(good_schools),
"weak_count": len(weak_schools),
}
def _build_correlation_heatmap(self, report_data: Dict) -> Dict:
"""构建维度间相关性热力图"""
correlation = report_data.get("correlation", {})
if not correlation:
return {}
dims = list(correlation.keys())
# 构建二维数组 [x_index, y_index, value]
data = []
for i, d1 in enumerate(dims):
for j, d2 in enumerate(dims):
val = correlation.get(d1, {}).get(d2, 0)
data.append([i, j, round(val, 3) if val is not None else 0])
# 短名
short_names = [d.replace("", "").replace("教育", "") for d in dims]
return {
"dimensions": dims,
"short_names": short_names,
"data": data,
}
def _build_level_distribution(self, report_data: Dict) -> Dict:
"""构建各三级维度水平分布堆叠条形图"""
charts = {}
part_names = {
"课程领导力": "part3", "教学变革力": "part4", "学生发展指导力": "part5",
"教师发展支持力": "part6", "教育质量评估力": "part7",
"教育条件保障力": "part8", "数字化赋能力": "part9",
}
for dim_name, info in DIMENSION_FRAMEWORK.items():
part_id = part_names[dim_name]
sub_dims = info["sub_dimensions"]
categories = []
level1_pcts = []
level2_pcts = []
level3_pcts = []
level4_pcts = []
school_levels = []
for sd in sub_dims:
sd_data = report_data["sub_dimensions"].get(sd, {})
if not sd_data:
continue
dist = sd_data.get("level_distribution", {})
total = sum(dist.values())
if total == 0:
continue
categories.append(sd)
level1_pcts.append(round(dist.get("水平1", 0) / total * 100, 1))
level2_pcts.append(round(dist.get("水平2", 0) / total * 100, 1))
level3_pcts.append(round(dist.get("水平3", 0) / total * 100, 1))
level4_pcts.append(round(dist.get("水平4", 0) / total * 100, 1))
school_levels.append(sd_data.get("level", 0))
charts[part_id] = {
"categories": categories,
"level1": level1_pcts,
"level2": level2_pcts,
"level3": level3_pcts,
"level4": level4_pcts,
"school_levels": school_levels,
}
return charts
def _build_school_ranking(self, report_data: Dict) -> Dict:
"""构建区内各校维度排名对比图"""
school = report_data["school"]
all_dim_scores = report_data.get("all_schools_dim_scores", {})
dims = list(report_data["dimensions"].keys())
if not all_dim_scores:
return {}
# 获取所有学校名
first_dim = list(all_dim_scores.values())[0] if all_dim_scores else {}
all_schools = list(first_dim.keys())
# 计算每校总体得分并排序
school_totals = {}
for s in all_schools:
total = sum(all_dim_scores.get(d, {}).get(s, 50) for d in dims)
school_totals[s] = round(total / len(dims), 2)
sorted_schools = sorted(school_totals.items(), key=lambda x: x[1], reverse=True)
return {
"schools": [s[0] for s in sorted_schools],
"total_scores": [s[1] for s in sorted_schools],
"current_school": school,
"dimensions": dims,
"dim_scores": {
d: [round(all_dim_scores.get(d, {}).get(s[0], 50), 2) for s in sorted_schools]
for d in dims
},
}
def _build_cluster_type_distribution(self, report_data: Dict) -> Dict:
"""
构建课程实施类型分布饼/条形图数据(如参考报告图2-2)
展示 较好类 vs 待提升类 在区内各校的分布
"""
all_dim_scores = report_data.get("all_schools_dim_scores", {})
if not all_dim_scores:
return {}
school = report_data["school"]
dims = list(report_data["dimensions"].keys())
# 计算各学校总体得分并二分聚类
school_totals = {}
first_dim_data = list(all_dim_scores.values())[0] if all_dim_scores else {}
all_schools = list(first_dim_data.keys())
for s in all_schools:
total = sum(all_dim_scores.get(d, {}).get(s, 50) for d in dims)
school_totals[s] = total / len(dims)
median_score = sorted(school_totals.values())[len(school_totals) // 2]
good_schools = [s for s, v in school_totals.items() if v >= median_score]
weak_schools = [s for s, v in school_totals.items() if v < median_score]
school_cluster = "较好" if school in good_schools else "待提升"
return {
"good_count": len(good_schools),
"weak_count": len(weak_schools),
"total": len(all_schools),
"school_cluster": school_cluster,
"school_name": school,
"good_schools": good_schools,
"weak_schools": weak_schools,
}
def _build_cluster_line_compare(self, report_data: Dict) -> Dict:
"""
构建两类学校特征折线对比图(如参考报告图2-3)
两条折线:较好类 vs 待提升类在7个维度上的得分
"""
all_dim_scores = report_data.get("all_schools_dim_scores", {})
if not all_dim_scores:
return {}
school = report_data["school"]
dims = list(report_data["dimensions"].keys())
# 计算聚类
first_dim_data = list(all_dim_scores.values())[0] if all_dim_scores else {}
all_schools = list(first_dim_data.keys())
school_totals = {}
for s in all_schools:
total = sum(all_dim_scores.get(d, {}).get(s, 50) for d in dims)
school_totals[s] = total / len(dims)
median_score = sorted(school_totals.values())[len(school_totals) // 2]
good_schools = [s for s, v in school_totals.items() if v >= median_score]
weak_schools = [s for s, v in school_totals.items() if v < median_score]
good_avgs = []
weak_avgs = []
for d in dims:
dim_data = all_dim_scores.get(d, {})
good_avgs.append(round(sum(dim_data.get(s, 50) for s in good_schools) / max(len(good_schools), 1), 2))
weak_avgs.append(round(sum(dim_data.get(s, 50) for s in weak_schools) / max(len(weak_schools), 1), 2))
return {
"dimensions": dims,
"good_values": good_avgs,
"weak_values": weak_avgs,
"good_count": len(good_schools),
"weak_count": len(weak_schools),
}
def _build_dim_scatter_charts(self, report_data: Dict) -> Dict:
"""
构建各二级维度的聚类散点图数据(2D / 3D)
参考报告中每个维度都有一个散点图显示所有学校的聚类分布
对于有2个子维度的 → 2D散点图
对于有3个子维度的 → 3D散点图
对于有4个子维度的 → 取前2个主成分的2D散点图
"""
all_sub_scores = report_data.get("all_schools_sub_scores", {})
if not all_sub_scores:
return {}
school = report_data["school"]
charts = {}
part_names = {
"课程领导力": "part3", "教学变革力": "part4", "学生发展指导力": "part5",
"教师发展支持力": "part6", "教育质量评估力": "part7",
"教育条件保障力": "part8", "数字化赋能力": "part9",
}
first_sub = list(all_sub_scores.values())[0] if all_sub_scores else {}
all_schools = list(first_sub.keys())
for dim_name, info in DIMENSION_FRAMEWORK.items():
part_id = part_names[dim_name]
sub_dims = info["sub_dimensions"]
n_subs = len(sub_dims)
# 获取各学校在这些子维度上的得分
school_scores = {}
for s in all_schools:
scores = []
for sd in sub_dims:
val = all_sub_scores.get(sd, {}).get(s, 50)
scores.append(round(float(val), 2))
school_scores[s] = scores
# 简单二分聚类
totals = {s: sum(v) / len(v) for s, v in school_scores.items()}
med = sorted(totals.values())[len(totals) // 2]
clusters = {s: 0 if totals[s] >= med else 1 for s in all_schools}
if n_subs == 2:
# 2D散点图
data_good = []
data_weak = []
school_point = None
for s in all_schools:
point = school_scores[s]
if s == school:
school_point = point
elif clusters[s] == 0:
data_good.append(point)
else:
data_weak.append(point)
charts[part_id] = {
"type": "2d",
"axes": sub_dims,
"good_data": data_good,
"weak_data": data_weak,
"school_point": school_point,
"school_name": school,
"dim_name": dim_name,
}
elif n_subs == 3:
# 3D散点图
data_good = []
data_weak = []
school_point = None
for s in all_schools:
point = school_scores[s]
if s == school:
school_point = point
elif clusters[s] == 0:
data_good.append(point)
else:
data_weak.append(point)
charts[part_id] = {
"type": "3d",
"axes": sub_dims,
"good_data": data_good,
"weak_data": data_weak,
"school_point": school_point,
"school_name": school,
"dim_name": dim_name,
}
elif n_subs >= 4:
# 取前两个子维度做2D散点
axes = sub_dims[:2]
data_good = []
data_weak = []
school_point = None
for s in all_schools:
point = school_scores[s][:2]
if s == school:
school_point = point
elif clusters[s] == 0:
data_good.append(point)
else:
data_weak.append(point)
charts[part_id] = {
"type": "2d",
"axes": axes,
"good_data": data_good,
"weak_data": data_weak,
"school_point": school_point,
"school_name": school,
"dim_name": dim_name,
}
return charts
def _build_dim_score_bars(self, report_data: Dict) -> Dict:
"""
构建各维度独立得分柱状图数据(如参考报告图3-1、图4-1等)
展示本校 vs 区均值 vs 同类学校均值 的对比
"""
school = report_data["school"]
charts = {}
part_names = {
"课程领导力": "part3", "教学变革力": "part4", "学生发展指导力": "part5",
"教师发展支持力": "part6", "教育质量评估力": "part7",
"教育条件保障力": "part8", "数字化赋能力": "part9",
}
for dim_name, dim_data in report_data["dimensions"].items():
part_id = part_names.get(dim_name, "")
if not part_id:
continue
charts[part_id] = {
"dim_name": dim_name,
"school_name": school,
"school_score": round(dim_data["score"], 2),
"district_avg": round(dim_data["district_avg"], 2),
"same_type_avg": round(dim_data["same_type_avg"], 2),
}
return charts
def _build_dim_sub_radar_charts(self, report_data: Dict) -> Dict:
"""
构建各二级维度的子维度雷达图(如参考报告图3-2)
多条线对比: 本校 vs 区均值 vs 同类学校均值
"""
school = report_data["school"]
all_sub_scores = report_data.get("all_schools_sub_scores", {})
charts = {}
part_names = {
"课程领导力": "part3", "教学变革力": "part4", "学生发展指导力": "part5",
"教师发展支持力": "part6", "教育质量评估力": "part7",
"教育条件保障力": "part8", "数字化赋能力": "part9",
}
# Get same-type schools
school_info = report_data.get("school_info", {})
school_type = school_info.get("type", "")
all_schools = list(list(all_sub_scores.values())[0].keys()) if all_sub_scores else []
same_type_schools = [s for s in all_schools if SCHOOL_TYPE_MAP.get(s, {}).get("type") == school_type]
for dim_name, info in DIMENSION_FRAMEWORK.items():
part_id = part_names.get(dim_name, "")
if not part_id:
continue
sub_dims = info["sub_dimensions"]
school_values = []
district_avg = []
same_type_avg = []
for sd in sub_dims:
sd_data = report_data["sub_dimensions"].get(sd, {})
if sd_data:
school_values.append(round(sd_data["score"], 2))
district_avg.append(round(sd_data["district_avg"], 2))
# 计算同类学校均值
if same_type_schools and all_sub_scores:
st_vals = [all_sub_scores.get(sd, {}).get(s, 50) for s in same_type_schools]
same_type_avg.append(round(sum(float(v) for v in st_vals) / len(st_vals), 2))
else:
same_type_avg.append(district_avg[-1])
if len(sub_dims) >= 3:
charts[part_id] = {
"type": "radar",
"sub_dims": sub_dims,
"school_name": school,
"school_values": school_values,
"district_avg": district_avg,
"same_type_avg": same_type_avg,
}
else:
charts[part_id] = {
"type": "bar",
"sub_dims": sub_dims,
"school_name": school,
"school_values": school_values,
"district_avg": district_avg,
"same_type_avg": same_type_avg,
}
return charts
# ========== 创新图表数据构建 ==========
def _build_profile_card(self, report_data: Dict) -> Dict:
"""
A. 学校画像卡(综合仪表盘)
- 总体得分环形仪表盘
- 7个维度的红绿灯状态(基于各维度下子维度的最低水平)
- 20个三级维度的水平分布概览
"""
school = report_data["school"]
overall = report_data["overall"]
# 维度红绿灯:每个二级维度取其子维度的最低水平作为"短板"指示
dim_signals = []
for dim_name, dim_data in report_data["dimensions"].items():
sub_dims = DIMENSION_FRAMEWORK[dim_name]["sub_dimensions"]
levels = []
for sd in sub_dims:
sd_data = report_data["sub_dimensions"].get(sd, {})
if sd_data:
levels.append(sd_data.get("level", 0))
min_level = min(levels) if levels else 0
avg_level = round(sum(levels) / len(levels), 1) if levels else 0
dim_signals.append({
"name": dim_name,
"score": round(dim_data["score"], 2),
"min_level": min_level,
"avg_level": avg_level,
"rank": dim_data.get("rank_in_district", 0),
})
# 20个三级维度的水平分布统计
level_counts = {1: 0, 2: 0, 3: 0, 4: 0}
for sd_name, sd_data in report_data["sub_dimensions"].items():
lv = sd_data.get("level", 0)
if lv in level_counts:
level_counts[lv] += 1
return {
"school_name": school,
"school_type": report_data["school_info"].get("type", ""),
"total_score": overall["score"],
"district_avg": overall["district_avg"],
"rank": overall["rank_in_district"],
"total_schools": overall["total_schools"],
"cluster": overall.get("cluster", ""),
"dim_signals": dim_signals,
"level_counts": level_counts,
"total_sub_dims": sum(level_counts.values()),
}
def _build_quadrant_chart(self, report_data: Dict) -> Dict:
"""
B. 优势-短板象限图(Gap Analysis
X轴 = 得分, Y轴 = 与区均值的差值
四象限:右上=核心优势, 左下=急需改进, 右下=隐性风险, 左上=潜力项
"""
school = report_data["school"]
items = []
for sd_name, sd_data in report_data["sub_dimensions"].items():
parent_dim = sd_data.get("parent_dimension", "")
items.append({
"name": sd_name,
"parent": parent_dim,
"score": round(sd_data["score"], 2),
"diff": round(sd_data["diff_district"], 2),
"level": sd_data.get("level", 0),
})
return {
"school_name": school,
"items": items,
"x_center": 50, # 区均值(标准化后均值=50
"y_center": 0, # 差值=0 的参照线
}
def _build_thermometer_data(self, report_data: Dict) -> Dict:
"""
C. 维度温度计条形图数据
为每个三级维度构建横向温度计数据:
- 得分范围20-80
- 水平分界线
- 本校位置、区均值位置、同类学校均值位置
"""
school = report_data["school"]
school_type = report_data["school_info"].get("type", "")
all_sub_scores = report_data.get("all_schools_sub_scores", {})
same_type_schools = [s for s in SCHOOL_TYPE_MAP
if SCHOOL_TYPE_MAP[s].get("type") == school_type]
thermometers = {}
for sd_name, sd_data in report_data["sub_dimensions"].items():
# 同类学校均值
if same_type_schools and sd_name in all_sub_scores:
st_vals = [float(all_sub_scores[sd_name].get(s, 50)) for s in same_type_schools]
same_type_avg = round(sum(st_vals) / len(st_vals), 2)
else:
same_type_avg = round(sd_data["district_avg"], 2)
# 水平阈值
from ..config import LEVEL_THRESHOLDS
thresholds = LEVEL_THRESHOLDS.get(sd_name, {})
thermometers[sd_name] = {
"score": round(sd_data["score"], 2),
"district_avg": round(sd_data["district_avg"], 2),
"same_type_avg": same_type_avg,
"level": sd_data.get("level", 0),
"thresholds": {
"level4": thresholds.get("level4", 57),
"level3": thresholds.get("level3", 50),
"level2": thresholds.get("level2", 43),
},
}
return {
"school_name": school,
"data": thermometers,
}
def _build_waterfall_chart(self, report_data: Dict) -> Dict:
"""
D. 进步空间瀑布图
展示:如果每个低于水平3的子维度提升到水平3的阈值,总分增加多少
让校长看到"改哪几个点收益最大"
"""
from ..config import LEVEL_THRESHOLDS
school = report_data["school"]
current_total = report_data["overall"]["score"]
# 找出所有低于水平3的子维度
improvement_items = []
for sd_name, sd_data in report_data["sub_dimensions"].items():
level = sd_data.get("level", 0)
if level < 3:
current_score = sd_data["score"]
# 提升到水平3的阈值
target_score = LEVEL_THRESHOLDS.get(sd_name, {}).get("level3", 50)
gap = round(target_score - current_score, 2)
if gap > 0:
improvement_items.append({
"name": sd_name,
"parent": sd_data.get("parent_dimension", ""),
"current_score": round(current_score, 2),
"target_score": round(target_score, 2),
"gap": gap,
"current_level": level,
})
# 按收益从大到小排序
improvement_items.sort(key=lambda x: x["gap"], reverse=True)
# 估算总分提升(简化:假设20个子维度等权重影响总分)
total_sub_dims = len(report_data["sub_dimensions"])
cumulative = current_total
waterfall_steps = [{"name": "当前总分", "value": round(current_total, 2), "type": "current"}]
for item in improvement_items:
# 粗略估算:子维度提升gap分 → 总分提升 gap / total_sub_dims * 权重
# 实际PCA权重不同,此处用等权近似
estimated_gain = round(item["gap"] / total_sub_dims, 2)
cumulative += estimated_gain
waterfall_steps.append({
"name": item["name"],
"value": round(estimated_gain, 2),
"type": "gain",
"detail": f"从水平{item['current_level']}→水平3 (+{item['gap']}分)",
})
waterfall_steps.append({"name": "潜在总分", "value": round(cumulative, 2), "type": "potential"})
return {
"school_name": school,
"current_total": round(current_total, 2),
"potential_total": round(cumulative, 2),
"total_gain": round(cumulative - current_total, 2),
"steps": waterfall_steps,
"improvements": improvement_items,
}
@staticmethod
def _to_namespace(d: Dict) -> Dict:
"""将dict转为可用点号访问的对象(Jinja2兼容)"""
class Namespace(dict):
def __getattr__(self, key):
try:
return self[key]
except KeyError:
return None
return Namespace(d) if isinstance(d, dict) else d
+850
View File
@@ -0,0 +1,850 @@
"""
赋分引擎:将原始题目回答按赋分规则转换为分数
基于赋分整理表的规则,实现各类赋分函数
"""
import pandas as pd
import numpy as np
from typing import Dict, List, Optional, Tuple
import logging
import re
from ..config import DIMENSION_FRAMEWORK, SUBJECTS
from .data_engine import DataEngine
logger = logging.getLogger(__name__)
class ScoringEngine:
"""
赋分引擎
赋分逻辑基于"高中课程实施监测指标、题目、赋分整理0127.xlsx"
核心策略:按维度分组,从原始数据中提取相关字段,按规则赋分,
然后将赋分结果交给统计引擎做PCA合成
"""
def __init__(self, data_engine: DataEngine):
self.data_engine = data_engine
def score_all_schools(self) -> Dict[str, Dict[str, List[float]]]:
"""
对所有学校的所有维度进行赋分
返回: {
school_name: {
sub_dimension_name: [score1, score2, ...]
}
}
"""
result = {}
for school in self.data_engine.schools:
logger.info(f"正在对 {school} 进行赋分...")
result[school] = self._score_school(school)
return result
def _score_school(self, school: str) -> Dict[str, List[float]]:
"""对单个学校进行全维度赋分"""
scores = {}
# 课程领导力
scores["国家标准遵循"] = self._score_national_standard(school)
scores["课程结构建设"] = self._score_course_structure(school)
scores["课程规范落实"] = self._score_course_norms(school)
# 教学变革力
scores["教学方式变革"] = self._score_teaching_reform(school)
scores["作业设计与管理变革"] = self._score_homework_reform(school)
# 学生发展指导力
scores["学科发展的个性化辅导"] = self._score_personalized_tutoring(school)
scores["学生生涯发展指导"] = self._score_career_guidance(school)
# 教师发展支持力
scores["培训支持"] = self._score_training_support(school)
scores["教研支持"] = self._score_research_support(school)
scores["项目支持"] = self._score_project_support(school)
# 教育质量评估力
scores["科学评价观"] = self._score_scientific_evaluation(school)
scores["学业质量评估"] = self._score_academic_evaluation(school)
scores["综合素质评估"] = self._score_comprehensive_evaluation(school)
scores["实践活动评估"] = self._score_practice_evaluation(school)
# 教育条件保障力
scores["区域推进"] = self._score_regional_promotion(school)
scores["环境支持"] = self._score_environment_support(school)
scores["资源支持"] = self._score_resource_support(school)
# 数字化赋能力
scores["教学方式创新"] = self._score_digital_teaching(school)
scores["评价精准化与个性化"] = self._score_digital_evaluation(school)
scores["课程迭代优化"] = self._score_digital_curriculum(school)
return scores
# ========== 课程领导力 ==========
def _score_national_standard(self, school: str) -> List[float]:
"""
国家标准遵循:开足开齐国家课程
赋分:必修课程学分低于标准=0,高于标准=1,与标准一致=2
选必/选修课程:低于标准=0,达到标准=1
"""
scores = []
hours_df = self.data_engine.get_weekly_hours(school)
if len(hours_df) == 0:
return [1.0] # 默认中等
# 按课程类型汇总
for course_type, field_name in [
("必修", "学科必修课周课时"),
("选必", "学科选择性必修课周课时"),
("选修", "学科类选修课周课时"),
]:
type_df = hours_df[hours_df["字段名称"] == field_name]
if len(type_df) == 0:
scores.append(0.5)
continue
# 按学科汇总总课时
total = type_df.groupby("学科")["字段取值"].sum()
has_courses = (total > 0).sum()
total_hours = total.sum()
if course_type == "必修":
# 必修课:与标准一致=2,高于=1,低于=0
# 简化处理:有多少学科开了课
exam_subjects = ["语文", "数学", "英语", "物理", "化学", "生物学",
"历史", "地理", "思想政治"]
opened = sum(1 for s in exam_subjects if s in total.index and total.get(s, 0) > 0)
if opened >= len(exam_subjects):
scores.append(2.0)
elif opened >= 6:
scores.append(1.0)
else:
scores.append(0.0)
else:
# 选必/选修:达到标准=1,低于=0
if total_hours > 0:
scores.append(1.0)
else:
scores.append(0.0)
return scores if scores else [1.0]
def _score_course_structure(self, school: str) -> List[float]:
"""
课程结构建设:学科类课程结构、校本特色课程结构、综合实践活动与劳动
赋分:按照离差百分比和填报数值
"""
scores = []
hours_df = self.data_engine.get_weekly_hours(school)
if len(hours_df) > 0:
# 1. 学科类课程结构:各学科三类课程的离差
by_subject = hours_df.groupby(["学科", "字段名称"])["字段取值"].sum().unstack(fill_value=0)
if len(by_subject) > 0:
total_per_subject = by_subject.sum(axis=1)
overall_total = total_per_subject.sum()
if overall_total > 0:
proportions = total_per_subject / overall_total
mean_prop = proportions.mean()
deviation = np.abs(proportions - mean_prop).sum()
# 离差越小越好,标准化到0-3分
structure_score = max(0, 3 - deviation * 10)
scores.append(structure_score)
else:
scores.append(1.0)
else:
scores.append(1.0)
# 2. 校本特色课程:跨学科选修课门数、综合主题选修课门数
course_df = self.data_engine.get_school_course_data(school)
for field in ["跨学科选修课门数", "综合主题选修课门数", "综合实践选修课门数"]:
vals = course_df[course_df["字段名称"] == field]["字段取值"]
if len(vals) > 0:
try:
v = float(vals.iloc[0])
scores.append(min(v / 5, 3.0)) # 归一化
except (ValueError, TypeError):
scores.append(0.5)
# 3. 综合实践活动与劳动
for field in ["三年应完成的研究性学习数量", "三年社会考察个数", "三年志愿服务时长"]:
vals = course_df[course_df["字段名称"] == field]["字段取值"]
if len(vals) > 0:
try:
v = float(vals.iloc[0])
scores.append(min(v / 10, 3.0))
except (ValueError, TypeError):
scores.append(0.5)
return scores if scores else [1.0]
def _score_course_norms(self, school: str) -> List[float]:
"""
课程规范落实:建设规范文本 + 档案规范
赋分:有=2,无=0
"""
scores = []
norms = self.data_engine.get_course_norms(school)
norm_score = 0
archive_score = 0
norm_count = 0
archive_count = 0
for n in norms:
name = str(n.get("字段名称", ""))
val = str(n.get("字段取值", ""))
if "建设规范文本" in name:
norm_count += 1
if val and val not in ["0", "nan", "None", ""]:
norm_score += 2
elif "档案" in name:
archive_count += 1
if "已经建成并使用" in name and val == "1":
archive_score += 2
elif "已经建成" in name and val == "1":
archive_score += 1
if norm_count > 0:
scores.append(norm_score / norm_count * 2)
if archive_count > 0:
scores.append(archive_score / archive_count * 2)
return scores if scores else [1.0]
# ========== 教学变革力 ==========
def _score_teaching_reform(self, school: str) -> List[float]:
"""
教学方式变革:认识程度 + 落实程度 + 实施方式
赋分:量表题4/3/2/1分;多选每项1分加总
"""
scores = []
subject_df = self.data_engine.get_school_subject_data(school)
for subject in SUBJECTS:
sub_df = subject_df[subject_df["学科"] == subject]
sub_scores = []
# 认识程度类题目
for keyword in ["认识程度", "认识"]:
rows = sub_df[sub_df["字段名称"].str.contains(keyword, na=False)]
for _, row in rows.iterrows():
s = self._score_likert(row["字段取值"], reverse=False)
if s is not None:
sub_scores.append(s)
# 落实程度类题目
for keyword in ["落实程度", "落实"]:
rows = sub_df[sub_df["字段名称"].str.contains(keyword, na=False)]
for _, row in rows.iterrows():
s = self._score_likert(row["字段取值"], reverse=False)
if s is not None:
sub_scores.append(s)
# 实施方式(多选,体现形式类)
form_rows = sub_df[sub_df["字段名称"].str.contains("体现形式", na=False)]
if len(form_rows) > 0:
form_count = (form_rows["字段取值"].astype(str) == "1").sum()
sub_scores.append(min(form_count, 6))
if sub_scores:
scores.append(np.mean(sub_scores))
return scores if scores else [2.0]
def _score_homework_reform(self, school: str) -> List[float]:
"""
作业设计与管理变革:作业设计 + 作业管理
"""
scores = []
subject_df = self.data_engine.get_school_subject_data(school)
for subject in SUBJECTS:
sub_df = subject_df[subject_df["学科"] == subject]
sub_scores = []
# 作业设计:实践类/表现类/跨学科/团队合作作业是否布置
for hw_type in ["实践类作业_有布置", "表现类作业_有布置", "跨学科作业_有布置", "团队合作类作业_有布置"]:
rows = sub_df[sub_df["字段名称"] == hw_type]
if len(rows) > 0 and str(rows.iloc[0]["字段取值"]) == "1":
sub_scores.append(1.0)
else:
sub_scores.append(0.0)
# 作业属性标注(多选)
attr_rows = sub_df[sub_df["字段名称"].str.contains("作业属性标注", na=False)]
if len(attr_rows) > 0:
attr_count = (attr_rows["字段取值"].astype(str) == "1").sum()
sub_scores.append(min(attr_count, 5))
# 作业管理:时长控制、批改、评价
for field, score_map in [
("回家作业时长控制_学校控制", 3),
("回家作业时长控制_教研组负责", 2),
("作业批改范围_全部批改", 3),
("作业批改范围_部分练习", 1),
]:
rows = sub_df[sub_df["字段名称"] == field]
if len(rows) > 0 and str(rows.iloc[0]["字段取值"]) == "1":
sub_scores.append(score_map)
if sub_scores:
scores.append(np.mean(sub_scores))
return scores if scores else [1.5]
# ========== 学生发展指导力 ==========
def _score_personalized_tutoring(self, school: str) -> List[float]:
"""个性化辅导"""
scores = []
subject_df = self.data_engine.get_school_subject_data(school)
for subject in SUBJECTS:
sub_df = subject_df[subject_df["学科"] == subject]
# 辅导时长
for field, val in [
("个别辅导时长_每周>2h", 4),
("个别辅导时长_每周1~2h", 3),
("个别辅导时长_每周<1h", 2),
("个别辅导时长_几乎无", 1),
]:
rows = sub_df[sub_df["字段名称"] == field]
if len(rows) > 0 and str(rows.iloc[0]["字段取值"]) == "1":
scores.append(val)
break
# 辅导方式
for field, val in [
("个别辅导实施方式_分散辅导", 3),
("个别辅导实施方式_分组统一辅导", 2),
("个别辅导实施方式_班级统一辅导", 1),
]:
rows = sub_df[sub_df["字段名称"] == field]
if len(rows) > 0 and str(rows.iloc[0]["字段取值"]) == "1":
scores.append(val)
break
return scores if scores else [2.0]
def _score_career_guidance(self, school: str) -> List[float]:
"""生涯发展指导"""
scores = []
course_df = self.data_engine.get_school_course_data(school)
# 生涯指导实施方式
for field, val in [
("生涯指导实施方式_专设课程", 3),
("生涯指导实施方式_社会考察和志愿服务", 2),
]:
rows = course_df[course_df["字段名称"] == field]
if len(rows) > 0 and str(rows.iloc[0]["字段取值"]) == "1":
scores.append(val)
# 覆盖率
rows = course_df[course_df["字段名称"] == "完成生涯指导的学生占比_90%+"]
if len(rows) > 0 and str(rows.iloc[0]["字段取值"]) == "1":
scores.append(4)
else:
scores.append(2)
# 教师构成
for field, val in [
("生涯指导教师构成_本校和外聘结合", 4),
("生涯指导教师构成_本校为主", 3),
]:
rows = course_df[course_df["字段名称"] == field]
if len(rows) > 0 and str(rows.iloc[0]["字段取值"]) == "1":
scores.append(val)
break
# 资源支持
for resource in ["生涯指导的校外资源支持程度", "生涯指导的校内资源支持程度"]:
rows = course_df[course_df["字段名称"] == resource]
if len(rows) > 0:
s = self._score_resource_level(rows.iloc[0]["字段取值"])
if s is not None:
scores.append(s)
return scores if scores else [2.0]
# ========== 教师发展支持力 ==========
def _score_training_support(self, school: str) -> List[float]:
"""培训支持"""
scores = []
subject_df = self.data_engine.get_school_subject_data(school)
for subject in SUBJECTS:
sub_df = subject_df[subject_df["学科"] == subject]
# 提供外校培训的教师人数
rows = sub_df[sub_df["字段名称"] == "提供外校培训的教师人数"]
if len(rows) > 0:
try:
v = float(rows.iloc[0]["字段取值"])
scores.append(min(v, 5))
except (ValueError, TypeError):
pass
# 区域培训指导人数
rows = sub_df[sub_df["字段名称"] == "区域培训指导人数"]
if len(rows) > 0:
try:
v = float(rows.iloc[0]["字段取值"])
scores.append(min(v, 5))
except (ValueError, TypeError):
pass
return scores if scores else [1.0]
def _score_research_support(self, school: str) -> List[float]:
"""教研支持"""
scores = []
subject_df = self.data_engine.get_school_subject_data(school)
for subject in SUBJECTS:
sub_df = subject_df[subject_df["学科"] == subject]
# 教研活动次数
rows = sub_df[sub_df["字段名称"] == "学科教研组每学期活动次数"]
if len(rows) > 0:
try:
v = float(rows.iloc[0]["字段取值"])
scores.append(min(v / 3, 4)) # 归一化
except (ValueError, TypeError):
pass
# 教研活动时长
rows = sub_df[sub_df["字段名称"] == "学科教研组平均每次活动时长"]
if len(rows) > 0:
try:
v = float(rows.iloc[0]["字段取值"])
scores.append(min(v / 30, 4)) # 30分钟为基准
except (ValueError, TypeError):
pass
# 教研计划
rows = sub_df[sub_df["字段名称"] == "学科教研工作计划_有"]
if len(rows) > 0 and str(rows.iloc[0]["字段取值"]) == "1":
scores.append(2)
# 校级展示
for field in ["学科教研组校级展示_有", "学科教研组区域展示_有", "学科教研组成果发表_有"]:
rows = sub_df[sub_df["字段名称"] == field]
if len(rows) > 0 and str(rows.iloc[0]["字段取值"]) == "1":
scores.append(2)
return scores if scores else [1.0]
def _score_project_support(self, school: str) -> List[float]:
"""项目支持"""
scores = []
subject_df = self.data_engine.get_school_subject_data(school)
course_df = self.data_engine.get_school_course_data(school)
# 学科层面的项目
for subject in SUBJECTS:
sub_df = subject_df[subject_df["学科"] == subject]
for field in ["学科承担的市级教改项目个数", "学科承担的区级教改项目个数", "学科承担的校级教改项目个数"]:
rows = sub_df[sub_df["字段名称"] == field]
if len(rows) > 0:
try:
v = float(rows.iloc[0]["字段取值"])
scores.append(min(v, 5))
except (ValueError, TypeError):
pass
# 学校层面的项目
for field in ["学校负责的市级教改项目个数", "学校参与的市级教改项目个数",
"学校负责的区级教改项目个数", "学校参与的区级教改项目个数",
"校级教改项目个数"]:
rows = course_df[course_df["字段名称"] == field]
if len(rows) > 0:
try:
v = float(rows.iloc[0]["字段取值"])
scores.append(min(v, 5))
except (ValueError, TypeError):
pass
return scores if scores else [0.5]
# ========== 教育质量评估力 ==========
def _score_scientific_evaluation(self, school: str) -> List[float]:
"""科学评价观"""
scores = []
subject_df = self.data_engine.get_school_subject_data(school)
for subject in SUBJECTS:
sub_df = subject_df[subject_df["学科"] == subject]
# 作业评价关注点(多选,关注素养发展的项目越多越好)
eval_items = sub_df[sub_df["字段名称"].str.contains("作业评价关注点|课堂表现评价关注点|学科实践活动评价关注点", na=False)]
if len(eval_items) > 0:
focus_count = (eval_items["字段取值"].astype(str) == "1").sum()
scores.append(min(focus_count / 3, 4))
return scores if scores else [2.0]
def _score_academic_evaluation(self, school: str) -> List[float]:
"""学业质量评估"""
scores = []
subject_df = self.data_engine.get_school_subject_data(school)
for subject in SUBJECTS:
sub_df = subject_df[subject_df["学科"] == subject]
# 评价工具建成
for field, val in [
("作业评价工具_已经建成并使用", 2),
("课堂表现评价工具_已经建成并使用", 2),
("作业评价工具_已经建成尚未使用", 1),
("作业评价工具_尚未建成和使用", 0),
]:
rows = sub_df[sub_df["字段名称"] == field]
if len(rows) > 0 and str(rows.iloc[0]["字段取值"]) == "1":
scores.append(val)
break
# 考试分析
for field, val in [
("学期考试分析_执行分析并存档", 3),
("学期考试分析_执行分析,不要求存档", 2),
("学期考试分析_教师自己决定", 1),
]:
rows = sub_df[sub_df["字段名称"] == field]
if len(rows) > 0 and str(rows.iloc[0]["字段取值"]) == "1":
scores.append(val)
break
# 表现性评价应用
for field, val in [
("表现性评价应用程度_经常", 4),
("表现性评价应用程度_有时", 3),
("表现性评价应用程度_总是", 4),
("表现性评价应用程度_从不", 1),
]:
rows = sub_df[sub_df["字段名称"] == field]
if len(rows) > 0 and str(rows.iloc[0]["字段取值"]) == "1":
scores.append(val)
break
return scores if scores else [2.0]
def _score_comprehensive_evaluation(self, school: str) -> List[float]:
"""综合素质评估"""
scores = []
course_df = self.data_engine.get_school_course_data(school)
# 综评评价体系
for field, val in [
("综评评价体系_已经建成并使用", 3),
("综评评价体系_已经建成尚未使用", 2),
("综评评价体系_未建成", 1),
("综评评价体系_不准备建设", 0),
]:
rows = course_df[course_df["字段名称"] == field]
if len(rows) > 0 and str(rows.iloc[0]["字段取值"]) == "1":
scores.append(val)
break
# 综评信息化
for field, val in [
("综评信息化实现_自建平台支持", 2),
("综评信息化实现_借助第三方平台支持", 1),
]:
rows = course_df[course_df["字段名称"] == field]
if len(rows) > 0 and str(rows.iloc[0]["字段取值"]) == "1":
scores.append(val)
break
# 综评结果使用(多选)
result_uses = course_df[course_df["字段名称"].str.contains("综评结果使用|综评应用", na=False)]
if len(result_uses) > 0:
use_count = (result_uses["字段取值"].astype(str) == "1").sum()
scores.append(min(use_count, 6))
return scores if scores else [1.0]
def _score_practice_evaluation(self, school: str) -> List[float]:
"""实践活动评估"""
scores = []
course_df = self.data_engine.get_school_course_data(school)
# 研究性学习评价
for field, val in [
("研究性学习评价工具_已经建成并使用", 2),
("研究性学习评价工具_尚未建成和使用", 0),
]:
rows = course_df[course_df["字段名称"] == field]
if len(rows) > 0 and str(rows.iloc[0]["字段取值"]) == "1":
scores.append(val)
break
# 社会考察评价
for field, val in [
("社会考察评价工具_已经建成并使用", 2),
("社会考察评价工具_尚未建成和使用", 0),
]:
rows = course_df[course_df["字段名称"] == field]
if len(rows) > 0 and str(rows.iloc[0]["字段取值"]) == "1":
scores.append(val)
break
# 学科实践活动评价
subject_df = self.data_engine.get_school_subject_data(school)
for subject in SUBJECTS[:5]: # 抽样几个学科
sub_df = subject_df[subject_df["学科"] == subject]
for field, val in [
("学科实践活动工具_已经建成并使用", 2),
("学科实践活动工具_已经建成尚未使用", 1),
("学科实践活动工具_尚未建成和使用", 0),
]:
rows = sub_df[sub_df["字段名称"] == field]
if len(rows) > 0 and str(rows.iloc[0]["字段取值"]) == "1":
scores.append(val)
break
return scores if scores else [1.0]
# ========== 教育条件保障力 ==========
def _score_regional_promotion(self, school: str) -> List[float]:
"""区域推进"""
scores = []
course_df = self.data_engine.get_school_course_data(school)
# 工作会频率
for field, val in [
("区域工作会参与_一月4次以上", 7),
("区域工作会参与_一月1次", 5),
("区域工作会参与_二月1次", 3),
("区域工作会参与_三月1次", 1),
]:
rows = course_df[course_df["字段名称"] == field]
if len(rows) > 0 and str(rows.iloc[0]["字段取值"]) == "1":
scores.append(val)
break
# 区域推进措施(多选)
measures = course_df[course_df["字段名称"].str.contains("区域推进措施", na=False)]
if len(measures) > 0:
measure_count = (measures["字段取值"].astype(str) == "1").sum()
scores.append(min(measure_count, 5))
return scores if scores else [2.0]
def _score_environment_support(self, school: str) -> List[float]:
"""环境支持:硬件+信息化"""
scores = []
course_df = self.data_engine.get_school_course_data(school)
# 场馆供给
for field, val in [
("场馆供给_能满足需要", 3),
("场馆供给_基本满足需要", 2),
("场馆供给_难以满足需要", 1),
]:
rows = course_df[course_df["字段名称"] == field]
if len(rows) > 0 and str(rows.iloc[0]["字段取值"]) == "1":
scores.append(val)
break
# 专用教室供给
for field, val in [
("专用教室供给_能满足需要", 3),
("专用教室供给_基本满足需要", 2),
]:
rows = course_df[course_df["字段名称"] == field]
if len(rows) > 0 and str(rows.iloc[0]["字段取值"]) == "1":
scores.append(val)
break
# 信息化平台功能(多选)
info_funcs = course_df[course_df["字段名称"].str.contains("信息化平台功能", na=False)]
if len(info_funcs) > 0:
func_count = (info_funcs["字段取值"].astype(str) == "1").sum()
scores.append(min(func_count / 3, 5))
return scores if scores else [2.0]
def _score_resource_support(self, school: str) -> List[float]:
"""资源支持:校内资源 + 校外资源 + 师资配置"""
scores = []
subject_df = self.data_engine.get_school_subject_data(school)
for subject in SUBJECTS:
sub_df = subject_df[subject_df["学科"] == subject]
# 校内资源支持程度
for field in ["必修课校内资源支持程度", "选择性必修课校内资源支持程度"]:
rows = sub_df[sub_df["字段名称"] == field]
if len(rows) > 0:
s = self._score_resource_level(rows.iloc[0]["字段取值"])
if s is not None:
scores.append(s)
# 校外资源支持程度
for field in ["必修课校外资源支持程度", "选择性必修课校外资源支持程度"]:
rows = sub_df[sub_df["字段名称"] == field]
if len(rows) > 0:
s = self._score_resource_level(rows.iloc[0]["字段取值"])
if s is not None:
scores.append(s)
# 师资:教研组总人数、高级教师比例等
rows = sub_df[sub_df["字段名称"] == "学科教研组总人数"]
if len(rows) > 0:
try:
total = float(rows.iloc[0]["字段取值"])
scores.append(min(total / 3, 4))
except (ValueError, TypeError):
pass
return scores if scores else [2.0]
# ========== 数字化赋能力 ==========
def _score_digital_teaching(self, school: str) -> List[float]:
"""教学方式创新"""
scores = []
subject_df = self.data_engine.get_school_subject_data(school)
for subject in SUBJECTS:
sub_df = subject_df[subject_df["学科"] == subject]
# 信息技术融合认识
for field in ["信息技术与教学融合的认识_所有人可做到"]:
rows = sub_df[sub_df["字段名称"] == field]
if len(rows) > 0 and str(rows.iloc[0]["字段取值"]) == "1":
scores.append(4)
break
else:
rows = sub_df[sub_df["字段名称"] == "信息技术与教学融合的认识_个别人可做到"]
if len(rows) > 0 and str(rows.iloc[0]["字段取值"]) == "1":
scores.append(1)
# 信息化终端使用比例
for field, val in [
("信息化终端使用比例_80%+", 4),
("信息化终端使用比例_60~79%", 3),
("信息化终端使用比例_30~59%", 2),
("信息化终端使用比例_30%-", 1),
]:
rows = sub_df[sub_df["字段名称"] == field]
if len(rows) > 0 and str(rows.iloc[0]["字段取值"]) == "1":
scores.append(val)
break
return scores if scores else [2.0]
def _score_digital_evaluation(self, school: str) -> List[float]:
"""评价精准化与个性化"""
scores = []
subject_df = self.data_engine.get_school_subject_data(school)
course_df = self.data_engine.get_school_course_data(school)
# 学业评价信息化
for subject in SUBJECTS:
sub_df = subject_df[subject_df["学科"] == subject]
for field, val in [
("学业评价信息化实现_自建平台支持", 3),
("学业评价信息化实现_借助第三方平台支持", 2),
("学业评价信息化实现_没有平台支持", 0),
]:
rows = sub_df[sub_df["字段名称"] == field]
if len(rows) > 0 and str(rows.iloc[0]["字段取值"]) == "1":
scores.append(val)
break
# 学校层面的信息化支持
for field, val in [
("学校信息系统对选课支持程度", None),
("学校信息系统对排课支持程度", None),
]:
rows = course_df[course_df["字段名称"] == field]
if len(rows) > 0:
s = self._score_resource_level(rows.iloc[0]["字段取值"])
if s is not None:
scores.append(s)
return scores if scores else [1.5]
def _score_digital_curriculum(self, school: str) -> List[float]:
"""课程迭代优化"""
scores = []
course_df = self.data_engine.get_school_course_data(school)
# 数据连通
for field, val in [
("数据连通_有数据能互通", 3),
("数据连通_有数据不互通", 1),
]:
rows = course_df[course_df["字段名称"] == field]
if len(rows) > 0 and str(rows.iloc[0]["字段取值"]) == "1":
scores.append(val)
break
# 管理业务信息化
for field, val in [
("管理业务的信息化应用_绝大部分", 4),
("教学业务的信息化应用_绝大部分", 4),
]:
rows = course_df[course_df["字段名称"] == field]
if len(rows) > 0 and str(rows.iloc[0]["字段取值"]) == "1":
scores.append(val)
# 已完成网络课程数
rows = course_df[course_df["字段名称"] == "已完成的网络课程门数"]
if len(rows) > 0:
try:
v = float(rows.iloc[0]["字段取值"])
scores.append(min(v / 3, 4))
except (ValueError, TypeError):
pass
return scores if scores else [1.5]
# ========== 辅助赋分函数 ==========
@staticmethod
def _score_likert(value, scale: int = 4, reverse: bool = False) -> Optional[float]:
"""
量表题赋分
value格式可能是 "1""2(有一些支持)"
"""
try:
val_str = str(value).strip()
# 提取数字部分
match = re.match(r'^(\d+)', val_str)
if match:
v = int(match.group(1))
if reverse:
return float(scale + 1 - v)
return float(v)
except (ValueError, TypeError):
pass
return None
@staticmethod
def _score_resource_level(value) -> Optional[float]:
"""资源支持程度赋分:几乎没有=1, 有一些=2, 有足够=3"""
val_str = str(value).strip()
match = re.match(r'^(\d+)', val_str)
if match:
v = int(match.group(1))
return float(v)
if "足够" in val_str or "3" in val_str:
return 3.0
elif "一些" in val_str or "2" in val_str:
return 2.0
elif "没有" in val_str or "1" in val_str:
return 1.0
return None
+363
View File
@@ -0,0 +1,363 @@
"""
统计引擎:PCA合成 + 标准化 + 水平判定 + 聚类 + T检验 + 相关性
将赋分后的原始数据合成为维度得分,并进行统计分析
"""
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
from ..config import (
PCA_MEAN, PCA_STD, LEVEL_THRESHOLDS, LEVEL_DESCRIPTIONS,
DIMENSION_FRAMEWORK, SUBJECTS, SCHOOL_TYPE_MAP,
)
logger = logging.getLogger(__name__)
class StatsEngine:
"""统计引擎"""
def __init__(self):
self._dimension_scores: Optional[pd.DataFrame] = None
self._sub_dimension_scores: Optional[pd.DataFrame] = None
self._subject_dimension_scores: Optional[pd.DataFrame] = None
def standardize_scores(self, raw_scores: np.ndarray) -> np.ndarray:
"""标准化到均值50标准差10"""
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:
"""
PCA合成:将多个变量合成为一个主成分分数
data_matrix: 行=学校, 列=变量
返回:合成后的分数(已标准化到50/10)
"""
# 处理缺失值:均值填充
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)
# PCA取第一主成分
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
# 标准化到50/10
return self.standardize_scores(scores)
def determine_level(self, score: float, dimension: str) -> int:
"""根据分数和维度确定水平(1-4"""
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:
"""
计算所有学校在各三级维度上的得分
school_raw_scores: {
school_name: {
sub_dimension_name: [score1, score2, ...] # 该维度下各题目的赋分
}
}
返回 DataFrame: 行=学校, 列=三级维度, 值=标准化得分
"""
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)
# PCA合成并标准化各维度
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_aggregates(self, sub_scores: pd.DataFrame) -> pd.DataFrame:
"""
从三级维度分数聚合到二级维度(均值)
sub_scores: 行=学校, 列=三级维度
返回: 行=学校, 列=二级维度
"""
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) -> Dict:
"""
聚类分析
scores: 行=学校, 列=维度
返回: 聚类标签和各类特征
"""
# 标准化
scaler = StandardScaler()
scaled = scaler.fit_transform(scores.fillna(PCA_MEAN))
# KMeans聚类
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)
cluster_names = {}
for rank, (c, _) in enumerate(sorted_clusters):
if rank == 0:
cluster_names[c] = "较好"
else:
cluster_names[c] = "待提升"
return {
"labels": labels.tolist(),
"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:
"""
单样本T检验:学校各学科得分 vs 参考均值
school_scores: 该学校在某维度各学科的得分
ref_mean: 参考均值(如区均值、全市均值)
"""
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) -> Dict:
"""
为某一所学校生成完整的报告数据包
返回包含所有统计分析结果的结构化数据
"""
schools = list(sub_scores.index)
if school not in schools:
raise ValueError(f"学校 '{school}' 不在数据中")
school_info = SCHOOL_TYPE_MAP.get(school, {})
school_type = school_info.get("type", "")
# 区均值
district_avg_sub = sub_scores.mean()
district_avg_dim = dim_scores.mean()
# 同类学校均值
same_type_schools = [s for s in schools if SCHOOL_TYPE_MAP.get(s, {}).get("type") == school_type]
if same_type_schools:
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
# 水平判定
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(dim_scores[dim_cols])
# 各二级维度聚类
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(sub_scores[sub_dims])
# 相关性
correlation = self.correlation_analysis(dim_scores)
# 构建报告数据
report = {
"school": school,
"school_info": school_info,
# 总体得分
"overall": {
"score": round(float(dim_scores.loc[school, "总体得分"]), 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": int((dim_scores["总体得分"] >= dim_scores.loc[school, "总体得分"]).sum()),
"total_schools": len(schools),
"cluster": overall_cluster["school_clusters"].get(school, ""),
},
# 二级维度
"dimensions": {},
# 三级维度
"sub_dimensions": {},
# 相关性矩阵
"correlation": correlation.to_dict(),
# 所有学校得分(用于对比)
"all_schools_dim_scores": dim_scores.to_dict(),
"all_schools_sub_scores": sub_scores.to_dict(),
}
# 填充二级维度数据
for dim in DIMENSION_FRAMEWORK:
score = float(dim_scores.loc[school, dim])
d_avg = float(district_avg_dim[dim])
st_avg = float(same_type_avg_dim.get(dim, PCA_MEAN))
# T检验:该学校在该维度下各三级维度得分 vs 区均值
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((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((sub_scores[sub_dim] >= score).sum())
# 水平分布统计
dim_levels = 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,
"level": level,
"level_description": self.get_level_description(sub_dim, level),
"level_distribution": level_dist,
}
return report
+253
View File
@@ -0,0 +1,253 @@
"""
FastAPI 主应用入口
报告管理系统:API + 前端静态文件,单端口服务
增加 JWT 认证保护
"""
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request, Depends, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse
from .config import OUTPUT_DIR, STATIC_DIR, PROJECT_ROOT
from .api.era2_routes import router as era2_router
from .api.era2_state import era2_state
from .auth import (
LoginRequest, TokenResponse,
authenticate_user, create_access_token, get_current_user, verify_token,
)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s - %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger(__name__)
# 前端构建产物目录
FRONTEND_DIST = PROJECT_ROOT / "frontend" / "dist"
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用启动/关闭生命周期"""
logger.info("🚀 正在初始化全市数据引擎...")
era2_state.initialize()
logger.info(f"✅ 数据引擎就绪: {len(era2_state.schools)}所学校, {len(era2_state.districts)}个区")
if FRONTEND_DIST.exists():
logger.info(f"📦 前端静态文件: {FRONTEND_DIST}")
else:
logger.warning(f"⚠️ 前端静态文件不存在: {FRONTEND_DIST},请先 cd frontend && npm run build")
logger.info("🔐 认证已启用,所有 API 需要登录访问")
yield
logger.info("👋 应用关闭")
import os
# 生产环境关闭 API 文档(设 DOCS_ENABLED=1 可临时打开)
_docs_enabled = os.environ.get("DOCS_ENABLED", "0") == "1"
app = FastAPI(
title="课程实施监测报告管理系统",
description="上海市高中课程实施监测数据分析与报告生成 API",
version="2.0.0",
lifespan=lifespan,
docs_url="/docs" if _docs_enabled else None,
redoc_url="/redoc" if _docs_enabled else None,
openapi_url="/openapi.json" if _docs_enabled else None,
)
# CORS — 收紧配置(部署时按需修改 allow_origins
ALLOWED_ORIGINS = [
"http://localhost:5173", # 前端开发服务器
"http://localhost:7777", # 后端自身
"http://127.0.0.1:5173",
"http://127.0.0.1:7777",
]
app.add_middleware(
CORSMiddleware,
allow_origins=ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["*"],
)
# ==================== 无需认证的路由 ====================
# 登录接口
@app.post("/api/auth/login", response_model=TokenResponse)
async def login(req: LoginRequest, response: Response):
"""用户登录,返回 JWT token"""
username = authenticate_user(req.username, req.password)
if not username:
return JSONResponse(
status_code=401,
content={"detail": "用户名或密码错误"},
)
token, expires_in = create_access_token(username)
# 同时设置 Cookie(方便浏览器直接访问静态资源)
response.set_cookie(
key="access_token",
value=token,
max_age=expires_in,
httponly=True,
samesite="lax",
# secure=True, # 生产环境使用 HTTPS 时取消注释
)
logger.info(f"🔑 用户 '{username}' 登录成功")
return TokenResponse(access_token=token, expires_in=expires_in)
# 验证 token 是否有效
@app.get("/api/auth/verify")
async def verify_auth(request: Request):
"""验证当前 token 是否有效(前端刷新页面时调用)"""
# 从 header 或 cookie 中提取 token
token = None
auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:]
if not token:
token = request.cookies.get("access_token")
if not token:
return JSONResponse(status_code=401, content={"valid": False})
username = verify_token(token)
if not username:
return JSONResponse(status_code=401, content={"valid": False})
return {"valid": True, "username": username}
# 登出
@app.post("/api/auth/logout")
async def logout(response: Response):
"""清除认证 Cookie"""
response.delete_cookie("access_token")
return {"message": "已退出登录"}
# ==================== 认证中间件 ====================
# 不需要认证的路径前缀(API 文档不对外暴露)
PUBLIC_PATHS = {
"/api/auth/login",
"/api/auth/logout",
"/api/auth/verify",
}
# 前端静态资源路径前缀(不需要 API 级别认证,前端自己处理路由守卫)
STATIC_PREFIXES = ("/assets/", "/favicon", "/vite.svg")
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
"""
全局认证中间件:
- 公开路径(登录、静态资源)直接放行
- API 路径需要有效的 JWT token
- 前端 SPA 页面路径放行(由前端路由守卫处理)
"""
path = request.url.path
# 1. 公开 API 路径 — 放行
if path in PUBLIC_PATHS:
return await call_next(request)
# 2. 前端静态资源 — 放行
if any(path.startswith(p) for p in STATIC_PREFIXES):
return await call_next(request)
# 3. API 路径 — 需要认证
if path.startswith("/api/") or path.startswith("/output/"):
token = None
# 来源1: Authorization header
auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:]
# 来源2: Cookie
if not token:
token = request.cookies.get("access_token")
# 来源3: URL query param(供 <a href> / <iframe src> 等无法设 header 的场景)
if not token:
token = request.query_params.get("token")
if not token or not verify_token(token):
return JSONResponse(
status_code=401,
content={"detail": "未授权访问,请先登录"},
)
# 4. 其他路径(前端 SPA 页面)— 放行,由前端路由守卫处理
response = await call_next(request)
# 安全响应头
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
# 防止浏览器缓存敏感 API 响应
if path.startswith("/api/"):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
response.headers["Pragma"] = "no-cache"
return response
# ==================== 业务路由(全部需要认证) ====================
# 注册 API 路由
app.include_router(era2_router, prefix="/api")
# 挂载 output 静态文件(报告 HTML/JSON)— 已由中间件保护
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
app.mount("/output", StaticFiles(directory=str(OUTPUT_DIR)), name="output")
# 挂载 backend/static(如有)
if STATIC_DIR.exists():
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
# 挂载前端静态资源(JS/CSS 等)
if FRONTEND_DIST.exists():
assets_dir = FRONTEND_DIST / "assets"
if assets_dir.exists():
app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="frontend-assets")
# SPA Fallback:所有未匹配的路由返回前端 index.html
@app.get("/{full_path:path}")
async def serve_frontend(request: Request, full_path: str):
"""
SPA 路由兜底:
- 如果请求的是 dist 目录下的真实文件(如 favicon.ico),直接返回
- 否则返回 index.html,让前端路由处理
"""
if FRONTEND_DIST.exists():
# 尝试匹配真实文件(防路径穿越: resolve 后必须在 FRONTEND_DIST 内)
file_path = (FRONTEND_DIST / full_path).resolve()
if file_path.is_file() and str(file_path).startswith(str(FRONTEND_DIST.resolve())):
return FileResponse(str(file_path))
# SPA fallback → index.html
index_path = FRONTEND_DIST / "index.html"
if index_path.exists():
return FileResponse(str(index_path))
# 前端未构建时返回 API 信息
return {
"name": "课程实施监测报告管理系统",
"version": "2.0.0",
"status": "running",
"message": "前端未构建,请访问 /api/auth/login 登录后使用 API",
}