Initial commit
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
commit
71db82393a
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"))
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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: "学校几乎不开展数字化转型活动,尚未建成信息化管理系统",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
}
|
||||
Reference in New Issue
Block a user