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,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()
|
||||
Reference in New Issue
Block a user