Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
873 lines
34 KiB
Python
873 lines
34 KiB
Python
"""
|
||
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
|