Spaces:
Running
Running
| from __future__ import annotations | |
| """ | |
| AI Courseware AI 接口 (Plan / Prepare / Reflect / Improve)。 | |
| 按照《AI Courseware AI 接口文档 v1.0》提供 6 个结构化 JSON API: | |
| - /ai/courseware/syllabus/generate | |
| - /ai/courseware/flow/generate | |
| - /ai/courseware/flow/regenerate-partial | |
| - /ai/courseware/plan/detail/generate | |
| - /ai/courseware/reflection/generate | |
| - /ai/courseware/improvement/generate | |
| """ | |
| import json | |
| import time | |
| from typing import List, Optional, Any | |
| from fastapi import APIRouter, HTTPException | |
| from pydantic import BaseModel, Field | |
| from api.config import client, DEFAULT_MODEL | |
| router = APIRouter(prefix="/ai/courseware", tags=["ai-courseware"]) | |
| PROMPT_VERSION = "v1.0" | |
| class Meta(BaseModel): | |
| """所有成功响应必须携带的 meta 信息。""" | |
| model: str | |
| model_version: Optional[str] = None | |
| prompt_version: str | |
| temperature: float | |
| tokens_used: int | |
| latency_ms: int | |
| # --------------------------------------------------------------------------- | |
| # 通用 OpenAI 调用辅助 | |
| # --------------------------------------------------------------------------- | |
| def _call_openai_json( | |
| op_name: str, | |
| system_prompt: str, | |
| user_prompt: str, | |
| temperature: float = 0.4, | |
| ) -> tuple[dict, Meta]: | |
| """调用 OpenAI 并期望得到 JSON 对象;若解析失败则抛 422。""" | |
| started = time.time() | |
| try: | |
| resp = client.chat.completions.create( | |
| model=DEFAULT_MODEL, | |
| messages=[ | |
| {"role": "system", "content": system_prompt}, | |
| {"role": "user", "content": user_prompt}, | |
| ], | |
| temperature=temperature, | |
| ) | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=f"{op_name} OpenAI error: {e}") | |
| latency_ms = int((time.time() - started) * 1000) | |
| model_name = getattr(resp, "model", DEFAULT_MODEL) | |
| usage = getattr(resp, "usage", None) | |
| tokens_used = int(getattr(usage, "total_tokens", 0) or 0) | |
| content = (resp.choices[0].message.content or "").strip() | |
| try: | |
| data = json.loads(content) | |
| if not isinstance(data, dict): | |
| raise ValueError("top-level JSON is not an object") | |
| except Exception as e: | |
| raise HTTPException( | |
| status_code=422, | |
| detail=f"INVALID_GENERATION for {op_name}: cannot parse JSON ({e}); raw={content[:400]}", | |
| ) | |
| meta = Meta( | |
| model=model_name, | |
| model_version=None, | |
| prompt_version=PROMPT_VERSION, | |
| temperature=temperature, | |
| tokens_used=tokens_used, | |
| latency_ms=latency_ms, | |
| ) | |
| return data, meta | |
| # --------------------------------------------------------------------------- | |
| # 2.1 生成大纲草案 (Generate Syllabus Preview) | |
| # --------------------------------------------------------------------------- | |
| class SyllabusContext(BaseModel): | |
| courseName: str | |
| learningOutcome: str | |
| studentLevel: str | |
| teachingFocus: str | |
| courseLength: int = Field(..., ge=1, le=52) | |
| class SyllabusGenerateRequest(BaseModel): | |
| requestId: str | |
| context: SyllabusContext | |
| class WeekSyllabus(BaseModel): | |
| weekNumber: int | |
| title: str | |
| learningObjectives: List[str] | |
| topics: List[str] | |
| class SyllabusGenerateData(BaseModel): | |
| syllabus: List[WeekSyllabus] | |
| class SyllabusGenerateResponse(BaseModel): | |
| data: SyllabusGenerateData | |
| meta: Meta | |
| def generate_syllabus(req: SyllabusGenerateRequest): | |
| """Phase 1 (Plan):根据课程元数据生成 Weeks/Modules 大纲草案。""" | |
| ctx = req.context | |
| sys_prompt = ( | |
| "You are an instructional designer. " | |
| "Given course metadata, generate a weekly syllabus as JSON. " | |
| "Output ONLY a JSON object with key 'syllabus', matching the provided schema." | |
| ) | |
| user_prompt = f""" | |
| Course name: {ctx.courseName} | |
| Learning outcome: {ctx.learningOutcome} | |
| Student level: {ctx.studentLevel} | |
| Teaching focus: {ctx.teachingFocus} | |
| Total weeks/modules: {ctx.courseLength} | |
| Return JSON: | |
| {{ | |
| "syllabus": [ | |
| {{ | |
| "weekNumber": 1, | |
| "title": "string", | |
| "learningObjectives": ["string", "..."], | |
| "topics": ["string", "..."] | |
| }}, | |
| ... | |
| ] | |
| }} | |
| Make sure the array length equals courseLength ({ctx.courseLength}) and weekNumber starts from 1. | |
| """ | |
| data_dict, meta = _call_openai_json("syllabus.generate", sys_prompt, user_prompt, temperature=0.4) | |
| if "syllabus" not in data_dict: | |
| raise HTTPException(status_code=422, detail="INVALID_GENERATION: 'syllabus' missing in JSON.") | |
| return SyllabusGenerateResponse(data=SyllabusGenerateData(**data_dict), meta=meta) | |
| # --------------------------------------------------------------------------- | |
| # 2.2 生成完整教学流程 (Generate Lesson Flow) | |
| # --------------------------------------------------------------------------- | |
| class ModuleContext(BaseModel): | |
| title: str | |
| learningObjectives: List[str] | |
| topics: List[str] | |
| durationMinutes: int = Field(..., ge=10, le=600) | |
| class FlowGenerateRequest(BaseModel): | |
| requestId: str | |
| moduleContext: ModuleContext | |
| systemPrompts: Optional[List[str]] = None | |
| class LessonStep(BaseModel): | |
| type: str | |
| title: str | |
| estimated_duration: int = Field(..., ge=1) | |
| ai_understanding: str | |
| class FlowGenerateData(BaseModel): | |
| steps: List[LessonStep] | |
| class FlowGenerateResponse(BaseModel): | |
| data: FlowGenerateData | |
| meta: Meta | |
| def generate_lesson_flow(req: FlowGenerateRequest): | |
| """Phase 2 (Prepare):为单个 Module 生成完整教学步骤列表。""" | |
| mc = req.moduleContext | |
| extra_system = "\n".join(req.systemPrompts or []) | |
| sys_prompt = ( | |
| "You are an AI courseware copilot. " | |
| "Generate a detailed lesson flow for one module as JSON. " | |
| "Output ONLY a JSON object with key 'steps', each step matching the schema." | |
| ) | |
| user_prompt = f""" | |
| Module title: {mc.title} | |
| Learning objectives: {mc.learningObjectives} | |
| Topics: {mc.topics} | |
| Duration (minutes): {mc.durationMinutes} | |
| Additional long-term system prompts (optional, may be empty): | |
| {extra_system} | |
| Return JSON: | |
| {{ | |
| "steps": [ | |
| {{ | |
| "type": "Teacher Explanation | Interactive Quiz | Group Discussion | ...", | |
| "title": "string", | |
| "estimated_duration": 10, | |
| "ai_understanding": "short explanation of why this step exists and what it should achieve" | |
| }}, | |
| ... | |
| ] | |
| }} | |
| The total sum of estimated_duration should be roughly close to durationMinutes ({mc.durationMinutes}). | |
| """ | |
| data_dict, meta = _call_openai_json("flow.generate", sys_prompt, user_prompt, temperature=0.5) | |
| if "steps" not in data_dict: | |
| raise HTTPException(status_code=422, detail="INVALID_GENERATION: 'steps' missing in JSON.") | |
| return FlowGenerateResponse(data=FlowGenerateData(**data_dict), meta=meta) | |
| # --------------------------------------------------------------------------- | |
| # 2.3 局部重构教学流程 (Regenerate Partial Flow) | |
| # --------------------------------------------------------------------------- | |
| class SimpleStep(BaseModel): | |
| id: str | |
| title: str | |
| duration: int | |
| class CurrentFlow(BaseModel): | |
| lockedSteps: List[SimpleStep] | |
| unlockedSteps: List[SimpleStep] | |
| class FlowPartialRequest(BaseModel): | |
| requestId: str | |
| prompt: str | |
| currentFlow: CurrentFlow | |
| class CopilotProposedStep(BaseModel): | |
| type: str | |
| title: str | |
| estimated_duration: int | |
| ai_understanding: str | |
| class FlowPartialData(BaseModel): | |
| explanation: str | |
| proposedSteps: List[CopilotProposedStep] | |
| class FlowPartialResponse(BaseModel): | |
| data: FlowPartialData | |
| meta: Meta | |
| def regenerate_partial_flow(req: FlowPartialRequest): | |
| """Phase 2 (Prepare):在锁定部分步骤后,根据 Prompt 重写剩余步骤。""" | |
| locked = [s.model_dump() for s in req.currentFlow.lockedSteps] | |
| unlocked = [s.model_dump() for s in req.currentFlow.unlockedSteps] | |
| sys_prompt = ( | |
| "You are an AI copilot for lesson flow editing. " | |
| "Given lockedSteps and unlockedSteps plus a user prompt, " | |
| "propose replacement steps ONLY for unlockedSteps and explain your reasoning." | |
| "Return JSON with 'explanation' and 'proposedSteps'." | |
| ) | |
| user_prompt = f""" | |
| User prompt: | |
| {req.prompt} | |
| Current flow: | |
| lockedSteps: {locked} | |
| unlockedSteps: {unlocked} | |
| Return JSON: | |
| {{ | |
| "explanation": "short natural language explanation for the teacher UI bubble", | |
| "proposedSteps": [ | |
| {{ | |
| "type": "Teacher Explanation | Interactive Quiz | Group Discussion | ...", | |
| "title": "string", | |
| "estimated_duration": 10, | |
| "ai_understanding": "short explanation of this step" | |
| }} | |
| ] | |
| }} | |
| """ | |
| data_dict, meta = _call_openai_json( | |
| "flow.regenerate-partial", sys_prompt, user_prompt, temperature=0.5 | |
| ) | |
| if "explanation" not in data_dict or "proposedSteps" not in data_dict: | |
| raise HTTPException( | |
| status_code=422, | |
| detail="INVALID_GENERATION: 'explanation' or 'proposedSteps' missing in JSON.", | |
| ) | |
| return FlowPartialResponse(data=FlowPartialData(**data_dict), meta=meta) | |
| # --------------------------------------------------------------------------- | |
| # 2.4 生成/润色实体教案长文 (Generate/Polish Lesson Plan Detail) | |
| # --------------------------------------------------------------------------- | |
| class PlanDetailRequest(BaseModel): | |
| requestId: str | |
| finalizedSteps: List[Any] | |
| class LessonSection(BaseModel): | |
| section_id: str | |
| type: str | |
| content: str | |
| class PlanDetailData(BaseModel): | |
| sections: List[LessonSection] | |
| class PlanDetailResponse(BaseModel): | |
| data: PlanDetailData | |
| meta: Meta | |
| def generate_plan_detail(req: PlanDetailRequest): | |
| """Phase 2 (Prepare):根据最终 Steps 生成/润色详细教案(富文本结构)。""" | |
| sys_prompt = ( | |
| "You are an expert lesson-plan writer. " | |
| "Given finalized lesson steps, generate detailed lesson plan sections as JSON. " | |
| "Each section has section_id, type, content (Markdown allowed)." | |
| ) | |
| user_prompt = f""" | |
| Finalized steps (JSON array, opaque to you but describes lesson flow): | |
| {json.dumps(req.finalizedSteps, ensure_ascii=False)} | |
| Return JSON: | |
| {{ | |
| "sections": [ | |
| {{ | |
| "section_id": "sec_obj_01", | |
| "type": "Lesson Objectives", | |
| "content": "By the end of this lesson, students will be able to..." | |
| }}, | |
| {{ | |
| "section_id": "sec_content_01", | |
| "type": "Content", | |
| "content": "### 1. Topic heading\\nDetailed explanation..." | |
| }} | |
| ] | |
| }} | |
| """ | |
| data_dict, meta = _call_openai_json("plan.detail.generate", sys_prompt, user_prompt, temperature=0.4) | |
| if "sections" not in data_dict: | |
| raise HTTPException(status_code=422, detail="INVALID_GENERATION: 'sections' missing in JSON.") | |
| return PlanDetailResponse(data=PlanDetailData(**data_dict), meta=meta) | |
| # --------------------------------------------------------------------------- | |
| # 2.5 生成反思看板数据 (Generate Reflection Report) | |
| # --------------------------------------------------------------------------- | |
| class TeachAnnotation(BaseModel): | |
| category: str | |
| selectedText: Optional[str] = None | |
| feedback: Optional[str] = None | |
| class QuizAggregations(BaseModel): | |
| averageScore: Optional[float] = None | |
| lowestTopic: Optional[str] = None | |
| class ReflectionRequest(BaseModel): | |
| requestId: str | |
| teachAnnotations: List[TeachAnnotation] = Field(default_factory=list) | |
| quizAggregations: Optional[QuizAggregations] = None | |
| class ReflectionUnderstanding(BaseModel): | |
| status: str | |
| summary: str | |
| bulletPoints: Optional[List[str]] = None | |
| class ReflectionEngagement(BaseModel): | |
| status: str | |
| summary: Optional[str] = None | |
| class ReflectionDifficulty(BaseModel): | |
| status: str | |
| challengingTopics: Optional[List[dict]] = None | |
| class ReflectionMisconception(BaseModel): | |
| status: str | |
| issues: Optional[List[dict]] = None | |
| class NextLessonSuggestion(BaseModel): | |
| actionText: str | |
| deepLinkType: Optional[str] = None | |
| class ReflectionData(BaseModel): | |
| understanding: ReflectionUnderstanding | |
| engagement: ReflectionEngagement | |
| difficulty: ReflectionDifficulty | |
| misconceptions: ReflectionMisconception | |
| nextLessonSuggestions: List[NextLessonSuggestion] | |
| class ReflectionResponse(BaseModel): | |
| data: ReflectionData | |
| meta: Meta | |
| def generate_reflection(req: ReflectionRequest): | |
| """Phase 4 (Reflect):基于反馈与测验数据生成反思看板。""" | |
| annotations = [a.model_dump() for a in req.teachAnnotations] | |
| qa = req.quizAggregations.model_dump() if req.quizAggregations else {} | |
| sys_prompt = ( | |
| "You are an AI teaching analytics assistant. " | |
| "Given teacher annotations and quiz aggregations, generate a reflection dashboard as JSON " | |
| "with understanding, engagement, difficulty, misconceptions, and nextLessonSuggestions." | |
| ) | |
| user_prompt = f""" | |
| Teach annotations: | |
| {json.dumps(annotations, ensure_ascii=False)} | |
| Quiz aggregations: | |
| {json.dumps(qa, ensure_ascii=False)} | |
| Return JSON: | |
| {{ | |
| "understanding": {{ | |
| "status": "Low | Medium | High", | |
| "summary": "short text", | |
| "bulletPoints": ["optional", "..."] | |
| }}, | |
| "engagement": {{ | |
| "status": "Low | Medium | High", | |
| "summary": "optional" | |
| }}, | |
| "difficulty": {{ | |
| "status": "Low | Medium | High", | |
| "challengingTopics": [{{"topic": "string", "percentStruggle": 45}}] | |
| }}, | |
| "misconceptions": {{ | |
| "status": "Low | Medium | High", | |
| "issues": [{{"title": "string", "description": "string", "evidence": "string"}}] | |
| }}, | |
| "nextLessonSuggestions": [ | |
| {{ | |
| "actionText": "string", | |
| "deepLinkType": "PREPARE_FLOW_EDIT | REFLECT_DASHBOARD | ..." | |
| }} | |
| ] | |
| }} | |
| """ | |
| data_dict, meta = _call_openai_json("reflection.generate", sys_prompt, user_prompt, temperature=0.4) | |
| required = ["understanding", "engagement", "difficulty", "misconceptions", "nextLessonSuggestions"] | |
| if any(k not in data_dict for k in required): | |
| raise HTTPException( | |
| status_code=422, | |
| detail=f"INVALID_GENERATION: missing keys in JSON, required={required}", | |
| ) | |
| return ReflectionResponse(data=ReflectionData(**data_dict), meta=meta) | |
| # --------------------------------------------------------------------------- | |
| # 2.6 生成跨期改进提案 (Generate Improvement Proposals) | |
| # --------------------------------------------------------------------------- | |
| class ImprovementRequest(BaseModel): | |
| requestId: str | |
| reflectionReports: List[Any] | |
| class ImprovementProposal(BaseModel): | |
| title: str | |
| priority: str | |
| affectedWeeks: Optional[str] = None | |
| evidence: Optional[str] = None | |
| rootCause: Optional[str] = None | |
| proposedSolution: Optional[str] = None | |
| expectedImpact: Optional[str] = None | |
| class ImprovementData(BaseModel): | |
| proposals: List[ImprovementProposal] | |
| class ImprovementResponse(BaseModel): | |
| data: ImprovementData | |
| meta: Meta | |
| def generate_improvement(req: ImprovementRequest): | |
| """Phase 5 (Improve):基于过往反思报告生成跨期改进提案。""" | |
| reports = json.dumps(req.reflectionReports, ensure_ascii=False) | |
| sys_prompt = ( | |
| "You are an AI curriculum improvement assistant. " | |
| "Given past reflection reports, generate high-level improvement proposals as JSON." | |
| ) | |
| user_prompt = f""" | |
| Past reflection reports (opaque JSON array): | |
| {reports} | |
| Return JSON: | |
| {{ | |
| "proposals": [ | |
| {{ | |
| "title": "Students struggle with gradient descent optimization", | |
| "priority": "High Priority | Medium Priority | Low Priority", | |
| "affectedWeeks": "Week 2, 3", | |
| "evidence": "45% reported difficulty...", | |
| "rootCause": "Introduced too quickly without visual math foundation.", | |
| "proposedSolution": "Split gradient descent into two lessons: (1) Intuition (2) Math.", | |
| "expectedImpact": "Increase scores by 15-20% and reduce frustration." | |
| }} | |
| ] | |
| }} | |
| """ | |
| data_dict, meta = _call_openai_json("improvement.generate", sys_prompt, user_prompt, temperature=0.4) | |
| if "proposals" not in data_dict: | |
| raise HTTPException(status_code=422, detail="INVALID_GENERATION: 'proposals' missing in JSON.") | |
| return ImprovementResponse(data=ImprovementData(**data_dict), meta=meta) | |