ClareCourseWare / api /routes_courseware_ai.py
claudqunwang's picture
Add structured AI courseware APIs
2a4f012
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
@router.post("/syllabus/generate", response_model=SyllabusGenerateResponse)
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
@router.post("/flow/generate", response_model=FlowGenerateResponse)
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
@router.post("/flow/regenerate-partial", response_model=FlowPartialResponse)
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
@router.post("/plan/detail/generate", response_model=PlanDetailResponse)
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
@router.post("/reflection/generate", response_model=ReflectionResponse)
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
@router.post("/improvement/generate", response_model=ImprovementResponse)
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)