Initial commit

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-07-13 15:38:41 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
commit 71db82393a
180 changed files with 170640 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
+872
View File
@@ -0,0 +1,872 @@
"""
Era2 API 路由: 全市266校报告生成系统
区选择 → 学校选择 → 报告生成 → 历史查看 → LLM助理
"""
import asyncio
import json
import logging
import time
from pathlib import Path
from typing import Optional
import numpy as np
from fastapi import APIRouter, HTTPException, BackgroundTasks
from fastapi.responses import HTMLResponse, StreamingResponse, FileResponse
from pydantic import BaseModel
from .era2_state import era2_state, OUTPUT_DIR
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/era2")
# ==================== Models ====================
class GenerateRequest(BaseModel):
school: str
district: str
use_cache: bool = True
skip_llm: bool = False
enable_agent: bool = False # AI助理已统一由管理平台前端ChatFab+后端/era2/chat提供,静态HTML不再内嵌
lang: str = "zh" # "zh" | "en" | "both"
class BatchGenerateRequest(BaseModel):
district: str
schools: Optional[list[str]] = None
use_cache: bool = True
skip_llm: bool = False
enable_agent: bool = False # AI助理已统一由管理平台前端ChatFab+后端/era2/chat提供,静态HTML不再内嵌
lang: str = "zh" # "zh" | "en" | "both"
class ChatRequest(BaseModel):
message: str
school: Optional[str] = None
district: Optional[str] = None
history: list[dict] = []
lang: str = "zh" # "zh" | "en"
# ==================== Helpers ====================
def _report_filename(school: str, lang: str) -> str:
"""根据 lang 返回报告 HTML 文件名(与 04_generate_report.py 一致)"""
if lang == "en":
return f"{school}_report_en.html"
return f"{school}_报告.html"
def _normalize_langs(lang: str) -> list[str]:
"""'zh' / 'en' / 'both' 标准化成 ['zh'] / ['en'] / ['zh','en']"""
if lang == "both":
return ["zh", "en"]
if lang == "en":
return ["en"]
return ["zh"]
# ==================== 区和学校 ====================
@router.get("/districts")
async def list_districts():
"""获取所有区的摘要"""
return {
"districts": era2_state.get_districts_summary(),
"total": len(era2_state.districts),
}
@router.get("/districts/{district}/schools")
async def list_schools_in_district(district: str):
"""获取某区的学校列表"""
if district not in era2_state.districts:
raise HTTPException(404, f"'{district}' 不存在")
schools = era2_state.get_schools_in_district(district)
return {
"district": district,
"schools": schools,
"total": len(schools),
}
# ==================== 报告生成 ====================
_generation_tasks = {}
def _clean_for_json(obj):
if isinstance(obj, dict):
return {k: _clean_for_json(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [_clean_for_json(v) for v in obj]
elif isinstance(obj, (np.integer,)):
return int(obj)
elif isinstance(obj, (np.floating,)):
return round(float(obj), 4)
elif isinstance(obj, np.ndarray):
return obj.tolist()
elif isinstance(obj, float):
if np.isnan(obj) or np.isinf(obj):
return None
return round(obj, 4)
return obj
@router.post("/reports/generate")
async def generate_report(req: GenerateRequest, background_tasks: BackgroundTasks):
"""异步生成单校报告"""
school = req.school
district = req.district
if district not in era2_state.districts:
raise HTTPException(404, f"'{district}' 不存在")
district_schools = era2_state.district_schools.get(district, [])
if school not in district_schools:
raise HTTPException(404, f"学校 '{school}' 不在 {district}")
langs = _normalize_langs(req.lang)
task_id = f"era2_{school}_{int(time.time())}"
_generation_tasks[task_id] = {
"status": "pending",
"school": school,
"district": district,
"lang": req.lang,
"langs": langs,
"progress": 0,
"total": 29 * len(langs),
"current_segment": "",
"current_lang": langs[0] if langs else "zh",
"started_at": time.time(),
}
background_tasks.add_task(
_do_generate, task_id, school, district,
req.use_cache, req.skip_llm, req.enable_agent, langs
)
return {
"task_id": task_id, "school": school, "district": district,
"status": "started", "lang": req.lang, "langs": langs,
}
def _do_generate(task_id: str, school: str, district: str,
use_cache: bool, skip_llm: bool, enable_agent: bool,
langs: list[str]):
"""后台执行era2报告生成(支持中/英/双语)"""
import sys
ERA2_SCRIPTS = Path(__file__).parent.parent.parent.parent / "scripts" / "era2"
sys.path.insert(0, str(ERA2_SCRIPTS))
status = _generation_tasks[task_id]
status["status"] = "running"
try:
start = time.time()
# 1. 获取报告数据(与语言无关)
report_data = era2_state.get_report_data(school, district)
from engines.report_renderer_era2 import ReportRendererEra2
renderer = ReportRendererEra2()
output_dir = OUTPUT_DIR / district
output_dir.mkdir(parents=True, exist_ok=True)
# 单语段数(用于多语言进度合并)
per_lang_total = 29
outputs = {}
# 2. 按语言依次生成
for lang_idx, lang in enumerate(langs):
status["current_lang"] = lang
if skip_llm:
llm_sections = {}
# 进度推到该语言段末尾
status["progress"] = (lang_idx + 1) * per_lang_total
status["total"] = len(langs) * per_lang_total
else:
from app.engines.llm_engine import LLMEngine
llm_engine = LLMEngine()
def progress_cb(completed, total, segment_id, _lang_idx=lang_idx, _lang=lang):
# 累计进度 = 之前语言已完成段数 + 当前段数
status["progress"] = _lang_idx * total + completed
status["total"] = len(langs) * total
status["current_segment"] = segment_id
status["current_lang"] = _lang
llm_engine.set_progress_callback(progress_cb)
llm_sections = llm_engine.generate_report_segments(
report_data, use_cache=use_cache, lang=lang,
)
# 3. 渲染HTML(按语言区分文件名)
output_path = output_dir / _report_filename(school, lang)
renderer.render_to_file(report_data, llm_sections, output_path,
enable_agent=enable_agent, lang=lang)
outputs[lang] = str(output_path)
# 4. 保存 JSON(与语言无关,覆盖即可)
json_path = output_dir / f"{school}_report_data.json"
with open(json_path, "w", encoding="utf-8") as f:
json.dump(_clean_for_json(report_data), f, ensure_ascii=False, indent=2)
elapsed = time.time() - start
status["status"] = "completed"
status["elapsed"] = round(elapsed, 1)
status["score"] = report_data["overall"]["score"]
status["rank"] = report_data["overall"]["rank_in_district"]
status["outputs"] = outputs
logger.info(f"✅ [Era2] {district}/{school} 报告生成完成 ({','.join(langs)}), {elapsed:.1f}s")
except Exception as e:
status["status"] = "failed"
status["error"] = str(e)
logger.error(f"❌ [Era2] {district}/{school} 报告生成失败: {e}", exc_info=True)
@router.get("/reports/generate/{task_id}/status")
async def get_task_status(task_id: str):
"""查询生成状态"""
if task_id not in _generation_tasks:
raise HTTPException(404, f"任务 '{task_id}' 不存在")
return _generation_tasks[task_id]
@router.get("/reports/generate/{task_id}/stream")
async def stream_task_progress(task_id: str):
"""SSE 实时进度"""
if task_id not in _generation_tasks:
raise HTTPException(404, f"任务 '{task_id}' 不存在")
async def event_generator():
while True:
status = _generation_tasks.get(task_id, {})
data = json.dumps(status, ensure_ascii=False, default=str)
yield f"data: {data}\n\n"
if status.get("status") in ("completed", "failed"):
break
await asyncio.sleep(0.5)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# ==================== 批量生成 ====================
_batch_tasks = {}
@router.post("/reports/batch")
async def batch_generate(req: BatchGenerateRequest, background_tasks: BackgroundTasks):
"""批量生成某区报告"""
district = req.district
if district not in era2_state.districts:
raise HTTPException(404, f"'{district}' 不存在")
all_in_d = era2_state.district_schools.get(district, [])
schools = req.schools or all_in_d
invalid = [s for s in schools if s not in all_in_d]
if invalid:
raise HTTPException(400, f"无效学校: {invalid}")
langs = _normalize_langs(req.lang)
batch_id = f"era2_batch_{int(time.time())}"
_batch_tasks[batch_id] = {
"status": "pending",
"district": district,
"schools": schools,
"lang": req.lang,
"langs": langs,
"total": len(schools),
"completed": 0,
"current_school": None,
"current_lang": langs[0] if langs else "zh",
"results": [],
"started_at": time.time(),
}
background_tasks.add_task(
_do_batch, batch_id, district, schools,
req.use_cache, req.skip_llm, req.enable_agent, langs
)
return {
"batch_id": batch_id, "district": district,
"total": len(schools), "lang": req.lang, "langs": langs,
}
def _do_batch(batch_id: str, district: str, schools: list,
use_cache: bool, skip_llm: bool, enable_agent: bool,
langs: list[str]):
"""后台批量生成(支持中/英/双语)"""
import sys
ERA2_SCRIPTS = Path(__file__).parent.parent.parent.parent / "scripts" / "era2"
sys.path.insert(0, str(ERA2_SCRIPTS))
status = _batch_tasks[batch_id]
status["status"] = "running"
llm_engine = None
if not skip_llm:
from app.engines.llm_engine import LLMEngine
llm_engine = LLMEngine()
from engines.report_renderer_era2 import ReportRendererEra2
renderer = ReportRendererEra2()
output_dir = OUTPUT_DIR / district
output_dir.mkdir(parents=True, exist_ok=True)
for idx, school in enumerate(schools):
school_start = time.time()
status["current_school"] = school
try:
report_data = era2_state.get_report_data(school, district)
outputs = {}
for lang in langs:
status["current_lang"] = lang
if skip_llm or llm_engine is None:
llm_sections = {}
else:
llm_sections = llm_engine.generate_report_segments(
report_data, use_cache=use_cache, lang=lang,
)
output_path = output_dir / _report_filename(school, lang)
renderer.render_to_file(report_data, llm_sections, output_path,
enable_agent=enable_agent, lang=lang)
outputs[lang] = str(output_path)
json_path = output_dir / f"{school}_report_data.json"
with open(json_path, "w", encoding="utf-8") as f:
json.dump(_clean_for_json(report_data), f, ensure_ascii=False, indent=2)
elapsed = time.time() - school_start
status["results"].append({
"school": school,
"status": "success",
"score": report_data["overall"]["score"],
"rank": report_data["overall"]["rank_in_district"],
"langs": langs,
"outputs": outputs,
"time": round(elapsed, 1),
})
except Exception as e:
elapsed = time.time() - school_start
status["results"].append({
"school": school,
"status": "failed",
"error": str(e),
"time": round(elapsed, 1),
})
logger.error(f"❌ [Era2] 批量 {district}/{school} 失败: {e}", exc_info=True)
status["completed"] = idx + 1
status["status"] = "completed"
status["elapsed"] = round(time.time() - status["started_at"], 1)
status["current_school"] = None
@router.get("/reports/batch/{batch_id}/status")
async def get_batch_status(batch_id: str):
if batch_id not in _batch_tasks:
raise HTTPException(404, f"批次 '{batch_id}' 不存在")
return _batch_tasks[batch_id]
# ==================== 报告预览/下载/历史 ====================
@router.get("/reports/{district}/{school}/preview")
async def preview_report(district: str, school: str, lang: str = "zh"):
"""预览HTML报告(支持 ?lang=zh|en"""
path = OUTPUT_DIR / district / _report_filename(school, lang)
if not path.exists():
raise HTTPException(404, f"报告不存在: {district}/{school} (lang={lang})")
return HTMLResponse(path.read_text("utf-8"))
@router.get("/reports/{district}/{school}/download")
async def download_report(district: str, school: str, lang: str = "zh"):
"""下载HTML报告(支持 ?lang=zh|en"""
path = OUTPUT_DIR / district / _report_filename(school, lang)
if not path.exists():
raise HTTPException(404, f"报告不存在: {district}/{school} (lang={lang})")
if lang == "en":
download_name = f"{school}_Curriculum_Implementation_Monitoring_Report.html"
else:
download_name = f"{school}_课程实施监测报告.html"
return FileResponse(
str(path),
filename=download_name,
media_type="text/html; charset=utf-8",
)
@router.get("/reports/{district}/{school}/data")
async def get_report_json(district: str, school: str):
"""获取报告JSON数据"""
path = OUTPUT_DIR / district / f"{school}_report_data.json"
if not path.exists():
raise HTTPException(404, f"报告数据不存在: {district}/{school}")
return json.loads(path.read_text("utf-8"))
@router.get("/reports/history")
async def get_report_history():
"""获取所有已生成报告的历史"""
return {
"reports": era2_state.get_report_history(),
}
# ==================== LLM 聊天助理 ====================
def _build_chat_system_prompt(school: str = None, district: str = None, lang: str = "zh") -> str:
"""
构建AI聊天助理的系统prompt,注入完整报告数据上下文。
对齐原 chat_widget.html 内嵌版的上下文丰富度:
- 总体得分、区均值、排名、聚类
- 七大维度:得分、区均值、差值、排名、聚类
- 二十个三级维度:得分、区均值、差值、水平、排名
- 回答规范
支持 lang='en' 输出英文 system prompt。
"""
if lang == "en":
return _build_chat_system_prompt_en(school, district)
ctx = f"你是上海市高中课程实施监测数据分析助理。\n"
ctx += f"当前系统覆盖上海市{len(era2_state.districts)}个区、{len(era2_state.schools)}所高中。\n\n"
if not school or not district:
ctx += ("你可以回答关于学校课程实施监测的各种问题,"
"包括七大维度(课程领导力、教学变革力、学生发展指导力、教师发展支持力、"
"教育质量评估力、教育条件保障力、数字化赋能力)的解读、对比分析和改进建议。\n")
return ctx
# 尝试加载完整报告数据
data = None
try:
json_path = OUTPUT_DIR / district / f"{school}_report_data.json"
if json_path.exists():
data = json.loads(json_path.read_text("utf-8"))
except Exception as e:
logger.warning(f"加载学校JSON失败: {e}")
# JSON不存在时实时计算
if data is None:
try:
data = era2_state.get_report_data(school, district)
except Exception as e:
logger.warning(f"实时计算报告数据失败: {e}")
ctx += f"当前上下文学校: {school}{district}),数据加载失败。\n"
return ctx
o = data.get("overall", {})
dims = data.get("dimensions", {})
sub_dims = data.get("sub_dimensions", {})
ctx += f"## 报告核心数据\n\n"
ctx += f"### 总体表现\n"
ctx += f"- 当前学校: {school}{district}\n"
ctx += f"- 总体得分: {o.get('score')}\n"
ctx += f"- 区均值: {o.get('district_avg')}\n"
ctx += f"- 区内排名: 第{o.get('rank_in_district')}/{o.get('total_schools')}\n"
if o.get('rank_in_city') is not None and o.get('total_schools_in_city'):
ctx += f"- 全市排名: 第{o.get('rank_in_city')}/{o.get('total_schools_in_city')}\n"
ctx += f"- 聚类类型: 课程实施{o.get('cluster', '')}\n"
ctx += f"- 学校类型: {o.get('school_type', '')}\n\n"
total_city = o.get('total_schools_in_city')
# 七大维度表格
ctx += "### 七大维度得分\n"
if total_city:
ctx += "| 维度 | 得分 | 区均值 | 差值 | 区排名 | 全市排名 | 聚类 |\n"
ctx += "|------|------|--------|------|--------|----------|------|\n"
else:
ctx += "| 维度 | 得分 | 区均值 | 差值 | 区排名 | 聚类 |\n"
ctx += "|------|------|--------|------|--------|------|\n"
for dim_name, d in dims.items():
score = d.get('score')
davg = d.get('district_avg')
diff = d.get('diff_district')
rank = d.get('rank_in_district')
rank_city = d.get('rank_in_city')
cluster = d.get('cluster', '')
score_s = f"{score:.2f}" if isinstance(score, (int, float)) else str(score)
davg_s = f"{davg:.2f}" if isinstance(davg, (int, float)) else str(davg)
if isinstance(diff, (int, float)):
diff_s = f"+{diff:.2f}" if diff >= 0 else f"{diff:.2f}"
else:
diff_s = str(diff)
if total_city:
rc_s = f"{rank_city}/{total_city}" if rank_city is not None else "-"
ctx += f"| {dim_name} | {score_s} | {davg_s} | {diff_s} | {rank}/{o.get('total_schools')} | {rc_s} | {cluster} |\n"
else:
ctx += f"| {dim_name} | {score_s} | {davg_s} | {diff_s} | {rank}/{o.get('total_schools')} | {cluster} |\n"
ctx += "\n"
# 二十个三级维度表格
ctx += "### 二十个三级维度详情\n"
if total_city:
ctx += "| 三级维度 | 得分 | 区均值 | 差值 | 水平 | 区排名 | 全市排名 |\n"
ctx += "|----------|------|--------|------|------|--------|----------|\n"
else:
ctx += "| 三级维度 | 得分 | 区均值 | 差值 | 水平 | 区排名 |\n"
ctx += "|----------|------|--------|------|------|--------|\n"
for sd_name, sd in sub_dims.items():
score = sd.get('score')
davg = sd.get('district_avg')
diff = sd.get('diff_district')
level = sd.get('level')
rank = sd.get('rank_in_district')
rank_city = sd.get('rank_in_city')
score_s = f"{score:.2f}" if isinstance(score, (int, float)) else "N/A"
davg_s = f"{davg:.2f}" if isinstance(davg, (int, float)) else "N/A"
if isinstance(diff, (int, float)):
diff_s = f"+{diff:.2f}" if diff >= 0 else f"{diff:.2f}"
else:
diff_s = "N/A"
if total_city:
rc_s = f"{rank_city}/{total_city}" if rank_city is not None else "-"
ctx += f"| {sd_name} | {score_s} | {davg_s} | {diff_s} | 水平{level} | {rank}/{o.get('total_schools')} | {rc_s} |\n"
else:
ctx += f"| {sd_name} | {score_s} | {davg_s} | {diff_s} | 水平{level} | {rank}/{o.get('total_schools')} |\n"
ctx += "\n"
# 回答规范
ctx += "## 回答规范\n"
ctx += "1. 始终基于上述数据回答,引用具体数值\n"
ctx += '2. 称呼被分析学校为"贵校"\n'
ctx += "3. 语言风格:专业、客观、平实\n"
ctx += '4. 使用"高于/低于XX均值X.XX分"句式进行对比\n'
ctx += "5. 给出改进建议时要具体可操作\n"
ctx += "6. 如果用户问的内容不在数据范围内,诚实告知\n"
ctx += "7. 回答控制在200-500字以内,避免冗长\n"
return ctx
# 中→英 维度名映射(用于 chat 上下文)
_DIM_EN = {
"课程领导力": "Curriculum Leadership",
"教学变革力": "Instructional Reform Capacity",
"学生发展指导力": "Student Development Guidance",
"教师发展支持力": "Teacher Development Support",
"教育质量评估力": "Educational Quality Assessment",
"教育条件保障力": "Educational Conditions and Resources",
"数字化赋能力": "Digital Empowerment",
}
_SUB_EN = {
"国家标准遵循": "National Standards Compliance",
"课程结构建设": "Curriculum Structure Design",
"课程规范落实": "Curriculum Governance Implementation",
"教学方式变革": "Pedagogical Reform",
"作业设计与管理变革": "Homework Design and Management",
"学科发展的个性化辅导": "Personalized Subject Tutoring",
"学生生涯发展指导": "Student Career Development Guidance",
"培训支持": "Professional Training Support",
"教研支持": "Teaching Research Support",
"项目支持": "Research Project Support",
"科学评价观": "Scientific Assessment Perspective",
"学业质量评估": "Academic Quality Assessment",
"综合素质评估": "Holistic Competency Assessment",
"实践活动评估": "Practice-Based Activity Assessment",
"区域推进": "District-Level Implementation Drive",
"环境支持": "Environmental Support",
"资源支持": "Resource Support",
"教学方式创新": "Innovative Instructional Methods",
"评价精准化与个性化": "Precise and Personalized Assessment",
"课程迭代优化": "Iterative Curriculum Optimization",
}
_CLUSTER_EN = {
"较好": "High-Performing",
"中等": "Mid-Tier",
"待提升": "Improvement-Needed",
}
_DISTRICT_EN = {
"长宁区": "Changning District",
"杨浦区": "Yangpu District",
"闵行区": "Minhang District",
"浦东新区": "Pudong New Area",
"嘉定区": "Jiading District",
"宝山区": "Baoshan District",
"金山区": "Jinshan District",
"静安区": "Jing'an District",
"奉贤区": "Fengxian District",
"普陀区": "Putuo District",
"徐汇区": "Xuhui District",
}
def _build_chat_system_prompt_en(school: str = None, district: str = None) -> str:
"""English version of chat system prompt (OECD/PISA register)."""
ctx = f"You are a data-analysis assistant for the Shanghai Senior Secondary School Curriculum Implementation Monitoring system.\n"
ctx += f"The system covers {len(era2_state.districts)} districts and {len(era2_state.schools)} senior secondary schools across Shanghai.\n\n"
if not school or not district:
ctx += (
"You may respond to questions on curriculum-implementation monitoring, "
"including interpretation, comparison, and improvement recommendations across the seven dimensions: "
"Curriculum Leadership, Instructional Reform Capacity, Student Development Guidance, "
"Teacher Development Support, Educational Quality Assessment, "
"Educational Conditions and Resources, and Digital Empowerment.\n"
)
return ctx
# 加载完整报告数据
data = None
try:
json_path = OUTPUT_DIR / district / f"{school}_report_data.json"
if json_path.exists():
data = json.loads(json_path.read_text("utf-8"))
except Exception as e:
logger.warning(f"加载学校JSON失败: {e}")
if data is None:
try:
data = era2_state.get_report_data(school, district)
except Exception as e:
logger.warning(f"实时计算报告数据失败: {e}")
ctx += f"Current school context: {school} ({_DISTRICT_EN.get(district, district)}); data load failed.\n"
return ctx
o = data.get("overall", {})
dims = data.get("dimensions", {})
sub_dims = data.get("sub_dimensions", {})
district_en = _DISTRICT_EN.get(district, district)
cluster_en = _CLUSTER_EN.get(o.get("cluster", ""), o.get("cluster", ""))
ctx += "## Core Report Data\n\n"
ctx += "### Overall Performance\n"
ctx += f"- School (analysis target): {school} ({district_en})\n"
ctx += f"- Overall score: {o.get('score')}\n"
ctx += f"- District average: {o.get('district_avg')}\n"
ctx += f"- District rank: {o.get('rank_in_district')} of {o.get('total_schools')}\n"
if o.get("rank_in_city") is not None and o.get("total_schools_in_city"):
ctx += f"- Municipal rank: {o.get('rank_in_city')} of {o.get('total_schools_in_city')}\n"
if cluster_en:
ctx += f"- Implementation cluster: {cluster_en}\n"
ctx += f"- School type: {o.get('school_type', '')}\n\n"
total_city = o.get("total_schools_in_city")
# 七大维度
ctx += "### Scores Across Seven Dimensions\n"
if total_city:
ctx += "| Dimension | Score | District Avg. | Δ | District Rank | Municipal Rank | Cluster |\n"
ctx += "|-----------|-------|---------------|---|----------------|-----------------|---------|\n"
else:
ctx += "| Dimension | Score | District Avg. | Δ | District Rank | Cluster |\n"
ctx += "|-----------|-------|---------------|---|----------------|---------|\n"
for dim_name, d in dims.items():
score = d.get("score")
davg = d.get("district_avg")
diff = d.get("diff_district")
rank = d.get("rank_in_district")
rank_city = d.get("rank_in_city")
cluster = _CLUSTER_EN.get(d.get("cluster", ""), d.get("cluster", ""))
score_s = f"{score:.2f}" if isinstance(score, (int, float)) else str(score)
davg_s = f"{davg:.2f}" if isinstance(davg, (int, float)) else str(davg)
diff_s = (f"+{diff:.2f}" if diff >= 0 else f"{diff:.2f}") if isinstance(diff, (int, float)) else str(diff)
dim_en = _DIM_EN.get(dim_name, dim_name)
if total_city:
rc_s = f"{rank_city}/{total_city}" if rank_city is not None else "-"
ctx += f"| {dim_en} | {score_s} | {davg_s} | {diff_s} | {rank}/{o.get('total_schools')} | {rc_s} | {cluster} |\n"
else:
ctx += f"| {dim_en} | {score_s} | {davg_s} | {diff_s} | {rank}/{o.get('total_schools')} | {cluster} |\n"
ctx += "\n"
# 二十个三级维度
ctx += "### Twenty Sub-Dimensions\n"
if total_city:
ctx += "| Sub-dimension | Score | District Avg. | Δ | Level | District Rank | Municipal Rank |\n"
ctx += "|---------------|-------|---------------|---|-------|----------------|-----------------|\n"
else:
ctx += "| Sub-dimension | Score | District Avg. | Δ | Level | District Rank |\n"
ctx += "|---------------|-------|---------------|---|-------|----------------|\n"
for sd_name, sd in sub_dims.items():
score = sd.get("score")
davg = sd.get("district_avg")
diff = sd.get("diff_district")
level = sd.get("level")
rank = sd.get("rank_in_district")
rank_city = sd.get("rank_in_city")
score_s = f"{score:.2f}" if isinstance(score, (int, float)) else "N/A"
davg_s = f"{davg:.2f}" if isinstance(davg, (int, float)) else "N/A"
diff_s = (f"+{diff:.2f}" if diff >= 0 else f"{diff:.2f}") if isinstance(diff, (int, float)) else "N/A"
sd_en = _SUB_EN.get(sd_name, sd_name)
if total_city:
rc_s = f"{rank_city}/{total_city}" if rank_city is not None else "-"
ctx += f"| {sd_en} | {score_s} | {davg_s} | {diff_s} | Level {level} | {rank}/{o.get('total_schools')} | {rc_s} |\n"
else:
ctx += f"| {sd_en} | {score_s} | {davg_s} | {diff_s} | Level {level} | {rank}/{o.get('total_schools')} |\n"
ctx += "\n"
ctx += "## Response Conventions\n"
ctx += "1. Ground every observation in the data above; cite numerical values explicitly.\n"
ctx += '2. Refer to the analysed school as "your school".\n'
ctx += "3. Maintain a formal, evidence-based academic register (OECD/PISA style).\n"
ctx += '4. Use phrasing such as "X.XX points above/below the district average" for comparisons.\n'
ctx += "5. Improvement recommendations must be specific and actionable.\n"
ctx += "6. If a question lies outside the supplied data, say so honestly.\n"
ctx += "7. Keep responses concise — typically 150 to 350 words. Output strictly in formal English; do NOT use Chinese characters.\n"
return ctx
@router.post("/chat")
async def chat_with_assistant(req: ChatRequest):
"""
LLM 聊天助理:真正的异步流式响应
如果指定了 school+district,会注入该校的数据上下文
修复说明(2024-03):
- 旧版使用同步 OpenAI client,在 async generator 中阻塞事件循环,
导致:(1) 所有 chunk 攒到最后才发出 (2) 长时间阻塞触发超时
- 新版使用 AsyncOpenAI,真正 async for 逐 chunk yield
"""
from openai import AsyncOpenAI
from ..config import LLM_BASE_URL, LLM_API_KEY, LLM_MODEL
lang = (req.lang or "zh").lower()
if lang not in ("zh", "en"):
lang = "zh"
# 构建系统prompt(对齐内嵌chat_widget版本的完整上下文)
system_prompt = _build_chat_system_prompt(req.school, req.district, lang=lang)
# 构建消息
# NOTE: cdr.digiman.live 等 API 代理会丢弃 system 角色消息,
# 所以将系统prompt伪装成 user+assistant 对话对来注入上下文。
school_name = req.school or ("the school" if lang == "en" else "该校")
if lang == "en":
sys_inject = (
f"[SYSTEM INSTRUCTIONS] {system_prompt}\n\n"
"Please confirm that you have read and understood the data and conventions above; "
"subsequent answers must be grounded in this data."
)
ack = (
f"I have reviewed the full curriculum-implementation monitoring data for your school "
f"({school_name}), including the overall score, the seven dimensions, and the twenty sub-dimensions. "
"I will respond strictly on the basis of this data, in a formal academic register. "
"What would you like to know?"
)
else:
sys_inject = f"[系统指令] {system_prompt}\n\n请确认你已了解以上数据和规范,后续将基于这些数据回答问题。"
ack = (
f"我已了解贵校({school_name})课程实施监测的全部数据,"
"包括总体得分、七大维度和二十个三级维度的详细数据。"
"我将严格基于这些数据,以专业、客观的风格回答您的问题。请问有什么想了解的?"
)
messages = [
{"role": "user", "content": sys_inject},
{"role": "assistant", "content": ack},
]
for h in req.history[-10:]: # 最多保留10轮历史
messages.append({"role": h.get("role", "user"), "content": h.get("content", "")})
user_msg = req.message
if lang == "en":
# 进一步追加输出语言指示,防止模型回中文
user_msg = (
user_msg
+ "\n\n(Please answer strictly in formal English following the OECD/PISA register; "
"do not include Chinese characters.)"
)
messages.append({"role": "user", "content": user_msg})
# 异步流式调用(不阻塞事件循环)
client = AsyncOpenAI(
base_url=LLM_BASE_URL,
api_key=LLM_API_KEY,
timeout=120.0, # 连接+读取总超时 120s
)
async def stream_response():
try:
response = await client.chat.completions.create(
model=LLM_MODEL,
messages=messages,
stream=True,
max_tokens=2000,
)
async for chunk in response:
if chunk.choices and chunk.choices[0].delta.content:
content = chunk.choices[0].delta.content
yield f"data: {json.dumps({'content': content}, ensure_ascii=False)}\n\n"
yield "data: [DONE]\n\n"
except Exception as e:
logger.error(f"❌ Chat stream error: {e}")
yield f"data: {json.dumps({'error': str(e)}, ensure_ascii=False)}\n\n"
finally:
await client.close()
return StreamingResponse(
stream_response(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", # Nginx 禁用代理缓冲
"Content-Type": "text/event-stream",
},
)
# ==================== 配置 ====================
@router.get("/config/framework")
async def get_framework():
"""测评框架"""
return {
"dimensions": {
name: {"sub_dimensions": info["sub_dimensions"]}
for name, info in DIMENSION_FRAMEWORK.items()
},
"level_descriptions": LEVEL_DESCRIPTIONS,
}
# ==================== 数据溯源 ====================
@router.get("/trace/{district}/{school}")
async def get_trace(district: str, school: str):
"""
获取单校的完整计算链路溯源数据(6阶段)
供前端 React Three Fiber 数据溯源可视化消费
"""
district_schools = era2_state.district_schools.get(district, [])
if not district_schools:
raise HTTPException(404, f"区域不存在: {district}")
if school not in era2_state.schools:
raise HTTPException(404, f"学校不存在: {school}")
import sys as _sys
_era2_path = str(Path(__file__).parent.parent.parent.parent / "scripts" / "era2")
if _era2_path not in _sys.path:
_sys.path.insert(0, _era2_path)
from engines.trace_engine import TraceEngine
trace_engine = TraceEngine(
data_engine=era2_state.data_engine,
pca_engine=era2_state.pca_engine,
stats_engine=era2_state.stats_engine,
sub_scores=era2_state.sub_scores,
dim_scores=era2_state.dim_scores,
)
result = trace_engine.compute_trace(school, district_schools)
return result
+246
View File
@@ -0,0 +1,246 @@
"""
Era2 全局状态:全市266校 数据引擎 + PCA赋分引擎 + 统计引擎
启动时一次性加载,后续API直接使用
"""
import logging
import time
import sys
from pathlib import Path
from typing import Dict, List, Optional
from datetime import datetime
import pandas as pd
import numpy as np
# era2 引擎路径
ERA2_SCRIPTS = Path(__file__).parent.parent.parent.parent / "scripts" / "era2"
sys.path.insert(0, str(ERA2_SCRIPTS))
from data_engine_era2 import DataEngineEra2
from engines.pca_scoring_engine_era2 import PcaScoringEngineEra2
from engines.stats_engine_era2 import StatsEngineEra2
from config_era2 import (
SCHOOL_TYPE_MAP, DIMENSION_FRAMEWORK, LEVEL_DESCRIPTIONS,
CLUSTER_CONFIG,
)
logger = logging.getLogger(__name__)
# 输出目录
OUTPUT_DIR = Path(__file__).parent.parent.parent.parent / "output" / "era2"
class Era2State:
"""Era2 全市数据的全局单例状态"""
def __init__(self):
self._initialized = False
self.data_engine: Optional[DataEngineEra2] = None
self.pca_engine: Optional[PcaScoringEngineEra2] = None
self.stats_engine: Optional[StatsEngineEra2] = None
self.sub_scores: Optional[pd.DataFrame] = None
self.dim_scores: Optional[pd.DataFrame] = None
self.schools: List[str] = []
self.districts: List[str] = []
self.district_schools: Dict[str, List[str]] = {}
self.school_meta: Dict[str, dict] = {}
def initialize(self):
"""启动时加载全市数据并计算分数(耗时约40-60秒)"""
if self._initialized:
return
start = time.time()
logger.info("🚀 [Era2] 加载全市数据...")
# 1. 加载全市数据
self.data_engine = DataEngineEra2(use_city_data=True)
self.data_engine.load_all()
self.schools = self.data_engine.schools
# 2. 构建区→学校映射
self.school_meta = SCHOOL_TYPE_MAP
self.district_schools = {}
for school, info in SCHOOL_TYPE_MAP.items():
district = info.get("district", "未知")
if district not in self.district_schools:
self.district_schools[district] = []
if school in self.schools:
self.district_schools[district].append(school)
self.districts = sorted(self.district_schools.keys())
# 3. PCA赋分(全市)
logger.info(f"🔢 [Era2] PCA赋分 ({len(self.schools)}校)...")
self.pca_engine = PcaScoringEngineEra2(self.data_engine)
pca_sub_scores = self.pca_engine.compute_all()
# 4. 统计引擎
self.stats_engine = StatsEngineEra2()
self.sub_scores = self.stats_engine.compute_dimension_scores_pca(pca_sub_scores)
self.dim_scores = self.stats_engine.compute_dimension_aggregates(self.sub_scores)
self._initialized = True
elapsed = time.time() - start
logger.info(f"✅ [Era2] 初始化完成: {len(self.schools)}校, {len(self.districts)}区, 耗时{elapsed:.1f}s")
def get_districts_summary(self) -> List[dict]:
"""返回所有区的摘要(中英两份报告分别计数)"""
result = []
for district in self.districts:
schools_in_d = self.district_schools.get(district, [])
# 区内平均分
if schools_in_d and self.dim_scores is not None:
valid = [s for s in schools_in_d if s in self.dim_scores.index]
avg = float(self.dim_scores.loc[valid, "总体得分"].mean()) if valid else 50.0
else:
avg = 50.0
# 已生成报告数(中文 / 英文)
district_dir = OUTPUT_DIR / district
if district_dir.exists():
report_count_zh = len(list(district_dir.glob("*_报告.html")))
report_count_en = len(list(district_dir.glob("*_report_en.html")))
else:
report_count_zh = 0
report_count_en = 0
# 任意一种语言已有视为 has_report,用于 UI 总数显示
report_count = report_count_zh
result.append({
"district": district,
"school_count": len(schools_in_d),
"avg_score": round(avg, 2),
"report_count": report_count,
"report_count_zh": report_count_zh,
"report_count_en": report_count_en,
})
return result
def get_schools_in_district(self, district: str) -> List[dict]:
"""返回某区所有学校的详细信息"""
schools_in_d = self.district_schools.get(district, [])
if not schools_in_d:
return []
# 排名(全市排名 + 区内排名)
sorted_all = self.dim_scores["总体得分"].sort_values(ascending=False)
dist_scores = self.dim_scores.loc[
[s for s in schools_in_d if s in self.dim_scores.index], "总体得分"
].sort_values(ascending=False)
result = []
for dist_rank, (school, score) in enumerate(dist_scores.items(), 1):
info = self.school_meta.get(school, {})
city_rank = int((sorted_all >= score).sum())
# 报告状态(中文 + 英文)
report_path_zh = OUTPUT_DIR / district / f"{school}_报告.html"
report_path_en = OUTPUT_DIR / district / f"{school}_report_en.html"
has_report_zh = report_path_zh.exists()
has_report_en = report_path_en.exists()
# 兼容老字段
has_report = has_report_zh or has_report_en
report_generated_at_zh = ""
report_size_zh = 0
report_generated_at_en = ""
report_size_en = 0
if has_report_zh:
stat = report_path_zh.stat()
report_size_zh = stat.st_size
report_generated_at_zh = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M")
if has_report_en:
stat = report_path_en.stat()
report_size_en = stat.st_size
report_generated_at_en = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M")
# 兼容老字段:优先用中文版,没有则用英文版
report_size = report_size_zh or report_size_en
report_generated_at = report_generated_at_zh or report_generated_at_en
# 聚类
dim_cols = [c for c in self.dim_scores.columns if c in DIMENSION_FRAMEWORK]
valid_schools = [s for s in schools_in_d if s in self.dim_scores.index]
cluster_result = self.stats_engine.cluster_analysis(
self.dim_scores.loc[valid_schools, dim_cols], dimension_name="总体"
)
cluster = cluster_result["school_clusters"].get(school, "")
result.append({
"name": school,
"district": district,
"type": info.get("type", ""),
"nature": info.get("nature", ""),
"area": info.get("area", ""),
"score": round(float(score), 2),
"district_rank": dist_rank,
"city_rank": city_rank,
"total_in_district": len(dist_scores),
"total_in_city": len(sorted_all),
"cluster": cluster,
# 兼容老字段
"has_report": has_report,
"report_size": report_size,
"report_generated_at": report_generated_at,
# 中英分开
"has_report_zh": has_report_zh,
"has_report_en": has_report_en,
"report_size_zh": report_size_zh,
"report_size_en": report_size_en,
"report_generated_at_zh": report_generated_at_zh,
"report_generated_at_en": report_generated_at_en,
})
return result
def get_report_data(self, school: str, district: str) -> dict:
"""生成单校报告数据包"""
district_schools = self.district_schools.get(district, [])
report_data = self.stats_engine.compute_school_report_data(
school, self.sub_scores, self.dim_scores,
district_schools=district_schools,
)
report_data["district"] = district
report_data["total_schools_in_district"] = len(district_schools)
return report_data
def get_report_history(self) -> List[dict]:
"""扫描所有已生成的报告,返回历史列表(合并中英两份,每校最多两条)"""
history = []
if not OUTPUT_DIR.exists():
return history
for district_dir in sorted(OUTPUT_DIR.iterdir()):
if not district_dir.is_dir():
continue
district = district_dir.name
# 同时收集中文 (_报告.html) 和英文 (_report_en.html) 报告
collected = []
for html_file in sorted(district_dir.glob("*_报告.html")):
school = html_file.stem.replace("_报告", "")
collected.append((school, html_file, "zh"))
for html_file in sorted(district_dir.glob("*_report_en.html")):
school = html_file.stem.replace("_report_en", "")
collected.append((school, html_file, "en"))
for school, html_file, lang in collected:
stat = html_file.stat()
json_path = district_dir / f"{school}_report_data.json"
score = None
if json_path.exists():
try:
import json
data = json.loads(json_path.read_text("utf-8"))
score = data.get("overall", {}).get("score")
except Exception:
pass
history.append({
"school": school,
"district": district,
"type": self.school_meta.get(school, {}).get("type", ""),
"score": score,
"file_size": stat.st_size,
"generated_at": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
"file_name": html_file.name,
"lang": lang,
})
return history
# 全局单例
era2_state = Era2State()
+412
View File
@@ -0,0 +1,412 @@
"""
API 路由:所有 REST 端点
"""
import asyncio
import json
import logging
import time
from pathlib import Path
from typing import Optional
import numpy as np
from fastapi import APIRouter, HTTPException, BackgroundTasks, Query
from fastapi.responses import HTMLResponse, StreamingResponse, FileResponse
from pydantic import BaseModel
from .state import app_state
from ..config import OUTPUT_DIR, DIMENSION_FRAMEWORK, LEVEL_DESCRIPTIONS
from ..engines.llm_engine import LLMEngine
from ..engines.report_renderer import ReportRenderer
logger = logging.getLogger(__name__)
router = APIRouter()
# ==================== Pydantic Models ====================
class GenerateRequest(BaseModel):
school: str
use_cache: bool = True
skip_llm: bool = False
class BatchGenerateRequest(BaseModel):
schools: Optional[list[str]] = None # None = 全部
use_cache: bool = True
skip_llm: bool = False
# ==================== 学校相关 ====================
@router.get("/schools")
async def list_schools():
"""获取所有学校的摘要列表"""
return {
"schools": app_state.get_all_schools_summary(),
"total": len(app_state.schools),
}
@router.get("/schools/{school_name}")
async def get_school_detail(school_name: str):
"""获取单个学校的详细数据"""
if school_name not in app_state.schools:
raise HTTPException(404, f"学校 '{school_name}' 不存在")
report_data = app_state.get_report_data(school_name)
# JSON 安全序列化
def _safe(obj):
if isinstance(obj, (np.integer,)):
return int(obj)
if isinstance(obj, (np.floating,)):
return float(obj)
if isinstance(obj, np.ndarray):
return obj.tolist()
raise TypeError(f"Type {type(obj)} not serializable")
# 转为 JSON 安全格式
safe_data = json.loads(json.dumps(report_data, default=_safe, ensure_ascii=False))
return safe_data
@router.get("/schools/{school_name}/dimensions")
async def get_school_dimensions(school_name: str):
"""获取学校的维度得分摘要"""
if school_name not in app_state.schools:
raise HTTPException(404, f"学校 '{school_name}' 不存在")
report_data = app_state.get_report_data(school_name)
return {
"school": school_name,
"overall": report_data["overall"],
"dimensions": report_data["dimensions"],
"sub_dimensions": report_data["sub_dimensions"],
}
# ==================== 报告生成 ====================
# 全局生成状态追踪
_generation_status = {}
@router.post("/reports/generate")
async def generate_report(req: GenerateRequest, background_tasks: BackgroundTasks):
"""
生成单校报告(异步后台任务)
返回任务 ID,前端通过 SSE 监听进度
"""
school = req.school
if school not in app_state.schools:
raise HTTPException(404, f"学校 '{school}' 不存在")
task_id = f"gen_{school}_{int(time.time())}"
_generation_status[task_id] = {
"status": "pending",
"school": school,
"progress": 0,
"total": 29,
"current_segment": "",
"started_at": time.time(),
}
background_tasks.add_task(
_do_generate, task_id, school, req.use_cache, req.skip_llm
)
return {"task_id": task_id, "school": school, "status": "started"}
def _do_generate(task_id: str, school: str, use_cache: bool, skip_llm: bool):
"""后台执行报告生成"""
status = _generation_status[task_id]
status["status"] = "running"
try:
start = time.time()
# 获取报告数据
report_data = app_state.get_report_data(school)
# LLM 生成
if skip_llm:
llm_sections = {}
status["progress"] = status["total"]
else:
llm_engine = LLMEngine()
def progress_cb(completed, total, segment_id):
status["progress"] = completed
status["total"] = total
status["current_segment"] = segment_id
llm_engine.set_progress_callback(progress_cb)
llm_sections = llm_engine.generate_report_segments(
report_data, use_cache=use_cache
)
# 渲染 HTML
renderer = ReportRenderer()
output_path = OUTPUT_DIR / f"{school}_报告.html"
renderer.render_to_file(report_data, llm_sections, output_path)
# 保存数据 JSON
def _safe(obj):
if isinstance(obj, (np.integer,)):
return int(obj)
if isinstance(obj, (np.floating,)):
return float(obj)
if isinstance(obj, np.ndarray):
return obj.tolist()
raise TypeError(f"Type {type(obj)} not serializable")
json_path = OUTPUT_DIR / f"{school}_report_data.json"
with open(json_path, "w", encoding="utf-8") as f:
json.dump(report_data, f, ensure_ascii=False, indent=2, default=_safe)
elapsed = time.time() - start
status["status"] = "completed"
status["elapsed"] = round(elapsed, 1)
status["output_path"] = str(output_path)
status["report_size"] = output_path.stat().st_size
status["score"] = report_data["overall"]["score"]
status["rank"] = report_data["overall"]["rank_in_district"]
logger.info(f"{school} 报告生成完成,耗时 {elapsed:.1f}s")
except Exception as e:
status["status"] = "failed"
status["error"] = str(e)
logger.error(f"{school} 报告生成失败: {e}", exc_info=True)
@router.get("/reports/generate/{task_id}/status")
async def get_generation_status(task_id: str):
"""查询生成任务状态"""
if task_id not in _generation_status:
raise HTTPException(404, f"任务 '{task_id}' 不存在")
return _generation_status[task_id]
@router.get("/reports/generate/{task_id}/stream")
async def stream_generation_progress(task_id: str):
"""SSE 实时进度流"""
if task_id not in _generation_status:
raise HTTPException(404, f"任务 '{task_id}' 不存在")
async def event_generator():
while True:
status = _generation_status.get(task_id, {})
data = json.dumps(status, ensure_ascii=False, default=str)
yield f"data: {data}\n\n"
if status.get("status") in ("completed", "failed"):
break
await asyncio.sleep(0.5)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# ==================== 批量生成 ====================
_batch_status = {}
@router.post("/reports/batch")
async def batch_generate(req: BatchGenerateRequest, background_tasks: BackgroundTasks):
"""批量生成所有学校报告"""
schools = req.schools or app_state.schools
invalid = [s for s in schools if s not in app_state.schools]
if invalid:
raise HTTPException(400, f"无效学校: {invalid}")
batch_id = f"batch_{int(time.time())}"
_batch_status[batch_id] = {
"status": "pending",
"schools": schools,
"total": len(schools),
"completed": 0,
"results": [],
"started_at": time.time(),
}
background_tasks.add_task(
_do_batch_generate, batch_id, schools, req.use_cache, req.skip_llm
)
return {"batch_id": batch_id, "schools": schools, "total": len(schools)}
def _do_batch_generate(batch_id: str, schools: list, use_cache: bool, skip_llm: bool):
"""后台执行批量生成"""
status = _batch_status[batch_id]
status["status"] = "running"
renderer = ReportRenderer()
llm_engine = None if skip_llm else LLMEngine()
for idx, school in enumerate(schools):
school_start = time.time()
status["current_school"] = school
status["current_index"] = idx
try:
report_data = app_state.get_report_data(school)
if skip_llm:
llm_sections = {}
else:
llm_sections = llm_engine.generate_report_segments(
report_data, use_cache=use_cache
)
output_path = OUTPUT_DIR / f"{school}_报告.html"
renderer.render_to_file(report_data, llm_sections, output_path)
# 保存数据 JSON
def _safe(obj):
if isinstance(obj, (np.integer,)):
return int(obj)
if isinstance(obj, (np.floating,)):
return float(obj)
if isinstance(obj, np.ndarray):
return obj.tolist()
raise TypeError(f"Type {type(obj)} not serializable")
json_path = OUTPUT_DIR / f"{school}_report_data.json"
with open(json_path, "w", encoding="utf-8") as f:
json.dump(report_data, f, ensure_ascii=False, indent=2, default=_safe)
elapsed = time.time() - school_start
status["results"].append({
"school": school,
"status": "success",
"score": report_data["overall"]["score"],
"rank": report_data["overall"]["rank_in_district"],
"cluster": report_data["overall"]["cluster"],
"time": round(elapsed, 1),
})
except Exception as e:
elapsed = time.time() - school_start
status["results"].append({
"school": school,
"status": "failed",
"error": str(e),
"time": round(elapsed, 1),
})
logger.error(f"❌ 批量生成 {school} 失败: {e}", exc_info=True)
status["completed"] = idx + 1
status["status"] = "completed"
status["elapsed"] = round(time.time() - status["started_at"], 1)
# 保存批量汇总 JSON
summary_path = OUTPUT_DIR / "batch_summary.json"
with open(summary_path, "w", encoding="utf-8") as f:
json.dump({
"generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
"total_schools": len(schools),
"success_count": sum(1 for r in status["results"] if r["status"] == "success"),
"total_time": status["elapsed"],
"results": status["results"],
}, f, ensure_ascii=False, indent=2)
@router.get("/reports/batch/{batch_id}/status")
async def get_batch_status(batch_id: str):
"""查询批量生成状态"""
if batch_id not in _batch_status:
raise HTTPException(404, f"批次 '{batch_id}' 不存在")
return _batch_status[batch_id]
@router.get("/reports/batch/{batch_id}/stream")
async def stream_batch_progress(batch_id: str):
"""批量生成 SSE 进度流"""
if batch_id not in _batch_status:
raise HTTPException(404, f"批次 '{batch_id}' 不存在")
async def event_generator():
while True:
status = _batch_status.get(batch_id, {})
data = json.dumps(status, ensure_ascii=False, default=str)
yield f"data: {data}\n\n"
if status.get("status") in ("completed", "failed"):
break
await asyncio.sleep(1)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# ==================== 报告预览/下载 ====================
@router.get("/reports/{school_name}/preview")
async def preview_report(school_name: str):
"""预览 HTML 报告(返回 HTML 内容)"""
report_path = OUTPUT_DIR / f"{school_name}_报告.html"
if not report_path.exists():
raise HTTPException(404, f"学校 '{school_name}' 的报告尚未生成")
return HTMLResponse(
content=report_path.read_text(encoding="utf-8"),
media_type="text/html; charset=utf-8",
)
@router.get("/reports/{school_name}/download")
async def download_report(school_name: str):
"""下载 HTML 报告"""
report_path = OUTPUT_DIR / f"{school_name}_报告.html"
if not report_path.exists():
raise HTTPException(404, f"学校 '{school_name}' 的报告尚未生成")
return FileResponse(
path=str(report_path),
filename=f"{school_name}_课程实施监测报告.html",
media_type="text/html; charset=utf-8",
)
@router.get("/reports/{school_name}/data")
async def get_report_data(school_name: str):
"""获取报告的 JSON 数据"""
json_path = OUTPUT_DIR / f"{school_name}_report_data.json"
if not json_path.exists():
raise HTTPException(404, f"学校 '{school_name}' 的报告数据不存在")
data = json.loads(json_path.read_text(encoding="utf-8"))
return data
# ==================== 系统配置 ====================
@router.get("/config/framework")
async def get_framework():
"""获取测评框架配置"""
return {
"dimensions": {
name: {
"sub_dimensions": info["sub_dimensions"],
}
for name, info in DIMENSION_FRAMEWORK.items()
},
"level_descriptions": LEVEL_DESCRIPTIONS,
}
@router.get("/reports/summary")
async def get_batch_summary():
"""获取最近一次批量生成的汇总"""
summary_path = OUTPUT_DIR / "batch_summary.json"
if not summary_path.exists():
return {"message": "尚无批量生成记录"}
return json.loads(summary_path.read_text(encoding="utf-8"))
+132
View File
@@ -0,0 +1,132 @@
"""
应用全局状态:管理数据引擎、赋分引擎、统计引擎的单例
避免每次请求重新加载 Excel 数据
"""
import logging
import time
from datetime import datetime
from typing import Dict, List, Optional
import pandas as pd
from ..engines.data_engine import DataEngine
from ..engines.scoring_engine import ScoringEngine
from ..engines.stats_engine import StatsEngine
from ..config import SCHOOL_TYPE_MAP, DIMENSION_FRAMEWORK
logger = logging.getLogger(__name__)
class AppState:
"""应用全局状态"""
def __init__(self):
self.data_engine: Optional[DataEngine] = None
self.scoring_engine: Optional[ScoringEngine] = None
self.stats_engine: Optional[StatsEngine] = None
self.raw_scores: Optional[Dict] = None
self.sub_scores: Optional[pd.DataFrame] = None
self.dim_scores: Optional[pd.DataFrame] = None
self.schools: List[str] = []
self._initialized = False
def initialize(self):
"""初始化所有引擎(只在应用启动时调用一次)"""
if self._initialized:
return
start = time.time()
# 1. 加载数据
self.data_engine = DataEngine()
self.data_engine.load_all()
self.schools = self.data_engine.schools
# 2. 赋分
self.scoring_engine = ScoringEngine(self.data_engine)
self.raw_scores = self.scoring_engine.score_all_schools()
# 3. 统计
self.stats_engine = StatsEngine()
self.sub_scores = self.stats_engine.compute_dimension_scores(self.raw_scores)
self.dim_scores = self.stats_engine.compute_dimension_aggregates(self.sub_scores)
self._initialized = True
elapsed = time.time() - start
logger.info(f"全局状态初始化完成,耗时 {elapsed:.1f}s")
def get_report_data(self, school: str) -> Dict:
"""获取某学校的报告数据包"""
if not self._initialized:
self.initialize()
return self.stats_engine.compute_school_report_data(
school, self.sub_scores, self.dim_scores
)
def get_school_info(self, school: str) -> Dict:
"""获取学校基本信息"""
info = SCHOOL_TYPE_MAP.get(school, {})
if not self.dim_scores is None and school in self.dim_scores.index:
score = round(float(self.dim_scores.loc[school, "总体得分"]), 2)
rank = int((self.dim_scores["总体得分"] >= self.dim_scores.loc[school, "总体得分"]).sum())
else:
score = 0
rank = 0
return {
"name": school,
"type": info.get("type", ""),
"code": info.get("code", ""),
"nature": info.get("nature", ""),
"feature": info.get("feature", ""),
"score": score,
"rank": rank,
"total_schools": len(self.schools),
}
def get_all_schools_summary(self) -> List[Dict]:
"""获取所有学校的摘要信息"""
if not self._initialized:
self.initialize()
summaries = []
# 排名
sorted_schools = self.dim_scores["总体得分"].sort_values(ascending=False)
for rank, (school, score) in enumerate(sorted_schools.items(), 1):
if school == "总体得分":
continue
info = SCHOOL_TYPE_MAP.get(school, {})
# 聚类
dim_cols = [c for c in self.dim_scores.columns if c in DIMENSION_FRAMEWORK]
cluster_result = self.stats_engine.cluster_analysis(self.dim_scores[dim_cols])
cluster = cluster_result["school_clusters"].get(school, "")
# 检查已生成的报告
from ..config import OUTPUT_DIR
report_path = OUTPUT_DIR / f"{school}_报告.html"
has_report = report_path.exists()
report_size = report_path.stat().st_size if has_report else 0
report_generated_at = ""
if has_report:
mtime = report_path.stat().st_mtime
report_generated_at = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
summaries.append({
"name": school,
"type": info.get("type", ""),
"code": info.get("code", ""),
"nature": info.get("nature", ""),
"score": round(float(score), 2),
"rank": rank,
"cluster": cluster,
"has_report": has_report,
"report_size": report_size,
"report_generated_at": report_generated_at,
})
return summaries
# 全局单例
app_state = AppState()