Files
Zhang Jing Xuan 0841b1a103 智教助手平台:完整初始化
- 前端:Vue3 + TS + Element Plus,24 个页面路由(课件/组题/教案/动画/思维导图/作文批改/命题/课堂/资源/社区等)
- 后端:FastAPI + SQLAlchemy + SQLite,17 个路由模块,AI 服务层含降级模板
- AI:glm-5.x 推理模型已禁用思维链,确保输出真实内容
- 修复:ai_service 两处请求体注入 enable_thinking/thinking disabled
- 测试账号:13900999999 / test1234
2026-07-29 16:29:10 +08:00

3191 lines
222 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
import re
import html as html_lib
import httpx
import logging
import time
import base64
from config import get_settings
settings = get_settings()
logger = logging.getLogger(__name__)
class AIService:
# Shared circuit-breaker timestamp: all instances share one breaker so a
# single AI outage trips the breaker globally across every router.
_ai_unavailable_until: float = 0.0
def __init__(self):
self.api_key = settings.AI_API_KEY
self.api_base = settings.AI_API_BASE.rstrip("/")
self.model = settings.AI_MODEL
self.client = httpx.AsyncClient(
base_url=self.api_base,
headers={"Authorization": f"Bearer {self.api_key}"},
timeout=httpx.Timeout(connect=10.0, read=120.0, write=15.0, pool=10.0),
)
async def _chat(self, messages: list[dict], temperature: float = 0.7, max_tokens: int = 8192) -> str:
if time.monotonic() < AIService._ai_unavailable_until:
logger.warning("AI provider skipped (circuit breaker active for %.0fs more)", AIService._ai_unavailable_until - time.monotonic())
raise RuntimeError("AI_PROVIDER_UNAVAILABLE")
try:
response = await self.client.post(
"/chat/completions",
json={
"model": self.model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
# glm-5.x is a reasoning model: without disabling
# thinking it burns the entire token budget on
# chain-of-thought and returns EMPTY content
# (finish_reason=length), forcing every feature to
# fall back to static templates. Disable for real output.
"enable_thinking": False,
"thinking": {"type": "disabled"},
},
)
response.raise_for_status()
except httpx.ConnectError as exc:
# Connection-level failure: server likely down. Short breaker so we retry sooner.
logger.error("AI provider connect failed: %s: %s", type(exc).__name__, exc)
AIService._ai_unavailable_until = time.monotonic() + 20
raise RuntimeError("AI_PROVIDER_CONNECT_ERROR") from exc
except httpx.TimeoutException as exc:
# Timeout (connect/read): server slow or unreachable. Short breaker.
logger.error("AI provider timeout: %s: %s", type(exc).__name__, exc)
AIService._ai_unavailable_until = time.monotonic() + 20
raise RuntimeError("AI_PROVIDER_TIMEOUT") from exc
except httpx.HTTPStatusError as exc:
# HTTP error (4xx/5xx): server is up but rejected the request.
logger.error("AI provider HTTP %s: %s", exc.response.status_code, str(exc.response.text)[:200])
breaker = 30 if exc.response.status_code >= 500 else 120
AIService._ai_unavailable_until = time.monotonic() + breaker
raise RuntimeError("AI_PROVIDER_HTTP_ERROR") from exc
except Exception as exc:
logger.error("AI provider request failed: %s: %s", type(exc).__name__, exc)
AIService._ai_unavailable_until = time.monotonic() + 30
raise RuntimeError("AI_PROVIDER_UNAVAILABLE") from exc
data = response.json()
content = data["choices"][0]["message"]["content"] or ""
# Reasoning models may return empty content if thinking consumed the token budget
if not content.strip():
logger.warning("AI provider returned empty content (finish=%s, model=%s) - reasoning budget likely exhausted", data["choices"][0].get("finish_reason","?"), self.model)
raise RuntimeError("AI_PROVIDER_EMPTY_RESPONSE")
finish_reason = data["choices"][0].get("finish_reason", "")
if finish_reason == "length":
content = content + '..."}'
return content
async def _chat_json(self, messages: list[dict], temperature: float = 0.3, max_tokens: int = 8192) -> dict:
content = await self._chat(messages, temperature, max_tokens)
return self._extract_json(content)
async def ocr_image(self, image_bytes: bytes, filename: str = "") -> str:
"""Extract text from an image using the AI model's vision capability."""
if not self.api_key or self.api_key == "change-me-in-production":
raise RuntimeError("AI_PROVIDER_UNAVAILABLE")
if time.monotonic() < AIService._ai_unavailable_until:
raise RuntimeError("AI_PROVIDER_UNAVAILABLE")
b64 = base64.b64encode(image_bytes).decode("ascii")
ext = "png"
lower_name = (filename or "").lower()
if lower_name.endswith((".jpg", ".jpeg")):
ext = "jpeg"
elif lower_name.endswith(".webp"):
ext = "webp"
elif lower_name.endswith(".gif"):
ext = "gif"
messages = [
{
"role": "user",
"content": [
{
"type": "text",
"text": (
"请仔细识别这张图片中的所有文字内容,包括题目、公式、表格和标注。"
"完整提取文字,保持原有结构和层次。"
"如果是题目,请保留题号和选项格式。"
"数学公式用可读文本表示(例如 a² + b² = c²)。"
"只输出识别到的文字内容,不要添加解释或说明。"
"如果图片中没有文字或无法识别,请回复:[无法识别文字内容]"
),
},
{
"type": "image_url",
"image_url": {"url": f"data:image/{ext};base64,{b64}"},
},
],
}
]
try:
response = await self.client.post(
"/chat/completions",
json={
"model": self.model,
"messages": messages,
"temperature": 0.1,
"max_tokens": 4096,
"enable_thinking": False,
"thinking": {"type": "disabled"},
},
timeout=httpx.Timeout(connect=10.0, read=90.0, write=15.0, pool=10.0),
)
response.raise_for_status()
except Exception as exc:
logger.error("Vision OCR request failed: %s", exc)
AIService._ai_unavailable_until = time.monotonic() + 60
raise RuntimeError("AI_PROVIDER_UNAVAILABLE") from exc
data = response.json()
return data["choices"][0]["message"]["content"].strip()
def _extract_json(self, content: str) -> dict:
content = content.strip()
# Strip markdown code fences
import re as _re
content = _re.sub(r'^```\w*\n?', '', content)
content = _re.sub(r'\n?```$', '', content)
content = content.strip()
first_brace = content.find('{')
if first_brace == -1:
return {"raw_content": content}
last_brace = content.rfind('}')
if last_brace <= first_brace:
return {"raw_content": content}
# Try the full span first
candidate = content[first_brace:last_brace + 1]
try:
result = json.loads(candidate)
if isinstance(result, dict):
return result
except json.JSONDecodeError:
pass
# Try auto-fixing truncated JSON by closing open structures
fixed = self._try_fix_truncated_json(candidate)
if fixed:
return fixed
# Try replacing HTML double quotes with single quotes inside the html field
fixed2 = _re.sub(r'(style|onclick|onchange)=\\?"([^"]*?)\\?"', r"\1='\2'", candidate)
if fixed2 != candidate:
try:
result = json.loads(fixed2)
if isinstance(result, dict):
return result
except json.JSONDecodeError:
pass
# Find all '}' positions from end to start
pos = last_brace
while pos > first_brace:
if content[pos] == '}':
try:
result = json.loads(content[first_brace:pos + 1])
if isinstance(result, dict):
return result
except json.JSONDecodeError:
pass
pos -= 1
# Last resort: try to extract html field via regex
html_match = _re.search(r'"html"\s*:\s*"((?:[^"\\]|\\.)*)"', content, _re.DOTALL)
if html_match:
try:
html_val = json.loads('"' + html_match.group(1) + '"')
return {"title": "动画", "description": "", "html": html_val}
except:
pass
# All attempts failed - save debug info
try:
with open("debug_json_fail.txt", "w", encoding="utf-8") as f:
f.write(f"Content length: {len(content)}\n")
f.write(f"First 500 chars:\n{content[:500]}\n\n")
f.write(f"Last 500 chars:\n{content[-500:]}\n\n")
f.write(f"Full content:\n{content}\n")
except:
pass
return {"raw_content": content}
def _try_fix_truncated_json(self, candidate: str) -> dict | None:
"""Try to fix truncated JSON by closing open strings/brackets."""
import re as _re
# Count brackets
opens = candidate.count('{') - candidate.count('}')
opens_bracket = candidate.count('[') - candidate.count(']')
# Check if we're inside a string
in_string = False
escape = False
for ch in candidate:
if escape:
escape = False
continue
if ch == '\\':
escape = True
continue
if ch == '"':
in_string = not in_string
fixed = candidate
if in_string:
fixed += ''
# Close open string if inside one
if in_string:
fixed += '"'
# Close open brackets
for _ in range(max(0, opens_bracket)):
fixed += ']'
for _ in range(max(0, opens)):
fixed += '}'
try:
result = json.loads(fixed)
if isinstance(result, dict):
# If html field exists, this is usable
if 'html' in result:
return result
except json.JSONDecodeError:
pass
# Try simpler fix: just close the outermost object
if in_string:
# Find last complete key-value pair before truncation
# Try: add closing quote, skip to end of object
for suffix in ['"}', '"}]}', '"}]}', '"]}', '"}' ]:
try:
result = json.loads(candidate + suffix)
if isinstance(result, dict):
return result
except json.JSONDecodeError:
continue
return None
async def chat_teaching(self, history: list[dict], user_message: str, subject: str = "", grade: str = "") -> str:
"""General-purpose conversational teaching assistant (multi-turn)."""
context_bits = []
if subject:
context_bits.append(f"任教学科:{subject}")
if grade:
context_bits.append(f"教学学段/年级:{grade}")
context_line = ("".join(context_bits) + "。") if context_bits else ""
system_prompt = (
"你是“智教助手”,一位经验丰富、耐心细致的 K12 全学科教学顾问与备课搭档。"
"你的任务是帮助一线教师解决真实的教学问题:备课思路、知识点讲解、活动设计、"
"课堂提问、学情诊断、家校沟通、教法选择等。\n"
"回答要求:\n"
"1. 用简体中文回答,语气专业、亲切、可落地,避免空话套话。\n"
"2. 结构清晰,善用标题、要点、表格和示例,必要时分步骤说明。\n"
"3. 紧扣中国课程标准和一线课堂实际,给出可直接使用的内容(如完整提问、活动脚本、讲解话术)。\n"
"4. 遇到模糊请求时主动追问关键信息(年级、学科、目标、时长)再给出方案。\n"
"5. 对学生的易错点、认知难点要有针对性提醒;涉及数值/事实要准确。\n"
f"{context_line}"
)
messages: list[dict] = [{"role": "system", "content": system_prompt}]
for turn in (history or []):
role = turn.get("role")
content = (turn.get("content") or "").strip()
if role in {"user", "assistant"} and content:
messages.append({"role": role, "content": content})
messages.append({"role": "user", "content": user_message})
try:
return await self._chat(messages, temperature=0.6, max_tokens=8192)
except RuntimeError:
return self._fallback_chat(history or [], user_message, subject, grade)
async def generate_mindmap(self, topic: str, subject: str = "综合", grade: str = "") -> dict:
"""生成知识思维导图,返回层级节点结构。"""
topic = self._repair_text(topic)
grade_hint = f",适用学段:{grade}" if grade else ""
system_prompt = f"""你是一位资深学科教学专家,擅长梳理知识结构。根据教师给出的主题,生成一份层次清晰、内容专业的知识思维导图。
要求:
- 围绕主题梳理 4 到 6 个一级分支(核心知识维度)
- 每个一级分支下展开 2 到 4 个二级子节点
- 节点标题用精炼短语(4 到 14 字),不要长句
- 内容要符合学科规范与教学逻辑,覆盖该主题的核心知识脉络(学科:{subject}{grade_hint}
- 如果主题偏小众,也尽量给出合理、可教学的结构
直接输出JSON(不要markdown代码块,不要其他文字):
{{"title":"主题名","nodes":[{{"title":"一级分支标题","color":"#00a870","children":[{{"title":"二级子节点标题","children":[{{"title":"可展开的细节要点"}}]}}]}}]}}"""
try:
result = await self._chat_json([
{"role": "system", "content": system_prompt},
{"role": "user", "content": topic},
], temperature=0.5, max_tokens=8192)
return self._normalize_mindmap_result(result, topic, subject)
except RuntimeError:
return self._fallback_mindmap(topic, subject)
def _normalize_mindmap_result(self, result: dict, topic: str, subject: str) -> dict:
topic = self._repair_text(topic)
nodes = result.get("nodes")
if not isinstance(nodes, list) or not nodes:
return self._fallback_mindmap(topic, subject)
palette = ["#00a870", "#2563eb", "#d97706", "#db2777", "#7c3aed", "#0891b2", "#4f46e5", "#ea580c"]
clean: list = []
for i, branch in enumerate(nodes):
if not isinstance(branch, dict) or not str(branch.get("title", "")).strip():
continue
color = branch.get("color") or palette[i % len(palette)]
children = self._collect_mindmap_children(branch.get("children"))
if not children:
continue
clean.append({"title": str(branch["title"]).strip()[:30], "color": color, "children": children})
if len(clean) < 2:
return self._fallback_mindmap(topic, subject)
return {"title": str(result.get("title") or topic)[:80], "nodes": clean[:8]}
def _collect_mindmap_children(self, raw) -> list:
if not isinstance(raw, list):
return []
out: list = []
for item in raw:
if isinstance(item, dict) and str(item.get("title", "")).strip():
title = str(item["title"]).strip()[:30]
sub = self._collect_mindmap_children(item.get("children"))
node = {"title": title}
if sub:
node["children"] = sub
out.append(node)
elif isinstance(item, str) and item.strip():
out.append({"title": item.strip()[:30]})
return out[:6]
def _fallback_chat(self, history: list, user_message: str, subject: str = "", grade: str = "") -> str:
"""AI 离线时的教学顾问兜底:按意图匹配给出可落地、结构化的教学建议。"""
subject = (subject or "").strip()
grade = (grade or "").strip()
msg = self._repair_text(user_message).strip()
ctx_bits = []
if subject:
ctx_bits.append(subject)
if grade:
ctx_bits.append(grade)
ctx = ("" + "·".join(ctx_bits) + "") if ctx_bits else ""
offline_note = (
"\n\n— 当前为离线知识模式(AI 云端未连通),以上为基于教学经验的通用建议;"
"如需针对你具体班级、教材版本的个性化方案,请在 AI 服务恢复后再次提问。"
)
if any(k in msg for k in ["你好", "您好", "在吗", "谢谢", "感谢", "辛苦"]) and len(msg) <= 12:
hi = "你好!我是智教助手,你的教学顾问与备课搭档。" + (f"已记录你的任教学段{ctx}。" if ctx else "")
return (
hi + "\n\n我可以帮你:备课与教学设计、知识点讲解思路、课堂活动与提问设计、"
"学情诊断与分层教学、家校沟通、复习备考、课堂管理、学生动机激发等。\n\n"
"直接告诉我你想解决的教学问题,例如:\n"
"- \"如何讲清分数乘法的算理?\"\n"
"- \"设计一节《草船借箭》的导入环节\"\n"
"- \"班上两极分化严重怎么办?\"\n"
"- \"家长群里如何回复投诉?\"" + offline_note
)
subj_resp = self._subject_advice(msg, subject, grade)
if subj_resp:
return subj_resp + offline_note
if any(k in msg for k in ["备课", "教学设计", "教案", "教学环节", "教学目标", "怎么设计", "如何设计", "设计一节", "设计这节", "课时方案"]):
return self._chat_lesson_prep(ctx) + offline_note
if any(k in msg for k in ["怎么讲", "如何讲", "怎么解释", "如何解释", "讲清楚", "讲不清", "学生不懂", "听不懂", "理解不了", "突破难点", "重难点", "算理", "为什么这样", "原理"]):
return self._chat_explain(ctx) + offline_note
if any(k in msg for k in ["导入", "开场", "引入", "情境", "激发兴趣", "吸引"]):
return self._chat_intro(ctx) + offline_note
if any(k in msg for k in ["课堂活动", "互动活动", "小组活动", "合作学习", "游戏", "动手", "探究", "角色扮演", "情境表演"]):
return self._chat_activity(ctx) + offline_note
if any(k in msg for k in ["提问", "追问", "问题设计", "提问技巧", "课堂提问"]):
return self._chat_questioning(ctx) + offline_note
if any(k in msg for k in ["纪律", "管纪律", "课堂管理", "课堂常规", "调皮", "捣乱", "走神", "注意力", "不专心", "讲话", "吵闹", "坐不住"]):
return self._chat_management(ctx) + offline_note
if any(k in msg for k in ["学困生", "后进生", "学困", "待优生", "跟不上", "两极分化", "差距大", "分层", "差异化", "因材施教", "培优补差"]):
return self._chat_differentiation(ctx) + offline_note
if any(k in msg for k in ["家长", "家校", "家访", "家长会", "家长群", "沟通家长", "投诉"]):
return self._chat_parents(ctx) + offline_note
if any(k in msg for k in ["作业", "布置作业", "评价", "批改", "反馈", "形成性评价", "作业量", "作业设计"]):
return self._chat_assessment(ctx) + offline_note
if any(k in msg for k in ["不想学", "没兴趣", "没动力", "厌学", "积极性", "不想听", "敷衍", "内驱力", "学习动机"]):
return self._chat_motivation(ctx) + offline_note
if any(k in msg for k in ["复习", "备考", "期末", "期中", "考试", "复习课", "冲刺", "考点", "应试"]):
return self._chat_review(ctx) + offline_note
if any(k in msg for k in ["新教师", "新手", "刚入职", "实习", "第一次上课", "新老师", "刚上班"]):
return self._chat_newteacher(ctx) + offline_note
return self._chat_general(msg, ctx) + offline_note
def _subject_advice(self, msg: str, subject: str, grade: str) -> str:
"""识别学科相关提问,返回学科特色建议;无匹配返回空串。"""
is_math = any(k in msg for k in ["数学", "算理", "算式", "方程", "函数", "几何", "分数", "小数", "乘法", "除法", "加减法", "应用题"])
is_chinese = any(k in msg for k in ["语文", "课文", "识字", "写字", "阅读理解", "作文", "古诗词", "古诗", "文言文", "拼音", "草船借箭", "荷塘月色"])
is_english = any(k in msg for k in ["英语", "单词", "语法", "口语", "听力", "时态", "从句", "because"])
is_physics = any(k in msg for k in ["物理", "力学", "电学", "电路", "牛顿", "压强", "浮力", "光学"])
if is_math:
return (
"【数学教学建议】\n\n"
"一、算理优于算法\n"
"数学核心是让学生懂\"为什么\",而非记\"怎么做\"。讲新运算先用实物/画图/数轴把算理可视化(如分数乘法用面积模型),再给法则。\n\n"
"二、三讲三练结构\n"
"- 讲例题(边讲边写思维过程)→ 练模仿题\n"
"- 讲变式(改一个条件/数据)→ 练变式题\n"
"- 讲错题(学生典型错误当反面教材)→ 练易错题\n\n"
"三、让错误可见\n"
"数学最怕假装会。多用板演、白板小测、手势即时暴露思维。错题不只对答案,让学生讲\"我当时怎么想的\"\n\n"
"四、应用题三部曲\n"
"读题(圈关键信息)→ 画图/列表(文字转成关系)→ 列式(再算)。低年级卡第一步,高年级卡第二步。\n\n"
"告诉我具体课题(如\"分数除法\"\"平行四边形面积\"),我给更细的活动设计。"
)
if is_chinese:
return (
"【语文教学建议】\n\n"
"一、朗读是地基\n"
"低中年级把朗读做扎实:教师范读→跟读→指名读→齐读,注意停顿、重音、语气。朗读到位,理解自然跟上。\n\n"
"二、课文\"三问法\"\n"
"- 写了什么?(整体感知)\n"
"- 怎么写的?(品词析句、写法)\n"
"- 为什么这样写?(情感、主旨)\n\n"
"三、作文从\"有话写\"\"写得好\"\n"
"先解决没素材:多搞观察课、活动作文。再解决写不具体:训练动作分解(把\"他跑了\"拆成 5 个动作)。最后才润色文采。\n\n"
"四、识字/字词\n"
"字理识字(讲字源)、归类识字(同偏旁)、语境识字(放词句里记),比机械抄写有效。\n\n"
"告诉我具体课文或课型,我给针对性设计。"
)
if is_english:
return (
"【英语教学建议】\n\n"
"一、输入先行(i+1 原则)\n"
"新语言点先大量听/看→理解,再要求说/写。用 TPR、图片、情境让学生先懂意思,不要一上来就机械跟读。\n\n"
"二、词不离句,句不离境\n"
"单词放进有意义的句子里记,句子放进情境里用。避免单词—中文翻译的孤立记忆,那样学生只会背不会用。\n\n"
"三、PPP 模式\n"
"Presentation(呈现)→ Practice(控制练习)→ Production(自由产出)。第三步最常被省略,却最关键——要给真实任务去用语言。\n\n"
"四、让沉默期变短\n"
"不爱开口的学生:先做非语言回应(指、选、画),再重复性输出(跟读),最后创造性输出。降低开口焦虑。\n\n"
"告诉我具体单元/课型,我给细化方案。"
)
if is_physics:
return (
"【物理教学建议】\n\n"
"一、实验是物理的灵魂\n"
"能做实验绝不只讲实验。演示实验要让现象出乎意料(引发认知冲突),分组实验让每个人动手。器材不足用生活物品替代(矿泉水瓶测压强)。\n\n"
"二、从现象到规律四步\n"
"观察现象 → 提出问题 → 猜想/设计实验 → 得出规律。规律不要直接给,让学生发现。\n\n"
"三、建模与数学化\n"
"讲公式时一定讲清每个字母的物理意义和单位,强调公式是规律的语言,而非要背的符号。\n\n"
"四、典型误区预防\n"
"力与运动(惯性误解)、电流方向、浮力沉浮条件……把这些高频误区编成判断题课前测,对症讲解。"
)
return ""
def _chat_lesson_prep(self, ctx: str) -> str:
return (
f"关于备课,建议你按\"目标—学情—活动—评价\"四步设计{ctx}\n\n"
"一、明确可检验的教学目标(最关键)\n"
"用\"学生能做什么 + 什么条件下 + 达到什么程度\"来写。例:学生能在 5 分钟内独立完成 3 道两位数乘法,正确率≥80%。\n"
"避免\"让学生理解…\"\"培养…能力\"这类无法检测的目标。一节课 2-3 个核心目标即可。\n\n"
"二、诊断学情\n"
"这节课学生已有什么基础?常见误区是什么?课前用 2 道前测题摸底,或翻看上节课作业的典型错误。\n\n"
"三、设计主干活动(建议 3 环节)\n"
"- 导入(3-5 分钟):旧知、生活情境或认知冲突引入。\n"
"- 新知建构(15-20 分钟):不要直接给结论,设计让学生发现的活动——观察、操作、讨论、归纳。\n"
"- 巩固迁移(10-15 分钟):模仿→变式→应用,难度阶梯上升。\n\n"
"四、嵌入评价(防止假装听懂)\n"
"每个环节末尾设一个检测点:提问、小测、板演、同伴互评,让你随时知道学生学到哪了。\n\n"
"时间提醒:教师讲授总时长控制在 15 分钟内,把时间还给学生练与思。\n\n"
"告诉我具体课题和年级,我给完整的环节脚本与活动设计。"
)
def _chat_explain(self, ctx: str) -> str:
return (
f"讲清一个难点,核心是搭台阶 + 可视化 + 证伪误区{ctx}\n\n"
"一、先找到学生的卡点\n"
"学生不懂通常卡在某一个具体环节,不是整体不懂。问自己:这个知识依赖哪个旧知?哪个概念是转折点?把卡点定位到最小单元。\n\n"
"二、搭台阶(最近发展区)\n"
"把难点拆成 3-4 个递进小问题,每个学生能答上来,最终自己推出结论。例:讲分数除法→先复习分数意义→再问除以一个数=乘它倒数为什么成立→用面积图验证。\n\n"
"三、可视化(多通道呈现)\n"
"能画图就画图,能摆实物就摆实物。抽象概念配具象表征(数轴、面积模型、流程图、表格),让学生看见关系。\n\n"
"四、用错误反证(认知冲突)\n"
"先抛一个看起来对其实是错的判断,让多数学生踩坑,再一起分析为什么错——比直接讲正确结论印象深 3 倍。\n\n"
"五、让学生复述/教别人\n"
"讲完别问懂了吗(学生都会点头),而让 1-2 个学生用自己的话复述,或讲给同桌听。能讲清楚才是真懂。\n\n"
"把具体知识点告诉我,我帮你拆台阶、设计冲突问题。"
)
def _chat_intro(self, ctx: str) -> str:
return (
f"好的导入能在 3-5 分钟抓住学生,常用 5 种方式{ctx}\n\n"
"1. 情境导入:用学生熟悉的生活场景切入(买菜算账、校园事件、新闻热点),让知识有用。\n"
"2. 冲突导入:抛一个反直觉的现象或问题(如:1千克铁和1千克棉花谁重?),引发好奇。\n"
"3. 悬念导入:讲半截故事/留个谜,答案藏在今天的内容里。\n"
"4. 旧知导入:从上节课内容自然延伸,温故知新。\n"
"5. 操作导入:动手做个小实验/小游戏,身体先参与,注意力立刻集中。\n\n"
"避坑:导入别超过 5 分钟,别为热闹而热闹——导入必须直接服务于本课目标。\n\n"
"告诉我课题,我帮你设计 1-2 个具体导入脚本。"
)
def _chat_activity(self, ctx: str) -> str:
return (
f"设计有效课堂活动,关键是任务清晰 + 人人有事做 + 及时反馈{ctx}\n\n"
"常用活动类型\n"
"- 小组讨论:4 人一组,问题要有争议性/开放性,讨论前给 1 分钟独立思考,避免搭便车。\n"
"- 角色扮演:语文/英语/历史特别适合,提前给角色卡和台词框架。\n"
"- 操作探究:数学/科学课用学具、实验,让学生做出结论。\n"
"- 游戏竞赛:抢答、卡片配对、知识闯关,适合巩固环节,注意控制节奏。\n"
"- 同伴互教:会的学生教不会的,教的人反而学得更深。\n\n"
"让活动不失控的 3 个原则\n"
"1. 活动前讲清规则和产出(讨论结束每组派代表说 1 条),黑板上挂任务。\n"
"2. 活动中教师巡视、记录、点拨,不站在讲台。\n"
"3. 活动后必须汇报/展示/点评,否则学生会觉得讨论了没用。\n\n"
"告诉我学科和课型,我帮你设计一个具体活动。"
)
def _chat_questioning(self, ctx: str) -> str:
return (
f"高质量课堂提问,记住三秒原则 + 追问 + 全员应答{ctx}\n\n"
"一、提问前的设计\n"
"备课时写下 3-5 个核心问题,分三类:\n"
"- 记忆型(是什么)→ 唤醒旧知\n"
"- 理解型(为什么/怎么说)→ 促思考\n"
"- 应用/评价型(如果…会怎样/你同意吗)→ 促迁移\n"
"一节好课,后两类要占多数。\n\n"
"二、提问技巧\n"
"- 先问后叫人:先抛问题,停 3-5 秒(让所有人想),再随机点名。\n"
"- 追问:学生答完,追问你怎么想到的?还有别的可能吗?把思维引向深处。\n"
"- 不轻易否定:错误答案也是资源,追问这个想法哪里有问题,让全班分析。\n"
"- 全员应答:用白板、手势、答题卡、随机抽签,让每个学生被迫回应。\n\n"
"避坑:别只问对不对/是不是;别一问完立刻自己答。\n\n"
"把要讲的课题告诉我,我帮你设计一组阶梯式提问。"
)
def _chat_management(self, ctx: str) -> str:
return (
f"课堂纪律靠规则前置 + 节奏控制 + 关系先行,而非吼叫{ctx}\n\n"
"一、预防优于纠正\n"
"- 开学第一周把课堂常规立清楚(举手发言、不插嘴、安静信号),坚持执行。\n"
"- 用固定的安静信号(拍手节奏、举手倒数、手势),比喊安静有效。\n\n"
"二、走神/讲话:用教学手段拉回,而非批评\n"
"- 突然停顿 3 秒(全班会安静看你)。\n"
"- 把走神学生的名字编进例题(假设小明买了 3 个本子…),温和提醒。\n"
"- 走下讲台站到他旁边继续讲,距离本身就是管理。\n\n"
"三、调皮/对抗:私下处理,保护自尊\n"
"- 课堂上不当众发作(易激化),课后单独谈。\n"
"- 谈话用我观察到…+ 我担心…+ 我希望…句式,而非指责。\n"
"- 找到行为背后的需求(求关注?听不懂?家庭问题?),对症下药。\n\n"
"四、根本:让课足够有趣 + 师生关系好\n"
"纪律问题 80% 来自课堂太无聊或学生不喜欢老师。把课上精彩、多关心学生,纪律自然好转。\n\n"
"具体场景(哪个年级、什么行为)告诉我,我给针对性策略。"
)
def _chat_differentiation(self, ctx: str) -> str:
return (
f"应对两极分化/学困生,核心是分层不减负、抓两头带中间{ctx}\n\n"
"一、分层不分班(课内分层)\n"
"- 目标分层:同一节课,学困生只要达成基础目标,学优生有挑战任务。\n"
"- 任务分层:布置 A(基础)/B(提高)/C(拓展)三档作业,学生选做或指定。\n"
"- 提问分层:简单问题给学困生(建立信心),难题给学优生。\n\n"
"二、补差要抓前置基础\n"
"学困生卡住往往是旧知识漏洞,不是这节课听不懂。诊断他缺哪块基础,用课前 10 分钟或课后针对性补。\n\n"
"三、同伴互助(最省力)\n"
"小老师制:1 个学优生帮 1 个学困生,捆绑考核(两人都进步才表扬)。教的人学得更深,被教的人压力小、敢问。\n\n"
"四、保护学困生自尊\n"
"- 永远不当众羞辱(这么简单都不会是毒药)。\n"
"- 抓住任何小进步公开表扬,建立我能学的信念。\n"
"- 错题私下反馈,不公布排名。\n\n"
"五、培优要给空间\n"
"学优生最怕吃不饱被打发。给自主探究任务、当小老师、参加竞赛,让能量有出口。\n\n"
"具体学科和年级告诉我,我帮你设计分层任务清单。"
)
def _chat_parents(self, ctx: str) -> str:
return (
f"家校沟通关键是先共情 + 讲事实 + 给方案,而非告状{ctx}\n\n"
"一、沟通前心态\n"
"家长不是对手,是同盟。即使投诉的家长,本质也是为孩子好。先听他说完,别急着辩解。\n\n"
"二、报问题的标准话术(三明治法)\n"
"1. 先说一个孩子的优点/进步(让家长放下防备)。\n"
"2. 客观陈述问题(只讲事实,不带评价:最近三次作业有两次没交,而非这孩子太懒)。\n"
"3. 表达关心 + 给具体方案(我担心影响他后续学习,咱们一起想想办法,我建议…您看行吗?)。\n\n"
"三、家长群沟通禁忌\n"
"- 不在群里点名批评个别学生(保护隐私)。\n"
"- 不在群里跟家长争论(私聊处理)。\n"
"- 通知类信息简洁清晰,避免长语音。\n"
"- 投诉先回应已收到,X 点前给您答复,别晾着。\n\n"
"四、家访/约谈\n"
"- 提前预约,不突袭。\n"
"- 先表扬再谈问题,结束时达成 1-2 个可执行小目标。\n"
"- 记录谈话要点,下次跟进。\n\n"
"具体场景(投诉什么/孩子什么问题)告诉我,我帮你写一段沟通话术。"
)
def _chat_assessment(self, ctx: str) -> str:
return (
f"作业与评价的核心是少而精 + 即时反馈 + 促学而非排名{ctx}\n\n"
"一、作业设计原则\n"
"- 少而精:3 道有思维含量的题 > 30 道机械重复。\n"
"- 分层:基础题全员必做,挑战题选做,避免学困生抄、学优生闲。\n"
"- 形式多样:除书面外,加口头、实践、项目作业(如用本周学的统计调查家里一周用电量)。\n"
"- 必批必改必反馈:不批的作业等于没布置;批改后要讲评典型错题。\n\n"
"二、形成性评价(重过程)\n"
"不只看期末考试。日常用:课堂提问记录、小测、作品集、同伴互评,让评价贯穿学习全过程。\n\n"
"三、反馈的艺术\n"
"- 具体:第二步符号写反了 > 粗心。\n"
"- 可改进:指出下一步怎么做,而非只判对错。\n"
"- 及时:当天/次日反馈,隔一周就失效。\n"
"- 成长导向:比上次进步了比 85 分更有激励作用。\n\n"
"具体学科和题型告诉我,我帮你设计一份分层作业。"
)
def _chat_motivation(self, ctx: str) -> str:
return (
f"激发学习动机,从胜任感 + 自主感 + 归属感入手{ctx}\n\n"
"一、胜任感:让他尝到成功的甜头\n"
"没兴趣的学生,往往因为长期学不会而放弃。给他够得着的小目标,让他体验我会了——一次小测进步、一道题做对,立刻肯定。信心是兴趣的前提。\n\n"
"二、自主感:给他选择权\n"
"人对被命令的事天然抗拒。给有限选择:今天作业 A 还是 B?小组讨论你想当记录员还是汇报员?参与感提升投入度。\n\n"
"三、归属感:让他感到被看见\n"
"记住每个学生的名字和一件小事,课堂上让每个人都有发言机会。学生因为喜欢这个老师而喜欢这门课是真实存在的。\n\n"
"四、让知识有用 + 有趣\n"
"- 联系生活/热点/学生关心的事,让知识活起来。\n"
"- 用游戏、竞赛、悬念,让过程有乐趣。\n"
"- 慎用外在奖励:物质奖励/加分短期有效,长期会削弱内驱力。多用成长反馈、公开认可。\n\n"
"五、排查外部原因\n"
"突然厌学,先了解:家庭变故?同伴关系?沉迷手机?身体/心理问题?对症才能解。\n\n"
"具体是哪个学生/什么表现告诉我,我给针对性方案。"
)
def _chat_review(self, ctx: str) -> str:
return (
f"复习备考要结构化 + 错题驱动 + 模拟实战,而非题海{ctx}\n\n"
"一、先建知识结构(脑图)\n"
"复习不是把新课重讲一遍。先带学生画整章/整册知识脑图,理清脉络,让零散知识串成网。结构清晰,提取才快。\n\n"
"二、错题驱动(最高效)\n"
"把全学期高频错题/易错点整理成专题,针对性突破。会的题不重复刷,时间留给真问题。建议每个学生建错题本,定期重做。\n\n"
"三、三轮复习节奏\n"
"- 第一轮:单元过关,扫清知识盲点(重基础)。\n"
"- 第二轮:专题整合,跨章节串联(重综合)。\n"
"- 第三轮:模拟实战,限时训练(重应试与心态)。\n\n"
"四、模拟考试要仿真\n"
"限时、独立、评分标准对齐正式考试。考后不只对答案,要分析:哪类题失分?知识漏洞还是应试失误?制定针对性补救。\n\n"
"五、别忘了心态\n"
"考前焦虑的学生很多。适当减压,强调尽力就好,别把分数和人格绑定。\n\n"
"具体学科和学段告诉我,我帮你列复习专题清单。"
)
def _chat_newteacher(self, ctx: str) -> str:
return (
f"新手教师上路,先稳住课堂 + 关系 + 心态三件事{ctx}\n\n"
"一、第一年别追求花哨,先求稳\n"
"- 把常规立住(纪律、作业、发言规则),课堂乱什么都做不好。\n"
"- 备课写详案(每环节说什么、问什么、学生可能怎么答),别只写框架。\n"
"- 多听老教师的课,模仿是最好的学习。\n\n"
"二、师生关系:严慈相济\n"
"- 开学先立威(规则严、说到做到),再慢慢施恩(关心、幽默、认可)。顺序反了很难管。\n"
"- 记住每个学生名字,课后多聊天。被看见的学生才会配合你。\n"
"- 永远不当众发火/羞辱学生,一次就可能毁掉整学期关系。\n\n"
"三、教学:少讲多练,别贪多\n"
"- 一节课 2-3 个核心点足够,讲透练透比走马观花强。\n"
"- 教师讲授控制在 15 分钟内,剩下时间给学生。\n"
"- 每节课留 5 分钟检测,知道自己教得怎么样。\n\n"
"四、心态:别和名师比,和自己比\n"
"- 第一年上不好很正常,三年才成型,五年才出风格。\n"
"- 别把所有问题归咎于自己(学生基础、家庭都是变量)。\n"
"- 找一个愿意带你/听你课的师傅,成长快 3 倍。\n\n"
"具体困惑(管不住班?备课没底?家长沟通?)告诉我,我针对性支招。"
)
def _chat_general(self, msg: str, ctx: str) -> str:
return (
f"我收到了你的问题。为了让建议更精准,先按问题—对象—目标帮你理一理{ctx}\n\n"
"一、你想解决的核心问题是什么?\n"
"比如:备课没思路?某个知识点学生听不懂?课堂纪律乱?学生没兴趣?家长难沟通?复习效率低?\n\n"
"二、针对的对象和情境?\n"
"学科、年级、班级特点(人数、两极分化程度)、这节课/这个学生的具体情况。\n\n"
"三、你期望达成的目标?\n"
"想要一份完整教案?一个活动设计?一段沟通话术?一个分层作业?还是某个具体问题的解决思路?\n\n"
"把这些信息补充给我,我能给出可直接用的方案。你也可以直接试试这些常见问题:\n"
"- 如何讲清[某知识点]\n"
"- 设计一节[某课题]的导入/活动\n"
"- 班上两极分化怎么办?\n"
"- 学生上课走神怎么管理?\n"
"- 如何回复家长的投诉?"
)
def _fallback_mindmap(self, topic: str, subject: str) -> dict:
topic = self._repair_text(topic)
title = topic[:24] or "思维导图"
for keys, nodes in self._mindmap_kb():
if any(k in topic for k in keys):
return {"title": title, "nodes": nodes}
return {"title": title, "nodes": self._mindmap_subject_template(subject)}
# ---- 主题感知思维导图知识库 ----
_MM_COLORS = ["#00a870", "#2563eb", "#d97706", "#db2777", "#7c3aed", "#0891b2"]
def _mm_nodes(self, *branches):
out = []
for i, (title, kids) in enumerate(branches):
out.append({"title": title, "color": self._MM_COLORS[i % len(self._MM_COLORS)],
"children": [{"title": k} for k in kids]})
return out
def _mindmap_kb(self):
mm = self._mm_nodes
# 历史
if False:
pass
kb = [
(["朝代", "历代", "王朝", "中国古代史"], mm(
("先秦文明", ["夏商周更替", "春秋五霸", "战国七雄"]),
("秦汉大一统", ["秦始皇统一", "汉武帝强盛", "丝绸之路"]),
("隋唐盛世", ["隋朝开科", "贞观之治", "开元盛世"]),
("宋元变革", ["宋代理学", "元朝行省制", "四大发明"]),
)),
(["丝绸之路"], mm(
("陆上丝路", ["张骞出使西域", "长安出发", "途经河西走廊"]),
("海上丝路", ["泉州广州港口", "瓷器丝绸外销", "郑和下西洋"]),
("贸易商品", ["丝绸", "瓷器", "茶叶", "香料"]),
("文化交流", ["佛教东传", "四大发明西传", "中外互通"]),
)),
(["工业革命"], mm(
("第一次工业革命", ["蒸汽机", "纺织业机械化", "铁路交通"]),
("第二次工业革命", ["电力", "内燃机", "化学工业"]),
("社会影响", ["城市化", "阶级变化", "殖民扩张"]),
("科技进步", ["生产力飞跃", "世界市场形成", "环境污染"]),
)),
(["抗日战争", "抗战"], mm(
("局部抗战", ["九一八事变", "东北沦陷", "义勇军抗争"]),
("全面抗战", ["七七事变", "正面战场", "敌后战场"]),
("重大战役", ["台儿庄", "百团大战", "平型关"]),
("伟大胜利", ["统一战线", "世界反法西斯", "日本投降"]),
)),
(["文艺复兴"], mm(
("兴起背景", ["意大利城邦", "资本主义萌芽", "人文主义"]),
("代表人物", ["但丁", "达芬奇", "莎士比亚"]),
("核心思想", ["以人为本", "个性解放", "反对神权"]),
("深远影响", ["思想解放", "科学革命", "宗教改革"]),
)),
(["辛亥革命"], mm(
("革命背景", ["清末危机", "民族资本主义", "革命思想传播"]),
("革命过程", ["武昌起义", "各省响应", "建立民国"]),
("历史意义", ["推翻帝制", "民主共和", "思想解放"]),
("局限性", ["革命不彻底", "军阀割据", "任务未完成"]),
)),
# 地理
(["气候", "气候类型", "气温降水"], mm(
("影响因子", ["纬度", "海陆", "地形", "洋流"]),
("主要类型", ["热带雨林", "季风气候", "温带大陆性", "地中海气候"]),
("降水规律", ["赤道多雨", "副热带少雨", "温带增多"]),
("中国气候", ["季风显著", "雨热同期", "气候复杂多样"]),
)),
(["地形", "地貌", "地势"], mm(
("基本地形", ["山地", "平原", "高原", "盆地", "丘陵"]),
("中国地势", ["西高东低", "阶梯分布", "大河东流"]),
("外力作用", ["流水侵蚀", "风力沉积", "冰川作用"]),
("典型地貌", ["喀斯特", "丹霞", "黄土高原", "冲积平原"]),
)),
(["河流", "水系", "长江", "黄河"], mm(
("长江", ["发源青藏", "流经十一省", "入东海", "黄金水道"]),
("黄河", ["发源巴颜喀拉", "泥沙含量大", "地上河", "入渤海"]),
("河流作用", ["供水灌溉", "航运发电", "塑造平原"]),
("治理保护", ["防洪堤坝", "水土保持", "生态修复"]),
)),
(["地球", "地球运动", "自转公转"], mm(
("自转", ["绕轴旋转", "昼夜交替", "时差"]),
("公转", ["绕日运行", "四季更替", "五带划分"]),
("黄赤交角", ["23.5度", "太阳直射点移动", "昼夜长短变化"]),
("地理意义", ["正午太阳高度", "节气", "气候形成"]),
)),
# 生物
(["细胞"], mm(
("细胞结构", ["细胞膜", "细胞质", "细胞核", "细胞壁(植物)"]),
("细胞器", ["线粒体", "叶绿体", "核糖体", "液泡"]),
("细胞分裂", ["有丝分裂", "减数分裂", "无丝分裂"]),
("细胞分化", ["形态变化", "功能特化", "形成组织"]),
)),
(["光合作用"], mm(
("反应条件", ["光照", "叶绿体", "适宜温度"]),
("原料产物", ["二氧化碳", "水", "氧气", "有机物"]),
("两个阶段", ["光反应", "暗反应", "能量转化"]),
("重要意义", ["制造有机物", "释放氧气", "碳氧平衡"]),
)),
(["生态系统"], mm(
("组成成分", ["非生物物质", "生产者", "消费者", "分解者"]),
("食物链网", ["捕食关系", "能量单向", "物质循环"]),
("能量流动", ["太阳能输入", "逐级递减", "10%-20%传递"]),
("生态平衡", ["自我调节", "稳定性", "人类影响"]),
)),
(["遗传", "基因", "DNA"], mm(
("遗传物质", ["DNA", "基因", "染色体"]),
("基本规律", ["分离定律", "自由组合", "显隐性"]),
("遗传变异", ["基因突变", "基因重组", "染色体变异"]),
("应用拓展", ["育种", "遗传咨询", "基因工程"]),
)),
(["人体", "消化", "呼吸", "循环"], mm(
("消化系统", ["口腔胃小肠", "消化酶", "吸收营养"]),
("呼吸系统", ["呼吸道", "肺泡", "气体交换"]),
("循环系统", ["心脏", "血管", "血液循环"]),
("神经系统", ["大脑", "神经传导", "反射调节"]),
)),
# 化学
(["元素周期表", "元素"], mm(
("结构规律", ["周期", "族", "原子序数", "递变规律"]),
("主族元素", ["碱金属", "卤素", "氧族", "氮族"]),
("周期变化", ["原子半径递减", "金属性减弱", "非金属性增强"]),
("应用价值", ["预测性质", "寻找新材料", "指导分类"]),
)),
(["化学反应"], mm(
("反应类型", ["化合", "分解", "置换", "复分解"]),
("反应条件", ["加热", "点燃", "催化", "通电"]),
("能量变化", ["放热反应", "吸热反应", "化学能转化"]),
("方程式", ["质量守恒", "配平", "符号规范"]),
)),
(["酸碱盐", "酸碱"], mm(
("常见酸", ["盐酸", "硫酸", "硝酸", "醋酸"]),
("常见碱", ["氢氧化钠", "氢氧化钙", "氨水"]),
("酸碱反应", ["中和反应", "生成盐和水", "pH变化"]),
("盐的性质", ["溶解性", "复分解", "焰色反应"]),
)),
(["原子", "分子", "物质构成"], mm(
("原子结构", ["原子核", "质子", "中子", "核外电子"]),
("分子构成", ["共价键", "分子间作用力", "分子特性"]),
("离子化合物", ["得失电子", "离子键", "晶体结构"]),
("物质分类", ["单质", "化合物", "混合物"]),
)),
# 物理
(["力学", "力", "牛顿"], mm(
("常见的力", ["重力", "弹力", "摩擦力", "浮力"]),
("牛顿三定律", ["惯性定律", "加速度定律", "作用反作用"]),
("压强", ["固体压强", "液体压强", "大气压强"]),
("简单机械", ["杠杆", "滑轮", "斜面", "功的原理"]),
)),
(["电学", "电流", "电路", "欧姆"], mm(
("基本物理量", ["电流", "电压", "电阻", "电功率"]),
("欧姆定律", ["I=U/R", "串并联电阻", "电压电流关系"]),
("电路连接", ["串联", "并联", "混联", "短路断路"]),
("电与磁", ["电流磁效应", "电磁感应", "电动机发电机"]),
)),
(["光学", "光", "反射折射"], mm(
("光的传播", ["直线传播", "光速", "影子小孔成像"]),
("光的反射", ["反射定律", "镜面反射", "漫反射"]),
("光的折射", ["折射定律", "全反射", "色散"]),
("透镜成像", ["凸透镜", "凹透镜", "成像规律", "应用"]),
)),
(["运动", "速度", "加速度"], mm(
("运动描述", ["参照物", "路程位移", "速度"]),
("匀速运动", ["速度恒定", "s=vt", "图像分析"]),
("变速运动", ["加速度", "v=v0+at", "匀变速直线"]),
("相对运动", ["相对速度", "相遇追及", "实际应用"]),
)),
# 数学
(["函数"], mm(
("函数概念", ["定义域", "值域", "对应关系"]),
("基本初等函数", ["一次函数", "二次函数", "反比例函数"]),
("指数对数", ["指数函数", "对数函数", "运算法则"]),
("函数性质", ["单调性", "奇偶性", "周期性"]),
)),
(["几何", "三角形", "圆"], mm(
("线与角", ["点线面", "相交平行", "角的关系"]),
("三角形", ["内角和", "全等相似", "勾股定理"]),
("四边形", ["平行四边形", "矩形菱形", "梯形"]),
("圆", ["圆的性质", "切线", "弧长扇形"]),
)),
(["方程", "一元", "二元", "不等式"], mm(
("一元一次", ["等式性质", "求解步骤", "应用题"]),
("方程组", ["二元一次", "代入消元", "加减消元"]),
("一元二次", ["因式分解", "公式法", "韦达定理"]),
("不等式", ["性质", "一元一次不等式", "不等式组"]),
)),
(["概率", "统计"], mm(
("数据收集", ["普查抽样", "频数频率", "统计图表"]),
("数据特征", ["平均数", "中位数众数", "方差极差"]),
("概率初步", ["事件分类", "古典概型", "频率估计", "树状图列举"]),
("应用", ["决策分析", "风险评估", "生活实例"]),
)),
# 语文
(["古诗", "古诗词", "唐诗宋词"], mm(
("发展脉络", ["诗经楚辞", "唐诗", "宋词", "元曲"]),
("代表诗人", ["李白杜甫", "白居易", "苏轼李清照"]),
("常见题材", ["山水田园", "边塞", "咏物言志", "送别"]),
("鉴赏方法", ["意象意境", "表现手法", "炼字", "情感主旨"]),
)),
(["记叙文"], mm(
("六要素", ["时间地点", "人物", "起因经过结果"]),
("结构层次", ["开头引入", "主体展开", "结尾点题"]),
("表达方式", ["叙述", "描写", "议论抒情"]),
("写作技巧", ["详略得当", "线索贯穿", "细节描写"]),
)),
(["议论文"], mm(
("三要素", ["论点", "论据", "论证"]),
("论点确立", ["中心论点", "分论点", "鲜明准确"]),
("论证方法", ["举例论证", "道理论证", "对比比喻"]),
("结构思路", ["提出问题", "分析问题", "解决问题"]),
)),
# 英语
(["时态"], mm(
("一般时态", ["一般现在", "一般过去", "一般将来"]),
("进行时", ["现在进行", "过去进行", "be+doing"]),
("完成时", ["现在完成", "过去完成", "have+done"]),
("时间状语", ["always/often", "yesterday", "already/yet"]),
)),
(["从句"], mm(
("名词性从句", ["主语从句", "宾语从句", "表语从句"]),
("定语从句", ["关系代词", "关系副词", "限制非限制"]),
("状语从句", ["时间原因", "条件让步", "目的结果"]),
("用法要点", ["连接词", "语序", "时态呼应"]),
)),
]
return kb
def _mindmap_subject_template(self, subject: str) -> list:
subject = (subject or "").strip()
mm = self._mm_nodes
templates = {
"数学": mm(
("概念理解", ["定义", "性质", "判定条件"]),
("公式法则", ["基本公式", "运算法则", "推导过程"]),
("典型例题", ["基础题", "应用题", "易错题"]),
("知识应用", ["实际问题", "跨学科", "拓展提升"]),
),
"物理": mm(
("物理概念", ["定义", "物理意义", "单位"]),
("规律定律", ["适用条件", "公式表达", "实验基础"]),
("实验探究", ["实验器材", "操作步骤", "数据处理"]),
("生活应用", ["现象解释", "技术应用", "综合计算"]),
),
"化学": mm(
("物质组成", ["元素", "结构", "性质"]),
("变化规律", ["反应类型", "反应条件", "实验现象"]),
("化学计算", ["化学式", "方程式", "质量计算"]),
("实际应用", ["材料能源", "环境保护", "生命健康"]),
),
"生物": mm(
("生命现象", ["结构基础", "生理功能", "生命活动"]),
("核心概念", ["定义", "原理", "机制"]),
("实验观察", ["方法", "现象", "结论"]),
("联系应用", ["生产实践", "健康生活", "生态保护"]),
),
"历史": mm(
("时代背景", ["政治", "经济", "文化"]),
("重要事件", ["时间", "人物", "过程"]),
("深远影响", ["制度变革", "思想文化", "社会发展"]),
("历史启示", ["经验教训", "规律认识", "现实意义"]),
),
"地理": mm(
("空间分布", ["位置", "范围", "界线"]),
("自然要素", ["地形气候", "水文土壤", "植被"]),
("人文要素", ["人口城市", "农业工业", "交通"]),
("人地关系", ["资源利用", "环境问题", "可持续发展"]),
),
"语文": mm(
("基础知识", ["字词", "语句", "修辞"]),
("文本理解", ["内容", "结构", "主旨"]),
("表达技巧", ["描写方法", "表现手法", "语言特色"]),
("拓展运用", ["写作借鉴", "迁移阅读", "文化传承"]),
),
"英语": mm(
("词汇积累", ["核心单词", "短语搭配", "词形变化"]),
("语法要点", ["句型结构", "时态语态", "从句"]),
("功能话题", ["日常交际", "话题表达", "文化背景"]),
("技能训练", ["听说", "读写", "综合运用"]),
),
}
if subject in templates:
return templates[subject]
return mm(
("核心概念", ["定义", "特征", "分类"]),
("知识结构", ["基本原理", "重要规律", "内在联系"]),
("重点方法", ["解题思路", "分析方法", "易错提示"]),
("应用拓展", ["生活实例", "学科关联", "拓展提升"]),
)
async def generate_animation(self, prompt: str, anim_type: str = "general") -> dict:
prompt = self._repair_text(prompt)
system_prompt = f"""你是一位教学动画设计专家。根据教师描述,生成教学动画的HTML页面。
重要规则:
- 所有HTML属性必须用单引号,如 style='color:red;' onclick='fn()'
- 不要使用<script>、Canvas或外部资源,优先用纯CSS动画、SVG和HTML元素实现
- 不要使用vh/vw单位,用px或%
- HTML从<div>开始,不含<html><head><body>
- 容器设置 style='width:100%;height:100%;position:relative;overflow:hidden;'
- 代码尽量精简,避免过长的内联样式
设计要求:
- 使用CSS keyframes或SVG animate实现教学动画效果
- 视觉风格要像教师备课平台的课堂白板:浅色背景、绿色主色(#00a870 / #0f766e)、白色内容舞台、细边框、柔和阴影
- 不要使用深色科幻背景、霓虹光效、大面积高饱和渐变;动画应清爽、可信、适合课堂投屏
- 配色克制,动画流畅,重点元素可以用少量橙色/蓝色强调
- 必须有成熟产品质感:顶部信息栏、白色主舞台、圆角、细边框、柔和投影、右侧知识卡或底部步骤进度
- 视觉不能像临时demo,不能只有几个漂浮圆点;图形必须承载知识含义
- 包含和教师输入主题直接相关的图形、标注、步骤和说明
- 可以使用伪播放控件样式,但不要依赖JavaScript控制逻辑
- 包含文字标注和说明
- 动画类型: {anim_type}
直接输出JSON(不要markdown代码块,不要其他文字):
{{"title":"动画标题","description":"动画说明","html":"HTML代码字符串","scenes":[{{"id":1,"name":"场景名","duration":3,"narration":"旁白"}}],"totalDuration":15,"interactive":true}}"""
try:
result = await self._chat_json([
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt},
], temperature=0.5, max_tokens=16384)
return self._normalize_animation_result(result, prompt, anim_type)
except RuntimeError:
return self._fallback_animation(prompt, anim_type)
def _normalize_animation_result(self, result: dict, prompt: str, anim_type: str) -> dict:
prompt = self._repair_text(prompt)
html = result.get("html")
if not isinstance(html, str):
return self._fallback_animation(prompt, anim_type)
cleaned = html.strip()
# The preview iframe must never show a "successful" blank result. Canvas/script
# animations from the model are often syntactically invalid or depend on brittle
# globals, so prefer the stable CSS animation fallback in that case.
if (
len(cleaned) < 160
or "<div" not in cleaned.lower()
or re.search(r"<script\b|<canvas\b", cleaned, re.IGNORECASE)
or re.search(r"display\s*:\s*none|opacity\s*:\s*0\b|height\s*:\s*0\b", cleaned, re.IGNORECASE)
):
return self._fallback_animation(prompt, anim_type)
if not re.search(r"animation|@keyframes|svg|style", cleaned, re.IGNORECASE):
return self._fallback_animation(prompt, anim_type)
if not self._animation_matches_prompt(prompt, cleaned, result):
return self._fallback_animation(prompt, anim_type)
if not self._animation_has_quality(cleaned):
return self._fallback_animation(prompt, anim_type)
result["html"] = cleaned.replace("100vh", "100%").replace("100vw", "100%")
result.setdefault("title", prompt[:24] or "教学动画")
result.setdefault("description", f"{anim_type} 教学动画")
result.setdefault("scenes", [{"id": 1, "name": "演示", "duration": 8, "narration": "观察动画变化"}])
result.setdefault("totalDuration", 8)
result.setdefault("interactive", True)
return result
def _animation_category(self, prompt: str) -> str:
prompt = self._repair_text(prompt)
prompt_lower = prompt.lower()
if any(k in prompt for k in ["圆柱", "体积", "π", "半径", "底面积"]):
return "cylinder"
if any(k in prompt for k in ["函数", "抛物线", "二次", "坐标", "图像", "y="]) or any(k in prompt_lower for k in ["function", "parabola", "quadratic", "axis"]):
return "function"
if any(k in prompt for k in ["光合", "叶绿体", "植物", "二氧化碳", "氧气"]):
return "photosynthesis"
if any(k in prompt for k in ["化学", "反应", "分子", "原子", "方程式", "燃烧"]):
return "chemistry"
if any(k in prompt for k in ["地球", "公转", "自转", "四季", "太阳"]):
return "orbit"
if any(k in prompt for k in ["力", "速度", "运动", "加速度", "物理", "斜面"]):
return "motion"
if any(k in prompt for k in ["三角形", "内角和", "内角", "勾股", "直角", "锐角", "钝角", "几何", "角度", "全等", "相似", "多边形", "梯形", "平行四边", "矩形", "正方形", "长方形", "圆面积", "圆的面积", "圆周长", "圆周率", "顶角", "底角", "平角", "周角"]):
return "geometry"
return "general"
def _animation_matches_prompt(self, prompt: str, html: str, result: dict) -> bool:
prompt = self._repair_text(prompt)
category = self._animation_category(prompt)
if category == "general":
return True
text = html.lower().replace(prompt.lower(), "")
markers = {
"cylinder": ["圆柱", "体积", "π", "半径", "底面积", "v ="],
"function": ["函数", "抛物线", "坐标", "y =", "ax", "curve"],
"photosynthesis": ["光合", "叶绿体", "植物", "co₂", "co2", "氧气", "o₂", "o2"],
"chemistry": ["化学", "反应", "分子", "原子", "方程式", "生成物"],
"orbit": ["地球", "公转", "自转", "四季", "太阳", "orbit"],
"motion": ["速度", "运动", "加速度", "物理", "力", "vector"],
"geometry": ["三角形", "内角", "勾股", "几何", "角度", "全等", "相似", "多边形", "180", "圆面积", "圆周长", "∠"],
}
return any(marker.lower() in text for marker in markers.get(category, []))
def _animation_has_quality(self, html: str) -> bool:
text = html.lower()
if any(bad in text for bad in ["#020617", "#111827", "#0a1628", "neon", "霓虹"]):
return False
required = ["border-radius", "box-shadow"]
if not all(token in text for token in required):
return False
has_light_stage = any(token in text for token in ["background:#fff", "background: #fff", "background:white", "background: white"])
has_platform_green = any(token in text for token in ["#00a870", "#0f766e", "#00543d", "#10b981"])
return has_light_stage and has_platform_green
def _repair_text(self, value: str) -> str:
if not isinstance(value, str):
return ""
# Some Windows clients send UTF-8 text that has been decoded as Latin-1.
# Repair that common mojibake shape before keyword matching.
if any(ch in value for ch in ["å", "ç", "æ", "è", "é"]):
try:
repaired = value.encode("latin1").decode("utf-8")
if repaired and repaired != value:
return repaired
except UnicodeError:
pass
return value
async def generate_exercise(self, prompt: str, exercise_type: str = "choice",
subject: str = "", knowledge_points: list = None,
count: int = 5) -> dict:
kp_str = "、".join(knowledge_points) if knowledge_points else "未指定"
system_prompt = f"""你是一位教学练习设计专家。生成{exercise_type}类型的练习题。
知识点:{kp_str}
学科:{subject or '未指定'}
题目数量:{count}
每道题目需要生成一个可以直接在浏览器渲染的HTML互动组件。
- 选择题:显示选项按钮,点击后高亮显示对错
- 填空题:显示输入框和提交按钮
- 判断题:显示对/错按钮
- HTML片段不要包含<html><head><body>标签,直接从<div>开始
- 不要使用vh/vw单位,使用px或%代替
输出JSON
{{
"title": "练习标题",
"questions": [
{{
"id": 1,
"type": "choice|fill_blank|true_false|matching",
"question": "题干文本",
"options": ["A选项", "B选项", "C选项", "D选项"],
"answer": "正确答案",
"explanation": "详细解析",
"difficulty": "easy|medium|hard",
"html": "该题目的完整HTML互动代码"
}}
]
}}"""
try:
return await self._chat_json([
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt},
], temperature=0.5, max_tokens=8192)
except RuntimeError:
return self._fallback_exercise(prompt, exercise_type, subject, knowledge_points, count)
async def generate_lesson_plan(self, title: str, subject: str = "", grade: str = "",
objectives: list = None, duration: int = 45,
extra_requirements: str = "") -> dict:
obj_str = "\n".join(f"- {o}" for o in (objectives or []))
system_prompt = """你是一位资深教学设计专家。根据教师需求生成完整的教案。
教案内容要详实、可操作,每个环节的具体活动描述不少于50字。
输出JSON格式:
{
"title": "教案标题",
"objectives": ["教学目标1(具体可衡量的描述)", "教学目标2"],
"key_points": ["教学重点1(说明为什么是重点)"],
"difficulties": ["教学难点1(说明突破策略)"],
"phases": [
{
"name": "导入/新授/练习/巩固/总结",
"duration": 5,
"activities": ["活动1:具体描述活动内容和步骤", "活动2:具体描述"],
"teacher_actions": ["教师具体行为描述", "教师提问的问题"],
"student_actions": ["预期学生行为和回答", "学生参与方式"],
"resources": ["所需教具和材料"]
}
],
"homework": ["作业1:具体题目或任务描述"],
"reflection": "教学反思提示"
}"""
user_msg = f"课题:{title}\n学科:{subject}\n年级:{grade}\n时长:{duration}分钟\n教学目标:\n{obj_str}\n附加要求:{extra_requirements}"
try:
return await self._chat_json([
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_msg},
])
except RuntimeError:
return self._fallback_lesson_plan(title, subject, grade, objectives, duration)
async def grade_essay(self, essay_text: str, grade_level: str = "初中",
essay_type: str = "记叙文", total_score: int = 50) -> dict:
system_prompt = f"""你是一位资深的语文作文批改专家。请对以下作文进行全面批改。
年级段:{grade_level}
作文类型:{essay_type}
满分:{total_score}
评分维度及权重:
- 内容与立意 (30%)
- 结构与条理 (25%)
- 语言表达 (25%)
- 书写与规范 (20%)
批改要求:
1. 逐段给出具体评语
2. 指出好词好句并说明为什么好
3. 指出具体问题并给出修改建议
4. 总评要结合年级段要求来评价
输出JSON
{{
"total_score": 38,
"scores": {{
"content": {{"score": 25, "max": 30, "comment": "具体评语"}},
"structure": {{"score": 20, "max": 25, "comment": "具体评语"}},
"language": {{"score": 18, "max": 25, "comment": "具体评语"}},
"writing": {{"score": 15, "max": 20, "comment": "具体评语"}}
}},
"overall_comment": "总评(100字以上详细评语)",
"strengths": ["优点1(引用原文并分析)"],
"weaknesses": ["不足1(引用原文并给出修改)"],
"suggestions": ["改进建议1(具体可操作的建议)"],
"annotations": [
{{"text": "原文片段", "comment": "批注", "type": "error|good|suggestion"}}
],
"level": "优秀|良好|中等|及格|不及格"
}}"""
try:
return await self._chat_json([
{"role": "system", "content": system_prompt},
{"role": "user", "content": essay_text},
])
except RuntimeError:
return self._fallback_essay_grade(essay_text, total_score)
async def generate_exam(self, subject: str, grade: str = "", knowledge_points: list = None,
difficulty: str = "medium", question_types: list = None,
count: int = 10, total_score: int = 100) -> dict:
kp_str = "、".join(knowledge_points) if knowledge_points else "综合"
qt_str = "、".join(question_types) if question_types else "选择题"
system_prompt = f"""你是一位专业的命题专家。请根据要求生成试卷。
学科:{subject}
年级:{grade}
知识点:{kp_str}
难度:{difficulty}
题型:{qt_str}
题目数量:{count}
总分:{total_score}
题目要求:
1. 题目内容准确、严谨,符合课标要求
2. 选项设计合理,干扰项有效
3. 解析详细,说明解题思路
4. 难度分布合理(简单30%、中等50%、困难20%)
输出JSON
{{
"title": "试卷标题(含学科和知识点)",
"questions": [
{{
"id": 1,
"type": "choice|fill_blank|true_false|short_answer|calculation",
"score": 5,
"difficulty": "easy|medium|hard",
"content": "题干(含必要的数学公式、图表描述等)",
"options": ["A选项", "B选项", "C选项", "D选项"],
"answer": "答案",
"analysis": "详细解析(含解题步骤和知识点)"
}}
]
}}"""
try:
return await self._chat_json([
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"请生成{subject}试卷,共{count}题,总分{total_score}分"},
])
except RuntimeError:
return self._fallback_exam(subject, grade, knowledge_points, difficulty, question_types, count, total_score)
def _fallback_animation(self, prompt: str, anim_type: str) -> dict:
category = self._animation_category(prompt)
if category == "cylinder":
return self._fallback_cylinder_animation(prompt, anim_type)
if category == "function":
return self._fallback_function_animation(prompt, anim_type)
if category == "photosynthesis":
return self._fallback_photosynthesis_animation(prompt, anim_type)
if category == "chemistry":
return self._fallback_chemistry_animation(prompt, anim_type)
if category == "orbit":
return self._fallback_orbit_animation(prompt, anim_type)
if category == "motion":
return self._fallback_motion_animation(prompt, anim_type)
if category == "geometry":
return self._fallback_geometry_animation(prompt, anim_type)
return self._fallback_general_animation(prompt, anim_type)
def _animation_meta(self, prompt: str, anim_type: str) -> tuple[str, str]:
title = html_lib.escape(prompt[:24] or "教学动画")
type_label = {
"math": "数学",
"physics": "物理",
"chemistry": "化学",
"general": "通用",
}.get(anim_type, "通用")
return title, type_label
def _animation_result(self, prompt: str, anim_type: str, html: str, description: str, scenes: list[dict] | None = None) -> dict:
title = html_lib.escape(prompt[:24] or "教学动画")
return {
"title": title,
"description": description,
"html": html,
"scenes": scenes or [{"id": 1, "name": "演示", "duration": 8, "narration": "观察动画变化"}],
"totalDuration": 12,
"interactive": True,
}
def _fallback_general_animation(self, prompt: str, anim_type: str) -> dict:
title, type_label = self._animation_meta(prompt, anim_type)
html = f"""<div style='width:100%;height:100%;position:relative;overflow:hidden;background:#f6fbf7;font-family:Microsoft YaHei,sans-serif;color:#12372d;'>
<style>
@keyframes floatBall {{
0% {{ transform: translateX(0) translateY(0); }}
25% {{ transform: translateX(160px) translateY(-34px); }}
50% {{ transform: translateX(320px) translateY(0); }}
75% {{ transform: translateX(480px) translateY(-24px); }}
100% {{ transform: translateX(620px) translateY(0); }}
}}
@keyframes pulseGlow {{
0%,100% {{ box-shadow: 0 8px 20px rgba(0,168,112,.16); }}
50% {{ box-shadow: 0 12px 32px rgba(0,168,112,.28); }}
}}
@keyframes drift {{
0%,100% {{ transform: translateY(0); opacity: .28; }}
50% {{ transform: translateY(-14px); opacity: .44; }}
}}
@keyframes scanLine {{
0% {{ transform: translateX(-20%); opacity: .2; }}
50% {{ opacity: .8; }}
100% {{ transform: translateX(115%); opacity: .2; }}
}}
@keyframes stepPulse {{
0%,100% {{ transform: scale(1); border-color:#d9e7dc; }}
50% {{ transform: scale(1.03); border-color:#00a870; }}
}}
</style>
<div style='position:absolute;left:36px;right:36px;top:24px;height:64px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #d9e7dc;'>
<div>
<h2 style='font-size:26px;margin:0 0 6px;line-height:1.2;color:#00543d;'>{title}</h2>
<p style='font-size:14px;color:#6b7f76;margin:0;'>{type_label}教学动画 · 清爽课堂演示版</p>
</div>
<span style='padding:8px 14px;border-radius:18px;background:#e8f7ef;color:#007a52;font-size:13px;font-weight:700;'>AI 动画</span>
</div>
<div style='position:absolute;left:54px;right:54px;top:112px;bottom:96px;background:#fff;border:1px solid #dce8de;border-radius:16px;box-shadow:0 12px 30px rgba(15,84,61,.08);overflow:hidden;'>
<div style='position:absolute;left:48px;right:48px;top:112px;height:3px;background:#d9e7dc;border-radius:2px;'></div>
<div style='position:absolute;left:48px;right:48px;top:112px;height:3px;background:linear-gradient(90deg,transparent,#00a870,transparent);animation:scanLine 4s linear infinite;'></div>
<div style='position:absolute;left:70px;top:170px;width:66px;height:66px;border-radius:50%;background:#00a870;animation:floatBall 6s ease-in-out infinite,pulseGlow 2s ease-in-out infinite;'></div>
<div style='position:absolute;left:154px;top:150px;width:18px;height:18px;border-radius:50%;background:#f59e0b;animation:drift 3.2s ease-in-out infinite;'></div>
<div style='position:absolute;left:334px;top:136px;width:26px;height:26px;border-radius:50%;background:#93c5fd;animation:drift 4s ease-in-out infinite;'></div>
<div style='position:absolute;left:554px;top:158px;width:14px;height:14px;border-radius:50%;background:#86efac;animation:drift 2.8s ease-in-out infinite;'></div>
</div>
<div style='position:absolute;left:94px;right:94px;bottom:116px;display:grid;grid-template-columns:repeat(3,1fr);gap:12px;'>
<div style='padding:14px;border:1px solid #d9e7dc;border-radius:12px;background:#fff;animation:stepPulse 2.4s ease-in-out infinite;'>1. 提出问题</div>
<div style='padding:14px;border:1px solid #d9e7dc;border-radius:12px;background:#fff;animation:stepPulse 2.4s ease-in-out infinite .4s;'>2. 动态观察</div>
<div style='padding:14px;border:1px solid #d9e7dc;border-radius:12px;background:#fff;animation:stepPulse 2.4s ease-in-out infinite .8s;'>3. 归纳结论</div>
</div>
<div style='position:absolute;left:36px;right:36px;bottom:30px;display:flex;gap:10px;align-items:center;color:#6b7f76;'>
<span style='padding:9px 16px;border-radius:18px;background:#00543d;color:#fff;font-weight:700;'>演示动画</span>
<span style='font-size:13px;'>可保存、可预览、可插入课件</span>
</div>
</div>"""
return self._animation_result(prompt, anim_type, html, f"{anim_type} 教学动画演示")
def _fallback_geometry_animation(self, prompt: str, anim_type: str) -> dict:
title, _ = self._animation_meta(prompt, anim_type)
html = f"""<div style='width:100%;height:100%;position:relative;overflow:hidden;background:#f6fbf7;font-family:Microsoft YaHei,sans-serif;color:#143b31;'>
<style>
@keyframes drawSide {{ 0% {{ stroke-dashoffset: 760; }} 60%,100% {{ stroke-dashoffset: 0; }} }}
@keyframes popAngle {{ 0%,55% {{ transform: scale(0); opacity: 0; }} 70%,90% {{ transform: scale(1); opacity: 1; }} 100% {{ transform: scale(0); opacity: 0; }} }}
@keyframes growA {{ 0%,12% {{ width: 0; }} 34%,84% {{ width: 44.4%; }} 96%,100% {{ width: 0; }} }}
@keyframes growB {{ 0%,28% {{ width: 0; }} 50%,84% {{ width: 33.3%; }} 96%,100% {{ width: 0; }} }}
@keyframes growC {{ 0%,44% {{ width: 0; }} 66%,84% {{ width: 22.3%; }} 96%,100% {{ width: 0; }} }}
@keyframes stamp {{ 0%,70% {{ transform: scale(0) rotate(-12deg); opacity: 0; }} 82%,92% {{ transform: scale(1) rotate(-6deg); opacity: 1; }} 100% {{ transform: scale(0); opacity: 0; }} }}
@keyframes barPulse {{ 0%,100% {{ box-shadow:0 6px 16px rgba(0,84,61,.10); }} 50% {{ box-shadow:0 10px 24px rgba(0,84,61,.18); }} }}
</style>
<div style='position:absolute;left:36px;right:36px;top:24px;height:64px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #d9e7dc;'>
<div>
<h2 style='margin:0 0 6px;font-size:26px;color:#00543d;'>{title}</h2>
<p style='margin:0;font-size:14px;color:#6b7f76;'>三角形三个内角拼合成一个平角 = 180°</p>
</div>
<span style='padding:8px 14px;border-radius:18px;background:#e8f7ef;color:#007a52;font-size:13px;font-weight:700;'>几何图形</span>
</div>
<div style='position:absolute;left:48px;right:48px;top:110px;bottom:64px;background:#fff;border:1px solid #dce8de;border-radius:16px;box-shadow:0 12px 30px rgba(15,84,61,.08);overflow:hidden;'>
<div style='position:absolute;left:36px;top:30px;width:45%;bottom:30px;display:flex;align-items:center;justify-content:center;'>
<svg viewBox='0 0 320 280' style='width:100%;height:100%;'>
<polygon points='160,34 44,242 276,242' fill='rgba(0,168,112,.06)' stroke='#00a870' stroke-width='5' stroke-linejoin='round' stroke-dasharray='760' stroke-dashoffset='760' style='animation:drawSide 6s ease-in-out infinite;'/>
<circle cx='160' cy='34' r='10' fill='#00a870' style='transform-box:fill-box;transform-origin:center;animation:popAngle 6s ease-in-out infinite;'/>
<text x='176' y='56' fill='#00543d' font-size='17' font-weight='700'>∠α 80°</text>
<circle cx='44' cy='242' r='10' fill='#f59e0b' style='transform-box:fill-box;transform-origin:center;animation:popAngle 6s ease-in-out infinite .6s;'/>
<text x='14' y='234' fill='#92400e' font-size='17' font-weight='700'>∠β 60°</text>
<circle cx='276' cy='242' r='10' fill='#3b82f6' style='transform-box:fill-box;transform-origin:center;animation:popAngle 6s ease-in-out infinite 1.2s;'/>
<text x='220' y='234' fill='#1d4ed8' font-size='17' font-weight='700'>∠γ 40°</text>
</svg>
</div>
<div style='position:absolute;right:30px;top:34px;width:47%;bottom:34px;display:flex;flex-direction:column;justify-content:center;gap:20px;'>
<div style='text-align:center;font-size:16px;color:#00543d;font-weight:700;'>拼角验证:三个内角 → 一条平角</div>
<div style='position:relative;height:46px;border-radius:10px;background:#f1f5f4;border:1px solid #e1eee4;overflow:hidden;animation:barPulse 3s ease-in-out infinite;'>
<div style='position:absolute;left:0;top:0;height:100%;background:linear-gradient(90deg,#00a870,#34d399);display:flex;align-items:center;justify-content:center;color:#fff;font-size:14px;font-weight:700;overflow:hidden;white-space:nowrap;animation:growA 6s ease-in-out infinite;'>α 80°</div>
<div style='position:absolute;left:44.4%;top:0;height:100%;background:linear-gradient(90deg,#f59e0b,#fbbf24);display:flex;align-items:center;justify-content:center;color:#fff;font-size:14px;font-weight:700;overflow:hidden;white-space:nowrap;animation:growB 6s ease-in-out infinite;'>β 60°</div>
<div style='position:absolute;left:77.7%;top:0;height:100%;background:linear-gradient(90deg,#3b82f6,#60a5fa);display:flex;align-items:center;justify-content:center;color:#fff;font-size:14px;font-weight:700;overflow:hidden;white-space:nowrap;animation:growC 6s ease-in-out infinite;'>γ 40°</div>
</div>
<div style='display:flex;justify-content:space-between;font-size:13px;color:#6b7f76;padding:0 2px;'><span>0°</span><span>180°</span></div>
<div style='text-align:center;font-size:22px;font-weight:800;color:#00543d;'>80° + 60° + 40° = 180°</div>
<div style='margin:0 auto;width:116px;height:116px;border-radius:50%;background:#e8f7ef;border:3px solid #00a870;display:flex;align-items:center;justify-content:center;font-size:26px;font-weight:800;color:#00543d;animation:stamp 6s ease-in-out infinite;'>180°</div>
</div>
</div>
</div>"""
return self._animation_result(prompt, anim_type, html, "三角形内角和动态演示:拼角成平角 180°")
def _fallback_function_animation(self, prompt: str, anim_type: str) -> dict:
title, _ = self._animation_meta(prompt, anim_type)
html = f"""<div style='width:100%;height:100%;position:relative;overflow:hidden;background:#f6fbf7;font-family:Microsoft YaHei,sans-serif;color:#15324a;'>
<style>
@keyframes drawCurve {{ 0% {{ stroke-dashoffset: 760; }} 100% {{ stroke-dashoffset: 0; }} }}
@keyframes movePoint {{ 0% {{ transform:translate(70px,360px); }} 25% {{ transform:translate(205px,230px); }} 50% {{ transform:translate(340px,150px); }} 75% {{ transform:translate(475px,230px); }} 100% {{ transform:translate(610px,360px); }} }}
@keyframes coeffPulse {{ 0%,100% {{ background:#e0f2fe; transform:scale(1); }} 50% {{ background:#bbf7d0; transform:scale(1.04); }} }}
</style>
<div style='position:absolute;left:36px;right:36px;top:24px;height:64px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #d9e7dc;'>
<div>
<h2 style='margin:0 0 6px;font-size:26px;color:#00543d;'>{title}</h2>
<p style='margin:0;font-size:14px;color:#6b7f76;'>观察二次函数图像随参数变化的趋势</p>
</div>
<span style='padding:8px 14px;border-radius:18px;background:#e8f7ef;color:#007a52;font-size:13px;font-weight:700;'>函数图像</span>
</div>
<div style='position:absolute;left:48px;right:48px;top:110px;bottom:64px;background:#fff;border:1px solid #dce8de;border-radius:16px;box-shadow:0 12px 30px rgba(15,84,61,.08);overflow:hidden;'>
<svg viewBox='0 0 680 420' style='position:absolute;left:24px;top:18px;width:calc(100% - 270px);height:calc(100% - 36px);background:#fbfefd;border:1px solid #e1eee4;border-radius:14px;'>
<defs><pattern id='grid' width='32' height='32' patternUnits='userSpaceOnUse'><path d='M32 0H0V32' fill='none' stroke='#e5efe8' stroke-width='1'/></pattern></defs>
<rect x='0' y='0' width='680' height='420' fill='url(#grid)'/>
<line x1='45' y1='350' x2='640' y2='350' stroke='#8aa39a' stroke-width='2'/>
<line x1='340' y1='35' x2='340' y2='382' stroke='#8aa39a' stroke-width='2'/>
<path d='M70 360 C150 265 230 175 340 150 C450 175 530 265 610 360' fill='none' stroke='#00a870' stroke-width='6' stroke-linecap='round' stroke-dasharray='760' stroke-dashoffset='760' style='animation:drawCurve 4s ease-in-out infinite alternate;'/>
<circle r='9' fill='#f97316' style='animation:movePoint 4s ease-in-out infinite;'/>
<text x='356' y='70' fill='#00543d' font-size='20' font-weight='700'>y = ax² + bx + c</text>
<text x='522' y='332' fill='#64748b' font-size='14'>x</text>
<text x='355' y='54' fill='#64748b' font-size='14'>y</text>
</svg>
<div style='position:absolute;right:24px;top:28px;width:210px;display:grid;gap:12px;'>
<div style='padding:14px;border-radius:12px;background:#ecfdf5;border:1px solid #bbf7d0;animation:coeffPulse 2.5s ease-in-out infinite;'><strong style='color:#00543d;'>a</strong><br/><span style='font-size:13px;color:#6b7f76;'>控制开口方向和宽窄</span></div>
<div style='padding:14px;border-radius:12px;background:#fffbeb;border:1px solid #fde68a;animation:coeffPulse 2.5s ease-in-out infinite .4s;'><strong style='color:#92400e;'>b</strong><br/><span style='font-size:13px;color:#6b7f76;'>影响对称轴位置</span></div>
<div style='padding:14px;border-radius:12px;background:#eff6ff;border:1px solid #bfdbfe;animation:coeffPulse 2.5s ease-in-out infinite .8s;'><strong style='color:#1d4ed8;'>c</strong><br/><span style='font-size:13px;color:#6b7f76;'>决定与 y 轴交点</span></div>
</div>
</div>
</div>"""
return self._animation_result(prompt, anim_type, html, "函数图像动态演示")
def _fallback_cylinder_animation(self, prompt: str, anim_type: str) -> dict:
title, _ = self._animation_meta(prompt, anim_type)
html = f"""<div style='width:100%;height:100%;position:relative;overflow:hidden;background:#f6fbf7;font-family:Microsoft YaHei,sans-serif;color:#143b31;'>
<style>
@keyframes sliceMove {{ 0%,100% {{ transform:translateX(0); opacity:.9; }} 50% {{ transform:translateX(130px); opacity:.65; }} }}
@keyframes fillRise {{ 0% {{ height:0; }} 100% {{ height:156px; }} }}
@keyframes formulaGlow {{ 0%,100% {{ border-color:#d9e7dc; }} 50% {{ border-color:#00a870; }} }}
</style>
<div style='position:absolute;left:36px;right:36px;top:24px;height:64px;border-bottom:1px solid #d9e7dc;'>
<h2 style='margin:0 0 6px;font-size:26px;color:#00543d;'>{title}</h2>
<p style='margin:0;color:#6b7f76;font-size:14px;'>把圆柱看作“底面积 × 高”的累积</p>
</div>
<div style='position:absolute;left:48px;right:48px;top:110px;bottom:64px;background:#fff;border:1px solid #dce8de;border-radius:16px;box-shadow:0 12px 30px rgba(15,84,61,.08);'>
<div style='position:absolute;left:70px;top:70px;width:180px;height:220px;'>
<div style='position:absolute;left:30px;top:30px;width:120px;height:156px;background:linear-gradient(90deg,#86efac,#d1fae5,#86efac);border-left:2px solid #00a870;border-right:2px solid #00a870;'></div>
<div style='position:absolute;left:30px;top:10px;width:120px;height:42px;border-radius:50%;background:#d1fae5;border:2px solid #00a870;'></div>
<div style='position:absolute;left:30px;top:164px;width:120px;height:42px;border-radius:50%;background:#a7f3d0;border:2px solid #00a870;'></div>
<div style='position:absolute;left:30px;bottom:14px;width:120px;background:rgba(0,168,112,.22);animation:fillRise 3s ease-in-out infinite alternate;'></div>
</div>
<div style='position:absolute;left:320px;top:92px;display:grid;gap:7px;'>
<div style='width:150px;height:14px;border-radius:50%;background:#d1fae5;border:1px solid #00a870;animation:sliceMove 2.8s ease-in-out infinite;'></div>
<div style='width:150px;height:14px;border-radius:50%;background:#bbf7d0;border:1px solid #00a870;animation:sliceMove 2.8s ease-in-out infinite .15s;'></div>
<div style='width:150px;height:14px;border-radius:50%;background:#86efac;border:1px solid #00a870;animation:sliceMove 2.8s ease-in-out infinite .3s;'></div>
<div style='width:150px;height:14px;border-radius:50%;background:#4ade80;border:1px solid #00a870;animation:sliceMove 2.8s ease-in-out infinite .45s;'></div>
</div>
<div style='position:absolute;right:38px;top:86px;width:230px;padding:24px;border-radius:14px;background:#f8faf8;border:1px solid #d9e7dc;animation:formulaGlow 2.6s ease-in-out infinite;'>
<div style='font-size:16px;color:#00543d;margin-bottom:12px;font-weight:700;'>体积公式</div>
<div style='font-size:32px;font-weight:800;color:#12372d;'>V = πr²h</div>
<p style='font-size:14px;line-height:1.7;color:#6b7f76;'>底面积 πr² 沿高度 h 连续叠加,得到圆柱体积。</p>
</div>
</div>
</div>"""
return self._animation_result(prompt, anim_type, html, "圆柱体积推导动画")
def _fallback_photosynthesis_animation(self, prompt: str, anim_type: str) -> dict:
title, _ = self._animation_meta(prompt, anim_type)
html = f"""<div style='width:100%;height:100%;position:relative;overflow:hidden;background:#f6fbf7;font-family:Microsoft YaHei,sans-serif;color:#14532d;'>
<style>
@keyframes ray {{ 0% {{ opacity:.15; transform:translateY(-12px); }} 50% {{ opacity:.85; transform:translateY(8px); }} 100% {{ opacity:.15; transform:translateY(-12px); }} }}
@keyframes bubbleIn {{ 0% {{ transform:translateX(0); opacity:.2; }} 50% {{ opacity:1; }} 100% {{ transform:translateX(235px); opacity:.2; }} }}
@keyframes oxygenOut {{ 0% {{ transform:translate(0,0); opacity:.1; }} 100% {{ transform:translate(180px,-90px); opacity:1; }} }}
</style>
<div style='position:absolute;left:36px;right:36px;top:24px;height:64px;border-bottom:1px solid #d9e7dc;'>
<h2 style='margin:0 0 6px;font-size:26px;color:#00543d;'>{title}</h2>
<p style='margin:0;color:#6b7f76;font-size:14px;'>光能驱动二氧化碳和水转化为有机物</p>
</div>
<div style='position:absolute;left:48px;right:48px;top:110px;bottom:64px;background:#fff;border:1px solid #dce8de;border-radius:16px;box-shadow:0 12px 30px rgba(15,84,61,.08);overflow:hidden;'>
<div style='position:absolute;left:42px;top:36px;width:90px;height:90px;border-radius:50%;background:#facc15;box-shadow:0 10px 28px rgba(250,204,21,.35);'></div>
<div style='position:absolute;left:160px;top:72px;width:5px;height:170px;background:#fde047;transform:rotate(25deg);animation:ray 2.2s ease-in-out infinite;'></div>
<div style='position:absolute;left:204px;top:72px;width:5px;height:190px;background:#fde047;transform:rotate(18deg);animation:ray 2.2s ease-in-out infinite .25s;'></div>
<div style='position:absolute;left:270px;top:92px;width:280px;height:164px;border-radius:50% 8% 50% 8%;background:linear-gradient(135deg,#16a34a,#bbf7d0);transform:rotate(-8deg);box-shadow:inset 0 0 30px rgba(255,255,255,.45),0 14px 28px rgba(0,84,61,.12);'></div>
<div style='position:absolute;left:394px;top:164px;width:150px;height:6px;background:#166534;transform:rotate(-8deg);border-radius:4px;'></div>
<div style='position:absolute;left:52px;bottom:54px;padding:14px 18px;border-radius:14px;background:#eff6ff;border:1px solid #bfdbfe;font-weight:700;'>CO₂ + H₂O</div>
<div style='position:absolute;left:116px;bottom:125px;width:26px;height:26px;border-radius:50%;background:#38bdf8;animation:bubbleIn 3s ease-in-out infinite;'></div>
<div style='position:absolute;left:156px;bottom:160px;width:20px;height:20px;border-radius:50%;background:#22c55e;animation:bubbleIn 3s ease-in-out infinite .6s;'></div>
<div style='position:absolute;right:168px;top:120px;width:28px;height:28px;border-radius:50%;background:#e0f2fe;border:2px solid #38bdf8;animation:oxygenOut 2.8s ease-in-out infinite;'></div>
<div style='position:absolute;right:34px;bottom:54px;padding:16px 22px;border-radius:14px;background:#ecfdf5;border:1px solid #bbf7d0;box-shadow:0 8px 18px rgba(15,84,61,.08);'>
<strong style='color:#00543d;'>产物</strong><br/>葡萄糖 + O₂
</div>
</div>
</div>"""
return self._animation_result(prompt, anim_type, html, "光合作用过程动画")
def _fallback_chemistry_animation(self, prompt: str, anim_type: str) -> dict:
title, _ = self._animation_meta(prompt, anim_type)
html = f"""<div style='width:100%;height:100%;position:relative;overflow:hidden;background:#f6fbf7;font-family:Microsoft YaHei,sans-serif;color:#143b31;'>
<style>
@keyframes collideA {{ 0%,100% {{ transform:translate(0,0); }} 50% {{ transform:translate(220px,40px); }} }}
@keyframes collideB {{ 0%,100% {{ transform:translate(0,0); }} 50% {{ transform:translate(-220px,-40px); }} }}
@keyframes flash {{ 0%,45%,100% {{ opacity:.15; transform:scale(.7); }} 50% {{ opacity:1; transform:scale(1.5); }} }}
@keyframes product {{ 0%,55% {{ opacity:0; transform:scale(.5); }} 80%,100% {{ opacity:1; transform:scale(1); }} }}
</style>
<div style='position:absolute;left:36px;right:36px;top:24px;height:64px;border-bottom:1px solid #d9e7dc;'>
<h2 style='margin:0 0 6px;font-size:26px;color:#00543d;'>{title}</h2>
<p style='margin:0;color:#6b7f76;font-size:14px;'>观察微粒碰撞、重组并形成生成物</p>
</div>
<div style='position:absolute;left:48px;right:48px;top:110px;bottom:64px;background:#fff;border:1px solid #dce8de;border-radius:16px;box-shadow:0 12px 30px rgba(15,84,61,.08);'>
<div style='position:absolute;left:80px;top:122px;width:74px;height:74px;border-radius:50%;background:#93c5fd;animation:collideA 3s ease-in-out infinite;border:3px solid #2563eb;'></div>
<div style='position:absolute;right:100px;top:162px;width:68px;height:68px;border-radius:50%;background:#fdba74;animation:collideB 3s ease-in-out infinite;border:3px solid #f97316;'></div>
<div style='position:absolute;left:50%;top:150px;width:96px;height:96px;margin-left:-48px;border-radius:50%;background:#fef3c7;animation:flash 3s ease-in-out infinite;border:2px solid #f59e0b;'></div>
<div style='position:absolute;left:50%;top:138px;margin-left:-82px;display:flex;gap:12px;animation:product 3s ease-in-out infinite;'>
<div style='width:62px;height:62px;border-radius:50%;background:#86efac;border:3px solid #00a870;'></div>
<div style='width:62px;height:62px;border-radius:50%;background:#c4b5fd;border:3px solid #7c3aed;'></div>
</div>
<div style='position:absolute;left:60px;bottom:44px;font-size:20px;color:#00543d;font-weight:700;'>反应物</div>
<div style='position:absolute;left:50%;bottom:42px;transform:translateX(-50%);font-size:32px;color:#00a870;'>→</div>
<div style='position:absolute;right:70px;bottom:44px;font-size:20px;color:#00543d;font-weight:700;'>生成物</div>
</div>
</div>"""
return self._animation_result(prompt, anim_type, html, "化学反应碰撞动画")
def _fallback_orbit_animation(self, prompt: str, anim_type: str) -> dict:
title, _ = self._animation_meta(prompt, anim_type)
html = f"""<div style='width:100%;height:100%;position:relative;overflow:hidden;background:#f6fbf7;font-family:Microsoft YaHei,sans-serif;color:#143b31;'>
<style>
@keyframes spin {{ from {{ transform:rotate(0deg); }} to {{ transform:rotate(360deg); }} }}
@keyframes rayPulse {{ 0%,100% {{ opacity:.28; }} 50% {{ opacity:.82; }} }}
@keyframes focusGlow {{ 0%,100% {{ transform:scale(1); box-shadow:0 6px 14px rgba(0,84,61,.08); }} 50% {{ transform:scale(1.02); box-shadow:0 10px 22px rgba(0,84,61,.16); }} }}
.season-radio {{ position:absolute; opacity:0; pointer-events:none; }}
.season-btn {{ height:46px; border-radius:13px; border:1px solid #d9e7dc; background:#fff; color:#516b61; display:flex; align-items:center; justify-content:center; gap:6px; font-weight:700; cursor:pointer; transition:all .22s ease; box-shadow:0 4px 12px rgba(15,84,61,.05); user-select:none; }}
.season-btn:hover {{ transform:translateY(-1px); border-color:#00a870; color:#00543d; }}
.season-btn:active {{ transform:translateY(0); }}
.earth-dot {{ position:absolute; width:70px; height:70px; border-radius:50%; background:linear-gradient(135deg,#60a5fa 0%,#60a5fa 48%,#22c55e 49%,#86efac 100%); border:4px solid #fff; box-shadow:0 10px 24px rgba(0,84,61,.18); transition:all .35s ease; }}
.axis {{ position:absolute; left:50%; top:-8px; width:3px; height:86px; background:#00543d; transform:translateX(-50%) rotate(-23deg); border-radius:2px; opacity:.78; }}
.earth-dot::after {{ content:''; position:absolute; inset:0; border-radius:50%; background:linear-gradient(90deg,rgba(255,255,255,.38),transparent 55%,rgba(0,0,0,.14)); animation:spin 5s linear infinite; }}
.season-card {{ position:absolute; right:28px; top:34px; width:240px; min-height:132px; padding:18px; border:1px solid #d9e7dc; border-radius:16px; background:#f8faf8; box-shadow:0 8px 20px rgba(15,84,61,.08); opacity:0; transform:translateY(8px); transition:all .25s ease; pointer-events:none; }}
.season-card strong {{ display:block; color:#00543d; font-size:22px; margin-bottom:8px; }}
.season-card span {{ color:#6b7f76; font-size:14px; line-height:1.7; }}
#season-spring:checked ~ .stage .earth-dot {{ left:116px; top:126px; }}
#season-summer:checked ~ .stage .earth-dot {{ left:306px; top:44px; }}
#season-autumn:checked ~ .stage .earth-dot {{ left:496px; top:126px; }}
#season-winter:checked ~ .stage .earth-dot {{ left:306px; top:208px; }}
#season-spring:checked ~ .stage .spring-card,
#season-summer:checked ~ .stage .summer-card,
#season-autumn:checked ~ .stage .autumn-card,
#season-winter:checked ~ .stage .winter-card {{ opacity:1; transform:translateY(0); }}
#season-spring:checked ~ .stage .spring-card strong,
#season-summer:checked ~ .stage .summer-card strong,
#season-autumn:checked ~ .stage .autumn-card strong,
#season-winter:checked ~ .stage .winter-card strong {{ animation:focusGlow 2.2s ease-in-out infinite; }}
#season-spring:checked ~ .tabs label[for='season-spring'],
#season-summer:checked ~ .tabs label[for='season-summer'],
#season-autumn:checked ~ .tabs label[for='season-autumn'],
#season-winter:checked ~ .tabs label[for='season-winter'] {{ background:#00543d; color:#fff; border-color:#00543d; box-shadow:0 8px 18px rgba(0,84,61,.18); }}
</style>
<div style='position:absolute;left:36px;right:36px;top:24px;height:64px;border-bottom:1px solid #d9e7dc;'>
<h2 style='margin:0 0 6px;font-size:26px;color:#00543d;'>{title}</h2>
<p style='margin:0;color:#6b7f76;font-size:14px;'>点击春夏秋冬,观察地球公转位置与太阳直射变化</p>
</div>
<input class='season-radio' id='season-spring' name='season' type='radio' checked>
<input class='season-radio' id='season-summer' name='season' type='radio'>
<input class='season-radio' id='season-autumn' name='season' type='radio'>
<input class='season-radio' id='season-winter' name='season' type='radio'>
<div class='stage' style='position:absolute;left:48px;right:48px;top:110px;bottom:126px;background:#fff;border:1px solid #dce8de;border-radius:16px;box-shadow:0 12px 30px rgba(15,84,61,.08);overflow:hidden;z-index:1;'>
<div style='position:absolute;left:54px;top:24px;padding:8px 12px;border-radius:18px;background:#e8f7ef;color:#00543d;font-size:13px;font-weight:700;'>太阳直射点随公转变化</div>
<div style='position:absolute;left:300px;top:126px;width:92px;height:92px;border-radius:50%;background:#facc15;box-shadow:0 8px 26px rgba(250,204,21,.35);'></div>
<div style='position:absolute;left:116px;top:72px;width:450px;height:250px;border:1.5px dashed #9ca3af;border-radius:50%;'></div>
<div style='position:absolute;left:356px;top:166px;width:240px;height:4px;background:linear-gradient(90deg,#fde68a,transparent);transform:rotate(-18deg);transform-origin:left center;animation:rayPulse 2.4s ease-in-out infinite;'></div>
<div style='position:absolute;left:356px;top:166px;width:240px;height:4px;background:linear-gradient(90deg,#fde68a,transparent);transform:rotate(18deg);transform-origin:left center;animation:rayPulse 2.4s ease-in-out infinite .4s;'></div>
<div class='earth-dot'><div class='axis'></div></div>
<div class='season-card spring-card'><strong>春分</strong><span>太阳直射赤道附近,南北半球昼夜接近等长,气温逐渐回升。</span></div>
<div class='season-card summer-card'><strong>夏至</strong><span>北半球获得更多太阳辐射,白昼较长,正午太阳高度较高。</span></div>
<div class='season-card autumn-card'><strong>秋分</strong><span>太阳再次直射赤道附近,昼夜接近等长,气温逐渐降低。</span></div>
<div class='season-card winter-card'><strong>冬至</strong><span>北半球太阳高度较低,白昼较短,获得太阳辐射较少。</span></div>
<div style='position:absolute;left:58px;bottom:26px;color:#6b7f76;font-size:13px;'>提示:地轴方向基本保持不变,这是四季变化的重要原因。</div>
</div>
<div class='tabs' style='position:absolute;left:74px;right:74px;bottom:56px;display:grid;grid-template-columns:repeat(4,1fr);gap:12px;z-index:2;'>
<label class='season-btn' for='season-spring'>春分</label>
<label class='season-btn' for='season-summer'>夏至</label>
<label class='season-btn' for='season-autumn'>秋分</label>
<label class='season-btn' for='season-winter'>冬至</label>
</div>
</div>"""
return self._animation_result(prompt, anim_type, html, "地球公转与四季动画")
def _fallback_motion_animation(self, prompt: str, anim_type: str) -> dict:
title, _ = self._animation_meta(prompt, anim_type)
html = f"""<div style='width:100%;height:100%;position:relative;overflow:hidden;background:#f6fbf7;font-family:Microsoft YaHei,sans-serif;color:#0f172a;'>
<style>
@keyframes roll {{ 0% {{ transform:translateX(0) rotate(0deg); }} 100% {{ transform:translateX(520px) rotate(720deg); }} }}
@keyframes vector {{ 0%,100% {{ width:70px; }} 50% {{ width:150px; }} }}
@keyframes meter {{ 0% {{ width:12%; }} 100% {{ width:86%; }} }}
</style>
<div style='position:absolute;left:36px;right:36px;top:24px;height:64px;border-bottom:1px solid #d9e7dc;'>
<h2 style='margin:0 0 6px;font-size:26px;color:#00543d;'>{title}</h2>
<p style='margin:0;color:#6b7f76;font-size:14px;'>观察物体运动方向与速度大小变化</p>
</div>
<div style='position:absolute;left:48px;right:48px;top:110px;bottom:64px;background:#fff;border:1px solid #dce8de;border-radius:16px;box-shadow:0 12px 30px rgba(15,84,61,.08);'>
<div style='position:absolute;left:48px;top:220px;width:640px;height:8px;background:#8aa39a;transform:rotate(-10deg);transform-origin:left center;border-radius:6px;'></div>
<div style='position:absolute;left:72px;top:148px;width:56px;height:56px;border-radius:50%;background:#f97316;animation:roll 4s ease-in-out infinite alternate;box-shadow:inset -8px -8px 0 rgba(0,0,0,.16),0 10px 20px rgba(249,115,22,.2);'></div>
<div style='position:absolute;left:146px;top:110px;height:5px;background:#00a870;animation:vector 2.4s ease-in-out infinite;border-radius:4px;'></div>
<div style='position:absolute;left:288px;top:94px;color:#00543d;font-weight:700;'>速度方向</div>
<div style='position:absolute;left:56px;right:56px;bottom:72px;height:12px;background:#e2e8f0;border-radius:6px;overflow:hidden;'>
<div style='height:100%;background:linear-gradient(90deg,#86efac,#00a870);animation:meter 4s ease-in-out infinite alternate;'></div>
</div>
<div style='position:absolute;left:56px;bottom:34px;color:#6b7f76;'>速度随时间变化:慢 → 快</div>
</div>
</div>"""
return self._animation_result(prompt, anim_type, html, "物体运动与速度变化动画")
def _fallback_exercise(self, prompt, exercise_type, subject, knowledge_points, count):
total = max(1, min(count or 5, 10))
raw_topic = (prompt or "").strip()[:30] or "课堂练习"
# 去掉结尾的练习/题型字样,避免标题拼成「…练习练习」
topic = re.sub(
r"(?:的)?(?:互动)?(?:练习题|题目集|练习|题目|试题|习题|题库|选择题|填空题|判断题|应用题|解答题|计算题|题)+$",
"", raw_topic,
).strip() or raw_topic
subj = (subject or "").strip()
bank = self._question_bank(topic, subj)
if not bank:
bank = self._generic_question_bank(topic, subj)
questions = []
for idx in range(total):
q = dict(bank[idx % len(bank)])
q["id"] = idx + 1
q["difficulty"] = ["easy", "medium", "hard"][idx % 3]
q["html"] = self._exercise_html(q)
questions.append(q)
return {"title": (topic + "练习")[:40], "questions": questions}
def _question_bank(self, topic, subject):
t = (topic or "").lower()
s = (subject or "").lower()
combined = t + " " + s
topic_bank = self._topic_question_bank(t, subject or "")
if topic_bank:
return topic_bank
is_math = any(k in combined for k in ("数学", "math", "分数", "加减", "乘除", "方程", "几何", "面积", "比例"))
is_chinese = any(k in combined for k in ("语文", "古诗", "词语", "阅读", "修辞", "拼音", "汉字"))
is_english = any(k in combined for k in ("english", "英语", "grammar", "vocabulary", "tense"))
is_science = any(k in combined for k in ("科学", "物理", "化学", "生物", "地理", "光合作用", "重力", "电路"))
if is_math:
return self._math_questions(topic)
if is_chinese:
return self._chinese_questions(topic)
if is_english:
return self._english_questions(topic)
if is_science:
return self._science_questions(topic)
return []
def _generic_question_bank(self, topic, subject):
return [
{
"type": "choice",
"question": f"关于「{topic}」,下列说法正确的是?",
"options": [f"「{topic}」是本课的重要学习内容", f"「{topic}」与本课无关", f"「{topic}」只需死记硬背", f"「{topic}」无需理解"],
"answer": f"「{topic}」是本课的重要学习内容",
"explanation": f"「{topic}」是核心知识点,需要重点掌握并灵活运用。",
},
{
"type": "choice",
"question": f"学习「{topic}」时,最有效的方法是?",
"options": ["理解原理并结合实例练习", "只看不练", "抄袭同学的答案", "等考试前突击"],
"answer": "理解原理并结合实例练习",
"explanation": "理解性学习加反复练习是掌握知识的最佳途径。",
},
{
"type": "choice",
"question": f"下列哪项不属于「{topic}」的应用场景?",
"options": [f"课堂讲解中举例说明", f"生活实践中观察现象", "与本课完全无关的其他话题", f"课后作业中运用"],
"answer": "与本课完全无关的其他话题",
"explanation": "知识的应用应当围绕课堂、生活和作业场景,而非无关话题。",
},
{
"type": "fill_blank",
"question": f"请简述「{topic}」的核心含义(填空):{topic}是指______。",
"options": [],
"answer": f"本课程中关于{topic}的核心概念和方法",
"explanation": f"理解{topic}的定义是进一步学习的基础。",
},
{
"type": "true_false",
"question": f"判断:「{topic}」只需要死记硬背就能掌握。",
"options": ["正确", "错误"],
"answer": "错误",
"explanation": "任何知识都需要在理解的基础上灵活运用,死记硬背无法真正掌握。",
},
]
def _topic_question_bank(self, topic, subject):
"""主题感知题库:为高频学科主题提供有实质知识的题目。
采用"最长关键词命中"策略:当多个题组都可能命中时(例如"二次函数"
同时命中"函数"和"二次函数"两组关键词),优先返回关键词最长、最精确
的题组,避免宽泛关键词(如"函数")错误抢占细分知识点(如"二次函数")。
"""
t = (topic or "")
combined = t + " " + (subject or "")
best = None
best_len = 0
for keys, qs in self._exercise_kb():
for k in keys:
if k and k in combined and len(k) > best_len:
best = qs
best_len = len(k)
return best or []
def _exercise_kb(self):
"""主题感知题库:覆盖历史/地理/生物/化学/物理/数学/语文/英语高频知识点。"""
return [
# ========== 历史 ==========
(["朝代", "历代", "王朝", "唐朝", "唐代", "汉朝", "汉代", "宋朝", "宋代", "明朝", "明代", "清朝", "清代", "秦朝"], [
{"type": "choice", "question": "我国历史上第一个统一的中央集权封建王朝是?", "options": ["夏朝", "商朝", "秦朝", "汉朝"], "answer": "秦朝", "explanation": "公元前221年,秦始皇嬴政建立秦朝,是中国历史上第一个统一的中央集权封建王朝。"},
{"type": "choice", "question": "贞观之治的皇帝是?", "options": ["唐高祖李渊", "唐太宗李世民", "唐高宗李治", "唐玄宗李隆基"], "answer": "唐太宗李世民", "explanation": "唐太宗李世民在位期间(627-649年)开创了贞观之治,是唐朝鼎盛的开端。"},
{"type": "true_false", "question": "判断:四大发明中的活字印刷术是北宋毕昇发明的。", "options": ["正确", "错误"], "answer": "正确", "explanation": "北宋庆历年间(约1045年),毕昇发明了胶泥活字印刷术,比欧洲早约400年。"},
{"type": "fill_blank", "question": "丝绸之路的起点是长安(今____),终点到达中亚、西亚乃至欧洲。", "options": [], "answer": "西安", "explanation": "汉代张骞出使西域开辟的丝绸之路,起点是西汉都城长安(今陕西西安)。"},
]),
(["丝绸之路"], [
{"type": "choice", "question": "开通陆上丝绸之路的历史人物是?", "options": ["张骞", "玄奘", "郑和", "班超"], "answer": "张骞", "explanation": "汉武帝派张骞两次出使西域,开通了连接东西方的丝绸之路。"},
{"type": "choice", "question": "下列哪项不是丝绸之路运输的商品?", "options": ["丝绸", "瓷器", "茶叶", "电视机"], "answer": "电视机", "explanation": "古代丝绸之路主要运输丝绸、瓷器、茶叶、香料等,电视机是现代工业产品。"},
{"type": "true_false", "question": "判断:郑和下西洋属于海上丝绸之路的活动。", "options": ["正确", "错误"], "answer": "正确", "explanation": "明朝郑和七次下西洋,是海上丝绸之路的壮举,比哥伦布航海早约半个世纪。"},
]),
(["工业革命"], [
{"type": "choice", "question": "第一次工业革命的标志是?", "options": ["蒸汽机的改良与广泛使用", "电力的广泛应用", "计算机的发明", "内燃机的发明"], "answer": "蒸汽机的改良与广泛使用", "explanation": "18世纪60年代,瓦特改良蒸汽机,标志着第一次工业革命(蒸汽时代)的到来。"},
{"type": "choice", "question": "第二次工业革命的核心动力是?", "options": ["蒸汽", "电力", "核能", "太阳能"], "answer": "电力", "explanation": "19世纪70年代开始的第二次工业革命以电力的广泛应用为标志,人类进入电气时代。"},
{"type": "true_false", "question": "判断:珍妮纺纱机的发明拉开了第一次工业革命的序幕。", "options": ["正确", "错误"], "answer": "正确", "explanation": "1765年哈格里夫斯发明珍妮纺纱机,是第一次工业革命开始的标志之一。"},
]),
(["抗日战争", "抗战"], [
{"type": "choice", "question": "抗日战争全面爆发的标志是?", "options": ["九一八事变", "七七事变(卢沟桥事变)", "西安事变", "八一三事变"], "answer": "七七事变(卢沟桥事变)", "explanation": "1937年7月7日的卢沟桥事变标志着中国全民族抗战的开始。"},
{"type": "choice", "question": "抗日战争中敌后战场的总指挥是?", "options": ["蒋介石", "毛泽东", "朱德", "彭德怀"], "answer": "毛泽东", "explanation": "中国共产党领导八路军、新四军开辟敌后战场,毛泽东是战略总指挥。"},
{"type": "fill_blank", "question": "抗日战争从1931年九一八事变开始,到____年日本投降结束,历时14年。", "options": [], "answer": "1945", "explanation": "1945年8月15日日本宣布无条件投降,9月2日正式签署投降书。"},
]),
(["文艺复兴"], [
{"type": "choice", "question": "文艺复兴运动最早发源于?", "options": ["法国", "英国", "意大利", "德国"], "answer": "意大利", "explanation": "14世纪文艺复兴首先在意大利的佛罗伦萨等城市兴起,随后扩展到欧洲各地。"},
{"type": "choice", "question": "文艺复兴的核心思想是?", "options": ["神权至上", "人文主义", "君权神授", "禁欲主义"], "answer": "人文主义", "explanation": "人文主义主张以人为中心,肯定人的价值与尊严,反对中世纪的神学束缚。"},
{"type": "true_false", "question": "判断:莎士比亚是文艺复兴时期英国杰出的戏剧家。", "options": ["正确", "错误"], "answer": "正确", "explanation": "莎士比亚(1564-1616)是英国文艺复兴时期最伟大的剧作家,代表作有《哈姆雷特》等。"},
]),
(["辛亥革命"], [
{"type": "choice", "question": "辛亥革命爆发于哪一年?", "options": ["1898年", "1911年", "1919年", "1921年"], "answer": "1911年", "explanation": "1911年(农历辛亥年)武昌起义爆发,标志着辛亥革命的开始。"},
{"type": "choice", "question": "辛亥革命的历史意义不包括?", "options": ["推翻了两千多年的封建君主专制", "建立了中华民国", "使中国完全摆脱半殖民地", "传播了民主共和观念"], "answer": "使中国完全摆脱半殖民地", "explanation": "辛亥革命推翻了清朝统治和封建帝制,但没有改变中国半殖民地半封建的社会性质。"},
{"type": "fill_blank", "question": "辛亥革命的领导者是____(人名),被尊称为国父。", "options": [], "answer": "孙中山", "explanation": "孙中山领导同盟会发动辛亥革命,建立中华民国,被尊称为中华民国国父。"},
]),
# ========== 地理 ==========
(["气候", "气候类型", "气温降水"], [
{"type": "choice", "question": "影响气候的基本因素不包括?", "options": ["纬度位置", "海陆位置", "地形", "学生人数"], "answer": "学生人数", "explanation": "气候主要受纬度、海陆、地形、洋流等自然因素影响,与学生人数无关。"},
{"type": "choice", "question": "我国气候的主要特征是?", "options": ["单一热带气候", "季风气候显著", "全是温带海洋性", "全是极地气候"], "answer": "季风气候显著", "explanation": "我国气候复杂多样,季风气候显著是最大特征,雨热同期有利于农业。"},
{"type": "true_false", "question": "判断:赤道附近地区终年高温多雨。", "options": ["正确", "错误"], "answer": "正确", "explanation": "赤道地区受赤道低气压带控制,全年高温多雨,属于热带雨林气候。"},
]),
(["地形", "地貌", "地势"], [
{"type": "choice", "question": "我国地形的主要特征是?", "options": ["以平原为主", "山地高原面积广大", "全是盆地", "以丘陵为主"], "answer": "山地高原面积广大", "explanation": "我国地形复杂多样,山区(含山地、高原、丘陵)面积约占全国陆地面积的三分之二。"},
{"type": "choice", "question": "下列不属于五种基本地形的是?", "options": ["平原", "高原", "盆地", "冰川"], "answer": "冰川", "explanation": "五种基本地形是山地、平原、高原、盆地、丘陵,冰川不属于基本地形类型。"},
{"type": "fill_blank", "question": "我国地势第一级阶梯与第二级阶梯的分界线是____山脉。", "options": [], "answer": "昆仑—祁连—横断", "explanation": "昆仑山—祁连山—横断山脉是一、二级阶梯分界线;大兴安岭—太行山—巫山—雪峰山是二、三级阶梯分界线。"},
]),
(["河流", "水系", "长江", "黄河"], [
{"type": "choice", "question": "我国最长的河流是?", "options": ["黄河", "长江", "珠江", "黑龙江"], "answer": "长江", "explanation": "长江全长约6300公里,是中国第一长河,世界第三长河。"},
{"type": "choice", "question": "黄河下游河床高于地面的河段称为?", "options": ["地上河", "峡谷", "三角洲", "冲积扇"], "answer": "地上河", "explanation": "黄河中游流经黄土高原携带大量泥沙,下游泥沙沉积使河床抬高形成地上河。"},
{"type": "true_false", "question": "判断:长江发源于青藏高原的唐古拉山脉。", "options": ["正确", "错误"], "answer": "正确", "explanation": "长江发源于青藏高原唐古拉山脉主峰各拉丹冬,最终注入东海。"},
]),
(["地球", "地球运动", "自转公转"], [
{"type": "choice", "question": "地球自转的方向是?", "options": ["自东向西", "自西向东", "自南向北", "自北向南"], "answer": "自西向东", "explanation": "地球自西向东自转,周期约24小时,产生昼夜交替和时间差异。"},
{"type": "choice", "question": "地球上产生四季更替的原因是?", "options": ["地球自转", "地球公转且地轴倾斜", "月球引力", "太阳活动"], "answer": "地球公转且地轴倾斜", "explanation": "地球绕太阳公转,且地轴与公转轨道面成66.5°夹角,导致太阳直射点移动,形成四季。"},
{"type": "fill_blank", "question": "地球公转一周的时间约为____天。", "options": [], "answer": "365", "explanation": "地球公转一周约365.25天,即一年,故每四年设一个闰年。"},
]),
# ========== 生物 ==========
(["细胞"], [
{"type": "choice", "question": "植物细胞特有的结构是?", "options": ["细胞膜", "细胞质", "细胞壁", "细胞核"], "answer": "细胞壁", "explanation": "细胞壁、叶绿体和大的液泡是植物细胞特有的结构。"},
{"type": "choice", "question": "细胞工厂的动力车间是?", "options": ["线粒体", "叶绿体", "核糖体", "内质网"], "answer": "线粒体", "explanation": "线粒体是细胞的动力车间,为细胞生命活动提供约95%的能量。"},
{"type": "true_false", "question": "判断:动物细胞没有细胞壁。", "options": ["正确", "错误"], "answer": "正确", "explanation": "动物细胞最外层是细胞膜,没有细胞壁、叶绿体和大的中央液泡。"},
]),
(["光合作用"], [
{"type": "choice", "question": "光合作用进行的场所是?", "options": ["线粒体", "叶绿体", "细胞核", "液泡"], "answer": "叶绿体", "explanation": "光合作用在叶绿体中进行,叶绿体含有叶绿素能吸收光能。"},
{"type": "choice", "question": "光合作用的原料不包括?", "options": ["二氧化碳", "水", "光能", "氧气"], "answer": "氧气", "explanation": "氧气是光合作用的产物而非原料。原料是二氧化碳和水,条件是光照。"},
{"type": "fill_blank", "question": "光合作用产生有机物,同时释放____。", "options": [], "answer": "氧气", "explanation": "光合作用产生有机物和氧气,维持了生物圈的碳氧平衡。"},
]),
(["生态系统"], [
{"type": "choice", "question": "生态系统中数量最多的是?", "options": ["生产者", "消费者", "分解者", "非生物部分"], "answer": "生产者", "explanation": "生产者(绿色植物)通过光合作用将无机物转化为有机物,是生态系统的基石。"},
{"type": "choice", "question": "下列食物链正确的是?", "options": ["草→兔→鹰", "阳光→草→兔", "鹰→兔→草", "兔→草"], "answer": "草→兔→鹰", "explanation": "食物链从生产者开始,箭头指向捕食者,不包括非生物部分和分解者。"},
{"type": "true_false", "question": "判断:生态系统中物质是循环的,能量是单向流动的。", "options": ["正确", "错误"], "answer": "正确", "explanation": "物质在生物与无机环境间循环,能量沿食物链单向流动、逐级递减。"},
]),
(["遗传", "基因", "DNA"], [
{"type": "choice", "question": "生物体遗传的基本单位是?", "options": ["细胞", "基因", "染色体", "蛋白质"], "answer": "基因", "explanation": "基因是控制生物性状的遗传单位,是有遗传效应的DNA片段。"},
{"type": "choice", "question": "DNA分子的空间结构是?", "options": ["单螺旋", "双螺旋", "直线形", "环形"], "answer": "双螺旋", "explanation": "1953年沃森和克里克提出DNA分子的双螺旋结构模型。"},
{"type": "true_false", "question": "判断:子代的性状完全由父方或母方一方决定。", "options": ["正确", "错误"], "answer": "错误", "explanation": "子代遗传物质来自父母双方各一半,性状由父母双方的基因共同决定。"},
]),
(["人体", "消化", "呼吸", "循环"], [
{"type": "choice", "question": "人体消化和吸收的主要器官是?", "options": ["胃", "小肠", "大肠", "口腔"], "answer": "小肠", "explanation": "小肠内有多种消化液,内壁有绒毛和微绒毛增大吸收面积,是消化吸收的主要场所。"},
{"type": "choice", "question": "人体与外界进行气体交换的器官是?", "options": ["心脏", "肺", "肝脏", "肾脏"], "answer": "肺", "explanation": "肺是呼吸系统的主要器官,肺泡是气体交换的场所。"},
{"type": "fill_blank", "question": "血液循环中,体循环的起点是____心室。", "options": [], "answer": "左", "explanation": "体循环从左心室出发,经主动脉到达全身各处,再经上下腔静脉回到右心房。"},
]),
# ========== 化学 ==========
(["元素周期表", "元素"], [
{"type": "choice", "question": "元素周期表中,原子序数等于该原子的?", "options": ["中子数", "质子数", "电子数", "质量数"], "answer": "质子数", "explanation": "原子序数在数值上等于原子的质子数(核电荷数)。"},
{"type": "choice", "question": "同一周期从左到右,元素性质的变化规律是?", "options": ["金属性增强", "非金属性增强", "原子序数减小", "无明显规律"], "answer": "非金属性增强", "explanation": "同一周期从左到右,金属性逐渐减弱,非金属性逐渐增强。"},
{"type": "true_false", "question": "判断:水是由水分子构成的,不是由氢气和氧气组成的。", "options": ["正确", "错误"], "answer": "正确", "explanation": "水(H₂O)由水分子构成,水分子由氢原子和氧原子组成,不含氢气和氧气分子。"},
]),
(["化学反应"], [
{"type": "choice", "question": "化学变化的基本特征是?", "options": ["状态改变", "产生新物质", "颜色改变", "放热"], "answer": "产生新物质", "explanation": "化学变化的本质特征是有新物质生成,其他现象(发光放热、变色等)可作为判断的辅助依据。"},
{"type": "choice", "question": "下列属于化学变化的是?", "options": ["冰熔化", "蜡烛燃烧", "石块粉碎", "水蒸发"], "answer": "蜡烛燃烧", "explanation": "蜡烛燃烧生成二氧化碳和水等新物质,是化学变化;其余为物理变化。"},
{"type": "fill_blank", "question": "化学反应遵守质量守恒定律,反应前后原子的种类和____不变。", "options": [], "answer": "数目", "explanation": "质量守恒定律:化学反应前后,原子的种类、数目、质量都不变。"},
]),
(["酸碱盐", "酸碱", "pH"], [
{"type": "choice", "question": "pH=7的溶液呈?", "options": ["酸性", "碱性", "中性", "无法判断"], "answer": "中性", "explanation": "pH<7呈酸性,pH=7呈中性,pH>7呈碱性。纯水pH约为7。"},
{"type": "choice", "question": "下列物质属于酸的是?", "options": ["NaOH", "HCl", "NaCl", "CaO"], "answer": "HCl", "explanation": "酸是电离时产生的阳离子全部是H⁺的化合物,HCl(盐酸)是常见的强酸。"},
{"type": "true_false", "question": "判断:稀盐酸和氢氧化钠溶液混合会发生中和反应,生成盐和水。", "options": ["正确", "错误"], "answer": "正确", "explanation": "HCl + NaOH = NaCl + H₂O,酸碱中和反应生成盐和水。"},
]),
(["原子", "分子", "物质构成"], [
{"type": "choice", "question": "原子的核心结构是?", "options": ["原子核和核外电子", "质子和中子", "原子核和分子", "电子和中子"], "answer": "原子核和核外电子", "explanation": "原子由带正电的原子核(质子+中子)和带负电的核外电子构成。"},
{"type": "choice", "question": "保持水的化学性质的最小粒子是?", "options": ["氢原子", "氧原子", "水分子", "质子"], "answer": "水分子", "explanation": "分子是保持物质化学性质的最小粒子,保持水的化学性质的是水分子。"},
{"type": "fill_blank", "question": "原子中,质子带正电,中子不带电,电子带____电。", "options": [], "answer": "负", "explanation": "原子中质子带正电、中子不带电、电子带负电,整个原子呈电中性。"},
]),
# ========== 物理 ==========
(["力学", "力", "牛顿"], [
{"type": "choice", "question": "牛顿第一定律的内容是?", "options": ["力是维持物体运动的原因", "物体在不受力时保持静止或匀速直线运动", "力越大加速度越大", "作用力等于反作用力"], "answer": "物体在不受力时保持静止或匀速直线运动", "explanation": "牛顿第一定律(惯性定律):一切物体在不受外力时,总保持静止状态或匀速直线运动状态。"},
{"type": "choice", "question": "下列现象中属于利用惯性的是?", "options": ["系安全带", "跳远时助跑", "限速行驶", "保持车距"], "answer": "跳远时助跑", "explanation": "跳远助跑后起跳,人由于惯性继续向前,能跳得更远。安全带、限速、保持车距是防止惯性带来的危害。"},
{"type": "true_false", "question": "判断:物体不受力时一定静止。", "options": ["正确", "错误"], "answer": "错误", "explanation": "不受力时,物体可以静止,也可以做匀速直线运动(若原来在运动)。"},
]),
(["电学", "电流", "电路", "欧姆"], [
{"type": "choice", "question": "欧姆定律的表达式是?", "options": ["I=U/R", "I=UR", "I=U+R", "I=U-R"], "answer": "I=U/R", "explanation": "欧姆定律:导体中的电流与导体两端电压成正比,与导体电阻成反比,即I=U/R。"},
{"type": "choice", "question": "一段导体两端电压为6V,电阻为3Ω,通过的电流是?", "options": ["2A", "0.5A", "18A", "9A"], "answer": "2A", "explanation": "由I=U/R=6V/3Ω=2A。"},
{"type": "fill_blank", "question": "串联电路中,电流处处____。", "options": [], "answer": "相等", "explanation": "串联电路中电流处处相等(I=I₁=I₂),电压等于各部分电压之和。"},
]),
(["光学", "光", "反射折射"], [
{"type": "choice", "question": "光在真空中的传播速度约为?", "options": ["3×10⁵ km/s", "3×10⁸ m/s", "340 m/s", "3×10⁶ m/s"], "answer": "3×10⁸ m/s", "explanation": "光在真空中的传播速度c≈3×10⁸ m/s(即30万千米/秒),是宇宙中最大的速度。"},
{"type": "choice", "question": "平面镜成像的特点是?", "options": ["像比物小", "倒立的实像", "正立等大的虚像", "像比物大"], "answer": "正立等大的虚像", "explanation": "平面镜成正立、等大、左右相反的虚像,像与物到镜面的距离相等。"},
{"type": "true_false", "question": "判断:光从空气斜射入水中时,折射角小于入射角。", "options": ["正确", "错误"], "answer": "正确", "explanation": "光从空气(光疏介质)斜射入水(光密介质)时,折射光线靠近法线,折射角小于入射角。"},
]),
(["运动", "速度", "加速度"], [
{"type": "choice", "question": "速度的国际单位是?", "options": ["km/h", "m/s", "cm/s", "km/s"], "answer": "m/s", "explanation": "速度的国际单位是米/秒(m/s),常用单位还有千米/时(km/h),1 m/s = 3.6 km/h。"},
{"type": "choice", "question": "匀速直线运动的特点是?", "options": ["速度不断增大", "速度保持不变", "方向不断改变", "加速度不为零"], "answer": "速度保持不变", "explanation": "匀速直线运动中速度大小和方向都不变,加速度为零。"},
{"type": "fill_blank", "question": "一辆汽车以20 m/s的速度行驶,5秒内通过的路程是____米。", "options": [], "answer": "100", "explanation": "路程=速度×时间=20 m/s×5s=100 m。"},
]),
# ========== 数学 ==========
(["几何", "三角形", "四边形", "多边形", "圆周", "圆"], [
{"type": "choice", "question": "三角形内角和等于多少度?", "options": ["90°", "180°", "270°", "360°"], "answer": "180°", "explanation": "任意三角形三个内角的和等于180°,这是三角形的基本性质。"},
{"type": "choice", "question": "一个三角形三个内角度数之比为1:2:3,这个三角形是?", "options": ["锐角三角形", "直角三角形", "钝角三角形", "等腰三角形"], "answer": "直角三角形", "explanation": "三个角为30°、60°、90°,最大角90°,所以是直角三角形。"},
{"type": "fill_blank", "question": "直角三角形两直角边分别为3和4,斜边长为____。", "options": [], "answer": "5", "explanation": "勾股定理:c=√(3²+4²)=√25=5。"},
{"type": "true_false", "question": "判断:圆周率π是一个无理数,约等于3.14159。", "options": ["正确", "错误"], "answer": "正确", "explanation": "π是圆周长与直径的比值,是无理数,常用的近似值为3.14。"},
{"type": "choice", "question": "n边形内角和公式是?", "options": ["(n-1)×180°", "(n-2)×180°", "n×180°", "(n+2)×180°"], "answer": "(n-2)×180°", "explanation": "n边形内角和=(n-2)×180°,如四边形内角和=(4-2)×180°=360°。"},
]),
(["函数", "一次函数", "反比例", "正比例"], [
{"type": "choice", "question": "正比例函数 y=kx (k≠0) 的图象是?", "options": ["抛物线", "过原点的直线", "双曲线", "折线"], "answer": "过原点的直线", "explanation": "正比例函数y=kx的图象是一条经过坐标原点(0,0)的直线,k>0时过一三象限。"},
{"type": "choice", "question": "一次函数 y=2x+3 的图象与y轴的交点是?", "options": ["(0,2)", "(0,3)", "(3,0)", "(2,0)"], "answer": "(0,3)", "explanation": "令x=0,则y=3,图象与y轴交于(0,3),其中3就是截距b的值。"},
{"type": "fill_blank", "question": "反比例函数 y=k/x (k≠0) 的图象是____曲线。", "options": [], "answer": "双", "explanation": "反比例函数图象是双曲线,k>0时在一、三象限,k<0时在二、四象限。"},
]),
(["方程", "一元一次", "二元一次", "不等式"], [
{"type": "choice", "question": "方程 2x+3=7 的解是?", "options": ["x=1", "x=2", "x=3", "x=5"], "answer": "x=2", "explanation": "2x+3=72x=4x=2。验证:2×2+3=7 ✓"},
{"type": "choice", "question": "解二元一次方程组的基本思想是?", "options": ["画图法", "消元法", "配方", "因式分解"], "answer": "消元法", "explanation": "解二元一次方程组的基本思想是消元(代入消元或加减消元),化二元为一元。"},
{"type": "fill_blank", "question": "不等式 3x-1>5 的解集是 x>____。", "options": [], "answer": "2", "explanation": "3x-1>53x>6x>2。注意不等式两边同除正数,不等号方向不变。"},
]),
(["一元二次方程", "二次方程"], [
{"type": "choice", "question": "方程 x²-5x+6=0 的两个根是?", "options": ["x=1, x=6", "x=2, x=3", "x=-2, x=-3", "x=1, x=5"], "answer": "x=2, x=3", "explanation": "(x-2)(x-3)=0,所以x₁=2x₂=3。也可用求根公式验证。"},
{"type": "choice", "question": "一元二次方程 ax²+bx+c=0 (a≠0) 的求根公式是?", "options": ["x=(-b±√(b²-4ac))/2a", "x=(-b±√(b²+4ac))/2a", "x=(-b±√(b²-4ac))/a", "x=(-b±√(4ac-b²))/2a"], "answer": "x=(-b±(b²-4ac))/2a".replace("(b²-4ac)", "√(b²-4ac)"), "explanation": "求根公式中判别式Δ=b²-4ac,Δ>0两不等实根,Δ=0两等实根,Δ<0无实根。"},
{"type": "fill_blank", "question": "一元二次方程 x²-4=0 的解是 x=____。", "options": [], "answer": "±2", "explanation": "x²=4,所以x=2或x=-2,即x=±2。"},
]),
(["二次函数", "抛物线"], [
{"type": "choice", "question": "二次函数 y=ax²+bx+c (a≠0) 的图象是?", "options": ["直线", "抛物线", "双曲线", "圆"], "answer": "抛物线", "explanation": "二次函数的图象是抛物线,开口方向由a的符号决定。"},
{"type": "choice", "question": "抛物线 y=ax² 的开口方向取决于?", "options": ["a的大小", "a的符号", "b的值", "c的值"], "answer": "a的符号", "explanation": "a>0开口向上,a<0开口向下。|a|越大开口越小。"},
{"type": "fill_blank", "question": "二次函数 y=x²-2x-3 的对称轴是直线 x=____。", "options": [], "answer": "1", "explanation": "对称轴x=-b/(2a)=-(-2)/(2×1)=1。"},
]),
(["概率", "统计", "平均数", "中位数"], [
{"type": "choice", "question": "抛一枚均匀硬币,正面朝上的概率是?", "options": ["1", "1/2", "1/4", "1/3"], "answer": "1/2", "explanation": "硬币有两个面,正面和反面朝上机会均等,概率各为1/2。"},
{"type": "choice", "question": "一组数据2, 3, 5, 7, 8的中位数是?", "options": ["3", "5", "7", "5.5"], "answer": "5", "explanation": "数据已排序,共5个数,中位数是第3个数即5。"},
{"type": "fill_blank", "question": "一组数据1, 2, 3, 4的平均数是____。", "options": [], "answer": "2.5", "explanation": "平均数=(1+2+3+4)/4=10/4=2.5。"},
]),
# ========== 语文 ==========
(["荷塘月色"], [
{"type": "choice", "question": "《荷塘月色》的作者是?", "options": ["鲁迅", "朱自清", "巴金", "老舍"], "answer": "朱自清", "explanation": "《荷塘月色》是朱自清1927年创作的散文名篇,描绘了月下荷塘的静谧美景。"},
{"type": "choice", "question": "《荷塘月色》的主要写作手法是?", "options": ["通感修辞", "夸张", "讽刺", "象征对比"], "answer": "通感修辞", "explanation": "荷塘上的月色用通感(如仿佛远处高楼上渺茫的歌声)将视觉、听觉交融,是名篇特色。"},
{"type": "fill_blank", "question": "《荷塘月色》开头「这几天心里颇不宁静」奠定了全文____的基调。", "options": [], "answer": "淡淡的哀愁", "explanation": "心里颇不宁静反映了作者在特定时代下矛盾复杂的心境,奠定全文朦胧、淡淡的哀愁基调。"},
]),
(["古诗词", "古诗", "唐诗", "唐诗宋词"], [
{"type": "choice", "question": "「床前明月光」出自哪位诗人?", "options": ["杜甫", "李白", "白居易", "王维"], "answer": "李白", "explanation": "床前明月光,疑是地上霜,出自李白《静夜思》,表达思乡之情。"},
{"type": "choice", "question": "被誉为「诗圣」的诗人是?", "options": ["李白", "杜甫", "王维", "苏轼"], "answer": "杜甫", "explanation": "杜甫被称为诗圣,其诗被称为诗史,代表作有三吏三别。"},
{"type": "true_false", "question": "判断:宋词分为婉约派和豪放派两大流派。", "options": ["正确", "错误"], "answer": "正确", "explanation": "宋词主要分婉约派(李清照、柳永)和豪放派(苏轼、辛弃疾)两大流派。"},
]),
(["记叙文"], [
{"type": "choice", "question": "记叙文的六要素不包括?", "options": ["时间", "地点", "人物", "论据"], "answer": "论据", "explanation": "记叙文六要素:时间、地点、人物、起因、经过、结果。论据是议论文的要素。"},
{"type": "choice", "question": "记叙文中最常见的叙述方式是?", "options": ["顺叙", "倒叙", "插叙", "补叙"], "answer": "顺叙", "explanation": "顺叙是按照事件发生发展的先后顺序来写,是最基本最常见的叙述方式。"},
{"type": "fill_blank", "question": "记叙文中,把后发生的事提到前面写的叙述方式叫____。", "options": [], "answer": "倒叙", "explanation": "倒叙是把事件的结局或后面发生的事先写出来,再按顺序叙述。"},
]),
(["议论文"], [
{"type": "choice", "question": "议论文的三要素是?", "options": ["论点、论据、论证", "时间、地点、人物", "起因、经过、结果", "标题、开头、结尾"], "answer": "论点、论据、论证", "explanation": "议论文三要素:论点(观点)、论据(支撑观点的材料)、论证(用论据证明论点的过程)。"},
{"type": "choice", "question": "下列属于道理论据的是?", "options": ["统计数据", "名人名言", "历史事件", "亲身经历"], "answer": "名人名言", "explanation": "论据分事实论据(事例、数据)和道理论据(名言警句、科学原理)。名人名言属道理论据。"},
{"type": "true_false", "question": "判断:议论文的中心论点只能出现在文章开头。", "options": ["正确", "错误"], "answer": "错误", "explanation": "中心论点可在开头提出,也可在中间或结尾归纳,甚至标题即为论点。"},
]),
# ========== 英语 ==========
(["时态", "tense", "现在时", "过去时", "进行时", "完成时", "现在完成时"], [
{"type": "choice", "question": "Which sentence uses the present perfect tense?", "options": ["I eat apples.", "I ate apples.", "I have eaten apples.", "I am eating apples."], "answer": "I have eaten apples.", "explanation": "现在完成时结构为have/has+过去分词,强调过去动作对现在的影响。"},
{"type": "choice", "question": "「Yesterday」常与哪种时态连用?", "options": ["一般现在时", "一般过去时", "现在进行时", "现在完成时"], "answer": "一般过去时", "explanation": "yesterday是明确的过去时间状语,常与一般过去时连用。"},
{"type": "fill_blank", "question": "Fill: She ____ (go) to school every day. (用一般现在时)", "options": [], "answer": "goes", "explanation": "主语She是第三人称单数,一般现在时动词加-s/-es,go→goes。"},
]),
(["从句", "clause", "定语从句", "宾语从句"], [
{"type": "choice", "question": "在句子 'I know that he is a teacher.' 中,that 引导的是?", "options": ["定语从句", "宾语从句", "状语从句", "主语从句"], "answer": "宾语从句", "explanation": "that引导的从句作动词know的宾语,是宾语从句,that在宾语从句中不充当成分。"},
{"type": "choice", "question": "定语从句中,修饰人并在从句中作主语的关系代词常用?", "options": ["which", "who", "whose", "where"], "answer": "who", "explanation": "先行词指人且在从句中作主语时,关系代词用who或that(如The man who lives here is my teacher)。"},
{"type": "fill_blank", "question": "This is the book ____ I bought yesterday. (填关系代词)", "options": [], "answer": "that/which", "explanation": "先行词book指物,在从句中作bought的宾语,关系代词用that或which(可省略)。"},
]),
]
def _math_questions(self, topic):
import random
from fractions import Fraction
random.seed(hash(topic) & 0xFFFF)
qs = []
has_frac = "分数" in topic
for i in range(5):
if has_frac:
n1, d1 = random.randint(1, 5), random.randint(2, 9)
n2, d2 = random.randint(1, 5), random.randint(2, 9)
q = f"计算 {n1}/{d1} + {n2}/{d2} = ?"
ans = Fraction(n1, d1) + Fraction(n2, d2)
ans_s = f"{ans.numerator}/{ans.denominator}" if ans.denominator != 1 else str(ans.numerator)
wrong = [f"{n1+n2}/{max(d1,d2)}", f"{n1+n2}/{d1+d2}", f"{n1*n2}/{d1*d2}"]
opts = list(set([ans_s] + [w for w in wrong if w != ans_s][:3]))
while len(opts) < 4:
w = f"{random.randint(1,9)}/{random.randint(2,9)}"
if w not in opts:
opts.append(w)
random.shuffle(opts)
qs.append({
"type": "choice", "question": q,
"options": [f"{chr(65+j)}. {o}" for j, o in enumerate(opts)],
"answer": f"{chr(65+opts.index(ans_s))}. {ans_s}",
"explanation": f"同分母分数相加,分子相加分母不变;异分母先通分。{n1}/{d1}+{n2}/{d2}={ans_s}。",
})
else:
a, b = random.randint(12, 89), random.randint(12, 89)
if a < b:
a, b = b, a
ans = a + b
q = f"计算 {a} + {b} = ?"
wrong = []
while len(wrong) < 3:
w = ans + random.choice(range(-20, 21))
if w != ans and w >= 0 and w not in wrong:
wrong.append(w)
opts = [ans] + wrong
random.shuffle(opts)
qs.append({
"type": "choice", "question": q,
"options": [f"{chr(65+j)}. {o}" for j, o in enumerate(opts)],
"answer": f"{chr(65+opts.index(ans))}. {ans}",
"explanation": f"{a} + {b} = {ans}。注意进位的正确处理。",
})
return qs
def _chinese_questions(self, topic):
return [
{
"type": "choice",
"question": f"下列关于「{topic}」的表述,最准确的是?",
"options": ["需要结合具体语境理解", "只需字面意思即可", "与作者意图无关", "可以随意理解"],
"answer": "需要结合具体语境理解",
"explanation": "语文学习强调结合语境和作者意图来理解内容。",
},
{
"type": "choice",
"question": f"学习「{topic}」时,下列哪种方法最有助于深入理解?",
"options": ["反复诵读并结合语境分析", "只看一遍就能掌握", "抄写生字词就可以", "背诵答案即可"],
"answer": "反复诵读并结合语境分析",
"explanation": "多读多思是语文学习的基本方法,结合语境分析能加深理解。",
},
{
"type": "fill_blank",
"question": f"请用一句话概括「{topic}」的主要内容:______。",
"options": [],
"answer": f"本文围绕{topic}展开,表达了作者的思想感情和主要观点。",
"explanation": f"概括内容需要抓住{topic}的核心要点。",
},
{
"type": "true_false",
"question": f"判断:「{topic}」的学习只需要记住知识点就够了。",
"options": ["正确", "错误"],
"answer": "错误",
"explanation": "语文学习需要理解、感悟和运用,仅记住知识点是不够的。",
},
{
"type": "choice",
"question": f"下列哪个词语最适合用来描述「{topic}」?",
"options": ["生动形象、富有感染力", "干瘦枯燥、条理不清", "词不达意、语义不明", "重复啰嗦、缺乏新意"],
"answer": "生动形象、富有感染力",
"explanation": "词语的选择应当符合语境,生动形象的表达更有说服力。",
},
]
def _english_questions(self, topic):
return [
{
"type": "choice",
"question": 'Which sentence about "' + topic + '" is correct?',
"options": ["It is important to understand the concept in context.", "Memorization alone is sufficient.", "It has no practical application.", "It should be skipped in exams."],
"answer": "It is important to understand the concept in context.",
"explanation": "Understanding concepts in context is key to language learning.",
},
{
"type": "fill_blank",
"question": 'Fill in the blank: The key to learning "' + topic + '" is to ______ regularly.',
"options": [],
"answer": "practice",
"explanation": "Regular practice is essential for mastering any language topic.",
},
{
"type": "true_false",
"question": 'True or False: "' + topic + '" is only useful for exams.',
"options": ["True", "False"],
"answer": "False",
"explanation": "Language knowledge applies to real-world communication, not just exams.",
},
{
"type": "choice",
"question": 'What is the best way to practice "' + topic + '"?',
"options": ["Use it in sentences and conversations", "Only read about it", "Copy answers from others", "Wait until the exam"],
"answer": "Use it in sentences and conversations",
"explanation": "Active usage in context reinforces learning.",
},
{
"type": "choice",
"question": 'Which word is most closely related to "' + topic + '"?',
"options": ["comprehension", "irrelevant", "unnecessary", "optional"],
"answer": "comprehension",
"explanation": "Understanding and comprehension are central to mastering any topic.",
},
]
def _science_questions(self, topic):
return [
{
"type": "choice",
"question": f"关于「{topic}」,下列说法正确的是?",
"options": ["需要通过观察和实验来验证", "只需凭空想象", "与实际现象无关", "可以随意解释"],
"answer": "需要通过观察和实验来验证",
"explanation": "科学知识来源于观察和实验,需要实践验证。",
},
{
"type": "choice",
"question": f"学习「{topic}」最重要的方法是?",
"options": ["理解原理并动手实验", "死记公式", "背诵定义", "猜测结果"],
"answer": "理解原理并动手实验",
"explanation": "科学学习强调理论结合实践,动手实验能加深理解。",
},
{
"type": "fill_blank",
"question": f"「{topic}」的核心原理是:______。",
"options": [],
"answer": "通过观察、实验和推理总结出的规律",
"explanation": "科学原理是对自然现象规律的总结。",
},
{
"type": "true_false",
"question": f"判断:「{topic}」可以通过实验来验证。",
"options": ["正确", "错误"],
"answer": "正确",
"explanation": "科学理论可以通过实验来验证其正确性。",
},
{
"type": "choice",
"question": f"下列哪项属于科学探究「{topic}」的基本步骤?",
"options": ["提出问题→假设→实验→结论", "直接给出结论", "只看书本", "把问题留给老师"],
"answer": "提出问题→假设→实验→结论",
"explanation": "科学探究遵循问题→假设→实验→结论的基本流程。",
},
]
def _exercise_html(self, q):
qtype = q.get("type", "choice")
stem = (q.get("question") or "").replace("'", "\'")
opts = q.get("options") or []
answer = (q.get("answer") or "").replace("'", "\'")
if qtype in ("choice", "true_false") and opts:
btns = ""
for o in opts:
safe = o.replace("'", "\'")
btns += "<button class='eo' data-a='" + safe + "' onclick='ep(this)'>" + o + "</button>"
return (
"<style>.eq{width:100%;height:100%;box-sizing:border-box;padding:32px 40px;background:#f0fdf4;font-family:'Microsoft YaHei',sans-serif;overflow-y:auto}"
".eq h3{color:#166534;margin:0 0 16px;font-size:20px;line-height:1.6}"
".eo{display:block;width:100%;margin:8px 0;padding:14px 18px;border:2px solid #bbf7d0;border-radius:10px;background:#fff;font-size:17px;cursor:pointer;transition:.2s;text-align:left}"
".eo:hover{border-color:#22c55e}.eo.ok{background:#dcfce7;border-color:#16a34a}.eo.bad{background:#fef2f2;border-color:#ef4444}</style>"
"<div class='eq'><h3>" + stem + "</h3>" + btns
+ "<input type='hidden' id='ea' value='" + answer + "'></div>"
+ "<script>function ep(b){var a=document.getElementById('ea').value;"
"document.querySelectorAll('.eo').forEach(function(e){e.classList.remove('ok','bad')});"
"if(b.getAttribute('data-a')==a||b.textContent==a){b.classList.add('ok')}else{b.classList.add('bad');"
"document.querySelectorAll('.eo').forEach(function(e){if(e.getAttribute('data-a')==a||e.textContent==a)e.classList.add('ok')})}}"
+ "</script>"
)
if qtype == "fill_blank":
return (
"<style>.eq{width:100%;height:100%;box-sizing:border-box;padding:32px 40px;background:#f0fdf4;font-family:'Microsoft YaHei',sans-serif;display:flex;flex-direction:column;justify-content:center}"
".eq h3{color:#166534;margin:0 0 20px;font-size:20px;line-height:1.6}"
".ei{padding:14px 18px;border:2px solid #bbf7d0;border-radius:10px;font-size:18px;width:80%;margin-bottom:16px}"
".eb{padding:12px 32px;background:#16a34a;color:#fff;border:none;border-radius:10px;font-size:17px;cursor:pointer}"
".ef{margin-top:16px;font-size:15px;display:none}</style>"
"<div class='eq'><h3>" + stem + "</h3>"
+ "<input class='ei' id='fu' placeholder='请输入答案'>"
+ "<button class='eb' id='fb'>提交</button>"
+ "<p class='ef' id='fr'></p></div>"
+ "<script>(function(){var ans='" + answer.replace("'", "") + "';"
"document.getElementById('fb').addEventListener('click',function(){"
"var u=document.getElementById('fu').value.trim().toLowerCase();"
"var a=ans.toLowerCase();var f=document.getElementById('fr');"
"f.style.display='block';"
"if(u.indexOf(a)>=0||a.indexOf(u)>=0){f.style.color='#16a34a';f.textContent='✓ 回答正确!'}"
"else{f.style.color='#ef4444';f.textContent='✗ 正确答案:'+ans}})})();</script>"
)
return "<div class='eq' style='width:100%;height:100%;padding:32px;background:#f0fdf4;font-family:sans-serif'><h3 style='color:#166534'>" + stem + "</h3></div>"
def _fallback_lesson_plan(self, title, subject, grade, objectives, duration):
t = title or "大单元教案"
subj = (subject or "").strip()
prof = self._lesson_plan_profile(subj)
new_dur = max(10, duration - 20)
practice_dur = max(8, duration // 4)
return {
"title": t,
"objectives": objectives or prof["objectives"](t),
"key_points": prof["key_points"](t),
"difficulties": prof["difficulties"](t),
"phases": [
{"name": "导入", "duration": 5, "activities": [prof["intro"](t)], "teacher_actions": ["创设情境,激发兴趣。", "提出核心问题。"], "student_actions": ["观察思考。", "联系已有经验。"], "resources": prof["intro_resources"]},
{"name": "新授", "duration": new_dur, "activities": prof["new_activities"](t), "teacher_actions": prof["new_teacher"], "student_actions": prof["new_student"], "resources": prof["new_resources"]},
{"name": "练习巩固", "duration": practice_dur, "activities": prof["practice"](t), "teacher_actions": ["巡视指导,个别辅导。"], "student_actions": prof["practice_student"], "resources": prof["practice_resources"]},
{"name": "总结", "duration": 5, "activities": [prof["summary"](t)], "teacher_actions": ["梳理知识脉络,提炼方法。"], "student_actions": ["回顾反思,记录收获。"], "resources": ["板书", "思维导图"]},
],
"homework": prof["homework"](t),
"reflection": f"根据课堂反馈与学生掌握情况,调整「{t}」后续教学的深度、广度与练习分层策略。",
}
def _lesson_plan_profile(self, subject: str) -> dict:
s = subject
profiles = {
"数学": {
"objectives": lambda t: [f"理解「{t}」的概念内涵与几何或代数意义", f"掌握「{t}」的基本公式与运算法则", "能运用所学知识分析并解决实际问题"],
"key_points": lambda t: [f"「{t}」的定义与核心性质", "公式推导与规范书写"],
"difficulties": lambda t: [f"「{t}」的灵活运用与变形", "数形结合思想的建立"],
"intro": lambda t: f"借助生活实例或已有知识,引出「{t}」的研究必要性。",
"new_activities": lambda t: [f"通过探究活动归纳「{t}」的概念与性质。", "推导公式并分析适用条件。"],
"new_teacher": ["引导探究,板书推导过程。", "组织小组讨论。"],
"new_student": ["动手操作或画图探究。", "小组合作归纳结论。"],
"intro_resources": ["课件", "实物教具", "几何画板"],
"new_resources": ["学案", "探究任务单", "动态演示"],
"practice": lambda t: ["基础题巩固概念。", "变式训练提升能力。", "实际问题应用。"],
"practice_student": ["独立完成,规范步骤。", "同桌互批,错题订正。"],
"practice_resources": ["分层练习", "答题卡"],
"summary": lambda t: f"梳理「{t}」的知识结构图,提炼解题方法与易错点。",
"homework": lambda t: [f"完成「{t}」分层作业(基础+提升)。", "整理本节易错题集。"],
},
"物理": {
"objectives": lambda t: [f"理解「{t}」的物理意义与适用条件", "掌握相关公式并能进行简单计算", "经历实验探究过程,培养科学思维"],
"key_points": lambda t: [f"「{t}」的概念建立与公式表达", "实验现象的观察与分析"],
"difficulties": lambda t: [f"「{t}」物理模型的构建", "实验方案的设计与数据分析"],
"intro": lambda t: f"通过演示实验或生活现象,引发对「{t}」的思考。",
"new_activities": lambda t: ["分组实验探究规律。", "分析实验数据,归纳结论。"],
"new_teacher": ["演示关键实验,强调安全。", "指导小组实验,引导分析。"],
"new_student": ["分组实验,记录数据。", "分析数据,得出结论。"],
"intro_resources": ["演示器材", "课件", "视频"],
"new_resources": ["学生实验器材", "数据记录表"],
"practice": lambda t: ["基础概念辨析。", "公式应用计算。", "实验数据分析题。"],
"practice_student": ["独立解题,规范作图。", "小组互查实验报告。"],
"practice_resources": ["练习卷", "实验报告单"],
"summary": lambda t: f"构建「{t}」的知识网络,强调实验方法与物理思想。",
"homework": lambda t: [f"完成「{t}」课后练习。", "撰写实验探究报告。"],
},
}
# 化学/生物共用理科探究模板
sci = {
"objectives": lambda t: [f"理解「{t}」的基本概念与原理", "掌握核心知识并能解释相关现象", "培养实验观察与科学探究能力"],
"key_points": lambda t: [f"「{t}」的核心概念与特征", "实验现象的观察与解释"],
"difficulties": lambda t: [f"「{t}」微观机制的理解", "知识在真实情境中的迁移应用"],
"intro": lambda t: f"通过实验现象或生活实例,引出「{t}」的探究问题。",
"new_activities": lambda t: ["实验观察与操作。", "小组讨论归纳知识要点。"],
"new_teacher": ["组织实验,强调规范操作。", "引导分析现象背后的原理。"],
"new_student": ["动手实验,如实记录。", "讨论交流,形成结论。"],
"intro_resources": ["实验器材", "课件", "标本/模型"],
"new_resources": ["实验材料", "学习单"],
"practice": lambda t: ["概念辨析与判断。", "现象解释与应用。"],
"practice_student": ["独立完成练习。", "小组互评实验记录。"],
"practice_resources": ["练习卡", "实验记录册"],
"summary": lambda t: f"梳理「{t}」的知识体系,强调科学方法与探究思路。",
"homework": lambda t: [f"完成「{t}」巩固练习。", "预习下一节内容并完成预习单。"],
}
for k in ("化学", "生物"):
profiles[k] = sci
profiles["语文"] = {
"objectives": lambda t: [f"品味「{t}」的语言特色与表达技巧", "理解文章主旨与作者情感", "学习并运用相关的写作方法"],
"key_points": lambda t: [f"「{t}」的文本内容与结构梳理", "关键语句的赏析与理解"],
"difficulties": lambda t: [f"「{t}」深层意蕴与作者情感的体悟", "写作手法在表达中的迁移运用"],
"intro": lambda t: f"借助背景介绍或朗读导入,唤起对「{t}」的阅读期待。",
"new_activities": lambda t: ["初读感知,整体把握文意。", "精读品味,赏析关键语段。"],
"new_teacher": ["范读指导,组织品析。", "点拨赏析方法。"],
"new_student": ["朗读体会,圈点批注。", "小组交流赏析心得。"],
"intro_resources": ["课件", "背景资料", "音频朗读"],
"new_resources": ["文本", "批注学习单"],
"practice": lambda t: ["仿写练习。", "片段赏析与表达。"],
"practice_student": ["独立完成仿写。", "分享交流,互评互改。"],
"practice_resources": ["仿写学习单", "评价量表"],
"summary": lambda t: f"总结「{t}」的写法与主旨,提炼可借鉴的表达技巧。",
"homework": lambda t: [f"围绕「{t}」完成读写结合小练笔。", "积累本文好词好句。"],
}
profiles["英语"] = {
"objectives": lambda t: [f"掌握「{t}」相关核心词汇与句型", "能在真实情境中运用所学进行交流", "提升听、说、读、写综合语言能力"],
"key_points": lambda t: [f"「{t}」的核心词汇与目标句型", "语言功能的得体运用"],
"difficulties": lambda t: [f"「{t}」相关语法结构的正确使用", "在真实交际中的灵活表达"],
"intro": lambda t: f"通过歌曲、游戏或情境对话导入「{t}」话题。",
"new_activities": lambda t: ["词汇与句型学习。", "情境对话操练与角色扮演。"],
"new_teacher": ["创设情境,示范句型。", "组织pair work与group work。"],
"new_student": ["跟读模仿,记忆词汇。", "结对操练,大胆表达。"],
"intro_resources": ["课件", "音频视频", "单词卡"],
"new_resources": ["对话任务单", "角色卡片"],
"practice": lambda t: ["词汇句型巩固。", "情境交际任务。"],
"practice_student": ["完成听力与填空。", "小组表演对话。"],
"practice_resources": ["练习单", "评价表"],
"summary": lambda t: f"回顾「{t}」核心语言知识,梳理交际策略。",
"homework": lambda t: [f"完成「{t}」听读作业。", "用目标句型写一段对话。"],
}
profiles["历史"] = {
"objectives": lambda t: [f"了解「{t}」的基本史实与发展脉络", "分析历史事件的因果关系与影响", "培养历史思维与家国情怀"],
"key_points": lambda t: [f"「{t}」的关键事件、人物与时间", "历史发展规律与时代特征"],
"difficulties": lambda t: [f"对「{t}」历史现象的多角度分析", "史论结合,以史为鉴"],
"intro": lambda t: f"通过史料、图片或视频导入「{t}」的时代背景。",
"new_activities": lambda t: ["梳理时间线与重大事件。", "研读史料,分析因果关系。"],
"new_teacher": ["提供史料,引导分析。", "组织讨论,启发思考。"],
"new_student": ["阅读史料,做时间轴。", "小组讨论,发表见解。"],
"intro_resources": ["课件", "历史地图", "史料摘录"],
"new_resources": ["史料学习单", "时间轴模板"],
"practice": lambda t: ["史实梳理填空。", "材料分析题。"],
"practice_student": ["独立完成基础题。", "小组研讨材料题。"],
"practice_resources": ["练习卷", "材料研读单"],
"summary": lambda t: f"构建「{t}」的知识框架,总结历史启示。",
"homework": lambda t: [f"完成「{t}」巩固练习。", "撰写一段历史小短评。"],
}
profiles["地理"] = {
"objectives": lambda t: [f"认识「{t}」的空间分布与基本特征", "理解各地理要素的相互关系", "树立人地协调与可持续发展观念"],
"key_points": lambda t: [f"「{t}」的位置、分布与主要特征", "自然与人文地理要素的相互作用"],
"difficulties": lambda t: [f"「{t}」空间格局的综合分析", "读图分析与地理推理"],
"intro": lambda t: f"借助地图、卫星图或景观图导入「{t}」。",
"new_activities": lambda t: ["读图分析空间分布。", "小组探究地理要素关系。"],
"new_teacher": ["指导读图方法。", "组织探究活动。"],
"new_student": ["读图标注,提取信息。", "小组合作,归纳特征。"],
"intro_resources": ["课件", "地图", "景观图片"],
"new_resources": ["空白地图", "探究任务单"],
"practice": lambda t: ["读图填图训练。", "综合分析题。"],
"practice_student": ["独立完成读图题。", "小组研讨分析题。"],
"practice_resources": ["练习图册", "分析学习单"],
"summary": lambda t: f"整理「{t}」知识结构,强调人地关系与可持续发展。",
"homework": lambda t: [f"完成「{t}」读图与练习。", "调查身边的地理现象。"],
}
if s in profiles:
return profiles[s]
# 通用模板
return {
"objectives": lambda t: [f"理解「{t}」的核心内容与基本要求", "掌握关键知识与基本方法", "培养分析思维与合作探究能力"],
"key_points": lambda t: [f"「{t}」的核心知识建构", "方法与技能的训练"],
"difficulties": lambda t: [f"「{t}」的深入理解与灵活运用", "知识的迁移与综合"],
"intro": lambda t: f"创设情境,引出「{t}」的学习主题。",
"new_activities": lambda t: [f"围绕「{t}」进行讲解与互动探究。"],
"new_teacher": ["讲解引导,组织探究。"],
"new_student": ["主动思考,合作探究。"],
"intro_resources": ["课件", "情境素材"],
"new_resources": ["学习单", "探究材料"],
"practice": lambda t: ["分层练习巩固。", "互评与订正。"],
"practice_student": ["独立完成,规范作答。"],
"practice_resources": ["练习卡"],
"summary": lambda t: f"梳理「{t}」知识结构,提炼重点方法。",
"homework": lambda t: [f"完成「{t}」巩固作业。", "预习下节内容。"],
}
def _fallback_essay_grade(self, essay_text, total_score):
text = essay_text or ""
char_count = len(text)
import re as _re2
sentences = [s.strip() for s in _re2.split(r"[。!?]", text) if s.strip()]
sent_count = len(sentences)
has_dialog = "“" in text or "说" in text
has_detail = any(w in text for w in ("有一次", "记得", "那天", "当时", "忽然", "只见"))
avg_sent = char_count / max(sent_count, 1)
score_ratio = 0.82 if avg_sent > 25 else 0.74
score = max(1, int(total_score * score_ratio))
annotations = []
if has_detail:
for kw in ("有一次", "记得", "那天", "当时"):
pos = text.find(kw)
if pos >= 0:
snippet = text[max(0,pos-3):pos+12]
annotations.append({"text": snippet, "comment": "这里用具体事例展开,增加了文章的真实感。", "type": "good"})
break
if char_count < 100:
annotations.append({"text": text[:20], "comment": "文章字数偏少,建议增加具体描写。", "type": "error"})
strengths = []
weaknesses = []
suggestions = []
if has_detail:
strengths.append("能用具体事例展开叙述,增加了文章的感染力。")
else:
weaknesses.append("缺乏具体事例和细节描写,内容较空洞。")
suggestions.append("增加一到两个具体事例,展开细节描写。")
if has_dialog:
strengths.append("运用了对话描写,使人物形象更加鲜活。")
else:
suggestions.append("可以加入对话描写,让人物更鲜活。")
if sent_count <= 2:
weaknesses.append("句子偏少,结构较单谬。")
suggestions.append("增加句子数量,丰富文章层次。")
if avg_sent > 40:
weaknesses.append("部分句子较长,建议适当断句。")
suggestions.append("将过长的句子拆分为短句,提高可读性。")
content_ratio = 0.32 if has_detail else 0.25
content_s = int(total_score * content_ratio)
structure_s = int(total_score * 0.22)
language_s = int(total_score * 0.18)
writing_s = score - content_s - structure_s - language_s
if not strengths:
strengths = ["主题明确,有基本的表达。"]
if not weaknesses:
weaknesses = ["可以在细节上进一步丰富。"]
if not suggestions:
suggestions = ["继续保持认真观察和真诚表达。"]
level = "优秀" if score_ratio > 0.80 else ("良好" if score_ratio > 0.72 else "中等")
return {
"total_score": score,
"scores": {
"content": {"score": content_s, "max": int(total_score * 0.3), "comment": "内容较为充实。" if has_detail else "内容基本完整,建议增加细节。"},
"structure": {"score": structure_s, "max": int(total_score * 0.25), "comment": "结构较清晰。" if sent_count > 2 else "结构较为单谬,建议增加层次。"},
"language": {"score": language_s, "max": int(total_score * 0.25), "comment": "表达较通顺。" if avg_sent <= 40 else "部分句子较长,可适当断句。"},
"writing": {"score": writing_s, "max": int(total_score * 0.2), "comment": "书写规范尚可。"},
},
"overall_comment": "作文围绕主题展开," + ("能用具体事例进行叙述," if has_detail else "但缺乏具体细节,") + "表达有一定的真实感。" + ("建议继续加强细节描写和语言表现力,让文章更有感染力。" if not has_detail else "建议在语言精炼和结构层次上进一步提升。"),
"strengths": strengths,
"weaknesses": weaknesses,
"suggestions": suggestions,
"annotations": annotations,
"level": level,
}
def _fallback_exam(self, subject, grade, knowledge_points, difficulty, question_types, count, total_score):
total = max(1, min(count or 10, 20))
each = max(1, total_score // max(total, 1))
subj = subject or "综合"
kp = "、".join(knowledge_points) if knowledge_points else "基础知识"
bank = self._question_bank(kp, subj)
if not bank:
bank = self._generic_question_bank(kp, subj)
questions = []
for idx in range(total):
q = dict(bank[idx % len(bank)])
q["id"] = idx + 1
q["type"] = q.get("type", (question_types or ["choice"])[0])
q["score"] = each
q["difficulty"] = difficulty if isinstance(difficulty, str) else ["easy", "medium", "hard"][idx % 3]
q["content"] = q.pop("question", None) or q.get("content", "")
q["analysis"] = q.get("explanation", q.get("analysis", ""))
if "explanation" in q:
q.pop("explanation", None)
if "html" in q:
q.pop("html", None)
questions.append(q)
return {"title": f"{subj}{grade or ''}智能试卷", "questions": questions}
def _courseware_category(self, prompt: str) -> str:
prompt = self._repair_text(prompt)
if any(k in prompt for k in ["圆柱", "圆柱的体积", "体积公式", "底面积", "高"]):
return "cylinder"
return "general"
def _courseware_shell(self, title: str, subtitle: str, body: str, footer_left: str = "") -> str:
title_html = html_lib.escape(title)
subtitle_html = html_lib.escape(subtitle)
footer_html = html_lib.escape(footer_left)
return f"""<div style='width:100%;height:100%;position:relative;overflow:hidden;background:#f6fbf7;font-family:Microsoft YaHei,PingFang SC,sans-serif;color:#143b31;'>
<style>
.zj-top{{position:absolute;left:36px;right:36px;top:24px;height:64px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #d9e7dc;}}
.zj-top h2{{margin:0 0 6px;font-size:26px;color:#00543d;}}
.zj-top p{{margin:0;color:#6b7f76;font-size:14px;}}
.zj-badge{{padding:8px 14px;border-radius:18px;background:#e8f7ef;color:#007a52;font-size:13px;font-weight:700;}}
.zj-stage{{position:absolute;left:48px;right:48px;top:110px;bottom:64px;background:#fff;border:1px solid #dce8de;border-radius:16px;box-shadow:0 12px 30px rgba(15,84,61,.08);overflow:hidden;}}
.zj-footer{{position:absolute;left:36px;right:36px;bottom:20px;display:flex;align-items:center;justify-content:space-between;color:#6b7f76;font-size:13px;}}
.zj-chip{{display:inline-flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;border:1px solid #cfe0d3;background:#fff;color:#00543d;font-size:13px;cursor:pointer;}}
.zj-card{{background:#fff;border:1px solid #dce8de;border-radius:14px;box-shadow:0 10px 24px rgba(15,84,61,.06);}}
.zj-btn{{border:none;border-radius:12px;padding:10px 16px;background:#00543d;color:#fff;cursor:pointer;font-size:14px;}}
.zj-btn.alt{{background:#fff;color:#00543d;border:1px solid #cfe0d3;}}
.zj-pill{{padding:6px 12px;border-radius:999px;background:#ecfdf5;color:#00543d;font-size:12px;}}
.zj-grid2{{display:grid;grid-template-columns:1fr 1fr;gap:16px;}}
.zj-grid3{{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;}}
</style>
<div class='zj-top'>
<div><h2>{title_html}</h2><p>{subtitle_html}</p></div>
<div class='zj-badge'>AI生成</div>
</div>
<div class='zj-stage'>{body}</div>
<div class='zj-footer'><div>{footer_html}</div><div>页面内可点击交互</div></div>
</div>"""
def _fallback_courseware(self, prompt: str, subject: str, grade: str, page_count: int) -> dict:
prompt = self._repair_text(prompt)
if self._courseware_category(prompt) == "cylinder":
return self._fallback_cylinder_courseware(prompt, subject, grade, page_count)
return self._fallback_general_courseware(prompt, subject, grade, page_count)
def _normalize_courseware_result(self, result: dict, prompt: str, subject: str, grade: str, page_count: int, aspect_ratio: str) -> dict | None:
if not isinstance(result, dict):
return None
pages = result.get("pages")
if not isinstance(pages, list) or len(pages) < 5:
return None
normalized_pages = []
for page in pages:
if not isinstance(page, dict):
continue
content = page.get("content")
if not isinstance(content, str):
continue
cleaned = content.replace("100vh", "100%").replace("100vw", "100%")
if not cleaned.strip().lower().startswith("<div"):
cleaned = f"<div style='width:100%;height:100%;position:relative;overflow:hidden;'>{cleaned}</div>"
normalized_pages.append({
"type": page.get("type") or "content",
"title": self._repair_text(str(page.get("title") or "")),
"content": cleaned,
"notes": self._repair_text(str(page.get("notes") or "")),
})
if len(normalized_pages) < 5:
return None
return {
"title": self._repair_text(str(result.get("title") or prompt[:24] or "课件")),
"summary": self._repair_text(str(result.get("summary") or "")),
"tags": [self._repair_text(str(tag)) for tag in (result.get("tags") or []) if str(tag).strip()],
"pages": normalized_pages[:max(5, min(page_count, len(normalized_pages)))],
"aspect_ratio": aspect_ratio,
"subject": subject,
"grade": grade,
}
def _courseware_topic(self, prompt: str) -> str:
prompt = self._repair_text(prompt)
text = prompt.lower()
if any(k in prompt for k in ["地球公转", "四季", "春分", "夏至", "秋分", "冬至", "昼夜长短", "太阳直射"]) or any(k in text for k in ["orbit", "season"]):
return "orbit"
if any(k in prompt for k in ["圆柱", "体积", "底面积", "圆锥", "长方体"]):
return "cylinder"
return "general"
def _courseware_shell_clean(self, title: str, subtitle: str, body: str, footer_left: str = "") -> str:
title_html = html_lib.escape(title)
subtitle_html = html_lib.escape(subtitle)
footer_html = html_lib.escape(footer_left)
return (
"<div style='width:100%;height:100%;position:relative;overflow:hidden;background:#f6fbf7;font-family:Microsoft YaHei,PingFang SC,sans-serif;color:#143b31;'>"
"<style>"
".zj-top{position:absolute;left:28px;right:28px;top:16px;height:42px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #c9dacc;}"
".zj-top h2{margin:0;font-size:21px;line-height:1.15;color:#004737;font-weight:700;}"
".zj-top p{margin:3px 0 0;color:#617971;font-size:12px;}"
".zj-badge{width:78px;height:28px;border-radius:999px;border:1px solid #c9dacc;background:#fff;color:#004737;font-size:12px;font-weight:700;display:flex;align-items:center;justify-content:center;}"
".zj-stage{position:absolute;left:20px;right:20px;top:70px;bottom:20px;background:#fff;border:1px solid #c9dacc;border-radius:16px;box-shadow:0 16px 34px rgba(0,71,55,.08);overflow:auto;}"
".zj-footer{display:none;}"
".zj-chip{display:inline-flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;border:1px solid #cfe0d3;background:#fff;color:#00543d;font-size:13px;cursor:pointer;}"
".zj-card{background:#fff;border:1px solid #dce8de;border-radius:14px;box-shadow:0 10px 24px rgba(15,84,61,.06);}"
".zj-btn{border:none;border-radius:12px;padding:10px 16px;background:#00543d;color:#fff;cursor:pointer;font-size:14px;}"
".zj-btn.alt{background:#fff;color:#00543d;border:1px solid #cfe0d3;}"
".zj-pill{padding:6px 12px;border-radius:999px;background:#ecfdf5;color:#00543d;font-size:12px;}"
".zj-grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px;}"
".zj-grid3{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;}"
".season-btn{height:46px;border-radius:13px;border:1px solid #d9e7dc;background:#fff;color:#516b61;display:flex;align-items:center;justify-content:center;gap:6px;font-weight:700;cursor:pointer;transition:all .22s ease;box-shadow:0 4px 12px rgba(15,84,61,.05);user-select:none;}"
".season-btn.active{border-color:#00a870;background:#ecfdf5;color:#00543d;box-shadow:0 10px 18px rgba(0,168,112,.10);}"
".orbit-wrap{position:relative;height:100%;min-height:300px;background:linear-gradient(180deg,#f8fefb,#eef9f1);border-radius:16px;border:1px solid #dce8de;overflow:hidden;}"
".orbit-sun{position:absolute;left:52px;top:50%;transform:translateY(-50%);width:118px;height:118px;border-radius:50%;background:radial-gradient(circle at 35% 35%,#fff8d6 0,#fde68a 46%,#f59e0b 100%);box-shadow:0 0 0 12px rgba(245,158,11,.08),0 18px 40px rgba(245,158,11,.18);}"
".orbit-path{position:absolute;left:160px;right:44px;top:38px;bottom:38px;border:2px dashed #9dd9c0;border-radius:50%;}"
".earth-dot{position:absolute;right:104px;top:50%;transform:translateY(-50%);width:94px;height:94px;border-radius:50%;background:radial-gradient(circle at 34% 34%,#a7f3d0 0,#34d399 58%,#0f766e 100%);box-shadow:0 18px 32px rgba(15,118,110,.18);}"
".earth-dot::after{content:'';position:absolute;inset:0;border-radius:50%;background:linear-gradient(90deg,rgba(255,255,255,.42),transparent 55%,rgba(0,0,0,.12));animation:spin 5s linear infinite;}"
"@keyframes spin{from{transform:rotate(0deg);}to{transform:rotate(360deg);}}"
"</style>"
"<script>(function(){function setActive(group,key){if(!group)return;group.querySelectorAll('[data-switch]').forEach(function(btn){btn.classList.toggle('active',btn.getAttribute('data-switch')===key)});group.querySelectorAll('[data-panel]').forEach(function(panel){panel.style.display=panel.getAttribute('data-panel')===key?'':'none'});}document.addEventListener('click',function(e){var tab=e.target.closest&&e.target.closest('[data-switch]');if(tab){var group=tab.closest('[data-tab-group]');setActive(group,tab.getAttribute('data-switch'));return;}var toggle=e.target.closest&&e.target.closest('[data-toggle]');if(toggle){var target=toggle.getAttribute('data-toggle');var host=toggle.closest('[data-toggle-group]')||toggle.parentElement||document;var panel=host.querySelector('[data-target=\"'+target+'\"]');if(panel){panel.style.display=panel.style.display==='none'?'':'none';toggle.classList.toggle('active');}return;}});})();</script>"
"<div class='zj-top'><div><h2>" + title_html + "</h2><p>" + subtitle_html + "</p></div><div class='zj-badge'>AI生成</div></div>"
"<div class='zj-stage'>"
+ body +
"</div>"
"<div class='zj-footer'><div>" + footer_html + "</div><div>页面内可点击交互</div></div>"
"</div>"
)
def _fallback_cylinder_courseware(self, prompt: str, subject: str, grade: str, page_count: int) -> dict:
prompt = self._repair_text(prompt)
title = "圆柱的体积(第一课时)"
pages = [
{
"type": "title",
"title": "封面",
"content": self._courseware_shell_clean(
title,
f"{subject or '数学'} · {grade or '六年级'}",
"""
<div style='position:absolute;inset:0;background:linear-gradient(135deg,#f4fbf7 0%,#ffffff 52%,#eaf7ef 100%);'>
<div style='position:absolute;left:54px;top:52px;padding:8px 14px;border-radius:18px;background:rgba(0,84,61,.08);color:#00543d;font-size:13px;font-weight:700;'>课堂导入</div>
<div style='position:absolute;left:54px;right:54px;top:76px;bottom:26px;display:grid;grid-template-columns:1.1fr .9fr;gap:26px;align-items:center;'>
<div>
<div class='zj-pill'>数学 · 形体与测量</div>
<h1 style='margin:18px 0 0;font-size:42px;line-height:1.08;color:#12372d;'>圆柱的体积</h1>
<p style='margin:18px 0 0;font-size:20px;line-height:1.7;color:#33544b;max-width:460px;'>围绕“底面积 × 高”展开探究,通过观察、切分、比较和例题,把抽象公式变成可理解、可操作的课堂体验。</p>
<div style='margin-top:24px;display:flex;gap:12px;flex-wrap:wrap;'>
<span class='zj-chip'>层次清楚</span>
<span class='zj-chip'>页面可切换</span>
<span class='zj-chip'>支持课堂互动</span>
</div>
</div>
<div style='position:relative;height:100%;min-height:260px;'>
<div style='position:absolute;left:48px;top:30px;width:240px;height:240px;border-radius:50%;background:radial-gradient(circle at 50% 40%,#ecfdf5 0,#d1fae5 45%,#86efac 100%);box-shadow:0 18px 40px rgba(0,168,112,.14);'></div>
<div style='position:absolute;left:92px;top:62px;width:148px;height:182px;border-left:3px solid #00a870;border-right:3px solid #00a870;background:linear-gradient(90deg,rgba(0,168,112,.10),rgba(255,255,255,.58),rgba(0,168,112,.10));border-radius:26px;'></div>
<div style='position:absolute;left:92px;top:36px;width:148px;height:52px;border-radius:50%;background:#d1fae5;border:3px solid #00a870;'></div>
<div style='position:absolute;left:92px;top:212px;width:148px;height:52px;border-radius:50%;background:#a7f3d0;border:3px solid #00a870;'></div>
</div>
</div>
</div>
""",
"封面页",
),
"notes": "封面",
},
{
"type": "content",
"title": "知识回顾",
"content": self._courseware_shell_clean(
"知识回顾",
"先把旧知识拉回来,再进入新课探究",
"""
<div class='zj-grid2' data-tab-group='rev' style='position:absolute;left:22px;right:22px;top:22px;bottom:22px;display:grid;grid-template-columns:240px 1fr;gap:16px;'>
<div style='display:grid;gap:12px;align-content:start;'>
<button type='button' class='season-btn active' data-switch='rev1'>
<span style='font-size:18px;'>长方体体积</span>
</button>
<button type='button' class='season-btn' data-switch='rev2'>
<span style='font-size:18px;'>正方体体积</span>
</button>
<button type='button' class='season-btn' data-switch='rev3'>
<span style='font-size:18px;'>迁移到新知</span>
</button>
</div>
<div class='zj-card' style='padding:22px;display:flex;flex-direction:column;justify-content:center;'>
<div class='rev-panel rev1' data-panel='rev1'>
<div class='zj-pill'>旧知 1</div>
<h3 style='margin:14px 0 10px;font-size:28px;color:#00543d;'>长方体体积公式</h3>
<p style='margin:0;font-size:18px;line-height:1.8;color:#33544b;'>体积和底面积、高有关。先看“底面铺了多少”,再看“竖起来有多高”。</p>
</div>
<div class='rev-panel rev2' data-panel='rev2' style='display:none;'>
<div class='zj-pill'>旧知 2</div>
<h3 style='margin:14px 0 10px;font-size:28px;color:#00543d;'>正方体体积公式</h3>
<p style='margin:0;font-size:18px;line-height:1.8;color:#33544b;'>棱长相等,体积计算更简单,但核心思想仍是“每层面积 × 层数”。</p>
</div>
<div class='rev-panel rev3' data-panel='rev3' style='display:none;'>
<div class='zj-pill'>新课前置</div>
<h3 style='margin:14px 0 10px;font-size:28px;color:#00543d;'>圆柱能否转化?</h3>
<p style='margin:0;font-size:18px;line-height:1.8;color:#33544b;'>如果把圆柱沿着高切开,再拼成近似长方体,体积关系会更清楚。</p>
</div>
</div>
</div>
""",
"点击卡片切换旧知",
),
"notes": "知识回顾",
},
{
"type": "interactive",
"title": "引入新知",
"content": self._courseware_shell_clean(
"引入新知",
"先看形状和高度,再让学生猜一猜体积和什么有关",
"""
<div style='position:absolute;left:22px;right:22px;top:22px;bottom:22px;' class='zj-grid2'>
<div style='position:relative;min-height:300px;background:linear-gradient(180deg,#f8fefb,#eef9f1);border-radius:16px;border:1px solid #dce8de;overflow:hidden;'>
<div style='position:absolute;left:24px;top:24px;padding:8px 12px;border-radius:16px;background:#00543d;color:#fff;font-size:13px;font-weight:700;'>情境导入</div>
<div style='position:absolute;left:86px;top:82px;width:160px;height:230px;border-left:4px solid #00a870;border-right:4px solid #00a870;background:linear-gradient(90deg,rgba(0,168,112,.14),rgba(255,255,255,.65),rgba(0,168,112,.14));border-radius:30px;'></div>
<div style='position:absolute;left:86px;top:50px;width:160px;height:62px;border-radius:50%;background:#d1fae5;border:4px solid #00a870;'></div>
<div style='position:absolute;left:86px;top:250px;width:160px;height:62px;border-radius:50%;background:#a7f3d0;border:4px solid #00a870;'></div>
</div>
<div class='zj-card' style='padding:20px;display:flex;flex-direction:column;justify-content:center;'>
<div class='zj-pill'>课堂提问</div>
<h3 style='margin:14px 0 12px;font-size:28px;color:#12372d;'>你觉得圆柱体积和什么有关?</h3>
<div style='display:grid;gap:10px;margin-top:10px;'>
<div class='zj-card' style='padding:14px 16px;'>A. 只和底面形状有关</div>
<div class='zj-card' style='padding:14px 16px;'>B. 和底面积、高都有关系</div>
<div class='zj-card' style='padding:14px 16px;'>C. 只和颜色有关</div>
</div>
<div style='margin-top:18px;padding:14px 16px;border-left:4px solid #00a870;background:#f7faf8;border-radius:10px;color:#33544b;'>课堂结论:体积计算要抓住“底面积 × 高”这条主线。</div>
</div>
</div>
""",
"学生先猜想,再看图验证",
),
"notes": "引入",
},
{
"type": "content",
"title": "公式探究",
"content": self._courseware_shell_clean(
"公式探究",
"用切、拼、比的方式,把圆柱体积想清楚",
"""
<div class='zj-grid2' data-tab-group='step' style='position:absolute;left:22px;right:22px;top:22px;bottom:22px;display:grid;grid-template-columns:240px 1fr;gap:16px;'>
<div style='display:grid;gap:10px;align-content:start;'>
<button type='button' class='season-btn active' data-switch='step1'>
<span style='font-size:18px;'>切一切</span>
</button>
<button type='button' class='season-btn' data-switch='step2'>
<span style='font-size:18px;'>拼一拼</span>
</button>
<button type='button' class='season-btn' data-switch='step3'>
<span style='font-size:18px;'>想一想</span>
</button>
</div>
<div class='zj-card' style='padding:22px;position:relative;overflow:auto;'>
<div class='step-panel s1' data-panel='step1'>
<div class='zj-pill'>切分观察</div>
<h3 style='margin:14px 0 10px;font-size:30px;color:#00543d;'>把圆柱切成很多薄片</h3>
<p style='margin:0;font-size:18px;line-height:1.8;color:#33544b;max-width:560px;'>圆柱越像被分成很多很薄的小层,每一层越接近一个圆形小面。层数越多,拼出的形状就越接近长方体。</p>
</div>
<div class='step-panel s2' data-panel='step2' style='display:none;'>
<div class='zj-pill'>转化比较</div>
<h3 style='margin:14px 0 10px;font-size:30px;color:#00543d;'>拼成近似长方体</h3>
<p style='margin:0;font-size:18px;line-height:1.8;color:#33544b;max-width:560px;'>切开后重新排列,底面形状变了,但每一层的面积总和没有变,高也没有变,所以体积也没有变。</p>
<div style='margin-top:18px;font-size:34px;font-weight:800;color:#12372d;'>V = 底面积 × 高</div>
</div>
<div class='step-panel s3' data-panel='step3' style='display:none;'>
<div class='zj-pill'>归纳结论</div>
<h3 style='margin:14px 0 10px;font-size:30px;color:#00543d;'>圆柱体积公式</h3>
<div style='font-size:38px;font-weight:800;color:#12372d;'>V = S × h</div>
<p style='margin:12px 0 0;font-size:18px;line-height:1.8;color:#33544b;'>S 表示底面积,h 表示高。知道这两个量,就能求圆柱的体积。</p>
</div>
</div>
</div>
""",
"切、拼、比,逐步导出公式",
),
"notes": "公式探究",
},
{
"type": "interactive",
"title": "例题讲解",
"content": self._courseware_shell_clean(
"例题讲解",
"把公式落到计算里,按步骤说清楚",
"""
<div class='zj-grid2' data-tab-group='ex' style='position:absolute;left:22px;right:22px;top:22px;bottom:22px;display:grid;grid-template-columns:1fr 320px;gap:16px;'>
<div class='zj-card' style='padding:22px;'>
<div class='zj-pill'>例题</div>
<h3 style='margin:14px 0 12px;font-size:28px;color:#12372d;'>一个圆柱的底面积是 28.26 cm²,高是 10 cm,体积是多少?</h3>
<div class='zj-grid3' style='margin-top:16px;'>
<button type='button' class='season-btn active' data-switch='ex1'>
<span style='font-size:18px;'>第 1 步</span>
</button>
<button type='button' class='season-btn' data-switch='ex2'>
<span style='font-size:18px;'>第 2 步</span>
</button>
<button type='button' class='season-btn' data-switch='ex3'>
<span style='font-size:18px;'>第 3 步</span>
</button>
</div>
<div style='margin-top:18px;padding:18px;border-radius:14px;background:#f7faf8;border:1px solid #dce8de;'>
<div class='ex-panel ex1' data-panel='ex1'>
<div style='font-size:16px;color:#6b7f76;'>已知</div>
<div style='margin-top:8px;font-size:20px;color:#12372d;font-weight:700;'>S = 28.26 cm²,h = 10 cm</div>
</div>
<div class='ex-panel ex2' data-panel='ex2' style='display:none;'>
<div style='font-size:16px;color:#6b7f76;'>公式</div>
<div style='margin-top:8px;font-size:28px;color:#12372d;font-weight:800;'>V = S × h</div>
<div style='margin-top:8px;font-size:18px;color:#33544b;'>V = 28.26 × 10</div>
</div>
<div class='ex-panel ex3' data-panel='ex3' style='display:none;'>
<div style='font-size:16px;color:#6b7f76;'>结果</div>
<div style='margin-top:8px;font-size:32px;color:#00543d;font-weight:800;'>V = 282.6 cm³</div>
<div style='margin-top:8px;font-size:18px;color:#33544b;'>答:这个圆柱的体积是 282.6 立方厘米。</div>
</div>
</div>
</div>
<div class='zj-card' style='padding:20px;display:flex;flex-direction:column;gap:12px;justify-content:center;'>
<div class='zj-pill'>解题提醒</div>
<div style='font-size:18px;line-height:1.8;color:#33544b;'>圆柱体积公式中的 <strong>S</strong> 是底面积,不是底面周长。单位也要对应写成立方单位。</div>
<div style='padding:14px 16px;border-radius:12px;background:#ecfdf5;color:#00543d;'>写答语时要带上单位,例如 cm³、m³。</div>
</div>
</div>
""",
"按步骤理解计算过程",
),
"notes": "例题",
},
{
"type": "exercise",
"title": "巩固练习",
"content": self._courseware_shell_clean(
"巩固练习",
"点击选项,立即查看判断结果",
"""
<div class='zj-grid2' data-tab-group='q1' style='position:absolute;left:22px;right:22px;top:22px;bottom:22px;display:grid;grid-template-columns:1fr 320px;gap:16px;'>
<div class='zj-card' style='padding:22px;'>
<div class='zj-pill'>选择题</div>
<h3 style='margin:14px 0 12px;font-size:28px;color:#12372d;'>圆柱体积公式正确的是哪一个?</h3>
<div style='display:grid;gap:10px;margin-top:16px;'>
<button type='button' class='season-btn active' data-switch='qa' style='justify-content:flex-start;padding:0 16px;'>
<span style='font-size:16px;'>A. V = 底面周长 × 高</span>
</button>
<button type='button' class='season-btn' data-switch='qb' style='justify-content:flex-start;padding:0 16px;'>
<span style='font-size:16px;'>B. V = 底面积 × 高</span>
</button>
<button type='button' class='season-btn' data-switch='qc' style='justify-content:flex-start;padding:0 16px;'>
<span style='font-size:16px;'>C. V = 半径 × 高</span>
</button>
</div>
</div>
<div class='zj-card' style='padding:20px;display:flex;align-items:center;justify-content:center;text-align:left;'>
<div class='q-panel qa' data-panel='qa'>
<div class='zj-pill'>反馈</div>
<div style='margin-top:12px;font-size:18px;line-height:1.8;color:#b45309;'>A 不是正确答案。圆柱体积不是把周长直接相乘。</div>
</div>
<div class='q-panel qb' data-panel='qb' style='display:none;'>
<div class='zj-pill'>反馈</div>
<div style='margin-top:12px;font-size:18px;line-height:1.8;color:#00543d;'>回答正确。圆柱体积就是“底面积 × 高”。</div>
</div>
<div class='q-panel qc' data-panel='qc' style='display:none;'>
<div class='zj-pill'>反馈</div>
<div style='margin-top:12px;font-size:18px;line-height:1.8;color:#b45309;'>C 不是正确答案。半径只是求底面积的一个中间量。</div>
</div>
</div>
</div>
""",
"选择答案后直接反馈",
),
"notes": "练习",
},
{
"type": "summary",
"title": "总结",
"content": self._courseware_shell_clean(
"总结与作业",
"把公式、方法和易错点一次收好",
"""
<div class='zj-grid2' style='position:absolute;left:22px;right:22px;top:22px;bottom:22px;display:grid;grid-template-columns:1fr 340px;gap:16px;'>
<div class='zj-card' style='padding:22px;'>
<div class='zj-pill'>本课总结</div>
<div style='display:grid;gap:12px;margin-top:16px;'>
<div class='zj-card' style='padding:14px 16px;'>1. 圆柱体积公式:V = S × h</div>
<div class='zj-card' style='padding:14px 16px;'>2. 核心方法:切、拼、转化成近似长方体</div>
<div class='zj-card' style='padding:14px 16px;'>3. 易错提醒:S 是底面积,不是底面周长</div>
</div>
</div>
<div class='zj-card' style='padding:20px;display:flex;flex-direction:column;justify-content:center;gap:12px;' data-toggle-group='homework-block'>
<div class='zj-pill'>课后任务</div>
<div style='font-size:18px;line-height:1.8;color:#33544b;'>1. 完成课本练习。<br>2. 找一个生活中的圆柱体,量一量底面和高,试着算体积。</div>
<button type='button' class='zj-btn alt' data-toggle='hw' style='display:inline-flex;align-items:center;justify-content:center;'>点击展开提醒</button>
<div class='hw-panel' data-target='hw' style='padding:14px 16px;border-radius:12px;background:#f7faf8;border:1px solid #dce8de;display:none;'>记得带单位,写成立方厘米、立方分米或立方米。</div>
</div>
</div>
""",
"总结方法并布置作业",
),
"notes": "总结",
},
]
total = max(5, min(page_count or 7, len(pages)))
return {"title": title, "summary": "围绕底面积与高探究圆柱体积", "tags": ["圆柱", "体积", "互动课件"], "pages": pages[:total]}
def _fallback_orbit_courseware(self, prompt: str, subject: str, grade: str, page_count: int) -> dict:
prompt = self._repair_text(prompt)
title = "地球公转与四季变化"
pages = [
{
"type": "title",
"title": "封面",
"content": self._courseware_shell_clean(
title,
f"{subject or '科学'} · {grade or '六年级'}",
"""
<div style='position:absolute;inset:0;background:linear-gradient(135deg,#f4fbf7 0%,#ffffff 52%,#eaf7ef 100%);'>
<div style='position:absolute;left:54px;top:52px;padding:8px 14px;border-radius:18px;background:rgba(0,84,61,.08);color:#00543d;font-size:13px;font-weight:700;'>情境导入</div>
<div style='position:absolute;left:54px;right:54px;top:76px;bottom:26px;display:grid;grid-template-columns:1.1fr .9fr;gap:26px;align-items:center;'>
<div>
<div class='zj-pill'>科学 · 地球与宇宙</div>
<h1 style='margin:18px 0 0;font-size:42px;line-height:1.08;color:#12372d;'>地球公转与四季变化</h1>
<p style='margin:18px 0 0;font-size:20px;line-height:1.7;color:#33544b;max-width:460px;'>通过轨道、倾角和阳光照射方向的变化,把春夏秋冬的形成过程讲清楚、看明白、能点击。</p>
<div style='margin-top:24px;display:flex;gap:12px;flex-wrap:wrap;'>
<span class='zj-chip'>轨道示意</span>
<span class='zj-chip'>四季按钮</span>
<span class='zj-chip'>课堂可演示</span>
</div>
</div>
<div style='position:relative;height:100%;min-height:260px;'>
<div class='orbit-wrap'>
<div class='orbit-sun'></div>
<div class='orbit-path'></div>
<div class='earth-dot'></div>
</div>
</div>
</div>
</div>
""",
"封面页",
),
"notes": "封面",
},
{
"type": "content",
"title": "知识回顾",
"content": self._courseware_shell_clean(
"知识回顾",
"先把轨道、地轴倾斜和太阳光照这三个要点记住",
"""
<div style='position:absolute;left:22px;right:22px;top:22px;bottom:22px;' class='zj-grid3'>
<div class='zj-card' style='padding:18px;'>
<div class='zj-pill'>要点 1</div>
<h3 style='margin:14px 0 8px;font-size:22px;color:#00543d;'>地球在公转</h3>
<p style='margin:0;font-size:16px;line-height:1.7;color:#33544b;'>地球绕太阳转一圈,需要大约一年。</p>
</div>
<div class='zj-card' style='padding:18px;'>
<div class='zj-pill'>要点 2</div>
<h3 style='margin:14px 0 8px;font-size:22px;color:#00543d;'>地轴是倾斜的</h3>
<p style='margin:0;font-size:16px;line-height:1.7;color:#33544b;'>地轴倾斜让同一地区在不同时间受到的阳光不同。</p>
</div>
<div class='zj-card' style='padding:18px;'>
<div class='zj-pill'>要点 3</div>
<h3 style='margin:14px 0 8px;font-size:22px;color:#00543d;'>太阳光照变化</h3>
<p style='margin:0;font-size:16px;line-height:1.7;color:#33544b;'>阳光照射角度和昼夜长短,会随着公转位置变化。</p>
</div>
</div>
""",
"三个关键概念先对齐",
),
"notes": "知识回顾",
},
{
"type": "interactive",
"title": "四季探究",
"content": self._courseware_shell_clean(
"四季探究",
"点击春夏秋冬,观察光照和气候变化",
"""
<div class='zj-grid2' data-tab-group='season' style='position:absolute;left:22px;right:22px;top:22px;bottom:22px;display:grid;grid-template-columns:230px 1fr;gap:16px;overflow:auto;'>
<div style='display:grid;gap:10px;align-content:start;'>
<button type='button' class='season-btn active' data-switch='spring'>春天</button>
<button type='button' class='season-btn' data-switch='summer'>夏天</button>
<button type='button' class='season-btn' data-switch='autumn'>秋天</button>
<button type='button' class='season-btn' data-switch='winter'>冬天</button>
</div>
<div class='zj-card' style='padding:22px;position:relative;overflow:auto;'>
<div class='season-panel' data-panel='spring'>
<div class='zj-pill'>春季</div>
<h3 style='margin:14px 0 10px;font-size:30px;color:#00543d;'>阳光开始变强,气温慢慢回升</h3>
<p style='margin:0;font-size:18px;line-height:1.8;color:#33544b;max-width:600px;'>春季时太阳直射位置逐渐北移,北半球白天变长,天气回暖,植物也开始生长。</p>
</div>
<div class='season-panel' data-panel='summer' style='display:none;'>
<div class='zj-pill'>夏季</div>
<h3 style='margin:14px 0 10px;font-size:30px;color:#00543d;'>阳光更强,白天更长</h3>
<p style='margin:0;font-size:18px;line-height:1.8;color:#33544b;max-width:600px;'>夏季时北半球受到的太阳光更直接,地面获得的热量更多,所以气温更高、白昼更长。</p>
</div>
<div class='season-panel' data-panel='autumn' style='display:none;'>
<div class='zj-pill'>秋季</div>
<h3 style='margin:14px 0 10px;font-size:30px;color:#00543d;'>阳光减弱,天气转凉</h3>
<p style='margin:0;font-size:18px;line-height:1.8;color:#33544b;max-width:600px;'>秋季太阳直射位置继续变化,白天变短,气温下降,作物开始成熟和收获。</p>
</div>
<div class='season-panel' data-panel='winter' style='display:none;'>
<div class='zj-pill'>冬季</div>
<h3 style='margin:14px 0 10px;font-size:30px;color:#00543d;'>阳光较弱,白天更短</h3>
<p style='margin:0;font-size:18px;line-height:1.8;color:#33544b;max-width:600px;'>冬季时太阳光照更斜,地面接收热量少,所以天气寒冷,白昼也更短。</p>
</div>
</div>
</div>
<style>
.season-btn.active{border-color:#00a870;background:#ecfdf5;color:#00543d;box-shadow:0 10px 18px rgba(0,168,112,.10);}
</style>
""",
"四季按钮可直接切换",
),
"notes": "四季探究",
},
{
"type": "content",
"title": "现象解释",
"content": self._courseware_shell_clean(
"现象解释",
"同样是太阳,为什么不同季节感觉不一样?",
"""
<div style='position:absolute;left:22px;right:22px;top:22px;bottom:22px;display:grid;grid-template-columns:1fr 1fr;gap:16px;'>
<div class='zj-card' style='padding:20px;'>
<div class='zj-pill'>原因 1</div>
<h3 style='margin:14px 0 10px;font-size:28px;color:#00543d;'>阳光直射角度不同</h3>
<p style='margin:0;font-size:18px;line-height:1.8;color:#33544b;'>角度越直,单位面积接收到的热量越多;角度越斜,热量越分散。</p>
</div>
<div class='zj-card' style='padding:20px;'>
<div class='zj-pill'>原因 2</div>
<h3 style='margin:14px 0 10px;font-size:28px;color:#00543d;'>白昼长短不同</h3>
<p style='margin:0;font-size:18px;line-height:1.8;color:#33544b;'>白天越长,太阳照射时间越久,地面吸收热量也更多。</p>
</div>
</div>
""",
"从光照与时间两个角度解释",
),
"notes": "现象解释",
},
{
"type": "summary",
"title": "总结",
"content": self._courseware_shell_clean(
"总结与作业",
"把公转、地轴倾斜和四季变化连起来理解",
"""
<div class='zj-grid2' style='position:absolute;left:22px;right:22px;top:22px;bottom:22px;display:grid;grid-template-columns:1fr 340px;gap:16px;'>
<div class='zj-card' style='padding:22px;'>
<div class='zj-pill'>本课总结</div>
<div style='display:grid;gap:12px;margin-top:16px;'>
<div class='zj-card' style='padding:14px 16px;'>1. 地球围绕太阳公转,形成一年四季的周期变化。</div>
<div class='zj-card' style='padding:14px 16px;'>2. 地轴倾斜,让不同季节接受的阳光不同。</div>
<div class='zj-card' style='padding:14px 16px;'>3. 阳光直射角度和白昼长短,决定了冷热变化。</div>
</div>
</div>
<div class='zj-card' style='padding:20px;display:flex;flex-direction:column;justify-content:center;gap:12px;' data-toggle-group='season-homework'>
<div class='zj-pill'>课后任务</div>
<div style='font-size:18px;line-height:1.8;color:#33544b;'>1. 画一张地球绕太阳公转示意图。<br>2. 说一说春夏秋冬各自最明显的特点。</div>
<button type='button' class='zj-btn alt' data-toggle='season-hw' style='display:inline-flex;align-items:center;justify-content:center;'>点击展开提示</button>
<div class='season-hw-panel' data-target='season-hw' style='padding:14px 16px;border-radius:12px;background:#f7faf8;border:1px solid #dce8de;display:none;'>答题时可以先写“原因”,再写“现象”,这样更完整。</div>
</div>
</div>
""",
"把四季原因说完整",
),
"notes": "总结",
},
]
total = max(5, min(page_count or 6, len(pages)))
return {"title": title, "summary": "围绕地球公转与太阳光照变化解释四季形成", "tags": ["地球公转", "四季", "科学探究"], "pages": pages[:total]}
def _fallback_general_courseware(self, prompt: str, subject: str, grade: str, page_count: int) -> dict:
prompt = self._repair_text(prompt)
title = prompt[:24] or "互动课件"
pages = [
{
"type": "title",
"title": "封面",
"content": self._courseware_shell_clean(
title,
f"{subject or '综合'} · {grade or '课堂教学'}",
"""
<div style='position:absolute;inset:0;background:linear-gradient(135deg,#f4fbf7 0%,#ffffff 52%,#eaf7ef 100%);'>
<div style='position:absolute;left:54px;top:52px;padding:8px 14px;border-radius:18px;background:rgba(0,84,61,.08);color:#00543d;font-size:13px;font-weight:700;'>课堂导入</div>
<div style='position:absolute;left:54px;right:54px;top:76px;bottom:26px;display:grid;grid-template-columns:1.1fr .9fr;gap:26px;align-items:center;'>
<div>
<div class='zj-pill'>知识生成 · 互动呈现</div>
<h1 style='margin:18px 0 0;font-size:42px;line-height:1.08;color:#12372d;'>""" + html_lib.escape(title) + """</h1>
<p style='margin:18px 0 0;font-size:20px;line-height:1.7;color:#33544b;max-width:460px;'>用清爽、分层、可点击的页面组织课堂内容,适合投屏展示和逐步讲解。</p>
<div style='margin-top:24px;display:flex;gap:12px;flex-wrap:wrap;'>
<span class='zj-chip'>层次清楚</span>
<span class='zj-chip'>页面可切换</span>
<span class='zj-chip'>支持课堂互动</span>
</div>
</div>
<div style='position:relative;height:100%;min-height:260px;'>
<div class='orbit-wrap'>
<div class='orbit-sun'></div>
<div class='orbit-path'></div>
<div class='earth-dot'></div>
</div>
</div>
</div>
</div>
""",
"封面页",
),
"notes": "封面",
},
{
"type": "content",
"title": "核心概念",
"content": self._courseware_shell_clean(
"核心概念",
"先把问题拆开,再逐步组织内容",
"""
<div style='position:absolute;left:22px;right:22px;top:22px;bottom:22px;' class='zj-grid3'>
<div class='zj-card' style='padding:18px;'>
<div class='zj-pill'>第 1 点</div>
<h3 style='margin:14px 0 8px;font-size:22px;color:#00543d;'>要讲什么</h3>
<p style='margin:0;font-size:16px;line-height:1.7;color:#33544b;'>把主题拆成 2 到 3 个核心问题,再分别展开。</p>
</div>
<div class='zj-card' style='padding:18px;'>
<div class='zj-pill'>第 2 点</div>
<h3 style='margin:14px 0 8px;font-size:22px;color:#00543d;'>怎么讲</h3>
<p style='margin:0;font-size:16px;line-height:1.7;color:#33544b;'>先观察,再归纳,最后用例子验证。</p>
</div>
<div class='zj-card' style='padding:18px;'>
<div class='zj-pill'>第 3 点</div>
<h3 style='margin:14px 0 8px;font-size:22px;color:#00543d;'>怎么练</h3>
<p style='margin:0;font-size:16px;line-height:1.7;color:#33544b;'>用点击反馈、小练习和总结页收尾。</p>
</div>
</div>
""",
"搭好课堂结构",
),
"notes": "核心概念",
},
{
"type": "interactive",
"title": "互动探究",
"content": self._courseware_shell_clean(
"互动探究",
"点击左侧选项,右侧显示不同内容",
"""
<div class='zj-grid2' data-tab-group='g' style='position:absolute;left:22px;right:22px;top:22px;bottom:22px;display:grid;grid-template-columns:230px 1fr;gap:16px;overflow:auto;'>
<div style='display:grid;gap:10px;align-content:start;'>
<button type='button' class='season-btn active' data-switch='g1'>观察</button>
<button type='button' class='season-btn' data-switch='g2'>分析</button>
<button type='button' class='season-btn' data-switch='g3'>总结</button>
</div>
<div class='zj-card' style='padding:22px;position:relative;overflow:auto;'>
<div class='g-panel g1' data-panel='g1'>
<div class='zj-pill'>观察</div>
<h3 style='margin:14px 0 10px;font-size:30px;color:#00543d;'>先看现象,再找规律</h3>
<p style='margin:0;font-size:18px;line-height:1.8;color:#33544b;max-width:600px;'>围绕图片、数据或情境,先让学生说出“看见了什么”,再引导他们说“为什么会这样”。</p>
</div>
<div class='g-panel g2' data-panel='g2' style='display:none;'>
<div class='zj-pill'>分析</div>
<h3 style='margin:14px 0 10px;font-size:30px;color:#00543d;'>把线索整理成逻辑</h3>
<p style='margin:0;font-size:18px;line-height:1.8;color:#33544b;max-width:600px;'>把原因、结果和条件分开写清楚,再用一两句话把关系串起来。</p>
</div>
<div class='g-panel g3' data-panel='g3' style='display:none;'>
<div class='zj-pill'>总结</div>
<h3 style='margin:14px 0 10px;font-size:30px;color:#00543d;'>把关键结论收拢</h3>
<p style='margin:0;font-size:18px;line-height:1.8;color:#33544b;max-width:600px;'>最后把今天最重要的一句话写成结论,方便学生复述和记忆。</p>
</div>
</div>
</div>
""",
"交互区可直接切换",
),
"notes": "互动探究",
},
{
"type": "exercise",
"title": "课堂练习",
"content": self._courseware_shell_clean(
"课堂练习",
"选择一个答案,马上看反馈",
"""
<div class='zj-grid2' data-tab-group='h' style='position:absolute;left:22px;right:22px;top:22px;bottom:22px;display:grid;grid-template-columns:1fr 320px;gap:16px;overflow:auto;'>
<div class='zj-card' style='padding:22px;overflow:auto;'>
<div class='zj-pill'>练习题</div>
<h3 style='margin:14px 0 12px;font-size:28px;color:#12372d;'>下面哪一种说法最符合今天的主题?</h3>
<div style='display:grid;gap:10px;margin-top:16px;'>
<button type='button' class='season-btn active' data-switch='h1' style='justify-content:flex-start;padding:0 16px;'>
<span style='font-size:16px;'>A. 只要记住一个结论就够了</span>
</button>
<button type='button' class='season-btn' data-switch='h2' style='justify-content:flex-start;padding:0 16px;'>
<span style='font-size:16px;'>B. 要把观察、分析和总结连起来</span>
</button>
<button type='button' class='season-btn' data-switch='h3' style='justify-content:flex-start;padding:0 16px;'>
<span style='font-size:16px;'>C. 课堂上不需要互动</span>
</button>
</div>
</div>
<div class='zj-card' style='padding:20px;display:flex;align-items:center;justify-content:center;text-align:left;'>
<div class='h-panel h1' data-panel='h1'>
<div class='zj-pill'>反馈</div>
<div style='margin-top:12px;font-size:18px;line-height:1.8;color:#b45309;'>A 不完整。课堂结构不能只靠一个结论支撑。</div>
</div>
<div class='h-panel h2' data-panel='h2' style='display:none;'>
<div class='zj-pill'>反馈</div>
<div style='margin-top:12px;font-size:18px;line-height:1.8;color:#00543d;'>回答正确。课堂内容应该有完整的观察、分析和总结链条。</div>
</div>
<div class='h-panel h3' data-panel='h3' style='display:none;'>
<div class='zj-pill'>反馈</div>
<div style='margin-top:12px;font-size:18px;line-height:1.8;color:#b45309;'>C 不符合互动课件的设计原则。</div>
</div>
</div>
</div>
""",
"选择后看反馈",
),
"notes": "课堂练习",
},
{
"type": "summary",
"title": "总结",
"content": self._courseware_shell_clean(
"总结与作业",
"把今天的重点提炼成一页",
"""
<div class='zj-grid2' style='position:absolute;left:22px;right:22px;top:22px;bottom:22px;display:grid;grid-template-columns:1fr 340px;gap:16px;overflow:auto;'>
<div class='zj-card' style='padding:22px;overflow:auto;'>
<div class='zj-pill'>本课总结</div>
<div style='display:grid;gap:12px;margin-top:16px;'>
<div class='zj-card' style='padding:14px 16px;'>1. 先观察,再分析,再总结。</div>
<div class='zj-card' style='padding:14px 16px;'>2. 把复杂内容拆成若干清楚的部分。</div>
<div class='zj-card' style='padding:14px 16px;'>3. 用点击互动提高课堂参与感。</div>
</div>
</div>
<div class='zj-card' style='padding:20px;display:flex;flex-direction:column;justify-content:center;gap:12px;' data-toggle-group='general-homework'>
<div class='zj-pill'>课后任务</div>
<div style='font-size:18px;line-height:1.8;color:#33544b;'>1. 把本课内容整理成 3 条要点。<br>2. 试着为一个新的知识点设计 1 个互动问题。</div>
<button type='button' class='zj-btn alt' data-toggle='g-hw' style='display:inline-flex;align-items:center;justify-content:center;'>点击展开提示</button>
<div class='g-hw-panel' data-target='g-hw' style='padding:14px 16px;border-radius:12px;background:#f7faf8;border:1px solid #dce8de;display:none;'>提示:结论要短,任务要具体,方便学生完成。</div>
</div>
</div>
""",
"整理成可复述的结论",
),
"notes": "总结",
},
]
total = max(5, min(page_count or 5, len(pages)))
return {"title": title, "summary": "把主题拆分成观察、分析、总结的互动课件", "tags": ["互动课件", "可点击", "课堂讲解"], "pages": pages[:total]}
async def generate_courseware(self, prompt: str, subject: str = "", grade: str = "", page_count: int = 8, aspect_ratio: str = "16:9") -> dict:
prompt = self._repair_text(prompt)
topic = self._courseware_topic(prompt)
if topic == "cylinder":
return self._fallback_cylinder_courseware(prompt, subject or "数学", grade or "六年级", page_count)
if topic == "orbit":
return self._fallback_orbit_courseware(prompt, subject or "科学", grade or "六年级", page_count)
try:
result = await self._chat_json([
{"role": "system", "content": "请生成多页互动课件 JSON,包含 title、summary、tags、pages。每页 content 必须是可直接渲染的 <div> HTML 片段,页面结构清爽,适合教室投屏。"},
{"role": "user", "content": f"学科:{subject or '未指定'}\n年级:{grade or '未指定'}\n页数要求:{page_count}\n\n教师需求:{prompt}"},
], temperature=0.45, max_tokens=12000)
normalized = self._normalize_courseware_result(result, prompt, subject, grade, page_count, aspect_ratio)
if normalized:
return normalized
except Exception:
pass
return self._fallback_general_courseware(prompt, subject, grade, page_count)