@yottameta/yotta-lesson 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/NOTICE +12 -0
- package/README.md +156 -0
- package/README.zh-CN.md +148 -0
- package/SKILL.md +87 -0
- package/assets/banner.png +0 -0
- package/bin/install.js +163 -0
- package/install.sh +132 -0
- package/package.json +35 -9
- package/references/copyright.md +34 -0
- package/references/map-format.md +88 -0
- package/references/plan-format.md +61 -0
- package/scripts/yotta_lesson.py +653 -0
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""元案 yotta-lesson —— 本地、确定性的教案 / 学案骨架与覆盖校验引擎(零依赖,Python 3.8+)。
|
|
4
|
+
|
|
5
|
+
设计依据:项目设计稿(2026-09-24 定稿)。
|
|
6
|
+
|
|
7
|
+
边界:只读本地映射包与教案文本;核心路径不联网、不调用模型;不输出教材 / 题目原文;
|
|
8
|
+
结论只作备课辅助,最终教学设计由教师负责。
|
|
9
|
+
|
|
10
|
+
退出码:0 正常 / 1 触发闸门 / 2 输入错误 / 3 映射或配置错误 / 4 运行时错误。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import io
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
VERSION = "0.1.0"
|
|
22
|
+
SCHEMA_VERSION = "1.0"
|
|
23
|
+
TOOL_NAME = "yotta-lesson"
|
|
24
|
+
|
|
25
|
+
DISCLAIMER = (
|
|
26
|
+
"本文件由元案(yotta-lesson)基于本地映射包与结构配置的确定性计算生成,仅作备课辅助;"
|
|
27
|
+
"教学设计与时间安排的最终决定由教师负责,课标引用请以官方文本为准。"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
DEFAULT_STRUCTURE = {
|
|
31
|
+
"minutes_per_period": 45,
|
|
32
|
+
"stages": [
|
|
33
|
+
{"id": "lead_in", "name": "导入", "ratio": 0.10},
|
|
34
|
+
{"id": "explore", "name": "新知探究", "ratio": 0.30},
|
|
35
|
+
{"id": "explain", "name": "例题精讲", "ratio": 0.25},
|
|
36
|
+
{"id": "practice", "name": "练习巩固", "ratio": 0.25},
|
|
37
|
+
{"id": "summary", "name": "小结与作业", "ratio": 0.10},
|
|
38
|
+
],
|
|
39
|
+
"required_stages": ["lead_in", "explore", "practice", "summary"],
|
|
40
|
+
"check_keywords": {
|
|
41
|
+
"lead_in": ["导入", "情境", "复习"],
|
|
42
|
+
"explore": ["探究", "新知", "概念"],
|
|
43
|
+
"explain": ["例题", "精讲", "示范"],
|
|
44
|
+
"practice": ["练习", "巩固", "变式"],
|
|
45
|
+
"summary": ["小结", "作业", "总结"],
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
STAGE_FOCUS = {
|
|
50
|
+
"lead_in": "激活旧知、呈现真实情境,明确本课目标",
|
|
51
|
+
"explore": "围绕关键问题展开探究,形成概念与结论",
|
|
52
|
+
"explain": "以典型例题示范思路与方法,讲清易错点",
|
|
53
|
+
"practice": "分层练习与变式训练,当堂反馈",
|
|
54
|
+
"summary": "梳理知识结构,布置分层作业与预习任务",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class LessonError(Exception):
|
|
59
|
+
exit_code = 4
|
|
60
|
+
|
|
61
|
+
def __init__(self, message):
|
|
62
|
+
super().__init__(message)
|
|
63
|
+
self.message = message
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class InputError(LessonError):
|
|
67
|
+
exit_code = 2
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ConfigError(LessonError):
|
|
71
|
+
exit_code = 3
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def normalize_text(text):
|
|
75
|
+
if isinstance(text, bytes):
|
|
76
|
+
text = text.decode("utf-8")
|
|
77
|
+
return text.replace("\ufeff", "").replace("\r\n", "\n").replace("\r", "\n")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _read_json(payload, where):
|
|
81
|
+
if isinstance(payload, (str, Path)):
|
|
82
|
+
path = Path(payload)
|
|
83
|
+
if not path.is_file():
|
|
84
|
+
raise ConfigError("%s:文件不存在:%s" % (where, path))
|
|
85
|
+
try:
|
|
86
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
87
|
+
except ValueError as exc:
|
|
88
|
+
raise ConfigError("%s:JSON 解析失败:%s" % (where, exc))
|
|
89
|
+
return payload
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _string_list(value, where, field, required=True):
|
|
93
|
+
if value is None:
|
|
94
|
+
if required:
|
|
95
|
+
raise ConfigError("%s:%s 不能为空" % (where, field))
|
|
96
|
+
return []
|
|
97
|
+
if not isinstance(value, list) or not all(isinstance(v, str) and v.strip() for v in value):
|
|
98
|
+
raise ConfigError("%s:%s 必须是字符串数组" % (where, field))
|
|
99
|
+
if required and not value:
|
|
100
|
+
raise ConfigError("%s:%s 不能为空数组" % (where, field))
|
|
101
|
+
return [v.strip() for v in value]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def load_map(payload, where="map"):
|
|
105
|
+
"""加载并校验课标映射包。"""
|
|
106
|
+
data = _read_json(payload, where)
|
|
107
|
+
if not isinstance(data, dict):
|
|
108
|
+
raise ConfigError("%s:顶层应为 JSON 对象" % where)
|
|
109
|
+
if data.get("schema_version") != SCHEMA_VERSION:
|
|
110
|
+
raise ConfigError("%s:schema_version 必须是 %s(当前 %r)"
|
|
111
|
+
% (where, SCHEMA_VERSION, data.get("schema_version")))
|
|
112
|
+
raw_lessons = data.get("lessons")
|
|
113
|
+
if not isinstance(raw_lessons, list) or not raw_lessons:
|
|
114
|
+
raise ConfigError("%s:lessons 必须是非空数组" % where)
|
|
115
|
+
lessons = []
|
|
116
|
+
by_id = {}
|
|
117
|
+
for raw in raw_lessons:
|
|
118
|
+
if not isinstance(raw, dict):
|
|
119
|
+
raise ConfigError("%s:lessons 元素必须是对象" % where)
|
|
120
|
+
lesson_id = raw.get("id")
|
|
121
|
+
if not isinstance(lesson_id, str) or not lesson_id.strip():
|
|
122
|
+
raise ConfigError("%s:课题 id 不能为空" % where)
|
|
123
|
+
lesson_id = lesson_id.strip()
|
|
124
|
+
if lesson_id in by_id:
|
|
125
|
+
raise ConfigError("%s:课题 id 重复:%s" % (where, lesson_id))
|
|
126
|
+
title = raw.get("title")
|
|
127
|
+
if not isinstance(title, str) or not title.strip():
|
|
128
|
+
raise ConfigError("%s:课题 %s 缺少 title" % (where, lesson_id))
|
|
129
|
+
periods = raw.get("periods", 1)
|
|
130
|
+
if isinstance(periods, bool) or not isinstance(periods, int) or periods < 1:
|
|
131
|
+
raise ConfigError("%s:课题 %s 的 periods 必须是不小于 1 的整数" % (where, lesson_id))
|
|
132
|
+
standard = raw.get("standard")
|
|
133
|
+
if not isinstance(standard, str) or not standard.strip():
|
|
134
|
+
raise ConfigError("%s:课题 %s 缺少 standard(课标来源,可追溯性必需)" % (where, lesson_id))
|
|
135
|
+
lesson = {
|
|
136
|
+
"id": lesson_id,
|
|
137
|
+
"title": title.strip(),
|
|
138
|
+
"unit": (raw.get("unit") or "").strip(),
|
|
139
|
+
"periods": periods,
|
|
140
|
+
"knowledge": _string_list(raw.get("knowledge"), where, "课题 %s 的 knowledge" % lesson_id),
|
|
141
|
+
"key_points": _string_list(raw.get("key_points"), where,
|
|
142
|
+
"课题 %s 的 key_points" % lesson_id, required=False),
|
|
143
|
+
"difficulties": _string_list(raw.get("difficulties"), where,
|
|
144
|
+
"课题 %s 的 difficulties" % lesson_id, required=False),
|
|
145
|
+
"misconceptions": _string_list(raw.get("misconceptions"), where,
|
|
146
|
+
"课题 %s 的 misconceptions" % lesson_id, required=False),
|
|
147
|
+
"standard": standard.strip(),
|
|
148
|
+
}
|
|
149
|
+
lessons.append(lesson)
|
|
150
|
+
by_id[lesson_id] = lesson
|
|
151
|
+
return {
|
|
152
|
+
"schema_version": SCHEMA_VERSION,
|
|
153
|
+
"subject": (data.get("subject") or "").strip(),
|
|
154
|
+
"grade": (data.get("grade") or "").strip(),
|
|
155
|
+
"lessons": lessons,
|
|
156
|
+
"by_id": by_id,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def load_structure(payload, where="structure"):
|
|
161
|
+
"""加载教学结构配置;缺省用内置默认值,支持部分覆盖。"""
|
|
162
|
+
base = {
|
|
163
|
+
"minutes_per_period": DEFAULT_STRUCTURE["minutes_per_period"],
|
|
164
|
+
"stages": [dict(stage) for stage in DEFAULT_STRUCTURE["stages"]],
|
|
165
|
+
"required_stages": list(DEFAULT_STRUCTURE["required_stages"]),
|
|
166
|
+
"check_keywords": {k: list(v) for k, v in DEFAULT_STRUCTURE["check_keywords"].items()},
|
|
167
|
+
}
|
|
168
|
+
if payload is None:
|
|
169
|
+
return base
|
|
170
|
+
data = _read_json(payload, where)
|
|
171
|
+
if not isinstance(data, dict):
|
|
172
|
+
raise ConfigError("%s:顶层应为 JSON 对象" % where)
|
|
173
|
+
if "schema_version" in data and data["schema_version"] != SCHEMA_VERSION:
|
|
174
|
+
raise ConfigError("%s:schema_version 必须是 %s" % (where, SCHEMA_VERSION))
|
|
175
|
+
if "minutes_per_period" in data:
|
|
176
|
+
value = data["minutes_per_period"]
|
|
177
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 5:
|
|
178
|
+
raise ConfigError("%s:minutes_per_period 必须是不小于 5 的整数" % where)
|
|
179
|
+
base["minutes_per_period"] = value
|
|
180
|
+
if "stages" in data:
|
|
181
|
+
raw_stages = data["stages"]
|
|
182
|
+
if not isinstance(raw_stages, list) or not raw_stages:
|
|
183
|
+
raise ConfigError("%s:stages 必须是非空数组" % where)
|
|
184
|
+
stages = []
|
|
185
|
+
seen = set()
|
|
186
|
+
for raw in raw_stages:
|
|
187
|
+
if not isinstance(raw, dict):
|
|
188
|
+
raise ConfigError("%s:stages 元素必须是对象" % where)
|
|
189
|
+
stage_id = raw.get("id")
|
|
190
|
+
name = raw.get("name")
|
|
191
|
+
ratio = raw.get("ratio")
|
|
192
|
+
if not isinstance(stage_id, str) or not stage_id.strip():
|
|
193
|
+
raise ConfigError("%s:环节 id 不能为空" % where)
|
|
194
|
+
if stage_id in seen:
|
|
195
|
+
raise ConfigError("%s:环节 id 重复:%s" % (where, stage_id))
|
|
196
|
+
seen.add(stage_id)
|
|
197
|
+
if not isinstance(name, str) or not name.strip():
|
|
198
|
+
raise ConfigError("%s:环节 %s 缺少 name" % (where, stage_id))
|
|
199
|
+
if isinstance(ratio, bool) or not isinstance(ratio, (int, float)) or ratio <= 0:
|
|
200
|
+
raise ConfigError("%s:环节 %s 的 ratio 必须是正数" % (where, stage_id))
|
|
201
|
+
stages.append({"id": stage_id.strip(), "name": name.strip(), "ratio": float(ratio)})
|
|
202
|
+
base["stages"] = stages
|
|
203
|
+
stage_ids = [stage["id"] for stage in base["stages"]]
|
|
204
|
+
if "required_stages" in data:
|
|
205
|
+
required = _string_list(data["required_stages"], where, "required_stages")
|
|
206
|
+
unknown = [item for item in required if item not in stage_ids]
|
|
207
|
+
if unknown:
|
|
208
|
+
raise ConfigError("%s:required_stages 引用了未声明的环节:%s"
|
|
209
|
+
% (where, "、".join(unknown)))
|
|
210
|
+
base["required_stages"] = required
|
|
211
|
+
else:
|
|
212
|
+
base["required_stages"] = [item for item in base["required_stages"] if item in stage_ids]
|
|
213
|
+
if "check_keywords" in data:
|
|
214
|
+
raw_keywords = data["check_keywords"]
|
|
215
|
+
if not isinstance(raw_keywords, dict):
|
|
216
|
+
raise ConfigError("%s:check_keywords 必须是对象" % where)
|
|
217
|
+
keywords = {}
|
|
218
|
+
for key, value in raw_keywords.items():
|
|
219
|
+
keywords[key] = _string_list(value, where, "check_keywords.%s" % key)
|
|
220
|
+
base["check_keywords"] = keywords
|
|
221
|
+
return base
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def allocate_minutes(total_minutes, stages):
|
|
225
|
+
"""按比例分配整分钟,合计精确等于 total(最大余数法,确定性)。"""
|
|
226
|
+
if total_minutes < len(stages):
|
|
227
|
+
raise ConfigError("总时长 %d 分钟少于环节数量 %d,无法分配" % (total_minutes, len(stages)))
|
|
228
|
+
weight_sum = sum(stage["ratio"] for stage in stages)
|
|
229
|
+
if weight_sum <= 0:
|
|
230
|
+
raise ConfigError("环节比例之和必须为正数")
|
|
231
|
+
raw = [(stage["ratio"] / weight_sum) * total_minutes for stage in stages]
|
|
232
|
+
minutes = [int(value) for value in raw]
|
|
233
|
+
remainder = total_minutes - sum(minutes)
|
|
234
|
+
order = sorted(range(len(stages)), key=lambda i: (-(raw[i] - minutes[i]), i))
|
|
235
|
+
for index in order[:remainder]:
|
|
236
|
+
minutes[index] += 1
|
|
237
|
+
return minutes
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def build_plan(lesson, structure, periods=None, subject=None, grade=None, now=None):
|
|
241
|
+
"""生成教案骨架(确定性)。"""
|
|
242
|
+
use_periods = lesson["periods"] if periods is None else periods
|
|
243
|
+
if use_periods < 1:
|
|
244
|
+
raise InputError("课时数必须不小于 1")
|
|
245
|
+
total_minutes = use_periods * structure["minutes_per_period"]
|
|
246
|
+
minutes = allocate_minutes(total_minutes, structure["stages"])
|
|
247
|
+
stages = []
|
|
248
|
+
for stage, stage_minutes in zip(structure["stages"], minutes):
|
|
249
|
+
stages.append({
|
|
250
|
+
"id": stage["id"],
|
|
251
|
+
"name": stage["name"],
|
|
252
|
+
"minutes": stage_minutes,
|
|
253
|
+
"focus": STAGE_FOCUS.get(stage["id"], "按教学需要组织本环节"),
|
|
254
|
+
})
|
|
255
|
+
objectives = [{"knowledge": item, "source": lesson["standard"]} for item in lesson["knowledge"]]
|
|
256
|
+
checklist = [
|
|
257
|
+
"学习目标是否覆盖全部知识点并标注课标来源",
|
|
258
|
+
"关键问题与活动是否指向重难点",
|
|
259
|
+
"例题与练习是否覆盖易错点",
|
|
260
|
+
"时间分配是否与教案实际环节一致",
|
|
261
|
+
"是否安排当堂反馈与分层作业",
|
|
262
|
+
]
|
|
263
|
+
findings = []
|
|
264
|
+
if periods is not None and periods != lesson["periods"]:
|
|
265
|
+
findings.append({
|
|
266
|
+
"kind": "periods_override",
|
|
267
|
+
"detail": "课时数被覆盖:映射包 %d 课时 → 本次 %d 课时" % (lesson["periods"], periods),
|
|
268
|
+
})
|
|
269
|
+
if not lesson["misconceptions"]:
|
|
270
|
+
findings.append({
|
|
271
|
+
"kind": "no_misconceptions",
|
|
272
|
+
"detail": "映射包未登记易错点,例题与练习需教师自行补充易错点设计",
|
|
273
|
+
})
|
|
274
|
+
generated_at = now or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
275
|
+
return {
|
|
276
|
+
"schema_version": SCHEMA_VERSION,
|
|
277
|
+
"tool": TOOL_NAME,
|
|
278
|
+
"tool_version": VERSION,
|
|
279
|
+
"generated_at": generated_at,
|
|
280
|
+
"lesson": {
|
|
281
|
+
"id": lesson["id"],
|
|
282
|
+
"title": lesson["title"],
|
|
283
|
+
"unit": lesson["unit"],
|
|
284
|
+
"subject": (subject or "").strip(),
|
|
285
|
+
"grade": (grade or "").strip(),
|
|
286
|
+
"periods": use_periods,
|
|
287
|
+
"minutes_per_period": structure["minutes_per_period"],
|
|
288
|
+
"total_minutes": total_minutes,
|
|
289
|
+
},
|
|
290
|
+
"objectives": objectives,
|
|
291
|
+
"key_points": list(lesson["key_points"]),
|
|
292
|
+
"difficulties": list(lesson["difficulties"]),
|
|
293
|
+
"misconceptions": list(lesson["misconceptions"]),
|
|
294
|
+
"stages": stages,
|
|
295
|
+
"checklist": checklist,
|
|
296
|
+
"findings": findings,
|
|
297
|
+
"sources": [{"standard": lesson["standard"]}],
|
|
298
|
+
"disclaimer": DISCLAIMER,
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _contains(text, needle):
|
|
303
|
+
return needle in text
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def check_plan(text, lesson, structure):
|
|
307
|
+
"""对已有教案文本做确定性缺口检查。"""
|
|
308
|
+
text = normalize_text(text)
|
|
309
|
+
findings = []
|
|
310
|
+
for item in lesson["knowledge"]:
|
|
311
|
+
if not _contains(text, item):
|
|
312
|
+
findings.append({
|
|
313
|
+
"kind": "knowledge_missing",
|
|
314
|
+
"detail": "教案未出现知识点:%s" % item,
|
|
315
|
+
})
|
|
316
|
+
for stage_id in structure["required_stages"]:
|
|
317
|
+
stage = [s for s in structure["stages"] if s["id"] == stage_id]
|
|
318
|
+
name = stage[0]["name"] if stage else stage_id
|
|
319
|
+
keywords = structure["check_keywords"].get(stage_id) or [name]
|
|
320
|
+
if not any(_contains(text, keyword) for keyword in keywords):
|
|
321
|
+
findings.append({
|
|
322
|
+
"kind": "stage_missing",
|
|
323
|
+
"detail": "教案未识别到必填环节:%s(关键词:%s)" % (name, "、".join(keywords)),
|
|
324
|
+
})
|
|
325
|
+
standard = lesson["standard"]
|
|
326
|
+
probe = standard.split("·")[0].strip() if "·" in standard else standard
|
|
327
|
+
if not _contains(text, probe):
|
|
328
|
+
findings.append({
|
|
329
|
+
"kind": "source_missing",
|
|
330
|
+
"detail": "教案未标注课标来源:%s" % standard,
|
|
331
|
+
})
|
|
332
|
+
if not re.search(r"\d+\s*分钟", text):
|
|
333
|
+
findings.append({
|
|
334
|
+
"kind": "time_missing",
|
|
335
|
+
"detail": "教案未标注环节时间(如「10 分钟」)",
|
|
336
|
+
})
|
|
337
|
+
total_match = re.search(r"合计\s*(\d+)\s*分钟", text)
|
|
338
|
+
if total_match:
|
|
339
|
+
expected = lesson["periods"] * structure["minutes_per_period"]
|
|
340
|
+
if int(total_match.group(1)) != expected:
|
|
341
|
+
findings.append({
|
|
342
|
+
"kind": "minutes_mismatch",
|
|
343
|
+
"detail": "教案标注合计 %s 分钟,与 %d 课时 × %d 分钟 = %d 分钟不一致"
|
|
344
|
+
% (total_match.group(1), lesson["periods"],
|
|
345
|
+
structure["minutes_per_period"], expected),
|
|
346
|
+
})
|
|
347
|
+
findings.sort(key=lambda item: (item["kind"], item["detail"]))
|
|
348
|
+
return {
|
|
349
|
+
"schema_version": SCHEMA_VERSION,
|
|
350
|
+
"tool": TOOL_NAME,
|
|
351
|
+
"tool_version": VERSION,
|
|
352
|
+
"lesson": {"id": lesson["id"], "title": lesson["title"], "periods": lesson["periods"]},
|
|
353
|
+
"checked": {
|
|
354
|
+
"knowledge": list(lesson["knowledge"]),
|
|
355
|
+
"required_stages": list(structure["required_stages"]),
|
|
356
|
+
"standard": lesson["standard"],
|
|
357
|
+
},
|
|
358
|
+
"findings": findings,
|
|
359
|
+
"summary": {"findings": len(findings)},
|
|
360
|
+
"disclaimer": DISCLAIMER,
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def render_plan_markdown(plan):
|
|
365
|
+
lines = []
|
|
366
|
+
lesson = plan["lesson"]
|
|
367
|
+
lines.append("# 教案骨架:%s(元案 yotta-lesson %s)" % (lesson["title"], plan["tool_version"]))
|
|
368
|
+
lines.append("")
|
|
369
|
+
lines.append("- 学科 / 年级:%s %s" % (lesson["subject"] or "—", lesson["grade"] or "—"))
|
|
370
|
+
lines.append("- 课题:%s(%s)" % (lesson["title"], lesson["id"]))
|
|
371
|
+
lines.append("- 课时:%d 课时 × %d 分钟 = **合计 %d 分钟**"
|
|
372
|
+
% (lesson["periods"], lesson["minutes_per_period"], lesson["total_minutes"]))
|
|
373
|
+
lines.append("")
|
|
374
|
+
lines.append("## 1. 学习目标(带课标来源)")
|
|
375
|
+
lines.append("")
|
|
376
|
+
for objective in plan["objectives"]:
|
|
377
|
+
lines.append("- %s(来源:%s)" % (objective["knowledge"], objective["source"]))
|
|
378
|
+
lines.append("")
|
|
379
|
+
lines.append("## 2. 教学重难点与易错点")
|
|
380
|
+
lines.append("")
|
|
381
|
+
lines.append("- 重点:%s" % (";".join(plan["key_points"]) or "待教师补充"))
|
|
382
|
+
lines.append("- 难点:%s" % (";".join(plan["difficulties"]) or "待教师补充"))
|
|
383
|
+
lines.append("- 易错点:%s" % (";".join(plan["misconceptions"]) or "待教师补充"))
|
|
384
|
+
lines.append("")
|
|
385
|
+
lines.append("## 3. 教学环节与时间分配")
|
|
386
|
+
lines.append("")
|
|
387
|
+
lines.append("| 环节 | 时间 | 要点 |")
|
|
388
|
+
lines.append("| --- | --- | --- |")
|
|
389
|
+
for stage in plan["stages"]:
|
|
390
|
+
lines.append("| %s | %d 分钟 | %s |" % (stage["name"], stage["minutes"], stage["focus"]))
|
|
391
|
+
lines.append("")
|
|
392
|
+
lines.append("## 4. 板书与评价清单")
|
|
393
|
+
lines.append("")
|
|
394
|
+
for item in plan["checklist"]:
|
|
395
|
+
lines.append("- [ ] %s" % item)
|
|
396
|
+
lines.append("")
|
|
397
|
+
lines.append("## 5. 备课提示")
|
|
398
|
+
lines.append("")
|
|
399
|
+
if plan["findings"]:
|
|
400
|
+
for finding in plan["findings"]:
|
|
401
|
+
lines.append("- %s" % finding["detail"])
|
|
402
|
+
else:
|
|
403
|
+
lines.append("- 无。")
|
|
404
|
+
lines.append("")
|
|
405
|
+
lines.append("## 6. 来源与免责")
|
|
406
|
+
lines.append("")
|
|
407
|
+
for source in plan["sources"]:
|
|
408
|
+
lines.append("- 课标来源:%s" % source["standard"])
|
|
409
|
+
lines.append("- %s" % plan["disclaimer"])
|
|
410
|
+
lines.append("")
|
|
411
|
+
return "\n".join(lines)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def render_check_markdown(result):
|
|
415
|
+
lines = []
|
|
416
|
+
lines.append("# 教案覆盖检查:%s(元案 yotta-lesson %s)"
|
|
417
|
+
% (result["lesson"]["title"], result["tool_version"]))
|
|
418
|
+
lines.append("")
|
|
419
|
+
lines.append("- 知识点:%s" % "、".join(result["checked"]["knowledge"]))
|
|
420
|
+
lines.append("- 必填环节:%s" % "、".join(result["checked"]["required_stages"]))
|
|
421
|
+
lines.append("- 课标来源:%s" % result["checked"]["standard"])
|
|
422
|
+
lines.append("")
|
|
423
|
+
lines.append("## 1. 缺口清单")
|
|
424
|
+
lines.append("")
|
|
425
|
+
if not result["findings"]:
|
|
426
|
+
lines.append("未发现结构性缺口。")
|
|
427
|
+
else:
|
|
428
|
+
for finding in result["findings"]:
|
|
429
|
+
lines.append("- [%s] %s" % (finding["kind"], finding["detail"]))
|
|
430
|
+
lines.append("")
|
|
431
|
+
lines.append("## 2. 免责声明")
|
|
432
|
+
lines.append("")
|
|
433
|
+
lines.append(result["disclaimer"])
|
|
434
|
+
lines.append("")
|
|
435
|
+
return "\n".join(lines)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def render_json(payload):
|
|
439
|
+
return json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def resolve_output_path(path):
|
|
443
|
+
parts = Path(str(path)).parts
|
|
444
|
+
if ".." in parts:
|
|
445
|
+
raise InputError("输出路径不允许包含 ..(防越界写入):%s" % path)
|
|
446
|
+
target = Path(str(path))
|
|
447
|
+
if target.exists() and target.is_dir():
|
|
448
|
+
raise InputError("输出路径是目录,不能写入:%s" % path)
|
|
449
|
+
if not target.parent.exists():
|
|
450
|
+
raise InputError("输出目录不存在:%s" % target.parent)
|
|
451
|
+
return target
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _write_text(path, text):
|
|
455
|
+
with open(str(path), "w", encoding="utf-8", newline="\n") as handle:
|
|
456
|
+
handle.write(text)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
TEMPLATE_MAP = {
|
|
460
|
+
"schema_version": SCHEMA_VERSION,
|
|
461
|
+
"subject": "示例学科",
|
|
462
|
+
"grade": "示例年级",
|
|
463
|
+
"lessons": [
|
|
464
|
+
{
|
|
465
|
+
"id": "L1",
|
|
466
|
+
"title": "示例课题",
|
|
467
|
+
"unit": "示例单元",
|
|
468
|
+
"periods": 1,
|
|
469
|
+
"knowledge": ["示例知识点A", "示例知识点B"],
|
|
470
|
+
"key_points": ["示例知识点A"],
|
|
471
|
+
"difficulties": ["示例难点"],
|
|
472
|
+
"misconceptions": ["示例易错点"],
|
|
473
|
+
"standard": "示例课程标准(2022 年版)· 示例条目",
|
|
474
|
+
}
|
|
475
|
+
],
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def cmd_template(args):
|
|
480
|
+
out_dir = Path(args.output_dir)
|
|
481
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
482
|
+
_write_text(out_dir / "lesson-map.json",
|
|
483
|
+
json.dumps(TEMPLATE_MAP, ensure_ascii=False, indent=2) + "\n")
|
|
484
|
+
_write_text(out_dir / "structure.json",
|
|
485
|
+
json.dumps(DEFAULT_STRUCTURE, ensure_ascii=False, indent=2) + "\n")
|
|
486
|
+
print("已生成模板:%s(lesson-map.json / structure.json)" % out_dir)
|
|
487
|
+
return 0
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _load_lesson(args, where="map"):
|
|
491
|
+
lesson_map = load_map(args.map, where)
|
|
492
|
+
lesson = lesson_map["by_id"].get(args.lesson)
|
|
493
|
+
if lesson is None:
|
|
494
|
+
raise ConfigError("映射包中不存在课题:%s(可用:%s)"
|
|
495
|
+
% (args.lesson, "、".join(lesson_map["by_id"])))
|
|
496
|
+
return lesson_map, lesson
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _parse_gate(expr):
|
|
500
|
+
if not expr:
|
|
501
|
+
return None
|
|
502
|
+
if not expr.startswith("findings="):
|
|
503
|
+
raise InputError("--gate 只支持 findings=<数量> 形式(当前 %r)" % expr)
|
|
504
|
+
try:
|
|
505
|
+
value = int(expr.split("=", 1)[1])
|
|
506
|
+
except ValueError:
|
|
507
|
+
raise InputError("--gate 的 findings 阈值必须是整数:%r" % expr)
|
|
508
|
+
if value < 1:
|
|
509
|
+
raise InputError("--gate 的 findings 阈值必须不小于 1:%r" % expr)
|
|
510
|
+
return value
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _emit(payload, markdown, args):
|
|
514
|
+
output = render_json(payload) if args.format == "json" else markdown
|
|
515
|
+
if getattr(args, "out", None):
|
|
516
|
+
target = resolve_output_path(args.out)
|
|
517
|
+
_write_text(target, output + ("" if output.endswith("\n") else "\n"))
|
|
518
|
+
print("已写入:%s" % target)
|
|
519
|
+
else:
|
|
520
|
+
print(output)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def cmd_plan(args):
|
|
524
|
+
lesson_map, lesson = _load_lesson(args)
|
|
525
|
+
structure = load_structure(args.config)
|
|
526
|
+
plan = build_plan(lesson, structure, periods=args.periods,
|
|
527
|
+
subject=lesson_map["subject"], grade=lesson_map["grade"])
|
|
528
|
+
_emit(plan, render_plan_markdown(plan), args)
|
|
529
|
+
gate = _parse_gate(args.gate)
|
|
530
|
+
if gate is not None and len(plan["findings"]) >= gate:
|
|
531
|
+
return 1
|
|
532
|
+
return 0
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def cmd_check(args, stdin_text=None):
|
|
536
|
+
lesson_map, lesson = _load_lesson(args)
|
|
537
|
+
structure = load_structure(args.config)
|
|
538
|
+
if args.stdin:
|
|
539
|
+
text = stdin_text if stdin_text is not None else sys.stdin.read()
|
|
540
|
+
else:
|
|
541
|
+
if not args.plan:
|
|
542
|
+
raise InputError("check 需要 --plan <文件> 或 --stdin")
|
|
543
|
+
path = Path(args.plan)
|
|
544
|
+
if not path.is_file():
|
|
545
|
+
raise InputError("教案文件不存在:%s" % path)
|
|
546
|
+
text = path.read_text(encoding="utf-8")
|
|
547
|
+
result = check_plan(text, lesson, structure)
|
|
548
|
+
_emit(result, render_check_markdown(result), args)
|
|
549
|
+
gate = _parse_gate(args.gate)
|
|
550
|
+
if gate is not None and len(result["findings"]) >= gate:
|
|
551
|
+
return 1
|
|
552
|
+
return 0
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def cmd_map(args):
|
|
556
|
+
if args.map_command == "list":
|
|
557
|
+
lesson_map = load_map(args.map, "map")
|
|
558
|
+
print("课题清单(%s %s,共 %d 条):" % (lesson_map["subject"], lesson_map["grade"],
|
|
559
|
+
len(lesson_map["lessons"])))
|
|
560
|
+
for lesson in lesson_map["lessons"]:
|
|
561
|
+
print("- %s %s %d 课时 知识点 %d 条" % (
|
|
562
|
+
lesson["id"], lesson["title"], lesson["periods"], len(lesson["knowledge"])))
|
|
563
|
+
return 0
|
|
564
|
+
if args.map_command == "show":
|
|
565
|
+
lesson_map = load_map(args.map, "map")
|
|
566
|
+
lesson = lesson_map["by_id"].get(args.lesson)
|
|
567
|
+
if lesson is None:
|
|
568
|
+
raise ConfigError("映射包中不存在课题:%s" % args.lesson)
|
|
569
|
+
print(json.dumps(lesson, ensure_ascii=False, indent=2, sort_keys=True))
|
|
570
|
+
return 0
|
|
571
|
+
if args.map_command == "validate":
|
|
572
|
+
lesson_map = load_map(args.map, "map")
|
|
573
|
+
print("映射包校验通过:%s %s,%d 个课题"
|
|
574
|
+
% (lesson_map["subject"], lesson_map["grade"], len(lesson_map["lessons"])))
|
|
575
|
+
return 0
|
|
576
|
+
raise InputError("未知 map 子命令")
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def build_parser():
|
|
580
|
+
parser = argparse.ArgumentParser(
|
|
581
|
+
prog="yotta-lesson",
|
|
582
|
+
description="元案 —— 本地、确定性的教案骨架与覆盖校验引擎(零依赖,不联网)",
|
|
583
|
+
)
|
|
584
|
+
parser.add_argument("--version", action="version", version=VERSION)
|
|
585
|
+
sub = parser.add_subparsers(dest="command")
|
|
586
|
+
|
|
587
|
+
plan = sub.add_parser("plan", help="按映射包生成教案骨架")
|
|
588
|
+
plan.add_argument("--map", required=True)
|
|
589
|
+
plan.add_argument("--lesson", required=True)
|
|
590
|
+
plan.add_argument("--config")
|
|
591
|
+
plan.add_argument("--periods", type=int)
|
|
592
|
+
plan.add_argument("--format", choices=("md", "json"), default="md")
|
|
593
|
+
plan.add_argument("--out")
|
|
594
|
+
plan.add_argument("--gate")
|
|
595
|
+
|
|
596
|
+
check = sub.add_parser("check", help="对已有教案文本做缺口检查")
|
|
597
|
+
check.add_argument("--plan")
|
|
598
|
+
check.add_argument("--stdin", action="store_true")
|
|
599
|
+
check.add_argument("--map", required=True)
|
|
600
|
+
check.add_argument("--lesson", required=True)
|
|
601
|
+
check.add_argument("--config")
|
|
602
|
+
check.add_argument("--format", choices=("md", "json"), default="md")
|
|
603
|
+
check.add_argument("--out")
|
|
604
|
+
check.add_argument("--gate")
|
|
605
|
+
|
|
606
|
+
map_cmd = sub.add_parser("map", help="映射包操作")
|
|
607
|
+
map_sub = map_cmd.add_subparsers(dest="map_command")
|
|
608
|
+
for name in ("list", "validate"):
|
|
609
|
+
item = map_sub.add_parser(name)
|
|
610
|
+
item.add_argument("--map", required=True)
|
|
611
|
+
show = map_sub.add_parser("show")
|
|
612
|
+
show.add_argument("--map", required=True)
|
|
613
|
+
show.add_argument("--lesson", required=True)
|
|
614
|
+
|
|
615
|
+
template = sub.add_parser("template", help="生成映射包与结构配置模板")
|
|
616
|
+
template.add_argument("--output-dir", required=True)
|
|
617
|
+
return parser
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def main(argv=None, stdin_text=None):
|
|
621
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
622
|
+
if argv and argv[0] == "--version":
|
|
623
|
+
print(VERSION)
|
|
624
|
+
return 0
|
|
625
|
+
parser = build_parser()
|
|
626
|
+
try:
|
|
627
|
+
args = parser.parse_args(argv)
|
|
628
|
+
except SystemExit as exc:
|
|
629
|
+
return int(exc.code or 0)
|
|
630
|
+
try:
|
|
631
|
+
if args.command == "plan":
|
|
632
|
+
return cmd_plan(args)
|
|
633
|
+
if args.command == "check":
|
|
634
|
+
return cmd_check(args, stdin_text=stdin_text)
|
|
635
|
+
if args.command == "map":
|
|
636
|
+
if getattr(args, "map_command", None):
|
|
637
|
+
return cmd_map(args)
|
|
638
|
+
sys.stderr.write("错误:map 需要子命令(list / show / validate)\n")
|
|
639
|
+
return 2
|
|
640
|
+
if args.command == "template":
|
|
641
|
+
return cmd_template(args)
|
|
642
|
+
sys.stderr.write("错误:未知命令。可用:plan / check / map / template\n")
|
|
643
|
+
return 2
|
|
644
|
+
except LessonError as exc:
|
|
645
|
+
sys.stderr.write("错误:%s\n" % exc.message)
|
|
646
|
+
return exc.exit_code
|
|
647
|
+
except OSError as exc:
|
|
648
|
+
sys.stderr.write("错误:文件操作失败:%s\n" % exc)
|
|
649
|
+
return 4
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
if __name__ == "__main__":
|
|
653
|
+
sys.exit(main())
|