commit 71db82393abeac798def532cf663fff651966cc1 Author: lofyer Date: Mon Jul 13 15:38:41 2026 +0800 Initial commit Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e439480 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# LLM 配置模板:复制为 .env 后填入真实值(.env 不要提交到版本库) +LLM_BASE_URL=https://cdr.digiman.live/v1 +LLM_API_KEY=your-api-key-here +LLM_MODEL=claude-sonnet-4-6 +LLM_MAX_CONCURRENCY=5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b3f37ee --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Secrets +.env + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ + +# Node +node_modules/ + +# OS +.DS_Store + +# Caches / generated +output/llm_cache/ +output/pca_cache/ +output/scoring_cache/ +output/trace_cache/ +frontend/dist.7z diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/api/era2_routes.py b/backend/app/api/era2_routes.py new file mode 100644 index 0000000..10e31b8 --- /dev/null +++ b/backend/app/api/era2_routes.py @@ -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 diff --git a/backend/app/api/era2_state.py b/backend/app/api/era2_state.py new file mode 100644 index 0000000..3af0881 --- /dev/null +++ b/backend/app/api/era2_state.py @@ -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() diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py new file mode 100644 index 0000000..8437eb2 --- /dev/null +++ b/backend/app/api/routes.py @@ -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")) diff --git a/backend/app/api/state.py b/backend/app/api/state.py new file mode 100644 index 0000000..10a987f --- /dev/null +++ b/backend/app/api/state.py @@ -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() diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..446af4f --- /dev/null +++ b/backend/app/auth.py @@ -0,0 +1,151 @@ +""" +认证模块:JWT Token + 密码哈希 +部署到外网时的安全认证层 +""" +import hashlib +import os +import secrets +from datetime import datetime, timedelta, timezone +from typing import Optional + +from fastapi import Depends, HTTPException, status, Request, Response +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from jose import JWTError, jwt +from pydantic import BaseModel + +# ==================== 配置 ==================== + +# JWT 密钥:优先从环境变量读取,否则生成随机密钥(每次重启失效) +SECRET_KEY = os.environ.get("AUTH_SECRET_KEY", secrets.token_urlsafe(32)) +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_HOURS = int(os.environ.get("AUTH_TOKEN_EXPIRE_HOURS", "24")) + +# 默认用户(可通过环境变量覆盖) +DEFAULT_USERNAME = os.environ.get("AUTH_USERNAME", "admin") +DEFAULT_PASSWORD = os.environ.get("AUTH_PASSWORD", "pswd4admin") + +# Bearer token 提取器 +security = HTTPBearer(auto_error=False) + + +# ==================== 密码哈希 (SHA-256 + salt) ==================== + +def _hash_password(password: str, salt: str | None = None) -> str: + """SHA-256 加盐哈希""" + if salt is None: + salt = secrets.token_hex(16) + hashed = hashlib.sha256(f"{salt}:{password}".encode()).hexdigest() + return f"{salt}${hashed}" + + +def _verify_password(plain_password: str, stored_hash: str) -> bool: + """验证密码""" + if "$" not in stored_hash: + return False + salt, _ = stored_hash.split("$", 1) + return _hash_password(plain_password, salt) == stored_hash + + +# ==================== 模型 ==================== + +class LoginRequest(BaseModel): + username: str + password: str + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + expires_in: int # 秒 + + +class UserInfo(BaseModel): + username: str + + +# ==================== 用户存储(简单内存版) ==================== + +# 启动时对默认密码做哈希 +_users_db: dict[str, str] = { + DEFAULT_USERNAME: _hash_password(DEFAULT_PASSWORD), +} + + +def authenticate_user(username: str, password: str) -> Optional[str]: + """验证用户,成功返回用户名,失败返回 None""" + hashed = _users_db.get(username) + if not hashed: + return None + if not _verify_password(password, hashed): + return None + return username + + +# ==================== JWT 签发/验证 ==================== + +def create_access_token(username: str) -> tuple[str, int]: + """创建 JWT token,返回 (token, expires_in_seconds)""" + expires_delta = timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS) + expire = datetime.now(timezone.utc) + expires_delta + payload = { + "sub": username, + "exp": expire, + "iat": datetime.now(timezone.utc), + } + token = jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) + return token, int(expires_delta.total_seconds()) + + +def verify_token(token: str) -> Optional[str]: + """验证 JWT token,成功返回用户名,失败返回 None""" + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + if username is None: + return None + # 检查用户是否仍然存在 + if username not in _users_db: + return None + return username + except JWTError: + return None + + +# ==================== FastAPI 依赖 ==================== + +async def get_current_user( + request: Request, + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), +) -> str: + """ + 从请求中提取并验证 JWT token + 支持两种方式: + 1. Authorization: Bearer (标准方式) + 2. Cookie: access_token= (浏览器便捷方式) + """ + token = None + + # 方式1: Authorization header + if credentials and credentials.credentials: + token = credentials.credentials + + # 方式2: Cookie fallback + if not token: + token = request.cookies.get("access_token") + + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="未登录,请先登录", + headers={"WWW-Authenticate": "Bearer"}, + ) + + username = verify_token(token) + if not username: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="登录已过期,请重新登录", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return username diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..cd5dbcf --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,248 @@ +"""项目配置""" +import os +from pathlib import Path + +from dotenv import load_dotenv + +# 项目根目录 +PROJECT_ROOT = Path(__file__).parent.parent.parent + +# 加载项目根目录下的 .env(若存在),使 LLM 等配置可外部覆盖 +load_dotenv(PROJECT_ROOT / ".env") +DATA_DIR = PROJECT_ROOT / "data" +TEMPLATES_DIR = PROJECT_ROOT / "templates" / "era1" +STATIC_DIR = PROJECT_ROOT / "backend" / "static" +OUTPUT_DIR = PROJECT_ROOT / "output" + +# 数据文件路径 +EXCEL_BASIC_INFO = DATA_DIR / "长宁区_基础信息表.xlsx" +EXCEL_COURSE_IMPL = DATA_DIR / "长宁区_课程实施情况表.xlsx" +EXCEL_SUBJECT_IMPL = DATA_DIR / "长宁区_学科课程实施情况表.xlsx" +EXCEL_SCORING_RULES = DATA_DIR / "赋分整理表.xlsx" + +# ===================================================================== +# LLM 配置 —— 全项目唯一真相源 (Single Source of Truth) +# 任何模块(backend / scripts / templates context)都应从此处读取, +# 不要在其他文件中重复硬编码 LLM_BASE_URL / LLM_API_KEY / LLM_MODEL。 +# 敏感信息(尤其 API Key)不写入代码,仅从环境变量 / 项目根目录 .env 读取。 +# ===================================================================== +LLM_BASE_URL = os.environ.get("LLM_BASE_URL", "") +LLM_API_KEY = os.environ.get("LLM_API_KEY", "") +LLM_MODEL = os.environ.get("LLM_MODEL", "") +LLM_MAX_CONCURRENCY = int(os.environ.get("LLM_MAX_CONCURRENCY", "5")) + +if not (LLM_BASE_URL and LLM_API_KEY and LLM_MODEL): + raise RuntimeError( + "缺少 LLM 配置:请在项目根目录 .env 中设置 " + "LLM_BASE_URL / LLM_API_KEY / LLM_MODEL(参考 .env.example)" + ) + +# PCA标准化参数 +PCA_MEAN = 50 +PCA_STD = 10 + +# 学校名称标准化映射(确保各表一致) +SCHOOL_NAME_MAP = { + "市三女中": "市三女中", + "复旦中学": "复旦中学", + "仙霞高中": "仙霞高中", + "建青实验": "建青实验", + "延安中学": "延安中学", + "华政附中": "华政附中", + "民办新虹桥": "民办新虹桥", + "天山学校": "天山学校", + "西郊学校": "西郊学校", +} + +# 学校类型 +SCHOOL_TYPE_MAP = { + "延安中学": {"type": "市实验性示范性高中", "code": "A类学校", "nature": "公办", "feature": "非特色高中"}, + "复旦中学": {"type": "市实验性示范性高中", "code": "A类学校", "nature": "公办", "feature": "非特色高中"}, + "市三女中": {"type": "区实验性示范性高中", "code": "B类学校", "nature": "公办", "feature": "非特色高中"}, + "建青实验": {"type": "区实验性示范性高中", "code": "B类学校", "nature": "公办", "feature": "非特色高中"}, + "华政附中": {"type": "特色高中", "code": "B类学校", "nature": "公办", "feature": "人文类特色高中"}, + "仙霞高中": {"type": "公办普通高中", "code": "C类学校", "nature": "公办", "feature": "非特色高中"}, + "天山学校": {"type": "公办普通高中", "code": "C类学校", "nature": "公办", "feature": "非特色高中"}, + "西郊学校": {"type": "公办普通高中", "code": "C类学校", "nature": "公办", "feature": "非特色高中"}, + "民办新虹桥": {"type": "民办高中", "code": "C类学校", "nature": "民办", "feature": "非特色高中"}, +} + +# 15个学科 +SUBJECTS = [ + "语文", "数学", "英语", "物理", "化学", "生物学", + "历史", "地理", "思想政治", "体育与健康", + "信息技术", "通用技术", "艺术", "音乐", "美术" +] + +# 七大维度体系 +DIMENSION_FRAMEWORK = { + "课程领导力": { + "sub_dimensions": ["国家标准遵循", "课程结构建设", "课程规范落实"], + }, + "教学变革力": { + "sub_dimensions": ["教学方式变革", "作业设计与管理变革"], + }, + "学生发展指导力": { + "sub_dimensions": ["学科发展的个性化辅导", "学生生涯发展指导"], + }, + "教师发展支持力": { + "sub_dimensions": ["培训支持", "教研支持", "项目支持"], + }, + "教育质量评估力": { + "sub_dimensions": ["科学评价观", "学业质量评估", "综合素质评估", "实践活动评估"], + }, + "教育条件保障力": { + "sub_dimensions": ["区域推进", "环境支持", "资源支持"], + }, + "数字化赋能力": { + "sub_dimensions": ["教学方式创新", "评价精准化与个性化", "课程迭代优化"], + }, +} + +# 三级维度水平划分阈值 (从赋分整理表和报告中提取) +LEVEL_THRESHOLDS = { + "国家标准遵循": {"level4": 57, "level3": 53.5, "level2": 43}, + "课程结构建设": {"level4": 55.5, "level3": 49, "level2": 45}, + "课程规范落实": {"level4": 58, "level3": 50, "level2": 44}, + "教学方式变革": {"level4": 54, "level3": 49, "level2": 45}, + "作业设计与管理变革": {"level4": 54, "level3": 49, "level2": 45}, + "学科发展的个性化辅导": {"level4": 60, "level3": 50, "level2": 46}, + "学生生涯发展指导": {"level4": 56, "level3": 50, "level2": 42}, + "培训支持": {"level4": 51, "level3": 49, "level2": 47.5}, + "教研支持": {"level4": 55, "level3": 50, "level2": 47}, + "项目支持": {"level4": 56.5, "level3": 50, "level2": 44}, + "科学评价观": {"level4": 57, "level3": 50, "level2": 43}, + "学业质量评估": {"level4": 54, "level3": 46, "level2": 41}, + "综合素质评估": {"level4": 58.5, "level3": 50, "level2": 39}, + "实践活动评估": {"level4": 50, "level3": 46.5, "level2": 43}, + "区域推进": {"level4": 59, "level3": 52, "level2": 40}, + "环境支持": {"level4": 56, "level3": 49.5, "level2": 41}, + "资源支持": {"level4": 57.5, "level3": 49, "level2": 42}, + "教学方式创新": {"level4": 54, "level3": 45, "level2": 40}, + "评价精准化与个性化": {"level4": 60, "level3": 50, "level2": 40}, + "课程迭代优化": {"level4": 55, "level3": 50, "level2": 45}, +} + +# 水平的质性描述(从报告表1-2提取) +LEVEL_DESCRIPTIONS = { + "国家标准遵循": { + 4: "所有科目必修课程开齐开足,选必和选修满足要求", + 3: "考试类科目必修开齐,选必和选修满足要求", + 2: "必修未能开齐开足,选必和选修有一个满足要求", + 1: "必修未能开齐开足,选必和选修均不满足要求", + }, + "课程结构建设": { + 4: "学科类必修课程离差总和在30%以内,总体三类课程在150%以下,校本课程和综合实践较好", + 3: "总体三类课程离差总和在250%以下,校本课程和综合实践高于平均水平", + 2: "总体三类课程离差总和在300%以下,校本课程和综合实践较差", + 1: "总体三类课程的离差和很高,校本课程和综合实践在末尾25%", + }, + "课程规范落实": { + 4: "都有建设规范文本和档案记录", + 3: "都有建设规范文本,但过程性档案记录已经全部建成,部分有尚未使用", + 2: "部分有建设规范文本,档案大部分已经建成但未使用", + 1: "基本没有建设规范文本,档案尚未建成", + }, + "教学方式变革": { + 4: "所有老师有共识和研究,并能够系统设计、有效实施,至少有3种常态化落实形式", + 3: "大部分老师有共识和研究,并能够落实教材中的相关要求,至少有2种常态化落实形式", + 2: "个别老师有研究,并能够偶尔引导学生开展学习,至少有1种常态化落实形式", + 1: "基本没有教学方法等相关研究,基本不组织相关学习,没有或仅有1种落实形式", + }, + "作业设计与管理变革": { + 4: "在每类创新性作业中至少掌握3种类型,作业时长有统一控制管理,定期批改评价,有多元化属性标注", + 3: "在每类创新性作业中至少掌握2种类型,偶有时长控制管理,面批为主,有至少两种属性标注", + 2: "至少掌握1种类型或擅长某1-2种创新作业,学生自己控制时长,很少反馈,有属性标注", + 1: "擅长某种创新作业,学生自己控制时长,几乎不反馈,无属性标注", + }, + "学科发展的个性化辅导": { + 4: "各学科平均辅导时长每周2小时以上,教师根据学情确定内容,采取个别辅导方式", + 3: "各学科平均辅导时长每周1-2小时,教师根据学情确定内容,主要个别分散辅导", + 2: "各学科平均辅导时长每周1小时以内,学生提出需求后教师辅导,分组统一或分散辅导", + 1: "辅导时长每周1小时以内或不辅导,学生提出需求后教师辅导,班级统一辅导", + }, + "学生生涯发展指导": { + 4: "专设生涯指导课程,三年覆盖90%+学生,本校+外聘教师队伍,校内外资源足够支持", + 3: "外请讲座实施,三年覆盖70-90%学生,外聘教师队伍,校内外资源有一些支持", + 2: "与社会考察/志愿服务结合实施,三年覆盖50-70%学生,外聘教师,几乎无资源支持", + 1: "与社会考察/志愿服务结合实施,三年覆盖50%以下,未形成稳定队伍,几乎无资源支持", + }, + "培训支持": { + 4: "各学科教师平均外出培训人数≥2.5人", + 3: "各学科教师平均外出培训人数≥1.5人", + 2: "各学科教师平均外出培训人数≥1人", + 1: "各学科教师几乎不进行外出培训", + }, + "教研支持": { + 4: "教研的数量和质量均较高", + 3: "有教研活动,质量较好", + 2: "有教研活动但质量一般", + 1: "活动数量和质量均较低", + }, + "项目支持": { + 4: "各学科均有负责的校级以上项目至少一个", + 3: "有部分学科负责校级以上项目至少一个", + 2: "各学科没有负责的校级以上项目,部分学科有校级项目至少一个", + 1: "各学科校级及校级以上的项目均没有", + }, + "科学评价观": { + 4: "在所有方面均能至少关注两项素养发展", + 3: "至少有三个方面关注两项素养发展", + 2: "能较多关注学生发展", + 1: "较少关注学生发展", + }, + "学业质量评估": { + 4: "校本化评价工具已研制并使用,学期考试质量分析并标注属性较为全面", + 3: "校本化评价工具已研制并使用,学期考试质量分析不做硬性要求", + 2: "至少已研制一项校本化评价工具,学期考试质量分析不做硬性要求", + 1: "未能重视学业质量评估,校本化评价工具均欠缺", + }, + "综合素质评估": { + 4: "校本化综合素质评价体系均有建设和使用,有平台支持且评价结果表达科学", + 3: "校本化综合素质评价体系均有建设和使用,支持平台和评价结果使用有待提高", + 2: "校本化综合素质评价方案建成,但具体评价工具有待开发", + 1: "未能重视校本化综合素质评价,方案、工具和结果使用等均欠缺", + }, + "实践活动评估": { + 4: "研究性学习、社会考察和学科实践活动的校本化评价工具已研制并使用", + 3: "学科实践活动的校本化评价工具已研制并使用,研究性学习/社会考察至少一项已研制但尚未使用", + 2: "学科实践活动的校本化评价工具已研制", + 1: "研究性学习、社会考察和学科实践活动的校本化评价工具均尚未研制和使用", + }, + "区域推进": { + 4: "召开会议平均一个月2次及以上,区域管理文件数量和措施均为最大值", + 3: "召开会议平均一个月1次及以上,区域管理文件3个以上,措施达3项", + 2: "召开会议、文件和措施至少有一项建设较好", + 1: "召开会议、文件和措施三项均建设较差", + }, + "环境支持": { + 4: "信息化支持和硬件支持均处于较高水平", + 3: "信息化支持和硬件在平均水平附近", + 2: "信息化支持和硬件支持至少有一项建设较好", + 1: "信息化支持和硬件支持均建设较差", + }, + "资源支持": { + 4: "校内外资源和师资水平均处于较高水平", + 3: "校内外资源至少有一项供给较好,师资水平较好", + 2: "校内外资源至少有一项仅略低于平均水平,师资水平略低于平均水平", + 1: "校内外资源和师资水平三项均建设较差", + }, + "教学方式创新": { + 4: "信息技术与教学融合程度高,教师能常态化使用信息技术", + 3: "信息技术与教学融合程度较高,教师能使用信息技术", + 2: "信息技术与教学融合程度一般,学校有信息化平台建设", + 1: "信息技术与教学融合程度较差,教师基本不使用信息技术", + }, + "评价精准化与个性化": { + 4: "有自建信息技术平台支持学科诊断与综合素质评价", + 3: "借助第三方平台支持学科诊断与综合素质评价", + 2: "有信息技术平台支持学业评价", + 1: "没有信息技术平台支持评价", + }, + "课程迭代优化": { + 4: "学校对促进教学数字化转型有专项研修计划并开展相关活动,业务绝大部分使用信息化系统", + 3: "学校尚未制定专项研修计划,业务绝大部分使用信息化系统", + 2: "学校较少开展数字化转型活动,少数业务使用信息化系统", + 1: "学校几乎不开展数字化转型活动,尚未建成信息化管理系统", + }, +} diff --git a/backend/app/engines/__init__.py b/backend/app/engines/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/engines/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/engines/data_engine.py b/backend/app/engines/data_engine.py new file mode 100644 index 0000000..1520b66 --- /dev/null +++ b/backend/app/engines/data_engine.py @@ -0,0 +1,255 @@ +""" +数据引擎:Excel解析 + 数据清洗 + 赋分计算 +负责将原始Excel数据转换为每所学校的结构化得分数据 +""" +import pandas as pd +import numpy as np +from pathlib import Path +from typing import Dict, List, Optional, Tuple +import logging + +from ..config import ( + EXCEL_BASIC_INFO, EXCEL_COURSE_IMPL, EXCEL_SUBJECT_IMPL, + SCHOOL_NAME_MAP, SUBJECTS, SCHOOL_TYPE_MAP, +) + +logger = logging.getLogger(__name__) + + +class DataEngine: + """数据引擎:读取Excel、清洗、结构化""" + + def __init__(self, data_dir: Optional[Path] = None): + self.data_dir = data_dir + self._basic_info: Optional[pd.DataFrame] = None + self._course_impl: Optional[pd.DataFrame] = None + self._subject_impl: Optional[pd.DataFrame] = None + + def load_all(self) -> None: + """加载所有Excel数据""" + logger.info("开始加载Excel数据...") + self._basic_info = self._load_basic_info() + self._course_impl = self._load_course_impl() + self._subject_impl = self._load_subject_impl() + logger.info( + f"数据加载完成: 基础信息={len(self._basic_info)}行, " + f"课程实施={len(self._course_impl)}行, " + f"学科课程={len(self._subject_impl)}行" + ) + + def _standardize_school_name(self, df: pd.DataFrame, col: str) -> pd.DataFrame: + """标准化学校名称""" + df[col] = df[col].map(lambda x: SCHOOL_NAME_MAP.get(x, x)) + return df + + def _load_basic_info(self) -> pd.DataFrame: + """加载基础信息表""" + df = pd.read_excel(EXCEL_BASIC_INFO) + df = self._standardize_school_name(df, "学校名称") + return df + + def _load_course_impl(self) -> pd.DataFrame: + """加载课程实施情况表""" + df = pd.read_excel(EXCEL_COURSE_IMPL) + df = self._standardize_school_name(df, "学校简称") + # 统一列名 + df = df.rename(columns={"学校简称": "学校名称"}) + return df + + def _load_subject_impl(self) -> pd.DataFrame: + """加载学科课程实施情况表""" + df = pd.read_excel(EXCEL_SUBJECT_IMPL) + df = self._standardize_school_name(df, "学校简称") + df = df.rename(columns={"学校简称": "学校名称"}) + return df + + @property + def schools(self) -> List[str]: + """获取所有学校名称列表""" + if self._course_impl is None: + self.load_all() + return sorted(self._course_impl["学校名称"].unique().tolist()) + + def get_school_basic_info(self, school: str) -> Dict: + """获取学校基本信息""" + if self._basic_info is None: + self.load_all() + df = self._basic_info[self._basic_info["学校名称"] == school] + info = SCHOOL_TYPE_MAP.get(school, {}) + + # 提取关键字段 + def _get_field(field_name): + rows = df[df["字段名称"] == field_name] + if len(rows) > 0: + return rows.iloc[0]["字段值"] + return None + + info.update({ + "school_name": school, + "建校年份": _get_field("建校年份"), + "教师总数": _get_field("学校教师总数"), + "占地面积": _get_field("占地面积"), + "建筑面积": _get_field("建筑面积"), + }) + return info + + def get_school_course_data(self, school: str) -> pd.DataFrame: + """获取某学校的课程实施情况数据""" + if self._course_impl is None: + self.load_all() + return self._course_impl[self._course_impl["学校名称"] == school].copy() + + def get_school_subject_data(self, school: str, subject: Optional[str] = None) -> pd.DataFrame: + """获取某学校的学科课程实施数据""" + if self._subject_impl is None: + self.load_all() + df = self._subject_impl[self._subject_impl["学校名称"] == school].copy() + if subject: + df = df[df["学科"] == subject] + return df + + # ========== 课程领导力相关数据提取 ========== + + def get_weekly_hours(self, school: str) -> pd.DataFrame: + """获取学校各学科各年级各学期的周课时数据""" + df = self.get_school_course_data(school) + # 筛选周课时相关字段 + hours_fields = ["学科必修课周课时", "学科选择性必修课周课时", "学科类选修课周课时"] + result = df[df["字段名称"].isin(hours_fields)].copy() + result["字段取值"] = pd.to_numeric(result["字段取值"], errors="coerce") + return result + + def get_course_norms(self, school: str) -> Dict: + """获取学校课程规范落实相关数据(建设规范、档案等)""" + df = self.get_school_course_data(school) + norm_keywords = ["建设规范文本", "档案", "已经建成并使用", "尚未建成"] + mask = df["字段名称"].apply( + lambda x: any(k in str(x) for k in norm_keywords) if pd.notna(x) else False + ) + return df[mask][["字段名称", "字段取值"]].to_dict("records") + + # ========== 教学变革力相关数据提取 ========== + + def get_teaching_reform_data(self, school: str) -> Dict: + """获取教学方式变革相关数据(认识程度、落实程度、实施方式)""" + df = self.get_school_subject_data(school) + reform_keywords = [ + "认识程度", "落实程度", "认识", "落实", + "理解式学习", "自主性学习", "实践性学习", "跨学科学习", + "信息技术与教学融合", "信息融入教学", + ] + mask = df["字段名称"].apply( + lambda x: any(k in str(x) for k in reform_keywords) if pd.notna(x) else False + ) + return df[mask] + + def get_homework_data(self, school: str) -> Dict: + """获取作业设计与管理数据""" + df = self.get_school_subject_data(school) + hw_keywords = [ + "作业", "实践类", "表现类", "跨学科", "团队合作", + "批改", "评价", "属性标注", "时长控制", + ] + mask = df["字段名称"].apply( + lambda x: any(k in str(x) for k in hw_keywords) if pd.notna(x) else False + ) + return df[mask] + + # ========== 通用数据提取方法 ========== + + def get_field_value(self, school: str, source: str, field_name: str, + subject: Optional[str] = None) -> Optional[str]: + """通用字段值获取""" + if source == "basic": + df = self._basic_info[self._basic_info["学校名称"] == school] + col = "字段名称" + val_col = "字段值" + elif source == "course": + df = self.get_school_course_data(school) + col = "字段名称" + val_col = "字段取值" + elif source == "subject": + df = self.get_school_subject_data(school, subject) + col = "字段名称" + val_col = "字段取值" + else: + return None + + rows = df[df[col] == field_name] + if len(rows) > 0: + return rows.iloc[0][val_col] + return None + + def get_field_by_id(self, school: str, source: str, field_id: str, + subject: Optional[str] = None) -> List[Dict]: + """通过字段ID获取数据""" + if source == "basic": + df = self._basic_info[self._basic_info["学校名称"] == school] + elif source == "course": + df = self.get_school_course_data(school) + elif source == "subject": + df = self.get_school_subject_data(school, subject) + else: + return [] + + rows = df[df["字段ID"] == field_id] + return rows.to_dict("records") + + def build_score_matrix(self) -> pd.DataFrame: + """ + 构建所有学校×所有字段的得分矩阵 + 这是赋分引擎的输入 + """ + if self._course_impl is None: + self.load_all() + + records = [] + + for school in self.schools: + # 课程实施表数据 + course_df = self.get_school_course_data(school) + for _, row in course_df.iterrows(): + records.append({ + "学校": school, + "来源": "course", + "题号": row.get("题号"), + "字段ID": row.get("字段ID"), + "字段名称": row.get("字段名称"), + "字段取值": row.get("字段取值"), + "学科": row.get("学科", "不分学科"), + "年级": row.get("年级", "不分年级"), + "学期": row.get("学期", ""), + }) + + # 学科课程表数据 + subject_df = self.get_school_subject_data(school) + for _, row in subject_df.iterrows(): + records.append({ + "学校": school, + "来源": "subject", + "题号": row.get("题号"), + "字段ID": row.get("字段ID"), + "字段名称": row.get("字段名称"), + "字段取值": row.get("字段取值"), + "学科": row.get("学科", "不分学科"), + "年级": "", + "学期": "", + }) + + matrix = pd.DataFrame(records) + logger.info(f"得分矩阵构建完成: {len(matrix)}行, {len(self.schools)}所学校") + return matrix + + def summary(self) -> Dict: + """数据摘要""" + if self._basic_info is None: + self.load_all() + return { + "schools": self.schools, + "school_count": len(self.schools), + "basic_info_rows": len(self._basic_info), + "course_impl_rows": len(self._course_impl), + "subject_impl_rows": len(self._subject_impl), + "subjects": SUBJECTS, + "school_types": {s: SCHOOL_TYPE_MAP[s]["type"] for s in self.schools}, + } diff --git a/backend/app/engines/llm_engine.py b/backend/app/engines/llm_engine.py new file mode 100644 index 0000000..bd02b6d --- /dev/null +++ b/backend/app/engines/llm_engine.py @@ -0,0 +1,1723 @@ +""" +LLM报告引擎:分段上下文 + 并行调用 + 报告文字生成 + +核心设计: +1. 报告拆分为~20个独立段落(segment),每段有独立prompt +2. 每段只喂该段需要的结构化数据(JSON),节省token +3. 10路并发调用LLM,~2分钟完成全部段落 +4. Jinja2模板渲染prompt,保证格式统一 +5. 支持缓存,同一数据不重复调用 +""" +import asyncio +import hashlib +import json +import logging +import re +import time +from pathlib import Path +from typing import Dict, List, Optional, Callable + +from openai import OpenAI + +from ..config import ( + LLM_BASE_URL, LLM_API_KEY, LLM_MODEL, LLM_MAX_CONCURRENCY, + DIMENSION_FRAMEWORK, LEVEL_DESCRIPTIONS, SCHOOL_TYPE_MAP, +) + +# 维度概念定义(来自参考报告,每个维度分析时作为开头段落的背景) +DIMENSION_DEFINITIONS = { + "课程领导力": "课程领导力是指学校在高质量落实国家课程,根据学校培养目标构建核心素养导向校本课程体系上的关键能力,强调对课程多样性及特色性的重视,强调在核心素养引领下建构富有学校特色的课程育人体系。具体可分为国家标准遵循、课程结构建设和课程规范落实。", + "教学变革力": "教学变革力是指学校由知识本位转向素养本位的变革力量,它在一定程度上决定了学生核心素养培育的成效,强调对过分重视接受学习、死记硬背、机械训练现状的转变。具体可分为教学方式变革和作业设计与管理变革。", + "学生发展指导力": "学生发展指导力是指学校为学生提供全面、个性化发展指导的能力,涵盖学科学习的精准辅导和面向未来的生涯规划。具体可分为学科发展的个性化辅导和学生生涯发展指导。", + "教师发展支持力": "教师发展支持力是指学校在教师专业成长方面的制度保障与资源投入能力,强调为教师提供多元化的发展路径与支持平台。具体可分为培训支持、教研支持和项目支持。", + "教育质量评估力": "教育质量评估力是指学校建立科学评价体系、全面监测教育质量的能力,强调以核心素养为导向,综合运用多种评价方式。具体可分为科学评价观、学业质量评估、综合素质评估和实践活动评估。", + "教育条件保障力": "教育条件保障力是指学校在区域政策支持、硬件环境和资源配置方面为课程实施提供保障的能力。具体可分为区域推进、环境支持和资源支持。", + "数字化赋能力": "数字化赋能力是指学校运用数字技术推动教学创新、评价精准化和课程持续优化的能力,反映学校数字化转型的深度与广度。具体可分为教学方式创新、评价精准化与个性化和课程迭代优化。", +} + +# 三级维度概念定义(来自参考报告) +SUB_DIMENSION_DEFINITIONS = { + "国家标准遵循": "国家标准遵循是课程领导力的首要条件,强调学校在校内开足开齐开好国家课程的重要性,以《普通高中课程方案(2017年版2020年修订)》《上海市普通高中课程实施方案》为标准,反映学校在各类型课程课时、学分上的达标情况。", + "课程结构建设": "课程结构建设强调学校在课程设置中需着重考虑各学科、各类型的课程结构与比例,强调课程设置的科学性与合理性,反映学校在三类课程中的结构合理性与内容丰富性。", + "课程规范落实": "课程规范落实强调学校课程规范落实作为课程的价值定位和制度保证,关注建设规范文本的完备性和过程性档案记录的建成与使用情况。", + "教学方式变革": "教学方式变革强调学生主动参与和探究合作,引导学生在实践中学习、在教师指导下深度学习,强调跨学科和信息技术常态化应用的学习。", + "作业设计与管理变革": "作业设计与管理变革关注学校在创新性作业设计、作业时长管理、批改反馈和属性标注等方面的系统化程度。", + "学科发展的个性化辅导": "学科发展的个性化辅导关注学校为学生提供学科学习方面的精准化、个性化辅导的能力,包括辅导时长、辅导内容确定方式和辅导形式。", + "学生生涯发展指导": "学生生涯发展指导关注学校为学生提供生涯规划教育的实施方式、覆盖率、师资队伍和资源支持情况。", + "培训支持": "培训支持反映学校为教师提供外出培训机会的力度,以各学科教师平均外出培训人数为核心指标。", + "教研支持": "教研支持反映学校教研活动的数量和质量,体现学校教研文化的深度和教研机制的有效性。", + "项目支持": "项目支持反映学校以课题项目引领教师专业发展的情况,以各学科负责校级以上项目的覆盖情况为核心指标。", + "科学评价观": "科学评价观反映学校在课程教学各方面对学生核心素养发展的关注程度和广度。", + "学业质量评估": "学业质量评估关注学校在校本化评价工具研制、使用以及学期考试质量分析的系统性。", + "综合素质评估": "综合素质评估关注学校在校本化综合素质评价体系的建设、平台支持和评价结果运用情况。", + "实践活动评估": "实践活动评估关注学校在研究性学习、社会考察和学科实践活动等领域校本化评价工具的研制与使用。", + "区域推进": "区域推进反映学校所在区教育局对高中课程教学工作的推动力度,包括会议频次、管理文件和配套措施。", + "环境支持": "环境支持反映学校信息化环境和硬件设施对课程教学的支撑情况。", + "资源支持": "资源支持反映学校校内外资源配置和师资水平的综合状况。", + "教学方式创新": "教学方式创新关注信息技术与教学融合的深度以及教师常态化使用信息技术开展教学的情况。", + "评价精准化与个性化": "评价精准化与个性化关注学校运用信息技术平台支持学科诊断与综合素质评价的能力层次。", + "课程迭代优化": "课程迭代优化关注学校教学数字化转型的规划制度和信息化系统在业务流程中的应用程度。", +} + +logger = logging.getLogger(__name__) + +# ========== System Prompt ========== + +SYSTEM_PROMPT_ZH = """你是一位资深的教育评估专家,正在撰写上海市高中课程实施监测数据分析报告。本报告由上海市教师教育学院(上海市教育委员会教学研究室)指导,面向学校管理层。 + +## 写作风格参照 +请严格参照以下范文风格撰写。范文来自教育部门正式报告: + +<范文示例-总体表现> +测评显示,贵校课程实施总体得分是56.69分。高于上海市均值6.73分,高于XX区均值5.15分,高于市实验性示范高中均值4.22分。通过基于学校领导力视角的各维度聚类分析发现,上海市学校课程实施状况共有2类,分别是:课程实施较好类、课程实施待提高类。贵校属于课程实施较好类。 +综合来看,贵校在教育条件保障力和数字化赋能力上表现好,得分为60.15和57.76。在教学变革力和学生发展指导力上表现相对较差,得分为52.41和54.05。 + + +<范文示例-维度整体> +课程领导力是指学校在高质量落实国家课程,根据学校培养目标构建核心素养导向校本课程体系上的关键能力,强调对课程多样性及特色性的重视。具体可分为国家标准遵循、课程结构建设和课程规范落实。 +从课程领导力的得分情况来看,贵校课程领导力的得分为57.51分,高于区均值4.41分,高于市实验性示范性高中均值4.82分。 +分维度来看,课程结构建设的得分最高为63.93分;其次为课程规范落实54.42分,国家标准遵循的得分最低为54.17分。 +通过对课程领导力的三个子维度进行聚类发现,贵校课程领导力类型为高领导能力型。 + + +<范文示例-子维度> +从国家标准遵循的得分情况来看,贵校国家标准遵循的得分为54.17分,高于区均值0.20分,高于市实验性示范性高中均值1.90分。 +总体来看,贵校国家标准遵循水平处于水平三,区内处于水平三及以上的学校比例为66.6%,同类学校处于水平三及以上的比例为42.0%。 + + +## 写作规范 +1. 称谓:统一使用"贵校"指代被分析的学校,不要使用学校名称 +2. 语言风格:客观、平实、严谨的学术报告体,避免修辞性、评价性、主观感受性表述 +3. 数据引用:所有分析必须基于提供的数据,引用具体数值,使用"高于/低于XX均值X.XX分"的句式 +4. 对比结构:按"贵校得分→与区均值对比→与同类学校对比→水平分布情况"的顺序展开 +5. 水平分布:引用区内处于各水平的学校数量或比例 +6. 改进建议:针对薄弱维度给出具体、可操作的建议,对不同类型学校有差异化要求 +7. 图表引用:不生成图表,但可用"如图X-X所示"等引用(图表由模板渲染) +8. 篇幅控制:总体表现400-600字,维度整体300-500字,子维度200-350字,总结400-600字 + +## 学校类型差异化要求 +- 市实验性示范性高中:对标更高要求,强调引领示范作用,改进建议要求迈向卓越 +- 区实验性示范性高中:强调在区域内的标杆作用,改进建议着眼特色发展 +- 特色高中:突出其特色定位,分析特色与各维度的关联 +- 公办普通高中/民办高中:关注基础达标情况,改进建议务实可操作 + +## HTML输出格式规范(必须严格遵守) +- 直接输出HTML片段,不要包裹在```html代码块中 +- 只使用以下标签: