@antoneeo/course-creator 0.1.0-beta.13 → 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.
- package/CHANGELOG.md +57 -0
- package/README.md +43 -0
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/scripts/init.js +62 -62
- package/scripts/lib.js +98 -98
- package/scripts/postinstall.js +16 -16
- package/scripts/preuninstall.js +12 -12
- package/skills/course-creator/ENFORCEMENT.md +3 -3
- package/skills/course-creator/SKILL.md +2 -1
- package/skills/course-creator/docs_site/app.js +7 -1
- package/skills/course-creator/docs_site/strings.js +9 -0
- package/skills/course-creator/docs_site/style.css +2 -0
- package/skills/course-creator/guides.md +8 -0
- package/skills/course-creator/learning_design.md +1 -1
- package/skills/course-creator/review.md +9 -1
- package/skills/course-creator/scripts/course_check.py +288 -132
- package/skills/course-creator/scripts/docs_site.py +17 -3
- package/skills/course-creator/scripts/knowledge.py +925 -33
- package/skills/course-creator/scripts/sdlc_core.py +562 -22
- package/skills/course-creator/scripts/shared_files.py +1 -0
- package/skills/course-creator/scripts/shared_manifest.json +13 -12
- package/skills/course-creator/slide_content.md +2 -2
- package/skills/course-creator/templates.md +59 -1
- package/skills/course-creator/vision.md +308 -308
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
var D = null; // documents, loaded on first use
|
|
13
13
|
var K = null; // knowledge base, loaded on first use
|
|
14
14
|
var app = document.getElementById("app");
|
|
15
|
-
var ORIGINS = ["given", "elicited", "derived", "ruling", "imported"];
|
|
15
|
+
var ORIGINS = ["given", "elicited", "derived", "ruling", "imported", "declared"];
|
|
16
16
|
|
|
17
17
|
// ------------------------------------------------------------- helpers
|
|
18
18
|
|
|
@@ -483,6 +483,7 @@
|
|
|
483
483
|
if (f.statement) marks.push(el("span", { "class": "tag statement", title: t("statement_notice"), text: t("statement_mark") }));
|
|
484
484
|
if (f.state === "superseded") marks.push(el("span", { "class": "tag replaced", title: t("state_superseded_why"), text: t("state_superseded") }));
|
|
485
485
|
if (f.state === "contested") marks.push(el("span", { "class": "tag conflict", title: t("state_contested_why"), text: t("state_contested") }));
|
|
486
|
+
if (f.state === "diverges") marks.push(el("span", { "class": "tag conflict", title: t("state_diverges_why"), text: t("state_diverges") }));
|
|
486
487
|
if (opts && opts.topic) marks.push(link("#/topic/" + encodeURIComponent(f.topic), f.topic, "chip"));
|
|
487
488
|
return el("li", { "class": "fact" + (f.state === "superseded" ? " replaced" : ""), onclick: function () { location.hash = "#/fact/" + encodeURIComponent(id); } },
|
|
488
489
|
el("span", { "class": "dot bg-" + oc }),
|
|
@@ -597,6 +598,11 @@
|
|
|
597
598
|
el("p", { "class": "why-line", text: t("state_contested_why") }),
|
|
598
599
|
el("ul", { "class": "facts" }, (f.with || []).map(function (s) { return factLine(s, { topic: true }); })));
|
|
599
600
|
}
|
|
601
|
+
if (f.state === "diverges") {
|
|
602
|
+
parts.push(el("h3", null, el("span", { "class": "tag conflict", text: t("state_diverges") }), " ", t("fact_diverges_from")),
|
|
603
|
+
el("p", { "class": "why-line", text: t("state_diverges_why") }),
|
|
604
|
+
el("ul", { "class": "facts" }, (f.diverges_from || []).map(function (s) { return factLine(s, { topic: true }); })));
|
|
605
|
+
}
|
|
600
606
|
if (f.grounds) {
|
|
601
607
|
parts.push(el("h3", { text: t("fact_rests_on") }),
|
|
602
608
|
el("p", { "class": "why-line", text: t("ground_class_" + f.ground_class) }),
|
|
@@ -56,7 +56,9 @@ window.DOCS_SITE_STRINGS = {
|
|
|
56
56
|
event_request: "request record",
|
|
57
57
|
event_note: "note",
|
|
58
58
|
event_source: "source document received",
|
|
59
|
+
event_sent: "document sent",
|
|
59
60
|
event_review: "review",
|
|
61
|
+
event_defect: "defect reported after merge",
|
|
60
62
|
event_decision: "decision record",
|
|
61
63
|
event_project_note: "project note",
|
|
62
64
|
event_spike: "exploration outcome",
|
|
@@ -231,6 +233,8 @@ window.DOCS_SITE_STRINGS = {
|
|
|
231
233
|
origin_ruling_why: "Taken to settle a conflict.",
|
|
232
234
|
origin_imported: "from another knowledge base",
|
|
233
235
|
origin_imported_why: "Brought in from another knowledge base, where its own sources are.",
|
|
236
|
+
origin_declared: "declared by this project",
|
|
237
|
+
origin_declared_why: "What this project told a recipient, on the day it was sent: a statement of ours, not a fact about the product.",
|
|
234
238
|
origin_other: "origin as written: {v}",
|
|
235
239
|
origin_other_why: "An origin this site does not know.",
|
|
236
240
|
|
|
@@ -239,6 +243,8 @@ window.DOCS_SITE_STRINGS = {
|
|
|
239
243
|
state_superseded_why: "A newer fact takes its place.",
|
|
240
244
|
state_contested: "in conflict",
|
|
241
245
|
state_contested_why: "Facts that disagree, not yet settled.",
|
|
246
|
+
state_diverges: "diverges",
|
|
247
|
+
state_diverges_why: "What we declared contradicts what the project decided. It is corrected by a new send, or the decision changes.",
|
|
242
248
|
|
|
243
249
|
statement_mark: "statement",
|
|
244
250
|
statement_prefix: "[statement] {text}",
|
|
@@ -254,6 +260,7 @@ window.DOCS_SITE_STRINGS = {
|
|
|
254
260
|
fact_replaced_by: "Replaced by",
|
|
255
261
|
fact_current_at_end: "Current fact at the end of each chain",
|
|
256
262
|
fact_in_conflict_with: "In conflict with",
|
|
263
|
+
fact_diverges_from: "Diverges from",
|
|
257
264
|
fact_rests_on: "What it rests on",
|
|
258
265
|
fact_untraced: "This part could not be traced.",
|
|
259
266
|
fact_sources: "Sources",
|
|
@@ -266,6 +273,7 @@ window.DOCS_SITE_STRINGS = {
|
|
|
266
273
|
fact_unknown: "This fact is not in this knowledge base.",
|
|
267
274
|
|
|
268
275
|
ground_document: "a document",
|
|
276
|
+
ground_declared: "a document this project sent",
|
|
269
277
|
ground_statement: "a person's statement",
|
|
270
278
|
ground_decision: "a decision",
|
|
271
279
|
ground_kb: "another knowledge base",
|
|
@@ -273,6 +281,7 @@ window.DOCS_SITE_STRINGS = {
|
|
|
273
281
|
ground_class_statement: "Rests, at least in part, on a person's statement.",
|
|
274
282
|
ground_class_untraceable: "Part of what it rests on could not be traced.",
|
|
275
283
|
ground_class_imported: "Rests on another knowledge base.",
|
|
284
|
+
ground_class_declared: "Rests on our own declaration to a recipient, not on evidence.",
|
|
276
285
|
ground_class_documents: "Rests on documents or decisions only.",
|
|
277
286
|
|
|
278
287
|
chain_loop: "The chain of replacements loops back to {id}: it stops here.",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
--o-derived: #a99be0;
|
|
19
19
|
--o-ruling: #7fa7e6;
|
|
20
20
|
--o-imported: #9cc96b;
|
|
21
|
+
--o-declared: #d98fb0;
|
|
21
22
|
--conflict: #e0605a;
|
|
22
23
|
--display: "Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Charter, Georgia, serif;
|
|
23
24
|
--body: "Segoe UI Variable Text", "Avenir Next", Avenir, "Gill Sans Nova", "Gill Sans", "Trebuchet MS", sans-serif;
|
|
@@ -179,6 +180,7 @@ a.factid code { color: var(--link); }
|
|
|
179
180
|
.o-derived { color: var(--o-derived); } .bg-derived { background: var(--o-derived); }
|
|
180
181
|
.o-ruling { color: var(--o-ruling); } .bg-ruling { background: var(--o-ruling); }
|
|
181
182
|
.o-imported { color: var(--o-imported); } .bg-imported { background: var(--o-imported); }
|
|
183
|
+
.o-declared { color: var(--o-declared); } .bg-declared { background: var(--o-declared); }
|
|
182
184
|
.o-other { color: var(--paper-3); } .bg-other { background: var(--paper-3); }
|
|
183
185
|
|
|
184
186
|
.panel {
|
|
@@ -262,6 +262,12 @@ in the snapshot, never to your assumption about what the code does.
|
|
|
262
262
|
(source-anchored), not an elaborated version of it.
|
|
263
263
|
- When in doubt about whether something is "supported" by the source, treat it
|
|
264
264
|
as not covered rather than stretching the marker to fit.
|
|
265
|
+
- **Declared exception: `## Preservation constraints`.** A code guide may carry
|
|
266
|
+
this section, copied at an L3 closure from the ANALYSIS's table (code lens,
|
|
267
|
+
`SKILL.md` Phase 5). The section's marker cites each row it holds as
|
|
268
|
+
`[source: solutions/ANALYSIS_<name>.md#P<n>]` instead of a snapshot anchor: each
|
|
269
|
+
row records what the code must keep doing, as that unit enumerated and probed it,
|
|
270
|
+
not an excerpt of the code.
|
|
265
271
|
|
|
266
272
|
## 4. Ingestion bound (T9)
|
|
267
273
|
|
|
@@ -293,5 +299,7 @@ state it explicitly when handing off a newly created guide.
|
|
|
293
299
|
closure (docs travel with the code); when you merely READ one, check it against
|
|
294
300
|
the code before trusting it. Too large to absorb → mark it `SUPERSEDED` rather
|
|
295
301
|
than leave a confident-wrong map badged CURRENT, which is worse than none.
|
|
302
|
+
- **A regenerated guide keeps `## Preservation constraints` unchanged**: the
|
|
303
|
+
section does not come from the snapshot (§3), so regeneration carries it over.
|
|
296
304
|
- **Agent-global KB guides** use the same pipeline and validator via
|
|
297
305
|
`--root ~/.agentic-sdlc`; freshness via the same `stale` engine.
|
|
@@ -40,7 +40,7 @@ When the procedure is performed by a tool or agent, the worked case shows the to
|
|
|
40
40
|
|
|
41
41
|
Introduce necessary terms before using them; keep related data, diagram labels and explanations together. Remove decorative or irrelevant information. Segment a complex explanation into meaningful steps while retaining how they connect. Tailor support to the learner rather than equating more words with better teaching.
|
|
42
42
|
|
|
43
|
-
The complete source can be detailed without making every slide dense. In self-study, all essential reasoning must remain visible across the learner's units; in guided delivery, author the necessary spoken explanation. If content does not fit, divide the canonical units before rendering instead of shrinking text or hiding meaning in notes. Preserve exact examples and visual relationships through the handoff.
|
|
43
|
+
The complete source can be detailed without making every slide dense. In self-study, all essential reasoning must remain visible across the learner's units; in guided delivery, author the necessary spoken explanation, and give the instructor the operating instructions and materials in `INSTRUCTOR_GUIDE.md` (`templates.md`). If content does not fit, divide the canonical units before rendering instead of shrinking text or hiding meaning in notes. Preserve exact examples and visual relationships through the handoff.
|
|
44
44
|
|
|
45
45
|
## 6. Use recall, practice and feedback to improve the explanation
|
|
46
46
|
|
|
@@ -187,7 +187,10 @@ can block forever gets removed. **Log one row per completed review, PASS or FAIL
|
|
|
187
187
|
logging only passes would erase exactly that evidence. The row goes in
|
|
188
188
|
`ai_docs/audit/reviews/REVIEW_LOG.md` (create it if absent — `templates.md`):
|
|
189
189
|
`| date | doc_key | tier | model | reviewer | findings_raised | findings_real | verdict | revise_rounds |`,
|
|
190
|
-
with `tier` = `design` or `closure` in Standalone.
|
|
190
|
+
with `tier` = `design` or `closure` in Standalone. In the code lens, a wrong behaviour
|
|
191
|
+
the owner reports in a merged L3 change is one row too, tier `post-merge`, `model`
|
|
192
|
+
`none (owner report)`, reviewer `owner`, the owner quoted in the notes: a defect
|
|
193
|
+
record, not a review, so `benefit` keeps it out of every review figure. **`model` records the capability
|
|
191
194
|
tier that actually ran** — `deep`, `light`, `economy`, `single (client exposes no
|
|
192
195
|
choice)`, `below floor: <reason>`, or `none (not due)` on a NOT DUE row — never a provider name in doctrine (a project
|
|
193
196
|
may of course write its own model names in its own log). Without it the floor above
|
|
@@ -285,6 +288,11 @@ The binding inputs, by review type:
|
|
|
285
288
|
clause reads both), and the **probe harness** (`ai_docs/solutions/harness_[feature]/`).
|
|
286
289
|
Hand these *in addition to* the design artifact — the reviewer checks the artifact
|
|
287
290
|
**against** them, not only for internal consistency.
|
|
291
|
+
- **The constraint view comes last.** Where the ANALYSIS carries ids, the reviewer
|
|
292
|
+
derives the callers and behaviours first, then compares with
|
|
293
|
+
`sdlc_check.py constraints --unit <id>`: a constraint the design states and the
|
|
294
|
+
diff breaks is a finding. The view never replaces the ANALYSIS or the diff in the
|
|
295
|
+
request.
|
|
288
296
|
- **For a closure review, the diff and the test evidence**: the run output that
|
|
289
297
|
backs every completion claim, dated after the final relevant change.
|
|
290
298
|
- **For a design or closure review of an L3/E3 unit, the request record**
|
|
@@ -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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
or
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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"
|