Files
report-admin/backend/app/api/era2_routes.py
T
lofyerandfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> 71db82393a Initial commit
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-07-13 15:38:41 +08:00

873 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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