@antoneeo/course-creator 0.1.0-beta.3 → 0.1.0-beta.5
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/README.md +1 -1
- package/gemini-extension.json +1 -1
- package/package.json +2 -2
- 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 +2 -2
- package/skills/course-creator/SKILL.md +3 -3
- package/skills/course-creator/scripts/course_check.py +132 -132
- package/skills/course-creator/scripts/knowledge.py +88 -34
- package/skills/course-creator/scripts/sdlc_core.py +358 -6
- package/skills/course-creator/scripts/shared_files.py +1 -0
- package/skills/course-creator/scripts/shared_manifest.json +6 -5
- package/skills/course-creator/templates.md +2 -2
- package/skills/course-creator/vision.md +245 -245
|
@@ -54,7 +54,7 @@ def _items(cell):
|
|
|
54
54
|
if part.strip() and part.strip() != "-"]
|
|
55
55
|
|
|
56
56
|
|
|
57
|
-
def _anchor_exists(path, anchor):
|
|
57
|
+
def _anchor_exists(path, anchor):
|
|
58
58
|
if path.suffix.lower() not in (".md", ".txt"):
|
|
59
59
|
if path.suffix.lower() == ".pdf":
|
|
60
60
|
return bool(re.fullmatch(r"page=[1-9]\d*", anchor))
|
|
@@ -63,132 +63,132 @@ def _anchor_exists(path, anchor):
|
|
|
63
63
|
return bool(re.fullmatch(r"\d{2}:\d{2}(?::\d{2})?", anchor))
|
|
64
64
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
65
65
|
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")
|
|
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")
|
|
192
192
|
|
|
193
193
|
|
|
194
194
|
def validate_course(root, course_dir):
|
|
@@ -448,11 +448,11 @@ def validate_course(root, course_dir):
|
|
|
448
448
|
else:
|
|
449
449
|
add(course_dir / "COURSE_PLAN.md", "efficacy",
|
|
450
450
|
"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):
|
|
451
|
+
_content_contract(course_dir, plan, plan_rows, read, ref, add)
|
|
452
|
+
return findings
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def validate_all(root):
|
|
456
456
|
root = Path(root).resolve()
|
|
457
457
|
docs = root / "ai_docs"
|
|
458
458
|
parent = docs / "solutions/courses"
|
|
@@ -125,6 +125,21 @@ def kb_unquote(value):
|
|
|
125
125
|
return v
|
|
126
126
|
|
|
127
127
|
|
|
128
|
+
def frontmatter_date(meta):
|
|
129
|
+
"""A note's or a sidecar's `date:` as `YYYY-MM-DD`, or None (F-073).
|
|
130
|
+
Quotes stripped, a datetime suffix tolerated, an impossible date refused --
|
|
131
|
+
the one parser behind orient's recency probe and any chronology reader."""
|
|
132
|
+
m = re.match(r"(\d{4}-\d{2}-\d{2})", kb_unquote((meta or {}).get("date")))
|
|
133
|
+
if not m:
|
|
134
|
+
return None
|
|
135
|
+
try:
|
|
136
|
+
import datetime as _dt
|
|
137
|
+
_dt.date.fromisoformat(m.group(1))
|
|
138
|
+
except ValueError:
|
|
139
|
+
return None
|
|
140
|
+
return m.group(1)
|
|
141
|
+
|
|
142
|
+
|
|
128
143
|
def kb_parse_scope(text):
|
|
129
144
|
"""'-' | 'from X' | 'until X' | 'from X until Y' | 'if <cond>' ->
|
|
130
145
|
('always',) | ('window', from, until) | ('if', cond). Raises on junk."""
|
|
@@ -336,6 +351,17 @@ def kb_check_claims(root):
|
|
|
336
351
|
if rel not in fm_cache:
|
|
337
352
|
fm_cache[rel] = _note_frontmatter(root, rel)
|
|
338
353
|
return fm_cache[rel]
|
|
354
|
+
# F-069: the same per-run memo for the source paths themselves -- each
|
|
355
|
+
# distinct path is resolved and stat'ed once, and each cited extraction
|
|
356
|
+
# read once (loc_cache, owned here, dropped when this call returns).
|
|
357
|
+
src_cache = {}
|
|
358
|
+
loc_cache = {}
|
|
359
|
+
|
|
360
|
+
def _resolved(path_s):
|
|
361
|
+
if path_s not in src_cache:
|
|
362
|
+
t = sdlc_core.confine_under(root, path_s)
|
|
363
|
+
src_cache[path_s] = (t, t is not None and t.is_file())
|
|
364
|
+
return src_cache[path_s]
|
|
339
365
|
per_file_rows = []
|
|
340
366
|
for p in sorted(topics.glob("*.md")):
|
|
341
367
|
rel = "topics/" + p.name
|
|
@@ -357,16 +383,16 @@ def kb_check_claims(root):
|
|
|
357
383
|
errors.append("%s: source %r has no locator" % (where, src))
|
|
358
384
|
continue
|
|
359
385
|
path_s, loc = src.rsplit("#", 1)
|
|
360
|
-
target =
|
|
386
|
+
target, is_file = _resolved(path_s)
|
|
361
387
|
if target is None:
|
|
362
388
|
errors.append("%s: source %r escapes the docs root" % (where, src))
|
|
363
389
|
continue
|
|
364
|
-
if not
|
|
390
|
+
if not is_file:
|
|
365
391
|
errors.append("%s: source %r does not resolve — a claim whose "
|
|
366
392
|
"origin cannot be reopened is model knowledge"
|
|
367
393
|
% (where, src))
|
|
368
394
|
continue
|
|
369
|
-
kb_check_locator(target, loc, where, errors)
|
|
395
|
+
kb_check_locator(target, loc, where, errors, loc_cache)
|
|
370
396
|
# --- provenance ---
|
|
371
397
|
prov = row["prov"]
|
|
372
398
|
# Resolved ONCE, for every provenance: the non-GIVEN classes read it
|
|
@@ -556,8 +582,14 @@ def kb_check_claims(root):
|
|
|
556
582
|
return errors, warnings, notes
|
|
557
583
|
|
|
558
584
|
|
|
559
|
-
def kb_check_locator(target, loc, where, errors):
|
|
560
|
-
"""A locator must address an existing span of the stored bytes.
|
|
585
|
+
def kb_check_locator(target, loc, where, errors, cache=None):
|
|
586
|
+
"""A locator must address an existing span of the stored bytes.
|
|
587
|
+
|
|
588
|
+
`cache` is an optional dict the CALLER owns for one run (F-069): keyed by
|
|
589
|
+
the path actually read, it holds only what the checks compare against --
|
|
590
|
+
page lengths, a line count, or False for a missing extraction -- never the
|
|
591
|
+
text. Findings are still appended per call, so per row. Without it, every
|
|
592
|
+
call reads the file, as before."""
|
|
561
593
|
m = LOC_PAGE_RE.match(loc)
|
|
562
594
|
if m:
|
|
563
595
|
page, a, b = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
|
@@ -566,17 +598,24 @@ def kb_check_locator(target, loc, where, errors):
|
|
|
566
598
|
return
|
|
567
599
|
# offsets address the stored extraction (<stem>.txt beside the original)
|
|
568
600
|
ext = target if target.suffix == ".txt" else target.with_suffix(".txt")
|
|
569
|
-
|
|
601
|
+
key = ("p", ext)
|
|
602
|
+
if cache is None or key not in cache:
|
|
603
|
+
lengths = ([len(pg) for pg in sdlc_core.read_text(ext).split("\f")]
|
|
604
|
+
if ext.is_file() else False)
|
|
605
|
+
if cache is not None:
|
|
606
|
+
cache[key] = lengths
|
|
607
|
+
else:
|
|
608
|
+
lengths = cache[key]
|
|
609
|
+
if lengths is False:
|
|
570
610
|
errors.append("%s: no stored extraction %s for locator %r — offsets "
|
|
571
611
|
"must address kept bytes" % (where, ext.name, loc))
|
|
572
612
|
return
|
|
573
|
-
|
|
574
|
-
if page < 1 or page > len(pages):
|
|
613
|
+
if page < 1 or page > len(lengths):
|
|
575
614
|
errors.append("%s: locator %r addresses page %d of %d" %
|
|
576
|
-
(where, loc, page, len(
|
|
577
|
-
elif b >
|
|
615
|
+
(where, loc, page, len(lengths)))
|
|
616
|
+
elif b > lengths[page - 1]:
|
|
578
617
|
errors.append("%s: locator %r spans past the end of page %d "
|
|
579
|
-
"(%d chars)" % (where, loc, page,
|
|
618
|
+
"(%d chars)" % (where, loc, page, lengths[page - 1]))
|
|
580
619
|
return
|
|
581
620
|
m = LOC_LINE_RE.match(loc)
|
|
582
621
|
if m:
|
|
@@ -584,7 +623,13 @@ def kb_check_locator(target, loc, where, errors):
|
|
|
584
623
|
if a > b or a < 1:
|
|
585
624
|
errors.append("%s: locator %r has an invalid line range" % (where, loc))
|
|
586
625
|
return
|
|
587
|
-
|
|
626
|
+
key = ("L", target)
|
|
627
|
+
if cache is None or key not in cache:
|
|
628
|
+
n = sdlc_core.read_text(target).count("\n") + 1
|
|
629
|
+
if cache is not None:
|
|
630
|
+
cache[key] = n
|
|
631
|
+
else:
|
|
632
|
+
n = cache[key]
|
|
588
633
|
if b > n:
|
|
589
634
|
errors.append("%s: locator %r spans past line %d" % (where, loc, n))
|
|
590
635
|
return
|
|
@@ -1085,12 +1130,18 @@ KB_GENERATED_RE = re.compile(r"GENERATED by \S+ index - do not edit by hand")
|
|
|
1085
1130
|
KB_HEX12_RE = re.compile(r"^[0-9a-f]{12}$")
|
|
1086
1131
|
|
|
1087
1132
|
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1133
|
+
# A citation of a claim id is a run of exactly its 12 lowercase hex characters
|
|
1134
|
+
# NOT embedded in a longer hex run: a git hash sharing a substring must never
|
|
1135
|
+
# count as a citation (either side, either case). Two such bounded runs cannot
|
|
1136
|
+
# overlap, so findall returns every one of them.
|
|
1137
|
+
KB_HEX_TOKEN_RE = re.compile(r"(?<![0-9a-fA-F])[0-9a-f]{12}(?![0-9a-fA-F])")
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
def _kb_hex_tokens(text):
|
|
1141
|
+
"""The ids a text cites, extracted in one pass (F-069). Callers test
|
|
1142
|
+
membership instead of scanning the text once per id: that per-(document,
|
|
1143
|
+
id) scan made the time cycle grow with superseded ids x living documents."""
|
|
1144
|
+
return frozenset(KB_HEX_TOKEN_RE.findall(text))
|
|
1094
1145
|
|
|
1095
1146
|
|
|
1096
1147
|
def _kb_df_entries(value):
|
|
@@ -1172,19 +1223,23 @@ def kb_time_cycle(docs):
|
|
|
1172
1223
|
if (meta.get("status") or "").strip() == "SUPERSEDED":
|
|
1173
1224
|
continue
|
|
1174
1225
|
living.append((rel, text))
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1226
|
+
# One token pass per living document, and none at all on a KB with no
|
|
1227
|
+
# supersession and no validity window (the orient line runs this at every
|
|
1228
|
+
# session start).
|
|
1229
|
+
tokens = ({rel: _kb_hex_tokens(text) for rel, text in living}
|
|
1230
|
+
if superseded or windows else {})
|
|
1231
|
+
for rel, _text in living:
|
|
1232
|
+
cites = tokens[rel] if tokens else frozenset()
|
|
1233
|
+
for old in cites.intersection(superseded):
|
|
1234
|
+
succs = superseded[old]
|
|
1235
|
+
missing = tuple(x for x in succs if x not in cites)
|
|
1236
|
+
if missing:
|
|
1237
|
+
cited.append((rel, old, missing, succs))
|
|
1183
1238
|
today = _dt.date.today().isoformat()
|
|
1184
1239
|
for topic_rel, rid, until in sorted(windows):
|
|
1185
1240
|
if until <= today:
|
|
1186
|
-
by = tuple(rel for rel,
|
|
1187
|
-
if rel != topic_rel and
|
|
1241
|
+
by = tuple(rel for rel, _text in living
|
|
1242
|
+
if rel != topic_rel and rid in tokens[rel])
|
|
1188
1243
|
expired.append((topic_rel, rid, until, by))
|
|
1189
1244
|
rev = {}
|
|
1190
1245
|
node_meta = {}
|
|
@@ -1905,14 +1960,13 @@ def kb_cmd_orient(argv):
|
|
|
1905
1960
|
# it -- the file was already fully read, so the cap bought
|
|
1906
1961
|
# only failure. load_frontmatter is the authoritative
|
|
1907
1962
|
# reader (fence-bounded, so a column-0 date: in the body
|
|
1908
|
-
# still cannot misdate the note);
|
|
1909
|
-
# dates
|
|
1963
|
+
# still cannot misdate the note); frontmatter_date covers
|
|
1964
|
+
# quoted dates and tolerates a datetime suffix.
|
|
1910
1965
|
meta = sdlc_core.load_frontmatter(
|
|
1911
1966
|
sdlc_core.read_text(p).splitlines()) or {}
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
stamp = _dt.date.fromisoformat(m.group(1))
|
|
1967
|
+
day = frontmatter_date(meta)
|
|
1968
|
+
if day:
|
|
1969
|
+
stamp = _dt.date.fromisoformat(day)
|
|
1916
1970
|
except Exception:
|
|
1917
1971
|
pass
|
|
1918
1972
|
if stamp is None:
|