@antoneeo/course-creator 0.1.0-beta.14 → 0.1.0-beta.15

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.
@@ -2,6 +2,7 @@
2
2
  """Structural checks for one course; never certifies teaching quality or learning."""
3
3
  from dataclasses import dataclass
4
4
  from pathlib import Path
5
+ import os
5
6
  import re
6
7
 
7
8
 
@@ -54,7 +55,7 @@ def _items(cell):
54
55
  if part.strip() and part.strip() != "-"]
55
56
 
56
57
 
57
- def _anchor_exists(path, anchor):
58
+ def _anchor_exists(path, anchor):
58
59
  if path.suffix.lower() not in (".md", ".txt"):
59
60
  if path.suffix.lower() == ".pdf":
60
61
  return bool(re.fullmatch(r"page=[1-9]\d*", anchor))
@@ -63,132 +64,284 @@ def _anchor_exists(path, anchor):
63
64
  return bool(re.fullmatch(r"\d{2}:\d{2}(?::\d{2})?", anchor))
64
65
  text = path.read_text(encoding="utf-8", errors="replace")
65
66
  return any(re.sub(r"[^a-z0-9 -]", "", heading.lower()).strip().replace(" ", "-") == anchor.lower()
66
- for heading in re.findall(r"^#+\s+(.+)$", text, re.M))
67
-
68
-
69
- def _content_contract(course_dir, plan, plan_rows, read, ref, add):
70
- """Validate authored-source structure, not its pedagogical sufficiency."""
71
- plan_path = course_dir / "COURSE_PLAN.md"
72
-
73
- def field(text, key, path, item):
74
- values = re.findall(r"^" + re.escape(key) + r":[ \t]*(.*)$", text, re.M)
75
- if len(values) != 1 or not values[0].strip() or values[0].strip() == "-":
76
- add(path, item, f"{key} must occur once with a value")
77
- return ""
78
- return values[0].strip()
79
-
80
- declarations = re.findall(r"^Content contract:[ \t]*(.*)$", plan, re.M)
81
- if not declarations:
82
- add(plan_path, "content", "legacy course: migrate to a complete textual content contract; quality is not certified", "WARN")
83
- return
84
- contract = field(plan, "Content contract", plan_path, "content")
85
- if contract not in ("slide-content-v1", "text-content-v1"):
86
- add(plan_path, "content", "unknown content contract")
87
- return
88
- prefix, name = ("S", "SLIDE_CONTENT.md") if contract == "slide-content-v1" else ("U", "COURSE_CONTENT.md")
89
- if prefix == "U":
90
- field(plan, "Format exception", plan_path, "content")
91
- path = course_dir / name
92
- text = read(path, "content")
93
- if text is None:
94
- return
95
- intro = text.split("\n## ", 1)[0]
96
- if field(intro, "Course version", path, "version") != field(plan, "Course version", plan_path, "version"):
97
- add(path, "version", "content version differs from COURSE_PLAN")
98
- if field(intro, "Delivery mode", path, "delivery") not in ("self-study", "instructor-led"):
99
- add(path, "delivery", "unknown delivery mode")
100
- for key in ("Efficacy", "Feedback flow"):
101
- if field(intro, key, path, key) != field(plan, key, plan_path, key):
102
- add(path, key, f"{key} differs from COURSE_PLAN")
103
- headers = ("profile", "baseline", "target capability", "teaching contribution", "observable check", "alternative and limits")
104
- values = _table(text, headers) or []
105
- expected_profiles = {row["profile"] for row in plan_rows}
106
- if {row["profile"] for row in values} != expected_profiles or len(values) != len(expected_profiles):
107
- add(path, "value", "Course Value must cover each taught profile exactly once")
108
- for row in values:
109
- if any(not row[h] or row[h] == "-" for h in headers):
110
- add(path, "value", "Course Value fields must be populated; semantic review still required")
111
- ref(row["observable check"], path, "value")
112
- modules = {row["module id"]: row for row in plan_rows}
113
- sections = re.split(r"(?m)^## (.+)\n", text)
114
- units = {}
115
- order = []
116
- for heading, body in zip(sections[1::2], sections[2::2]):
117
- if heading == "Course Value":
118
- continue
119
- if not re.fullmatch(prefix + r"[0-9]+", heading):
120
- add(path, heading, "unexpected unit heading")
121
- continue
122
- if heading in units:
123
- add(path, heading, "duplicate unit ID")
124
- metadata = body.split("### ", 1)[0]
125
- unit = {key: field(metadata, key, path, heading) for key in
126
- ("Module", "Objective", "Role", "Title", "Value contribution")}
127
- units[heading] = unit
128
- order.append(heading)
129
- unit["coverage"] = {(unit["Module"], unit["Objective"])}
130
- extra_coverage = re.findall(r"^Covers:[ \t]*(.*)$", metadata, re.M)
131
- if len(extra_coverage) > 1:
132
- add(path, heading, "Covers must occur at most once")
133
- if extra_coverage:
134
- pairs = _items(extra_coverage[0])
135
- if not pairs:
136
- add(path, heading, "Covers must list module/objective pairs")
137
- for pair in pairs:
138
- if not re.fullmatch(r"M\d+/O\d+", pair):
139
- add(path, heading, "invalid coverage pair; expected M1/O1")
140
- continue
141
- mid, objective = pair.split("/")
142
- if (mid, objective) in unit["coverage"]:
143
- add(path, heading, "duplicate coverage pair")
144
- unit["coverage"].add((mid, objective))
145
- for mid, objective in unit["coverage"]:
146
- if mid not in modules or modules[mid]["objective id"] != objective:
147
- add(path, heading, "coverage module/objective differs from COURSE_PLAN")
148
- module = modules.get(unit["Module"])
149
- if not module or module["objective id"] != unit["Objective"]:
150
- add(path, heading, "unit module/objective differs from COURSE_PLAN")
151
- if unit["Role"] not in ("orientation", "explanation", "check", "solution", "closing"):
152
- add(path, heading, "unknown unit role")
153
- if unit["Role"] == "check":
154
- unit["Solution"] = field(metadata, "Solution", path, heading)
155
- parts = re.split(r"(?m)^### (.+)\n", body)
156
- content = list(zip(parts[1::2], parts[2::2]))
157
- for key in ("Learner content", "Complete explanation", "Visual content", "Transition", "Sources"):
158
- blocks = [b.strip() for k, b in content if k == key]
159
- if len(blocks) != 1 or not blocks[0] or blocks[0] == "-":
160
- add(path, heading, f"{key} section must occur once with content")
161
- for key, block in content:
162
- if key == "Sources":
163
- for source in block.strip().splitlines():
164
- ref(source.removeprefix("- ").strip(), path, heading, allow_url=True)
165
- source_path = path.relative_to(course_dir.parents[3]).as_posix()
166
- for mid, row in modules.items():
167
- for key, roles in (("explanation", {"orientation", "explanation"}), ("check", {"check"})):
168
- references = _items(row[key])
169
- if not references:
170
- continue # existing module checker reports missing fields
171
- for value in references:
172
- target, _, anchor = value.partition("#")
173
- uid = anchor.upper()
174
- unit = units.get(uid, {})
175
- if (target != source_path or (mid, row["objective id"]) not in unit.get("coverage", set())
176
- or unit.get("Role") not in roles):
177
- add(plan_path, mid, f"{key} must locate its module's canonical content unit")
178
- for uid, unit in units.items():
179
- if unit["Role"] == "check":
180
- solution_id = unit.get("Solution", "")
181
- solution = units.get(solution_id, {})
182
- if (solution.get("Role") != "solution"
183
- or not unit["coverage"].issubset(solution.get("coverage", set()))
184
- or order.index(solution_id) <= order.index(uid)):
185
- add(path, uid, "check needs a subsequent solution covering every module/objective")
186
- for row in values:
187
- target, _, anchor = row["observable check"].partition("#")
188
- unit = units.get(anchor.upper(), {})
189
- covered_profiles = {modules[mid]["profile"] for mid, obj in unit.get("coverage", set()) if mid in modules}
190
- if target != source_path or unit.get("Role") != "check" or row["profile"] not in covered_profiles:
191
- add(path, "value", "observable check must locate a canonical check for its profile")
67
+ for heading in re.findall(r"^#+\s+(.+)$", text, re.M))
68
+
69
+
70
+ def _content_contract(course_dir, plan, plan_rows, read, ref, add):
71
+ """Validate authored-source structure, not its pedagogical sufficiency."""
72
+ plan_path = course_dir / "COURSE_PLAN.md"
73
+
74
+ def field(text, key, path, item):
75
+ values = re.findall(r"^" + re.escape(key) + r":[ \t]*(.*)$", text, re.M)
76
+ if len(values) != 1 or not values[0].strip() or values[0].strip() == "-":
77
+ add(path, item, f"{key} must occur once with a value")
78
+ return ""
79
+ return values[0].strip()
80
+
81
+ declarations = re.findall(r"^Content contract:[ \t]*(.*)$", plan, re.M)
82
+ if not declarations:
83
+ add(plan_path, "content", "legacy course: migrate to a complete textual content contract; quality is not certified", "WARN")
84
+ return
85
+ contract = field(plan, "Content contract", plan_path, "content")
86
+ if contract not in ("slide-content-v1", "text-content-v1"):
87
+ add(plan_path, "content", "unknown content contract")
88
+ return
89
+ prefix, name = ("S", "SLIDE_CONTENT.md") if contract == "slide-content-v1" else ("U", "COURSE_CONTENT.md")
90
+ if prefix == "U":
91
+ field(plan, "Format exception", plan_path, "content")
92
+ path = course_dir / name
93
+ text = read(path, "content")
94
+ if text is None:
95
+ return
96
+ intro = text.split("\n## ", 1)[0]
97
+ if field(intro, "Course version", path, "version") != field(plan, "Course version", plan_path, "version"):
98
+ add(path, "version", "content version differs from COURSE_PLAN")
99
+ mode = field(intro, "Delivery mode", path, "delivery")
100
+ if mode not in ("self-study", "instructor-led"):
101
+ add(path, "delivery", "unknown delivery mode")
102
+ for key in ("Efficacy", "Feedback flow"):
103
+ if field(intro, key, path, key) != field(plan, key, plan_path, key):
104
+ add(path, key, f"{key} differs from COURSE_PLAN")
105
+ headers = ("profile", "baseline", "target capability", "teaching contribution", "observable check", "alternative and limits")
106
+ values = _table(text, headers) or []
107
+ expected_profiles = {row["profile"] for row in plan_rows}
108
+ if {row["profile"] for row in values} != expected_profiles or len(values) != len(expected_profiles):
109
+ add(path, "value", "Course Value must cover each taught profile exactly once")
110
+ for row in values:
111
+ if any(not row[h] or row[h] == "-" for h in headers):
112
+ add(path, "value", "Course Value fields must be populated; semantic review still required")
113
+ ref(row["observable check"], path, "value")
114
+ modules = {row["module id"]: row for row in plan_rows}
115
+ sections = re.split(r"(?m)^## (.+)\n", text)
116
+ units = {}
117
+ order = []
118
+ for heading, body in zip(sections[1::2], sections[2::2]):
119
+ if heading == "Course Value":
120
+ continue
121
+ if not re.fullmatch(prefix + r"[0-9]+", heading):
122
+ add(path, heading, "unexpected unit heading")
123
+ continue
124
+ if heading in units:
125
+ add(path, heading, "duplicate unit ID")
126
+ metadata = body.split("### ", 1)[0]
127
+ unit = {key: field(metadata, key, path, heading) for key in
128
+ ("Module", "Objective", "Role", "Title", "Value contribution")}
129
+ units[heading] = unit
130
+ order.append(heading)
131
+ unit["coverage"] = {(unit["Module"], unit["Objective"])}
132
+ extra_coverage = re.findall(r"^Covers:[ \t]*(.*)$", metadata, re.M)
133
+ if len(extra_coverage) > 1:
134
+ add(path, heading, "Covers must occur at most once")
135
+ if extra_coverage:
136
+ pairs = _items(extra_coverage[0])
137
+ if not pairs:
138
+ add(path, heading, "Covers must list module/objective pairs")
139
+ for pair in pairs:
140
+ if not re.fullmatch(r"M\d+/O\d+", pair):
141
+ add(path, heading, "invalid coverage pair; expected M1/O1")
142
+ continue
143
+ mid, objective = pair.split("/")
144
+ if (mid, objective) in unit["coverage"]:
145
+ add(path, heading, "duplicate coverage pair")
146
+ unit["coverage"].add((mid, objective))
147
+ for mid, objective in unit["coverage"]:
148
+ if mid not in modules or modules[mid]["objective id"] != objective:
149
+ add(path, heading, "coverage module/objective differs from COURSE_PLAN")
150
+ module = modules.get(unit["Module"])
151
+ if not module or module["objective id"] != unit["Objective"]:
152
+ add(path, heading, "unit module/objective differs from COURSE_PLAN")
153
+ if unit["Role"] not in ("orientation", "explanation", "check", "solution", "closing"):
154
+ add(path, heading, "unknown unit role")
155
+ if unit["Role"] == "check":
156
+ unit["Solution"] = field(metadata, "Solution", path, heading)
157
+ parts = re.split(r"(?m)^### (.+)\n", body)
158
+ content = list(zip(parts[1::2], parts[2::2]))
159
+ for key in ("Learner content", "Complete explanation", "Visual content", "Transition", "Sources"):
160
+ blocks = [b.strip() for k, b in content if k == key]
161
+ if len(blocks) != 1 or not blocks[0] or blocks[0] == "-":
162
+ add(path, heading, f"{key} section must occur once with content")
163
+ for key, block in content:
164
+ if key == "Sources":
165
+ for source in block.strip().splitlines():
166
+ ref(source.removeprefix("- ").strip(), path, heading, allow_url=True)
167
+ source_path = path.relative_to(course_dir.parents[3]).as_posix()
168
+ for mid, row in modules.items():
169
+ for key, roles in (("explanation", {"orientation", "explanation"}), ("check", {"check"})):
170
+ references = _items(row[key])
171
+ if not references:
172
+ continue # existing module checker reports missing fields
173
+ for value in references:
174
+ target, _, anchor = value.partition("#")
175
+ uid = anchor.upper()
176
+ unit = units.get(uid, {})
177
+ if (target != source_path or (mid, row["objective id"]) not in unit.get("coverage", set())
178
+ or unit.get("Role") not in roles):
179
+ add(plan_path, mid, f"{key} must locate its module's canonical content unit")
180
+ for uid, unit in units.items():
181
+ if unit["Role"] == "check":
182
+ solution_id = unit.get("Solution", "")
183
+ solution = units.get(solution_id, {})
184
+ if (solution.get("Role") != "solution"
185
+ or not unit["coverage"].issubset(solution.get("coverage", set()))
186
+ or order.index(solution_id) <= order.index(uid)):
187
+ add(path, uid, "check needs a subsequent solution covering every module/objective")
188
+ for row in values:
189
+ target, _, anchor = row["observable check"].partition("#")
190
+ unit = units.get(anchor.upper(), {})
191
+ covered_profiles = {modules[mid]["profile"] for mid, obj in unit.get("coverage", set()) if mid in modules}
192
+ if target != source_path or unit.get("Role") != "check" or row["profile"] not in covered_profiles:
193
+ add(path, "value", "observable check must locate a canonical check for its profile")
194
+ title = re.search(r"^# (.+)$", text, re.M)
195
+ return {"path": path, "text": text, "mode": mode, "title": title.group(1).strip() if title else "",
196
+ "solutions": [uid for uid in dict.fromkeys(order) if units[uid]["Role"] == "solution"]}
197
+
198
+
199
+ GUIDE_SECTIONS = ("Before the course", "Schedule", "Reserved material", "When something deviates",
200
+ "After the course", "Materials")
201
+ VERSION_MENTION = re.compile(r"\bversion[e]?\s*:?\s*v?(\d+\.[A-Za-z0-9][A-Za-z0-9.-]*)", re.I)
202
+ COURSE_WORD = re.compile(r"\b(?:corso|course)\b", re.I)
203
+
204
+
205
+ def _version_mentions(path, body, version, names, add):
206
+ """Flag a line that names the course and mentions a version other than Course version."""
207
+ heading = "text"
208
+ for line in body.splitlines():
209
+ if line.startswith("## "):
210
+ heading = line[3:].strip()
211
+ continue
212
+ if not (COURSE_WORD.search(line) or any(
213
+ re.search(r"(?<!\w)" + re.escape(name) + r"(?!\w)", line, re.I) for name in names if name)):
214
+ continue
215
+ for match in VERSION_MENTION.finditer(line):
216
+ found = match.group(1).rstrip(".")
217
+ if found != version:
218
+ add(path, heading, f"mentions course version {found}; Course version is {version}")
219
+
220
+
221
+ def _delivery_package(root, course_dir, plan, plan_rows, contract, risks, read, add):
222
+ """Validate the instructor-led delivery package; structure only, never the quality of the instructions."""
223
+ version = (re.findall(r"^Course version:[ \t]*(.*)$", plan, re.M) or [""])[0].strip()
224
+ names = (course_dir.name, contract["title"])
225
+ if version:
226
+ _, _, rest = contract["text"].partition("\n## ")
227
+ _version_mentions(contract["path"], "## " + rest if rest else "", version, names, add)
228
+ guide_path = course_dir / "INSTRUCTOR_GUIDE.md"
229
+ if not guide_path.is_file():
230
+ if contract["mode"] == "instructor-led":
231
+ add(guide_path, "instructor guide", "instructor-led course needs INSTRUCTOR_GUIDE.md before delivery")
232
+ return
233
+ guide = read(guide_path, "instructor guide")
234
+ if guide is None:
235
+ return
236
+ intro, _, rest = guide.partition("\n## ")
237
+ body = "## " + rest if rest else ""
238
+ for key in ("Course version", "Feedback flow"):
239
+ values = re.findall(r"^" + re.escape(key) + r":[ \t]*(.*)$", intro, re.M)
240
+ expected = re.findall(r"^" + re.escape(key) + r":[ \t]*(.*)$", plan, re.M)
241
+ if len(values) != 1 or not expected or values[0].strip() != expected[0].strip():
242
+ add(guide_path, key, f"{key} must occur once and equal COURSE_PLAN")
243
+ if version:
244
+ _version_mentions(guide_path, body, version, names, add)
245
+ parts = re.split(r"(?m)^## (.+)\n", body)
246
+ sections = {}
247
+ for heading, block in zip(parts[1::2], parts[2::2]):
248
+ sections.setdefault(heading.strip(), []).append(block.strip())
249
+ for name in GUIDE_SECTIONS:
250
+ blocks = sections.get(name, [])
251
+ if len(blocks) != 1 or not blocks[0]:
252
+ add(guide_path, name, f"section '## {name}' must occur once with content")
253
+
254
+ def section(name):
255
+ return (sections.get(name) or [""])[0]
256
+
257
+ course_root = course_dir.resolve()
258
+ docs_root = (root / "ai_docs").resolve()
259
+
260
+ def packaged(value, item, locator_allowed):
261
+ """Resolve a guide path inside the project; report and return None when it is not usable."""
262
+ path_part, sep, anchor = value.partition("#")
263
+ path = Path(path_part)
264
+ if (not path.parts or path.is_absolute() or path.drive or path.root or ".." in path.parts
265
+ or (sep and (not locator_allowed or not anchor))):
266
+ add(guide_path, item, f"'{value}' must be a path relative to the project root, with no drive, "
267
+ "leading '/' or '..'" + ("" if locator_allowed else ", and no '#locator'"))
268
+ return None
269
+ target = (root / path).resolve()
270
+ if not target.is_relative_to(root):
271
+ add(guide_path, item, f"'{value}' resolves outside the project, so it cannot be delivered")
272
+ elif not target.exists():
273
+ add(guide_path, item, f"'{value}' does not exist: create it, or correct the path")
274
+ elif not locator_allowed and path_part.endswith("/") != target.is_dir():
275
+ add(guide_path, item, f"'{value}' must end with '/' exactly when it is a folder")
276
+ elif anchor and (not target.is_file() or not _anchor_exists(target, anchor)):
277
+ add(guide_path, item, f"'{value}' has no matching locator")
278
+ else:
279
+ return target
280
+ return None
281
+
282
+ def nested_repository(target):
283
+ """Read folder names only; never follow a symlink or a Windows junction out of the folder."""
284
+ for base, dirs, _ in os.walk(target):
285
+ if ".git" in dirs or "ai_docs" in dirs or Path(base, ".git").is_file():
286
+ return True
287
+ dirs[:] = [d for d in dirs if not (os.path.islink(os.path.join(base, d))
288
+ or getattr(os.path, "isjunction", lambda _: False)(
289
+ os.path.join(base, d)))]
290
+ return False
291
+
292
+ rows = _table(section("Materials"), ("material", "path", "seen by", "used in")) or []
293
+ if not rows:
294
+ add(guide_path, "Materials", "Materials table must list the delivered package")
295
+ targets = []
296
+ for row in rows:
297
+ target = packaged(row["path"], "Materials", False)
298
+ if target is not None:
299
+ targets.append(target)
300
+ if target.is_dir() and target.is_relative_to(docs_root) and nested_repository(target):
301
+ add(guide_path, "Materials", f"'{row['path']}' holds its own repository (.git or ai_docs/) "
302
+ "inside ai_docs/, so project memory indexes its files as this project's: "
303
+ "keep it outside ai_docs/", "WARN")
304
+ if row["seen by"] not in ("instructor", "learners"):
305
+ add(guide_path, "Materials", f"'{row['path']}' Seen by must be instructor or learners")
306
+ listed = {row["path"] for row in rows}
307
+ materials = {value.rstrip("/") for value in listed}
308
+ cited = {value.rstrip(".,;:") for value in re.findall(r"(?<![\w/.])ai_docs/[^\s|`<>\"'()*]+", guide)}
309
+ for value in sorted(cited - listed):
310
+ target = packaged(value, "citation", True)
311
+ if target is not None and not any(target == t or target.is_relative_to(t)
312
+ for t in (course_root, *targets)):
313
+ add(guide_path, "citation", f"'{value}' is cited but is not in the delivered package: "
314
+ "list it in '## Materials' or move it into the course folder")
315
+ reserved = section("Reserved material")
316
+ for uid in contract["solutions"]:
317
+ if not re.search(r"\b" + re.escape(uid) + r"\b", reserved):
318
+ add(guide_path, uid, f"solution unit {uid} must be named in '## Reserved material'")
319
+ rows = _table(section("When something deviates"),
320
+ ("risk id", "in class it looks like", "instructor action", "fallback material")) or []
321
+ if not rows:
322
+ add(guide_path, "When something deviates", "deviation table must have at least one row")
323
+ for row in rows:
324
+ rid = row["risk id"] or "deviation"
325
+ if row["risk id"] not in risks:
326
+ add(guide_path, rid, f"risk {rid} is not defined in P-TM.md")
327
+ fallback = row["fallback material"]
328
+ if not fallback:
329
+ add(guide_path, rid, "fallback material must be '-' or paths listed in '## Materials'")
330
+ elif fallback != "-":
331
+ for value in (part.strip() for part in fallback.split(",") if part.strip()):
332
+ if value.rstrip("/") not in materials:
333
+ add(guide_path, rid, f"fallback '{value}' is not listed in '## Materials'")
334
+ modules = {row["module id"] for row in plan_rows}
335
+ rows = _table(section("Schedule"), ("session", "duration", "modules")) or []
336
+ if not rows:
337
+ add(guide_path, "Schedule", "schedule table must have at least one session")
338
+ for row in rows:
339
+ if not row["session"] or not row["duration"] or not (_items(row["modules"]) or row["modules"] == "-"):
340
+ add(guide_path, "Schedule", "each schedule row needs a name, a duration and its modules, "
341
+ "or '-' for a break or closing")
342
+ for mid in _items(row["modules"]):
343
+ if mid not in modules:
344
+ add(guide_path, mid, f"schedule module {mid} is not in COURSE_PLAN")
192
345
 
193
346
 
194
347
  def validate_course(root, course_dir):
@@ -448,11 +601,14 @@ def validate_course(root, course_dir):
448
601
  else:
449
602
  add(course_dir / "COURSE_PLAN.md", "efficacy",
450
603
  "human review of efficacy evidence required", severity="WARN")
451
- _content_contract(course_dir, plan, plan_rows, read, ref, add)
452
- return findings
453
-
454
-
455
- def validate_all(root):
604
+ contract = _content_contract(course_dir, plan, plan_rows, read, ref, add)
605
+ if contract:
606
+ risks = {row["risk id"] for row in _table(material["P-TM.md"] or "", ("risk id",)) or []}
607
+ _delivery_package(root, course_dir, plan, plan_rows, contract, risks, read, add)
608
+ return findings
609
+
610
+
611
+ def validate_all(root):
456
612
  root = Path(root).resolve()
457
613
  docs = root / "ai_docs"
458
614
  parent = docs / "solutions/courses"
@@ -1,37 +1,37 @@
1
- {
2
- "_comment": "Generated by shared_files.py --update. IDENTICAL in every distribution of the family: if two copies of this file differ, the spine has diverged. Never edit by hand.",
3
- "files": {
4
- "LICENSE": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30",
5
- "NOTICE": "8414f424f7c1db19d89d74286fd2d6b1acdd9eedde910fa57a16d6835602e4bd",
6
- "dispatch.md": "d127057198415f81b921c74e3234a47d5794a2af662eeadaf3dff3cc1739ebc8",
7
- "docs_site/app.js": "ce9ed4341f7d24b648dee7531700d09f4af07f7e9ffc4be90aff29ecc276f27f",
8
- "docs_site/index.html": "2b64167470f754e46fa4bb4e73a3d2c34ee632d91fe2f17705a58d18b5018af5",
9
- "docs_site/strings.js": "440c11830f87602152422766b6d5b84da904150c37b0b6347e2ad53a7fec62b8",
10
- "docs_site/style.css": "1cbd71eb2e62bb9251ecb62796fe4cecf6a78eecf2f9a7493703290ae55d1244",
11
- "guides.md": "8fd85ab176d01ad7fc4c72020afa062f37f3c2f6b380f064c20f114c0680b46b",
12
- "memory.md": "765c8cf34e9161143d6f8b982106251de0ac7f4f6f5d768336f3e4b4b150823e",
13
- "review.md": "c8564114171feaeaa149247d2202005c2880d4507feb0d3a51acf513964f738c",
14
- "routing.md": "5116e8dbd3b215919b897506f28917ac8f1c44cd91f8b64ec901048320e6c1f6",
15
- "scripts/docs_site.py": "bfc7a521142251b85b229e1873ccf2e375bb5c7f1990cef574e1a78ef14f0091",
16
- "scripts/entry_point.py": "4a560d4d41db989d3426182f1b24270cbb934b3db0f3246c700578a4ed93089b",
17
- "scripts/knowledge.py": "6047deb7a2be4cf9ef2949cbcf8a52a8ea61500f4e12d203f1c58076540d50ab",
18
- "scripts/sdlc_core.py": "099151cc45d295218df1fa83d893ebf4b095c4a633bc1f2018b7ea6faad3117c",
19
- "scripts/shared_files.py": "ebc3c6cf207ccd98ec70f7565a12cb7c54da5c8a1a759bae7e62bdb74cb5c6e2",
20
- "scripts/test_audit_refs.py": "5c207be46da566db47069b08373195e748fca19c047418f09cc2f78841dfd9a5",
21
- "scripts/test_chronology.py": "ef482ed2261822778c69ade5dae6c875eca97ae8ef4f47b60bc262ac857eb95c",
22
- "scripts/test_constraints.py": "4be3bbff7032e53b464de2f4e2eb18a824311adb2359c77fb9be065aded09239",
23
- "scripts/test_docs_root.py": "19753e0316c0df63ed321d24b411a9ff36eea2058421fd69fc57e507d75566d0",
24
- "scripts/test_docs_site.py": "391ec5c635c67f0fbefe458426e352b5b1ef626ae65c099b1e8e1db491d0a5f9",
25
- "scripts/test_domain_rules.py": "33542b5dc2bb77c05ece55cf0446065cc4226c763ef5e040aa77364f226937bb",
26
- "scripts/test_drift.py": "c7b7121a8a308b0f3fe12c0c0c346444e632b7aaeff47dc8818b621e5c20cb38",
27
- "scripts/test_guide_check.py": "19c7a6f009fa1b7f70b0beba059b97fa05e69255f6b2dec8829a87bd9fba283f",
28
- "scripts/test_merge_safety.py": "8b9241077bf7ecd6dd335907c640044ab38968ff4c9fdc464450e732cb77adf4",
29
- "scripts/test_migrate.py": "c129823bf4feb0614acd975351632a6ae62d7896a0ef32724419a8e9335e3110",
30
- "scripts/test_plan.py": "4c011a63aa834b55e9c5d13fb4ea76b131b742356001364b0a508435ba013e27",
31
- "scripts/test_project_memory.py": "2cb85cef01161d881a11d84cfe9ebb72915104e8d1822e4de739f7d00ae6cd59",
32
- "scripts/test_session_start.py": "723f748c0aef461d1051191dbd18f4ea5697a1e0467df73bccb0af8024d17f56",
33
- "scripts/test_skill_invariants.py": "47d116563a37a16b7d25b322020dd730eecac8cf4cfc7916b877df4e729f8373",
34
- "scripts/test_verdict.py": "8620be2f962f900e9fa0f979f80984671baa373de1ce3f29045104d813e71844",
35
- "vision.md": "a19a7d7d5044215b726b66f74e422c9cdedcc856c0a53151f20a6d9840b8f6c4"
36
- }
37
- }
1
+ {
2
+ "_comment": "Generated by shared_files.py --update. IDENTICAL in every distribution of the family: if two copies of this file differ, the spine has diverged. Never edit by hand.",
3
+ "files": {
4
+ "LICENSE": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30",
5
+ "NOTICE": "8414f424f7c1db19d89d74286fd2d6b1acdd9eedde910fa57a16d6835602e4bd",
6
+ "dispatch.md": "d127057198415f81b921c74e3234a47d5794a2af662eeadaf3dff3cc1739ebc8",
7
+ "docs_site/app.js": "ce9ed4341f7d24b648dee7531700d09f4af07f7e9ffc4be90aff29ecc276f27f",
8
+ "docs_site/index.html": "2b64167470f754e46fa4bb4e73a3d2c34ee632d91fe2f17705a58d18b5018af5",
9
+ "docs_site/strings.js": "440c11830f87602152422766b6d5b84da904150c37b0b6347e2ad53a7fec62b8",
10
+ "docs_site/style.css": "1cbd71eb2e62bb9251ecb62796fe4cecf6a78eecf2f9a7493703290ae55d1244",
11
+ "guides.md": "8fd85ab176d01ad7fc4c72020afa062f37f3c2f6b380f064c20f114c0680b46b",
12
+ "memory.md": "765c8cf34e9161143d6f8b982106251de0ac7f4f6f5d768336f3e4b4b150823e",
13
+ "review.md": "c8564114171feaeaa149247d2202005c2880d4507feb0d3a51acf513964f738c",
14
+ "routing.md": "5116e8dbd3b215919b897506f28917ac8f1c44cd91f8b64ec901048320e6c1f6",
15
+ "scripts/docs_site.py": "bfc7a521142251b85b229e1873ccf2e375bb5c7f1990cef574e1a78ef14f0091",
16
+ "scripts/entry_point.py": "4a560d4d41db989d3426182f1b24270cbb934b3db0f3246c700578a4ed93089b",
17
+ "scripts/knowledge.py": "6047deb7a2be4cf9ef2949cbcf8a52a8ea61500f4e12d203f1c58076540d50ab",
18
+ "scripts/sdlc_core.py": "099151cc45d295218df1fa83d893ebf4b095c4a633bc1f2018b7ea6faad3117c",
19
+ "scripts/shared_files.py": "ebc3c6cf207ccd98ec70f7565a12cb7c54da5c8a1a759bae7e62bdb74cb5c6e2",
20
+ "scripts/test_audit_refs.py": "5c207be46da566db47069b08373195e748fca19c047418f09cc2f78841dfd9a5",
21
+ "scripts/test_chronology.py": "ef482ed2261822778c69ade5dae6c875eca97ae8ef4f47b60bc262ac857eb95c",
22
+ "scripts/test_constraints.py": "4be3bbff7032e53b464de2f4e2eb18a824311adb2359c77fb9be065aded09239",
23
+ "scripts/test_docs_root.py": "19753e0316c0df63ed321d24b411a9ff36eea2058421fd69fc57e507d75566d0",
24
+ "scripts/test_docs_site.py": "391ec5c635c67f0fbefe458426e352b5b1ef626ae65c099b1e8e1db491d0a5f9",
25
+ "scripts/test_domain_rules.py": "33542b5dc2bb77c05ece55cf0446065cc4226c763ef5e040aa77364f226937bb",
26
+ "scripts/test_drift.py": "c7b7121a8a308b0f3fe12c0c0c346444e632b7aaeff47dc8818b621e5c20cb38",
27
+ "scripts/test_guide_check.py": "19c7a6f009fa1b7f70b0beba059b97fa05e69255f6b2dec8829a87bd9fba283f",
28
+ "scripts/test_merge_safety.py": "8b9241077bf7ecd6dd335907c640044ab38968ff4c9fdc464450e732cb77adf4",
29
+ "scripts/test_migrate.py": "c129823bf4feb0614acd975351632a6ae62d7896a0ef32724419a8e9335e3110",
30
+ "scripts/test_plan.py": "4c011a63aa834b55e9c5d13fb4ea76b131b742356001364b0a508435ba013e27",
31
+ "scripts/test_project_memory.py": "2cb85cef01161d881a11d84cfe9ebb72915104e8d1822e4de739f7d00ae6cd59",
32
+ "scripts/test_session_start.py": "723f748c0aef461d1051191dbd18f4ea5697a1e0467df73bccb0af8024d17f56",
33
+ "scripts/test_skill_invariants.py": "47d116563a37a16b7d25b322020dd730eecac8cf4cfc7916b877df4e729f8373",
34
+ "scripts/test_verdict.py": "8620be2f962f900e9fa0f979f80984671baa373de1ce3f29045104d813e71844",
35
+ "vision.md": "a19a7d7d5044215b726b66f74e422c9cdedcc856c0a53151f20a6d9840b8f6c4"
36
+ }
37
+ }
@@ -59,7 +59,7 @@ Value contribution: <why this step is needed for the module objective>
59
59
  - ai_docs/.../sources.md#claim-1
60
60
  ```
61
61
 
62
- `Delivery mode` is `self-study` or `instructor-led`. `Role` is `orientation`, `explanation`, `check`, `solution` or `closing`. A check may cover several modules/objectives; do not force one separate exercise per module. A check adds `Solution: S04` (or U04); the referenced unit is a subsequent `solution` covering all the check’s module/objective pairs, and contains the actual answer, rationale and correction path in its authored content. Put fictional-case labels in learner-visible copy, not only in production notes. A necessary definition, inference or caveat cannot exist only in notes for a self-study course.
62
+ `Delivery mode` is `self-study` or `instructor-led`. In `instructor-led` the Complete explanation is the text to speak, but it is not enough to deliver the course: preparation, reserved material, what to do when a live step fails, clean-up and the list of delivered materials belong in `INSTRUCTOR_GUIDE.md` (`templates.md`). `Role` is `orientation`, `explanation`, `check`, `solution` or `closing`. A check may cover several modules/objectives; do not force one separate exercise per module. A check adds `Solution: S04` (or U04); the referenced unit is a subsequent `solution` covering all the check’s module/objective pairs, and contains the actual answer, rationale and correction path in its authored content. Put fictional-case labels in learner-visible copy, not only in production notes. A necessary definition, inference or caveat cannot exist only in notes for a self-study course.
63
63
 
64
64
  For a shared explanation or cumulative check, add optional `Covers: M2/O2; M3/O3` metadata listing the additional module/objective pairs beyond the primary `Module`/`Objective`. Every pair must match the plan. A shared unit may serve separate module rows for different profiles without duplicating content, provided its explanation is suitable for all those profiles. A cumulative solution covers every pair assessed by its check. Review the actual check and answer for every declared objective; a coverage label alone is not proof.
65
65
 
@@ -67,7 +67,7 @@ For a shared explanation or cumulative check, add optional `Covers: M2/O2; M3/O3
67
67
 
68
68
  ## Release review and rendering handoff
69
69
 
70
- Review the actual learner surface: self-study uses Learner content plus visible transitions and captions; guided delivery additionally uses the authored spoken explanation. Separate solutions from the learner test packet. Give a renderer enough detail to construct every unit without writing new teaching material. If splitting a dense slide is necessary, update the canonical units/IDs first. Return semantic changes to this source before regenerating a deck.
70
+ Review the actual learner surface: self-study uses Learner content plus visible transitions and captions; guided delivery additionally uses the authored spoken explanation, and is handed over with `INSTRUCTOR_GUIDE.md` and every material it lists. Separate solutions from the learner test packet. Give a renderer enough detail to construct every unit without writing new teaching material. If splitting a dense slide is necessary, update the canonical units/IDs first. Return semantic changes to this source before regenerating a deck.
71
71
 
72
72
  Apply the clarity-review handoff in `SKILL.md` §4 to this learner surface, using the course's declared audience rather than the author's familiarity with the subject.
73
73