@thanh01.pmt/domain-kit 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +682 -168
- package/dist/assembly/index.d.cts +2 -2
- package/dist/assembly/index.d.ts +2 -2
- package/dist/{chunk-HILSUZOF.mjs → chunk-2OGNQUXC.mjs} +171 -31
- package/dist/chunk-2OGNQUXC.mjs.map +1 -0
- package/dist/{chunk-LZQLLAYP.mjs → chunk-5OMSYKQP.mjs} +43 -23
- package/dist/chunk-5OMSYKQP.mjs.map +1 -0
- package/dist/{chunk-BL5NBKPH.mjs → chunk-67GXK3ZE.mjs} +18 -8
- package/dist/chunk-67GXK3ZE.mjs.map +1 -0
- package/dist/{chunk-PKUEOSZ3.mjs → chunk-CGNXGVHV.mjs} +46 -4
- package/dist/chunk-CGNXGVHV.mjs.map +1 -0
- package/dist/{chunk-VCPIWJ7R.mjs → chunk-ERDTHXQA.mjs} +21 -3
- package/dist/chunk-ERDTHXQA.mjs.map +1 -0
- package/dist/chunk-OYSZQVPY.mjs +10 -0
- package/dist/chunk-OYSZQVPY.mjs.map +1 -0
- package/dist/{chunk-QJF4QCBJ.mjs → chunk-WKTH3JPA.mjs} +202 -36
- package/dist/chunk-WKTH3JPA.mjs.map +1 -0
- package/dist/{conceptEscalator-YfMKIKCZ.d.ts → conceptEscalator-BCsw0ys_.d.ts} +10 -1
- package/dist/{conceptEscalator-ltRLtPhf.d.cts → conceptEscalator-DPrs4PwC.d.cts} +10 -1
- package/dist/concepts/index.d.cts +1 -1
- package/dist/concepts/index.d.ts +1 -1
- package/dist/{curriculumFeedSchema-B66H3gEV.d.ts → curriculumFeedSchema-DYayelAA.d.cts} +212 -1
- package/dist/{curriculumFeedSchema-B66H3gEV.d.cts → curriculumFeedSchema-DYayelAA.d.ts} +212 -1
- package/dist/detector/index.cjs +21 -6
- package/dist/detector/index.cjs.map +1 -1
- package/dist/detector/index.mjs +2 -1
- package/dist/feed/index.cjs +243 -33
- package/dist/feed/index.cjs.map +1 -1
- package/dist/feed/index.d.cts +32 -2
- package/dist/feed/index.d.ts +32 -2
- package/dist/feed/index.mjs +2 -2
- package/dist/graph/index.cjs +163 -28
- package/dist/graph/index.cjs.map +1 -1
- package/dist/graph/index.d.cts +14 -5
- package/dist/graph/index.d.ts +14 -5
- package/dist/graph/index.mjs +2 -1
- package/dist/{hybridGraphPipeline-DXQDbBEj.d.ts → hybridGraphPipeline-9dRKJj7r.d.ts} +6 -2
- package/dist/{hybridGraphPipeline-dT-bio-3.d.cts → hybridGraphPipeline-CocfYe4d.d.cts} +6 -2
- package/dist/{hybridGraphSchema-nrUeSwpU.d.ts → hybridGraphSchema-C37qxdTM.d.cts} +16 -3
- package/dist/{hybridGraphSchema-nrUeSwpU.d.cts → hybridGraphSchema-C37qxdTM.d.ts} +16 -3
- package/dist/index.cjs +3742 -3362
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +6 -6
- package/dist/index.d.ts +6 -6
- package/dist/index.mjs +7 -6
- package/dist/llmClient-CX5uUiQ1.d.cts +60 -0
- package/dist/llmClient-CX5uUiQ1.d.ts +60 -0
- package/dist/pipeline/index.cjs +1556 -1202
- package/dist/pipeline/index.cjs.map +1 -1
- package/dist/pipeline/index.d.cts +13 -5
- package/dist/pipeline/index.d.ts +13 -5
- package/dist/pipeline/index.mjs +6 -5
- package/dist/schemas/index.cjs +64 -2
- package/dist/schemas/index.cjs.map +1 -1
- package/dist/schemas/index.d.cts +2 -2
- package/dist/schemas/index.d.ts +2 -2
- package/dist/schemas/index.mjs +2 -2
- package/package.json +1 -1
- package/dist/chunk-BL5NBKPH.mjs.map +0 -1
- package/dist/chunk-HILSUZOF.mjs.map +0 -1
- package/dist/chunk-LZQLLAYP.mjs.map +0 -1
- package/dist/chunk-PKUEOSZ3.mjs.map +0 -1
- package/dist/chunk-QJF4QCBJ.mjs.map +0 -1
- package/dist/chunk-VCPIWJ7R.mjs.map +0 -1
- package/dist/curriculumFeedEmitter-6IK6PFDH.mjs +0 -4
- package/dist/curriculumFeedEmitter-6IK6PFDH.mjs.map +0 -1
- package/dist/llmClient-ysPhLjcH.d.cts +0 -16
- package/dist/llmClient-ysPhLjcH.d.ts +0 -16
package/dist/pipeline/index.cjs
CHANGED
|
@@ -1,936 +1,316 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
var zod = require('zod');
|
|
4
3
|
var fs = require('fs');
|
|
5
4
|
var path = require('path');
|
|
6
5
|
var child_process = require('child_process');
|
|
7
6
|
var os = require('os');
|
|
7
|
+
var zod = require('zod');
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
var
|
|
11
|
-
var
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
var
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
if (e.from === e.to) throw new Error("curriculum feed self-loop edge on " + e.from);
|
|
9
|
+
// src/parsers/swift.ts
|
|
10
|
+
var IMPORT_RE = /^import\s+(\w+)/gm;
|
|
11
|
+
var TYPE_RE = /\b(class|struct|enum|protocol)\s+(\w+)(?:\s*:\s*([^{]+))?/g;
|
|
12
|
+
var FUNC_RE = /\bfunc\s+(\w+)\s*\(([^)]*)\)(?:\s*->\s*([^{]+))?/g;
|
|
13
|
+
var WRAPPER_RE = /@(State|Binding|ObservedObject|StateObject|Published|Environment|EnvironmentObject|AppStorage|SceneStorage|FocusState|Observable)\b/g;
|
|
14
|
+
var FRAMEWORK_USAGE_RE = /\b(URLSession|URLRequest|JSONSerialization|JSONDecoder|JSONEncoder|Task|DispatchQueue|Timer|NotificationCenter|UserDefaults|FileManager|Bundle)\b/g;
|
|
15
|
+
function parseSwiftFile(filepath, content) {
|
|
16
|
+
const result = {
|
|
17
|
+
imports: [],
|
|
18
|
+
types: [],
|
|
19
|
+
functions: [],
|
|
20
|
+
property_wrappers: [],
|
|
21
|
+
error_handling: [],
|
|
22
|
+
frameworks_used: []
|
|
23
|
+
};
|
|
24
|
+
let match;
|
|
25
|
+
while ((match = IMPORT_RE.exec(content)) !== null) {
|
|
26
|
+
result.imports.push(match[1]);
|
|
29
27
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
const fwSeen = /* @__PURE__ */ new Set();
|
|
29
|
+
while ((match = FRAMEWORK_USAGE_RE.exec(content)) !== null) {
|
|
30
|
+
if (!fwSeen.has(match[1])) {
|
|
31
|
+
fwSeen.add(match[1]);
|
|
32
|
+
result.frameworks_used.push(match[1]);
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
while ((match = TYPE_RE.exec(content)) !== null) {
|
|
36
|
+
const conforms = (match[3] || "").split(",").map((p) => p.trim()).filter(Boolean);
|
|
37
|
+
result.types.push({
|
|
38
|
+
kind: match[1],
|
|
39
|
+
name: match[2],
|
|
40
|
+
conforms_to: conforms,
|
|
41
|
+
file: filepath
|
|
42
|
+
});
|
|
39
43
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
while ((match = FUNC_RE.exec(content)) !== null) {
|
|
45
|
+
const name = match[1];
|
|
46
|
+
if (name.startsWith("_")) continue;
|
|
47
|
+
result.functions.push({
|
|
48
|
+
name,
|
|
49
|
+
params: (match[2] || "").trim(),
|
|
50
|
+
returns: (match[3] || "").trim(),
|
|
51
|
+
file: filepath
|
|
46
52
|
});
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
}
|
|
54
|
+
const wrapperSet = /* @__PURE__ */ new Set();
|
|
55
|
+
while ((match = WRAPPER_RE.exec(content)) !== null) {
|
|
56
|
+
wrapperSet.add(match[1]);
|
|
57
|
+
}
|
|
58
|
+
result.property_wrappers = Array.from(wrapperSet);
|
|
59
|
+
if (content.includes("do {") || content.includes("do{")) {
|
|
60
|
+
result.error_handling.push("do-catch");
|
|
61
|
+
}
|
|
62
|
+
if (content.includes("throws")) {
|
|
63
|
+
result.error_handling.push("throws");
|
|
64
|
+
}
|
|
65
|
+
if (content.includes("Result<")) {
|
|
66
|
+
result.error_handling.push("Result type");
|
|
67
|
+
}
|
|
68
|
+
if (content.includes("try?")) {
|
|
69
|
+
result.error_handling.push("try?");
|
|
70
|
+
}
|
|
71
|
+
if (content.includes("try!")) {
|
|
72
|
+
result.error_handling.push("try!");
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
function mergeSwiftResults(results) {
|
|
77
|
+
const merged = {
|
|
78
|
+
imports: [],
|
|
79
|
+
types: [],
|
|
80
|
+
functions: [],
|
|
81
|
+
property_wrappers: [],
|
|
82
|
+
error_handling: [],
|
|
83
|
+
frameworks_used: []
|
|
84
|
+
};
|
|
85
|
+
const importCounts = /* @__PURE__ */ new Map();
|
|
86
|
+
const wrapperCounts = /* @__PURE__ */ new Map();
|
|
87
|
+
const errorCounts = /* @__PURE__ */ new Map();
|
|
88
|
+
const frameworkSet = /* @__PURE__ */ new Set();
|
|
89
|
+
for (const r of results) {
|
|
90
|
+
for (const imp of r.imports) {
|
|
91
|
+
importCounts.set(imp, (importCounts.get(imp) || 0) + 1);
|
|
92
|
+
}
|
|
93
|
+
merged.types.push(...r.types);
|
|
94
|
+
merged.functions.push(...r.functions);
|
|
95
|
+
for (const w of r.property_wrappers) {
|
|
96
|
+
wrapperCounts.set(w, (wrapperCounts.get(w) || 0) + 1);
|
|
97
|
+
}
|
|
98
|
+
for (const e of r.error_handling) {
|
|
99
|
+
errorCounts.set(e, (errorCounts.get(e) || 0) + 1);
|
|
100
|
+
}
|
|
101
|
+
for (const fw of r.frameworks_used) {
|
|
102
|
+
frameworkSet.add(fw);
|
|
56
103
|
}
|
|
57
104
|
}
|
|
58
|
-
|
|
105
|
+
merged.imports = Array.from(importCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([mod, count]) => mod);
|
|
106
|
+
merged.property_wrappers = Array.from(wrapperCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([w]) => w);
|
|
107
|
+
merged.error_handling = Array.from(errorCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([p]) => p);
|
|
108
|
+
merged.frameworks_used = Array.from(frameworkSet).sort();
|
|
109
|
+
merged.types = merged.types.slice(0, 100);
|
|
110
|
+
merged.functions = merged.functions.slice(0, 100);
|
|
111
|
+
return merged;
|
|
59
112
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
depth_hint: zod.z.enum(["ulo", "cio", "sio"]).nullable().default(null),
|
|
83
|
-
completion_level: zod.z.enum(["mvp", "standard", "stretch"]).default("standard"),
|
|
84
|
-
scaffolding: zod.z.object({
|
|
85
|
-
scaffold_candidates: zod.z.array(FeedScaffoldCandidateSchema).default([]),
|
|
86
|
-
core_value_minutes: zod.z.number().int().nonnegative().default(0)
|
|
87
|
-
}).default({ scaffold_candidates: [], core_value_minutes: 0 }),
|
|
88
|
-
references: zod.z.array(zod.z.object({ file: zod.z.string().min(1), evidence: zod.z.string().default("") })).default([]),
|
|
89
|
-
feature_id: zod.z.string().nullable().default(null),
|
|
90
|
-
concept_ref: zod.z.string().nullable().default(null),
|
|
91
|
-
// P52/T2.1: outcome.user_visible projected from the ProjectGraph step — the
|
|
92
|
-
// tangible checkpoint the session-cutting planner aims for (empty when the
|
|
93
|
-
// step declares no user-visible result). Concepts carry '' by default.
|
|
94
|
-
user_visible_deliverable: zod.z.string().default(""),
|
|
95
|
-
// P52/T3.1 — depth-reconcile inputs (optional/defaulted so old feeds
|
|
96
|
-
// validate unchanged):
|
|
97
|
-
// • depth_variants: minutes to teach this node at each depth level.
|
|
98
|
-
// ULO (know-it-exists) ≤ CIO (mechanism) ≤ SIO (implementation). All 3
|
|
99
|
-
// required so a downgrade never turns an anchor teach into a zero.
|
|
100
|
-
// • depth_scaffold_candidates: parallel to scaffold_candidates but
|
|
101
|
-
// acts on DEPTH (e.g. SIO→CIO) instead of lesson time.
|
|
102
|
-
// • is_core: concept is a Master-Tree core concept — reconciler must
|
|
103
|
-
// never downgrade it; escalate instead.
|
|
104
|
-
depth_variants: zod.z.object({ ulo: zod.z.number().int().nonnegative(), cio: zod.z.number().int().nonnegative(), sio: zod.z.number().int().nonnegative() }).optional(),
|
|
105
|
-
depth_scaffold_candidates: zod.z.array(zod.z.object({
|
|
106
|
-
from_depth: zod.z.enum(["ulo", "cio", "sio"]),
|
|
107
|
-
to_depth: zod.z.enum(["ulo", "cio", "sio"]),
|
|
108
|
-
minutes_saved: zod.z.number().int().nonnegative(),
|
|
109
|
-
reason: zod.z.string().default("")
|
|
110
|
-
})).optional(),
|
|
111
|
-
is_core: zod.z.boolean().default(false),
|
|
112
|
-
// Progressive-completion bookkeeping (feed v2, additive):
|
|
113
|
-
// phase_id = development phase that introduces/uses this node;
|
|
114
|
-
// introduce_aspect = how the phase teaches a concept node (first intro, advanced
|
|
115
|
-
// revisit, or preview — a light ULO awareness pass that is NOT the introduction).
|
|
116
|
-
phase_id: zod.z.string().default(""),
|
|
117
|
-
introduce_aspect: zod.z.enum(["new", "advanced", "preview"]).nullable().default(null)
|
|
118
|
-
});
|
|
119
|
-
FeedEdgeSchema = zod.z.object({
|
|
120
|
-
from: zod.z.string().min(1),
|
|
121
|
-
to: zod.z.string().min(1),
|
|
122
|
-
kind: zod.z.enum(["knowledge", "task"]),
|
|
123
|
-
reason: zod.z.string().default("")
|
|
124
|
-
});
|
|
125
|
-
FeedGroupingSchema = zod.z.object({
|
|
126
|
-
id: zod.z.string().min(1),
|
|
127
|
-
name: zod.z.string().min(1),
|
|
128
|
-
node_ids: zod.z.array(zod.z.string()).min(1),
|
|
129
|
-
rationale: zod.z.string().default("")
|
|
113
|
+
|
|
114
|
+
// src/parsers/typescript.ts
|
|
115
|
+
var IMPORT_RE2 = /import\s+.*?from\s+['"]([^'"]+)['"]/g;
|
|
116
|
+
var TYPE_RE2 = /\b(interface|type|class|enum)\s+(\w+)/g;
|
|
117
|
+
var FUNC_RE2 = /\bfunction\s+(\w+)\s*\(([^)]*)\)/g;
|
|
118
|
+
var ARROW_RE = /\bconst\s+(\w+)\s*=\s*\([^)]*\)\s*(?::\s*\w+)?\s*=>/g;
|
|
119
|
+
function parseTsFile(filepath, content) {
|
|
120
|
+
const result = {
|
|
121
|
+
imports: [],
|
|
122
|
+
types: [],
|
|
123
|
+
functions: [],
|
|
124
|
+
error_handling: []
|
|
125
|
+
};
|
|
126
|
+
let match;
|
|
127
|
+
while ((match = IMPORT_RE2.exec(content)) !== null) {
|
|
128
|
+
result.imports.push(match[1]);
|
|
129
|
+
}
|
|
130
|
+
while ((match = TYPE_RE2.exec(content)) !== null) {
|
|
131
|
+
result.types.push({
|
|
132
|
+
kind: match[1],
|
|
133
|
+
name: match[2],
|
|
134
|
+
file: filepath
|
|
130
135
|
});
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
name:
|
|
135
|
-
|
|
136
|
-
|
|
136
|
+
}
|
|
137
|
+
while ((match = FUNC_RE2.exec(content)) !== null) {
|
|
138
|
+
result.functions.push({
|
|
139
|
+
name: match[1],
|
|
140
|
+
params: (match[2] || "").trim(),
|
|
141
|
+
file: filepath
|
|
137
142
|
});
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}),
|
|
145
|
-
learning_nodes: zod.z.array(FeedNodeSchema).min(1, "curriculum feed must contain at least one learning node"),
|
|
146
|
-
dependency_edges: zod.z.array(FeedEdgeSchema).default([]),
|
|
147
|
-
suggested_groupings: zod.z.array(FeedGroupingSchema).default([]),
|
|
148
|
-
// Progressive completion phases (cross-feature). When non-empty, these — not
|
|
149
|
-
// suggested_groupings — define the teaching sequence and unit structure.
|
|
150
|
-
phases: zod.z.array(FeedPhaseSchema).default([])
|
|
143
|
+
}
|
|
144
|
+
while ((match = ARROW_RE.exec(content)) !== null) {
|
|
145
|
+
result.functions.push({
|
|
146
|
+
name: match[1],
|
|
147
|
+
params: "",
|
|
148
|
+
file: filepath
|
|
151
149
|
});
|
|
152
150
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
return
|
|
163
|
-
}
|
|
164
|
-
function pushWarning(opts, message) {
|
|
165
|
-
(opts.warnings ?? (opts.warnings = [])).push(message);
|
|
151
|
+
if (content.includes("try {") || content.includes("try{")) {
|
|
152
|
+
result.error_handling.push("try-catch");
|
|
153
|
+
}
|
|
154
|
+
if (content.includes("Promise")) {
|
|
155
|
+
result.error_handling.push("Promise");
|
|
156
|
+
}
|
|
157
|
+
if (content.includes(".catch(")) {
|
|
158
|
+
result.error_handling.push(".catch()");
|
|
159
|
+
}
|
|
160
|
+
return result;
|
|
166
161
|
}
|
|
167
|
-
function
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
162
|
+
function mergeTsResults(results) {
|
|
163
|
+
const merged = {
|
|
164
|
+
imports: [],
|
|
165
|
+
types: [],
|
|
166
|
+
functions: [],
|
|
167
|
+
error_handling: []
|
|
168
|
+
};
|
|
169
|
+
const importCounts = /* @__PURE__ */ new Map();
|
|
170
|
+
const errorCounts = /* @__PURE__ */ new Map();
|
|
171
|
+
for (const r of results) {
|
|
172
|
+
for (const imp of r.imports) {
|
|
173
|
+
importCounts.set(imp, (importCounts.get(imp) || 0) + 1);
|
|
174
|
+
}
|
|
175
|
+
merged.types.push(...r.types);
|
|
176
|
+
merged.functions.push(...r.functions);
|
|
177
|
+
for (const e of r.error_handling) {
|
|
178
|
+
errorCounts.set(e, (errorCounts.get(e) || 0) + 1);
|
|
177
179
|
}
|
|
178
180
|
}
|
|
181
|
+
merged.imports = Array.from(importCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([mod]) => mod);
|
|
182
|
+
merged.error_handling = Array.from(errorCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([p]) => p);
|
|
183
|
+
merged.types = merged.types.slice(0, 100);
|
|
184
|
+
merged.functions = merged.functions.slice(0, 100);
|
|
185
|
+
return merged;
|
|
179
186
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
references: arr(step.files).map((f) => ({ file: str(f), evidence: "step file" })).filter((f) => f.file),
|
|
194
|
-
feature_id: featureId,
|
|
195
|
-
concept_ref: null,
|
|
196
|
-
// P52/T2.1: carry the step's tangible outcome so the planner can cut
|
|
197
|
-
// sessions at user-visible checkpoints instead of raw minute counts.
|
|
198
|
-
user_visible_deliverable: str(step.outcome?.user_visible),
|
|
199
|
-
is_core: false,
|
|
200
|
-
phase_id: "",
|
|
201
|
-
introduce_aspect: null
|
|
187
|
+
|
|
188
|
+
// src/parsers/python.ts
|
|
189
|
+
var IMPORT_RE3 = /^(?:import|from)\s+(\w+)/gm;
|
|
190
|
+
var CLASS_RE = /\bclass\s+(\w+)(?:\s*\(([^)]+)\))?/g;
|
|
191
|
+
var FUNC_RE3 = /\bdef\s+(\w+)\s*\(([^)]*)\)/g;
|
|
192
|
+
var DOCSTRING_RE = /"""([^"]{20,300})"""/g;
|
|
193
|
+
function parsePythonFile(filepath, content) {
|
|
194
|
+
const result = {
|
|
195
|
+
imports: [],
|
|
196
|
+
types: [],
|
|
197
|
+
functions: [],
|
|
198
|
+
docstrings: [],
|
|
199
|
+
error_handling: []
|
|
202
200
|
};
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
if (explicit === "ulo" || explicit === "cio" || explicit === "sio") return explicit;
|
|
207
|
-
const sio = arr(concept.sio);
|
|
208
|
-
const prereqs = strs(concept.prerequisites);
|
|
209
|
-
const pts = arr(concept.problem_types);
|
|
210
|
-
const hasHighBloom = pts.some((p) => {
|
|
211
|
-
const b = str(p.bloom_level);
|
|
212
|
-
return b === "Apply" || b === "Analyze" || b === "Evaluate" || b === "Create";
|
|
213
|
-
});
|
|
214
|
-
if (sio.length >= 2 || hasHighBloom) return "sio";
|
|
215
|
-
if (str(concept.cio) || prereqs.length >= 1) return "cio";
|
|
216
|
-
return "ulo";
|
|
217
|
-
}
|
|
218
|
-
function deriveDepthInputs(minutes, depth) {
|
|
219
|
-
const ulo = Math.max(5, Math.round(minutes * 0.35));
|
|
220
|
-
const cio = Math.max(ulo + 5, Math.round(minutes * 0.65));
|
|
221
|
-
const sio = Math.max(cio + 5, minutes);
|
|
222
|
-
const variants = { ulo, cio, sio };
|
|
223
|
-
const candidates = [];
|
|
224
|
-
if (depth === "sio") {
|
|
225
|
-
candidates.push({ from_depth: "sio", to_depth: "cio", minutes_saved: sio - cio, reason: "SIO implementation practice deferred \u2014 CIO mechanism suffices for downstream consumers" });
|
|
226
|
-
}
|
|
227
|
-
if (depth === "cio" || depth === "sio") {
|
|
228
|
-
candidates.push({ from_depth: depth, to_depth: "ulo", minutes_saved: (depth === "sio" ? sio : cio) - ulo, reason: "Mechanism deferred \u2014 ULO awareness suffices (know-it-exists); never below the ULO floor" });
|
|
201
|
+
let match;
|
|
202
|
+
while ((match = IMPORT_RE3.exec(content)) !== null) {
|
|
203
|
+
result.imports.push(match[1]);
|
|
229
204
|
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
const topBloom = pts[pts.length - 1] ?? "Understand";
|
|
236
|
-
const rawRefs = arr(concept.references);
|
|
237
|
-
const references = rawRefs.length > 0 ? rawRefs.map((r) => ({ file: str(r.file, "syllabus.md"), evidence: str(r.evidence, str(concept.name)) })).filter((r) => r.file) : [{ file: "syllabus.md", evidence: str(concept.category ? `Topic under category ${str(concept.category)}` : str(concept.name), id) }];
|
|
238
|
-
const problemScaffolds = arr(concept.problem_types).map((pt) => ({
|
|
239
|
-
item: str(pt.name, "Problem type"),
|
|
240
|
-
reason: str(pt.description, "Formative practice check"),
|
|
241
|
-
minutes_saved: num(pt.estimated_minutes, 10),
|
|
242
|
-
suggest: "provide_partial"
|
|
243
|
-
}));
|
|
244
|
-
return {
|
|
245
|
-
id,
|
|
246
|
-
kind: "concept",
|
|
247
|
-
name: str(concept.name, id),
|
|
248
|
-
concept_codes: [str(concept.code) || id].filter(Boolean),
|
|
249
|
-
keywords: { all: strs(concept.keywords), new: [], prerequisite: [] },
|
|
250
|
-
estimated_minutes: Math.max(0, Math.round(num(concept.estimated_minutes, 15))),
|
|
251
|
-
bloom_hint: topBloom,
|
|
252
|
-
depth_hint: deriveConceptDepthHint(concept),
|
|
253
|
-
completion_level: "standard",
|
|
254
|
-
scaffolding: {
|
|
255
|
-
scaffold_candidates: problemScaffolds,
|
|
256
|
-
core_value_minutes: Math.max(0, Math.round(num(concept.estimated_minutes, 15)))
|
|
257
|
-
},
|
|
258
|
-
references,
|
|
259
|
-
feature_id: null,
|
|
260
|
-
concept_ref: id,
|
|
261
|
-
user_visible_deliverable: "",
|
|
262
|
-
// concepts are taught, not shipped — checkpoints live on product steps
|
|
263
|
-
...deriveDepthInputs(Math.max(0, Math.round(num(concept.estimated_minutes, 15))), deriveConceptDepthHint(concept)),
|
|
264
|
-
is_core: false,
|
|
265
|
-
phase_id: "",
|
|
266
|
-
introduce_aspect: null
|
|
267
|
-
};
|
|
268
|
-
}
|
|
269
|
-
function addFeatureGroup(f, fid, nodes, edges, groupings) {
|
|
270
|
-
const steps = arr(f.steps);
|
|
271
|
-
const stepIds = [];
|
|
272
|
-
let prev = "";
|
|
273
|
-
for (let i = 0; i < steps.length; i++) {
|
|
274
|
-
const stepNode = feedNodeFromStep(steps[i], fid);
|
|
275
|
-
if (!stepNode.id) continue;
|
|
276
|
-
stepIds.push(stepNode.id);
|
|
277
|
-
nodes.push(stepNode);
|
|
278
|
-
if (prev) edges.push({ from: prev, to: stepNode.id, kind: "task", reason: "feature build order" });
|
|
279
|
-
prev = stepNode.id;
|
|
205
|
+
while ((match = DOCSTRING_RE.exec(content)) !== null) {
|
|
206
|
+
const firstSentence = match[1].trim().split("\n")[0].slice(0, 120);
|
|
207
|
+
if (firstSentence.length > 15) {
|
|
208
|
+
result.docstrings.push(firstSentence);
|
|
209
|
+
}
|
|
280
210
|
}
|
|
281
|
-
|
|
282
|
-
const
|
|
283
|
-
|
|
211
|
+
while ((match = CLASS_RE.exec(content)) !== null) {
|
|
212
|
+
const bases = (match[2] || "").split(",").map((b) => b.trim()).filter(Boolean);
|
|
213
|
+
result.types.push({
|
|
214
|
+
kind: "class",
|
|
215
|
+
name: match[1],
|
|
216
|
+
bases,
|
|
217
|
+
file: filepath
|
|
218
|
+
});
|
|
284
219
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
const pg = hybrid.project_graph ?? {};
|
|
293
|
-
const kg = hybrid.knowledge_graph ?? {};
|
|
294
|
-
const rawPhases = arr(hybrid.phases).filter((p) => str(p.id));
|
|
295
|
-
const features = arr(pg.features);
|
|
296
|
-
const conceptRecs = arr(kg.concepts);
|
|
297
|
-
const conceptById = new Map(conceptRecs.map((c) => [str(c.id), c]));
|
|
298
|
-
if (rawPhases.length === 0) {
|
|
299
|
-
for (const c of conceptRecs) {
|
|
300
|
-
const node = feedNodeFromConcept(c);
|
|
301
|
-
nodes.push(node);
|
|
302
|
-
for (const p of strs(c.prerequisites)) edges.push({ from: p, to: node.id, kind: "knowledge", reason: "concept prerequisite" });
|
|
303
|
-
}
|
|
304
|
-
features.forEach((f, fi) => addFeatureGroup(f, str(f.id, "F" + pad22(fi + 1)), nodes, edges, groupings));
|
|
305
|
-
linkHybridKnowledgeEdges(hybrid, nodes, edges);
|
|
306
|
-
newKeywordsInOrder(nodes);
|
|
307
|
-
return validateCurriculumFeed({
|
|
308
|
-
schema_version: 2,
|
|
309
|
-
source: { graph_type: "hybrid_graph", warnings: opts.warnings ?? [], hallucination_count: opts.hallucinationCount ?? 0 },
|
|
310
|
-
learning_nodes: nodes,
|
|
311
|
-
dependency_edges: edges,
|
|
312
|
-
suggested_groupings: groupings,
|
|
313
|
-
phases: []
|
|
220
|
+
while ((match = FUNC_RE3.exec(content)) !== null) {
|
|
221
|
+
const name = match[1];
|
|
222
|
+
if (name.startsWith("__") && name.endsWith("__")) continue;
|
|
223
|
+
result.functions.push({
|
|
224
|
+
name,
|
|
225
|
+
params: (match[2] || "").trim(),
|
|
226
|
+
file: filepath
|
|
314
227
|
});
|
|
315
228
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
const stepIdsByFeature = /* @__PURE__ */ new Map();
|
|
319
|
-
for (const f of features) {
|
|
320
|
-
const fid = str(f.id);
|
|
321
|
-
stepIdsByFeature.set(fid, arr(f.steps).map((s) => str(s.id)).filter(Boolean));
|
|
229
|
+
if (content.includes("try:")) {
|
|
230
|
+
result.error_handling.push("try-except");
|
|
322
231
|
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
const previewsEmitted = /* @__PURE__ */ new Set();
|
|
326
|
-
const linkList = arr(hybrid.links);
|
|
327
|
-
for (const rp of rawPhases) {
|
|
328
|
-
const pid = str(rp.id);
|
|
329
|
-
const order = num(rp.order, emitCtx.length + 1);
|
|
330
|
-
const conceptOrder = [];
|
|
331
|
-
const intros = arr(rp.introduces);
|
|
332
|
-
const news = intros.filter((i) => str(i.aspect, "new") === "new");
|
|
333
|
-
const advanced = intros.filter((i) => str(i.aspect) === "advanced");
|
|
334
|
-
const previews = intros.filter((i) => str(i.aspect) === "preview");
|
|
335
|
-
for (const intro of [...news, ...advanced, ...previews]) {
|
|
336
|
-
const cid = str(intro.concept_id);
|
|
337
|
-
const c = conceptById.get(cid);
|
|
338
|
-
if (!c) {
|
|
339
|
-
throw new Error("Phase " + pid + " introduces unknown concept " + cid + " - fail-closed");
|
|
340
|
-
}
|
|
341
|
-
if (str(intro.aspect, "new") === "advanced" && !emittedConcepts.has(cid)) {
|
|
342
|
-
throw new Error("Phase " + pid + " advanced-revisits concept " + cid + " that was never emitted - fail-closed");
|
|
343
|
-
}
|
|
344
|
-
if (str(intro.aspect, "new") === "preview") {
|
|
345
|
-
if (emittedConcepts.has(cid)) {
|
|
346
|
-
throw new Error("Phase " + pid + " previews concept " + cid + " that was already introduced - preview must precede the anchor teach - fail-closed");
|
|
347
|
-
}
|
|
348
|
-
if (previewsEmitted.has(cid)) {
|
|
349
|
-
throw new Error("Phase " + pid + " duplicate preview of concept " + cid + " - fail-closed");
|
|
350
|
-
}
|
|
351
|
-
const pidIdx = rawPhases.findIndex((rp2) => str(rp2.id) === pid);
|
|
352
|
-
const anchorIdx = rawPhases.findIndex((rp2) => arr(rp2.introduces).some((i2) => str(i2.concept_id) === cid && str(i2.aspect, "new") !== "preview"));
|
|
353
|
-
if (anchorIdx < 0) {
|
|
354
|
-
throw new Error("Phase " + pid + " previews concept " + cid + " that is never fully introduced in any phase - fail-closed");
|
|
355
|
-
}
|
|
356
|
-
if (pidIdx >= anchorIdx) {
|
|
357
|
-
throw new Error("Phase " + pid + " previews concept " + cid + " whose anchor introduction is not in a LATER phase - fail-closed");
|
|
358
|
-
}
|
|
359
|
-
previewsEmitted.add(cid);
|
|
360
|
-
const prevNode = feedNodeFromConcept(c);
|
|
361
|
-
prevNode.id = cid + "__PREV" + pad22(order);
|
|
362
|
-
prevNode.name = str(c.name, cid) + " (preview)";
|
|
363
|
-
prevNode.phase_id = pid;
|
|
364
|
-
prevNode.introduce_aspect = "preview";
|
|
365
|
-
prevNode.estimated_minutes = Math.max(5, Math.round(prevNode.estimated_minutes * 0.25));
|
|
366
|
-
prevNode.depth_hint = "ulo";
|
|
367
|
-
prevNode.bloom_hint = "Understand";
|
|
368
|
-
prevNode.concept_codes = [str(c.code) || cid];
|
|
369
|
-
prevNode.keywords = { all: strs(c.keywords), new: [], prerequisite: [] };
|
|
370
|
-
prevNode.scaffolding = { scaffold_candidates: [], core_value_minutes: 0 };
|
|
371
|
-
prevNode.depth_variants = { ulo: prevNode.estimated_minutes, cio: prevNode.estimated_minutes, sio: prevNode.estimated_minutes };
|
|
372
|
-
prevNode.depth_scaffold_candidates = [];
|
|
373
|
-
nodes.push(prevNode);
|
|
374
|
-
conceptOrder.push(prevNode.id);
|
|
375
|
-
continue;
|
|
376
|
-
}
|
|
377
|
-
if (!emittedConcepts.has(cid)) {
|
|
378
|
-
const node = feedNodeFromConcept(c);
|
|
379
|
-
node.phase_id = pid;
|
|
380
|
-
node.introduce_aspect = "new";
|
|
381
|
-
nodes.push(node);
|
|
382
|
-
for (const p of strs(c.prerequisites)) {
|
|
383
|
-
if (!emittedConcepts.has(p)) {
|
|
384
|
-
throw new Error("Concept " + cid + " (phase " + pid + ") has prerequisite " + p + " that is not introduced in an earlier phase - fail-closed");
|
|
385
|
-
}
|
|
386
|
-
edges.push({ from: p, to: cid, kind: "knowledge", reason: "concept prerequisite" });
|
|
387
|
-
}
|
|
388
|
-
emittedConcepts.add(cid);
|
|
389
|
-
conceptOrder.push(cid);
|
|
390
|
-
} else {
|
|
391
|
-
const revisitId = cid + "__ADV" + pad22(order);
|
|
392
|
-
const node = feedNodeFromConcept(c);
|
|
393
|
-
node.id = revisitId;
|
|
394
|
-
node.name = str(c.name, cid) + " (advanced)";
|
|
395
|
-
node.phase_id = pid;
|
|
396
|
-
node.introduce_aspect = "advanced";
|
|
397
|
-
node.depth_scaffold_candidates = [{ from_depth: "sio", to_depth: "cio", minutes_saved: Math.round(node.estimated_minutes * 0.3), reason: "advanced revisit may drop to CIO recap without breaking first-teach chain" }];
|
|
398
|
-
node.estimated_minutes = Math.max(15, Math.round(node.estimated_minutes * 0.5));
|
|
399
|
-
node.concept_codes = [str(c.code) || cid];
|
|
400
|
-
edges.push({ from: cid, to: revisitId, kind: "knowledge", reason: "advanced revisit of " + cid + " at phase " + pid });
|
|
401
|
-
nodes.push(node);
|
|
402
|
-
conceptOrder.push(revisitId);
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
const featureIds = strs(rp.feature_ids).filter((fid) => featureOrder.has(fid)).sort((a, b) => featureOrder.get(a) - featureOrder.get(b));
|
|
406
|
-
emitCtx.push({ id: pid, order, name: str(rp.name, pid), productCompletion: str(rp.product_completion), conceptOrder, featureIds });
|
|
232
|
+
if (content.includes("raise")) {
|
|
233
|
+
result.error_handling.push("raise");
|
|
407
234
|
}
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
235
|
+
return result;
|
|
236
|
+
}
|
|
237
|
+
function mergePythonResults(results) {
|
|
238
|
+
const merged = {
|
|
239
|
+
imports: [],
|
|
240
|
+
types: [],
|
|
241
|
+
functions: [],
|
|
242
|
+
docstrings: [],
|
|
243
|
+
error_handling: []
|
|
244
|
+
};
|
|
245
|
+
const importCounts = /* @__PURE__ */ new Map();
|
|
246
|
+
const errorCounts = /* @__PURE__ */ new Map();
|
|
247
|
+
for (const r of results) {
|
|
248
|
+
for (const imp of r.imports) {
|
|
249
|
+
importCounts.set(imp, (importCounts.get(imp) || 0) + 1);
|
|
412
250
|
}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
}
|
|
419
|
-
const phaseNodeIds = [];
|
|
420
|
-
for (const ctx of emitCtx) {
|
|
421
|
-
const ids = [...ctx.conceptOrder];
|
|
422
|
-
for (const fid of ctx.featureIds) {
|
|
423
|
-
const f = features.find((x) => str(x.id) === fid);
|
|
424
|
-
const stepIds = addFeatureGroup(f, fid, nodes, edges, groupings);
|
|
425
|
-
for (const sid of stepIds) {
|
|
426
|
-
const node = nodes.find((n) => n.id === sid);
|
|
427
|
-
node.phase_id = ctx.id;
|
|
428
|
-
}
|
|
429
|
-
ids.push(...stepIds);
|
|
251
|
+
merged.types.push(...r.types);
|
|
252
|
+
merged.functions.push(...r.functions);
|
|
253
|
+
merged.docstrings.push(...r.docstrings);
|
|
254
|
+
for (const e of r.error_handling) {
|
|
255
|
+
errorCounts.set(e, (errorCounts.get(e) || 0) + 1);
|
|
430
256
|
}
|
|
431
|
-
phaseNodeIds.push(ids);
|
|
432
257
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
for (const n of nodes) if (!placed.has(n.id)) orderedNodes.push(n);
|
|
442
|
-
nodes.length = 0;
|
|
443
|
-
nodes.push(...orderedNodes);
|
|
444
|
-
const edgeSeen = new Set(edges.map((e) => e.from + ">" + e.to));
|
|
445
|
-
const pushEdge = (from, to, reason) => {
|
|
446
|
-
const key = from + ">" + to;
|
|
447
|
-
if (edgeSeen.has(key)) return;
|
|
448
|
-
edgeSeen.add(key);
|
|
449
|
-
edges.push({ from, to, kind: "knowledge", reason });
|
|
450
|
-
};
|
|
451
|
-
for (const l of linkList) {
|
|
452
|
-
const cid = str(l.concept_id);
|
|
453
|
-
const fid = str(l.feature_id);
|
|
454
|
-
if (!conceptById.has(cid)) continue;
|
|
455
|
-
const firstStep = (stepIdsByFeature.get(fid) || [])[0];
|
|
456
|
-
if (!firstStep) continue;
|
|
457
|
-
const depth = str(l.depth, "cio");
|
|
458
|
-
const anchorNode = nodes.find((n2) => n2.kind === "concept" && n2.concept_ref === cid && n2.introduce_aspect === "new");
|
|
459
|
-
const stepNode = nodes.find((n2) => n2.id === firstStep);
|
|
460
|
-
const anchorPhaseIdx = anchorNode ? emitCtx.findIndex((c2) => c2.id === anchorNode.phase_id) : -1;
|
|
461
|
-
const stepPhaseIdx = stepNode ? emitCtx.findIndex((c2) => c2.id === stepNode.phase_id) : -1;
|
|
462
|
-
if (emitCtx.length > 0 && anchorPhaseIdx > stepPhaseIdx) {
|
|
463
|
-
pushWarning(opts, "hybrid link drift: concept " + cid + " is fully taught in a later phase than consumer " + firstStep + " (a preview covers the dependency)");
|
|
464
|
-
continue;
|
|
465
|
-
}
|
|
466
|
-
pushEdge(cid, firstStep, str(l.relationship, "hybrid link") + " [" + depth + "]");
|
|
467
|
-
for (const n of nodes) {
|
|
468
|
-
if (n.kind === "concept" && n.concept_ref === cid && n.introduce_aspect === "advanced" && n.phase_id === str(l.phase_id)) {
|
|
469
|
-
pushEdge(n.id, firstStep, "advanced application [" + depth + "]");
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
newKeywordsInOrder(nodes);
|
|
474
|
-
const feedOrder = new Map(nodes.map((n, i) => [n.id, i]));
|
|
475
|
-
const introducer = /* @__PURE__ */ new Map();
|
|
476
|
-
for (const n of nodes) for (const k of n.keywords?.new ?? []) {
|
|
477
|
-
const key = k.toLowerCase();
|
|
478
|
-
if (!introducer.has(key)) introducer.set(key, n.id);
|
|
479
|
-
}
|
|
480
|
-
for (const n of nodes) {
|
|
481
|
-
for (const k of n.keywords?.prerequisite ?? []) {
|
|
482
|
-
const src = introducer.get(k.toLowerCase());
|
|
483
|
-
if (!src || src === n.id) continue;
|
|
484
|
-
const srcNode = nodes.find((x) => x.id === src);
|
|
485
|
-
const phaseOf = (id) => {
|
|
486
|
-
const n2 = nodes.find((x) => x.id === id);
|
|
487
|
-
return n2 ? emitCtx.findIndex((c) => c.id === n2.phase_id) : -1;
|
|
488
|
-
};
|
|
489
|
-
const conceptOvertaken = n.kind === "concept" && srcNode?.kind === "product_step";
|
|
490
|
-
const fromPhase = phaseOf(src);
|
|
491
|
-
const toPhase = phaseOf(n.id);
|
|
492
|
-
if (conceptOvertaken || emitCtx.length > 0 && fromPhase > toPhase) {
|
|
493
|
-
pushWarning(
|
|
494
|
-
opts,
|
|
495
|
-
"keyword-order drift: introducer " + src + " sits in a later phase than consumer " + n.id + ' (keyword "' + k + '") \u2014 host should move the introducer earlier'
|
|
496
|
-
);
|
|
497
|
-
continue;
|
|
498
|
-
}
|
|
499
|
-
if (feedOrder.get(src) < feedOrder.get(n.id)) pushEdge(src, n.id, "keyword prerequisite [" + k + "]");
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
const phases = emitCtx.map((ctx, i) => ({
|
|
503
|
-
id: ctx.id,
|
|
504
|
-
order: ctx.order,
|
|
505
|
-
name: ctx.name,
|
|
506
|
-
product_completion: ctx.productCompletion || "Phase " + (i + 1) + " deliverable",
|
|
507
|
-
node_ids: phaseNodeIds[i]
|
|
508
|
-
}));
|
|
509
|
-
return validateCurriculumFeed({
|
|
510
|
-
schema_version: 2,
|
|
511
|
-
source: { graph_type: "hybrid_graph", warnings: opts.warnings ?? [], hallucination_count: opts.hallucinationCount ?? 0 },
|
|
512
|
-
learning_nodes: nodes,
|
|
513
|
-
dependency_edges: edges,
|
|
514
|
-
suggested_groupings: groupings,
|
|
515
|
-
phases
|
|
516
|
-
});
|
|
517
|
-
}
|
|
518
|
-
function linkHybridKnowledgeEdges(hybrid, nodes, edges, setStepHints) {
|
|
519
|
-
const conceptIds = new Set(nodes.filter((n) => n.kind === "concept").map((n) => n.concept_ref || n.id));
|
|
520
|
-
for (const l of arr(hybrid.links)) {
|
|
521
|
-
const conceptId = str(l.concept_id);
|
|
522
|
-
const featureId = str(l.feature_id);
|
|
523
|
-
if (!conceptIds.has(conceptId)) continue;
|
|
524
|
-
const depth = str(l.depth, "cio");
|
|
525
|
-
const firstStep = nodes.find((n) => n.feature_id === featureId);
|
|
526
|
-
if (firstStep) {
|
|
527
|
-
edges.push({ from: conceptId, to: firstStep.id, kind: "knowledge", reason: str(l.relationship, "hybrid link") + " [" + depth + "]" });
|
|
528
|
-
if (!firstStep.depth_hint) {
|
|
529
|
-
firstStep.depth_hint = ["ulo", "cio", "sio"].includes(depth) ? depth : "cio";
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
function emitFromKnowledge(kg, opts) {
|
|
535
|
-
const nodes = [];
|
|
536
|
-
const edges = [];
|
|
537
|
-
const groupings = [];
|
|
538
|
-
const conceptIds = new Set(arr(kg.concepts).map((c) => str(c.id)));
|
|
539
|
-
for (const c of arr(kg.concepts)) {
|
|
540
|
-
const node = feedNodeFromConcept(c);
|
|
541
|
-
nodes.push(node);
|
|
542
|
-
for (const p of strs(c.prerequisites)) {
|
|
543
|
-
if (p !== node.id && conceptIds.has(p)) {
|
|
544
|
-
edges.push({
|
|
545
|
-
from: p,
|
|
546
|
-
to: node.id,
|
|
547
|
-
kind: "knowledge",
|
|
548
|
-
reason: `concept prerequisite: ${p} must be understood before ${node.id}`
|
|
549
|
-
});
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
if (arr(kg.learning_path).length > 0) {
|
|
554
|
-
const orderMap = /* @__PURE__ */ new Map();
|
|
555
|
-
arr(kg.learning_path).forEach((s, idx) => {
|
|
556
|
-
const cid = str(s.concept_id);
|
|
557
|
-
if (cid) orderMap.set(cid, num(s.order, idx + 1));
|
|
558
|
-
});
|
|
559
|
-
nodes.sort((a, b) => (orderMap.get(a.id) ?? 999) - (orderMap.get(b.id) ?? 999));
|
|
560
|
-
}
|
|
561
|
-
let ci = 0;
|
|
562
|
-
for (const cat of arr(kg.categories)) {
|
|
563
|
-
ci += 1;
|
|
564
|
-
const ids = strs(cat.concept_ids).filter((id) => nodes.some((n) => n.id === id));
|
|
565
|
-
if (ids.length > 0) {
|
|
566
|
-
groupings.push({
|
|
567
|
-
id: str(cat.id, "cat" + ci),
|
|
568
|
-
name: str(cat.name, "Category " + ci),
|
|
569
|
-
node_ids: ids,
|
|
570
|
-
rationale: str(cat.description, "")
|
|
571
|
-
});
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
newKeywordsInOrder(nodes);
|
|
575
|
-
return validateCurriculumFeed({
|
|
576
|
-
schema_version: 2,
|
|
577
|
-
source: { graph_type: "knowledge_graph", warnings: opts.warnings ?? [], hallucination_count: opts.hallucinationCount ?? 0 },
|
|
578
|
-
learning_nodes: nodes,
|
|
579
|
-
dependency_edges: edges,
|
|
580
|
-
suggested_groupings: groupings,
|
|
581
|
-
phases: []
|
|
582
|
-
});
|
|
583
|
-
}
|
|
584
|
-
function emitFromProject(pg, opts) {
|
|
585
|
-
const nodes = [];
|
|
586
|
-
const edges = [];
|
|
587
|
-
const groupings = [];
|
|
588
|
-
arr(pg.features).forEach((f, fi) => addFeatureGroup(f, str(f.id, "F" + pad22(fi + 1)), nodes, edges, groupings));
|
|
589
|
-
if (nodes.length === 0 && arr(pg.features).length > 0) {
|
|
590
|
-
const hasTasks = arr(pg.implementation ?? {}).length > 0 || arr(pg.implementation?.tasks).length > 0;
|
|
591
|
-
throw new Error(
|
|
592
|
-
"Project graph has " + arr(pg.features).length + " features but NO features[].steps - the curriculum feed cannot emit planning nodes." + (hasTasks ? " This looks like a legacy knowledge-tree graph (implementation.tasks). Regenerate it with the domain-kit v3 pipeline (runProjectGraphPipeline / hybridGraphPipeline), then re-run." : " Every feature must carry at least one step with id/name/keywords/effort for curriculum planning.")
|
|
593
|
-
);
|
|
594
|
-
}
|
|
595
|
-
newKeywordsInOrder(nodes);
|
|
596
|
-
return validateCurriculumFeed({
|
|
597
|
-
schema_version: 2,
|
|
598
|
-
source: { graph_type: "project_graph", warnings: opts.warnings ?? [], hallucination_count: opts.hallucinationCount ?? 0 },
|
|
599
|
-
learning_nodes: nodes,
|
|
600
|
-
dependency_edges: edges,
|
|
601
|
-
suggested_groupings: groupings,
|
|
602
|
-
phases: []
|
|
603
|
-
});
|
|
604
|
-
}
|
|
605
|
-
function emitCurriculumFeed(graph, opts = {}) {
|
|
606
|
-
const g = graph;
|
|
607
|
-
if (arr(g.learning_nodes).length > 0) {
|
|
608
|
-
return validateCurriculumFeed(g);
|
|
609
|
-
}
|
|
610
|
-
if (g.project_graph || g.knowledge_graph || arr(g.links).length > 0 || arr(g.phases).length > 0) {
|
|
611
|
-
return emitFromHybrid(g, opts);
|
|
612
|
-
}
|
|
613
|
-
if (arr(g.concepts).length > 0 || str(g.type) === "knowledge_graph") return emitFromKnowledge(g, opts);
|
|
614
|
-
return emitFromProject(g, opts);
|
|
258
|
+
merged.imports = Array.from(importCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([mod]) => mod);
|
|
259
|
+
merged.error_handling = Array.from(errorCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([p]) => p);
|
|
260
|
+
merged.types = merged.types.slice(0, 100);
|
|
261
|
+
merged.functions = merged.functions.slice(0, 100);
|
|
262
|
+
merged.docstrings = merged.docstrings.slice(0, 20);
|
|
263
|
+
return merged;
|
|
615
264
|
}
|
|
616
|
-
var BLOOM_RANK, pad22, arr, str, num, strs;
|
|
617
|
-
var init_curriculumFeedEmitter = __esm({
|
|
618
|
-
"src/feed/curriculumFeedEmitter.ts"() {
|
|
619
|
-
init_curriculumFeedSchema();
|
|
620
|
-
BLOOM_RANK = { Remember: 0, Understand: 1, Apply: 2, Analyze: 3, Evaluate: 4, Create: 5 };
|
|
621
|
-
pad22 = (n) => String(n).padStart(2, "0");
|
|
622
|
-
arr = (v) => Array.isArray(v) ? v : [];
|
|
623
|
-
str = (v, d = "") => typeof v === "string" ? v : d;
|
|
624
|
-
num = (v, d) => typeof v === "number" && Number.isFinite(v) ? v : d;
|
|
625
|
-
strs = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
|
|
626
|
-
}
|
|
627
|
-
});
|
|
628
265
|
|
|
629
|
-
// src/parsers/
|
|
630
|
-
var
|
|
631
|
-
var
|
|
632
|
-
var
|
|
633
|
-
|
|
634
|
-
var FRAMEWORK_USAGE_RE = /\b(URLSession|URLRequest|JSONSerialization|JSONDecoder|JSONEncoder|Task|DispatchQueue|Timer|NotificationCenter|UserDefaults|FileManager|Bundle)\b/g;
|
|
635
|
-
function parseSwiftFile(filepath, content) {
|
|
266
|
+
// src/parsers/cpp.ts
|
|
267
|
+
var INCLUDE_RE = /#include\s*[<"]([^>"]+)[>"]/g;
|
|
268
|
+
var TYPE_RE3 = /\b(?:class|struct|enum)\s+(\w+)/g;
|
|
269
|
+
var FUNC_RE4 = /\b(?:void|int|float|double|char|bool|String|uint8_t|uint16_t|uint32_t)\s+(\w+)\s*\(/g;
|
|
270
|
+
function parseCppFile(filepath, content) {
|
|
636
271
|
const result = {
|
|
637
272
|
imports: [],
|
|
638
273
|
types: [],
|
|
639
274
|
functions: [],
|
|
640
|
-
|
|
641
|
-
error_handling: [],
|
|
642
|
-
frameworks_used: []
|
|
275
|
+
error_handling: []
|
|
643
276
|
};
|
|
644
277
|
let match;
|
|
645
|
-
while ((match =
|
|
278
|
+
while ((match = INCLUDE_RE.exec(content)) !== null) {
|
|
646
279
|
result.imports.push(match[1]);
|
|
647
280
|
}
|
|
648
|
-
|
|
649
|
-
while ((match = FRAMEWORK_USAGE_RE.exec(content)) !== null) {
|
|
650
|
-
if (!fwSeen.has(match[1])) {
|
|
651
|
-
fwSeen.add(match[1]);
|
|
652
|
-
result.frameworks_used.push(match[1]);
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
while ((match = TYPE_RE.exec(content)) !== null) {
|
|
656
|
-
const conforms = (match[3] || "").split(",").map((p) => p.trim()).filter(Boolean);
|
|
281
|
+
while ((match = TYPE_RE3.exec(content)) !== null) {
|
|
657
282
|
result.types.push({
|
|
658
|
-
kind:
|
|
659
|
-
name: match[
|
|
660
|
-
conforms_to: conforms,
|
|
283
|
+
kind: "cpp",
|
|
284
|
+
name: match[1],
|
|
661
285
|
file: filepath
|
|
662
286
|
});
|
|
663
287
|
}
|
|
664
|
-
while ((match =
|
|
665
|
-
const name = match[1];
|
|
666
|
-
if (name.startsWith("_")) continue;
|
|
288
|
+
while ((match = FUNC_RE4.exec(content)) !== null) {
|
|
667
289
|
result.functions.push({
|
|
668
|
-
name,
|
|
669
|
-
params: (match[2] || "").trim(),
|
|
670
|
-
returns: (match[3] || "").trim(),
|
|
290
|
+
name: match[1],
|
|
671
291
|
file: filepath
|
|
672
292
|
});
|
|
673
293
|
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
wrapperSet.add(match[1]);
|
|
677
|
-
}
|
|
678
|
-
result.property_wrappers = Array.from(wrapperSet);
|
|
679
|
-
if (content.includes("do {") || content.includes("do{")) {
|
|
680
|
-
result.error_handling.push("do-catch");
|
|
681
|
-
}
|
|
682
|
-
if (content.includes("throws")) {
|
|
683
|
-
result.error_handling.push("throws");
|
|
684
|
-
}
|
|
685
|
-
if (content.includes("Result<")) {
|
|
686
|
-
result.error_handling.push("Result type");
|
|
687
|
-
}
|
|
688
|
-
if (content.includes("try?")) {
|
|
689
|
-
result.error_handling.push("try?");
|
|
690
|
-
}
|
|
691
|
-
if (content.includes("try!")) {
|
|
692
|
-
result.error_handling.push("try!");
|
|
294
|
+
if (/\btry\b|\bthrow\b|\bcatch\b/.test(content)) {
|
|
295
|
+
result.error_handling.push("try/throw/catch");
|
|
693
296
|
}
|
|
694
297
|
return result;
|
|
695
298
|
}
|
|
696
|
-
function
|
|
299
|
+
function mergeCppResults(results) {
|
|
697
300
|
const merged = {
|
|
698
301
|
imports: [],
|
|
699
302
|
types: [],
|
|
700
303
|
functions: [],
|
|
701
|
-
|
|
702
|
-
error_handling: [],
|
|
703
|
-
frameworks_used: []
|
|
304
|
+
error_handling: []
|
|
704
305
|
};
|
|
705
306
|
const importCounts = /* @__PURE__ */ new Map();
|
|
706
|
-
const wrapperCounts = /* @__PURE__ */ new Map();
|
|
707
|
-
const errorCounts = /* @__PURE__ */ new Map();
|
|
708
|
-
const frameworkSet = /* @__PURE__ */ new Set();
|
|
709
307
|
for (const r of results) {
|
|
710
308
|
for (const imp of r.imports) {
|
|
711
309
|
importCounts.set(imp, (importCounts.get(imp) || 0) + 1);
|
|
712
310
|
}
|
|
713
311
|
merged.types.push(...r.types);
|
|
714
312
|
merged.functions.push(...r.functions);
|
|
715
|
-
|
|
716
|
-
wrapperCounts.set(w, (wrapperCounts.get(w) || 0) + 1);
|
|
717
|
-
}
|
|
718
|
-
for (const e of r.error_handling) {
|
|
719
|
-
errorCounts.set(e, (errorCounts.get(e) || 0) + 1);
|
|
720
|
-
}
|
|
721
|
-
for (const fw of r.frameworks_used) {
|
|
722
|
-
frameworkSet.add(fw);
|
|
723
|
-
}
|
|
724
|
-
}
|
|
725
|
-
merged.imports = Array.from(importCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([mod, count]) => mod);
|
|
726
|
-
merged.property_wrappers = Array.from(wrapperCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([w]) => w);
|
|
727
|
-
merged.error_handling = Array.from(errorCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([p]) => p);
|
|
728
|
-
merged.frameworks_used = Array.from(frameworkSet).sort();
|
|
729
|
-
merged.types = merged.types.slice(0, 100);
|
|
730
|
-
merged.functions = merged.functions.slice(0, 100);
|
|
731
|
-
return merged;
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
// src/parsers/typescript.ts
|
|
735
|
-
var IMPORT_RE2 = /import\s+.*?from\s+['"]([^'"]+)['"]/g;
|
|
736
|
-
var TYPE_RE2 = /\b(interface|type|class|enum)\s+(\w+)/g;
|
|
737
|
-
var FUNC_RE2 = /\bfunction\s+(\w+)\s*\(([^)]*)\)/g;
|
|
738
|
-
var ARROW_RE = /\bconst\s+(\w+)\s*=\s*\([^)]*\)\s*(?::\s*\w+)?\s*=>/g;
|
|
739
|
-
function parseTsFile(filepath, content) {
|
|
740
|
-
const result = {
|
|
741
|
-
imports: [],
|
|
742
|
-
types: [],
|
|
743
|
-
functions: [],
|
|
744
|
-
error_handling: []
|
|
745
|
-
};
|
|
746
|
-
let match;
|
|
747
|
-
while ((match = IMPORT_RE2.exec(content)) !== null) {
|
|
748
|
-
result.imports.push(match[1]);
|
|
749
|
-
}
|
|
750
|
-
while ((match = TYPE_RE2.exec(content)) !== null) {
|
|
751
|
-
result.types.push({
|
|
752
|
-
kind: match[1],
|
|
753
|
-
name: match[2],
|
|
754
|
-
file: filepath
|
|
755
|
-
});
|
|
756
|
-
}
|
|
757
|
-
while ((match = FUNC_RE2.exec(content)) !== null) {
|
|
758
|
-
result.functions.push({
|
|
759
|
-
name: match[1],
|
|
760
|
-
params: (match[2] || "").trim(),
|
|
761
|
-
file: filepath
|
|
762
|
-
});
|
|
763
|
-
}
|
|
764
|
-
while ((match = ARROW_RE.exec(content)) !== null) {
|
|
765
|
-
result.functions.push({
|
|
766
|
-
name: match[1],
|
|
767
|
-
params: "",
|
|
768
|
-
file: filepath
|
|
769
|
-
});
|
|
770
|
-
}
|
|
771
|
-
if (content.includes("try {") || content.includes("try{")) {
|
|
772
|
-
result.error_handling.push("try-catch");
|
|
773
|
-
}
|
|
774
|
-
if (content.includes("Promise")) {
|
|
775
|
-
result.error_handling.push("Promise");
|
|
776
|
-
}
|
|
777
|
-
if (content.includes(".catch(")) {
|
|
778
|
-
result.error_handling.push(".catch()");
|
|
779
|
-
}
|
|
780
|
-
return result;
|
|
781
|
-
}
|
|
782
|
-
function mergeTsResults(results) {
|
|
783
|
-
const merged = {
|
|
784
|
-
imports: [],
|
|
785
|
-
types: [],
|
|
786
|
-
functions: [],
|
|
787
|
-
error_handling: []
|
|
788
|
-
};
|
|
789
|
-
const importCounts = /* @__PURE__ */ new Map();
|
|
790
|
-
const errorCounts = /* @__PURE__ */ new Map();
|
|
791
|
-
for (const r of results) {
|
|
792
|
-
for (const imp of r.imports) {
|
|
793
|
-
importCounts.set(imp, (importCounts.get(imp) || 0) + 1);
|
|
794
|
-
}
|
|
795
|
-
merged.types.push(...r.types);
|
|
796
|
-
merged.functions.push(...r.functions);
|
|
797
|
-
for (const e of r.error_handling) {
|
|
798
|
-
errorCounts.set(e, (errorCounts.get(e) || 0) + 1);
|
|
799
|
-
}
|
|
800
|
-
}
|
|
801
|
-
merged.imports = Array.from(importCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([mod]) => mod);
|
|
802
|
-
merged.error_handling = Array.from(errorCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([p]) => p);
|
|
803
|
-
merged.types = merged.types.slice(0, 100);
|
|
804
|
-
merged.functions = merged.functions.slice(0, 100);
|
|
805
|
-
return merged;
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
// src/parsers/python.ts
|
|
809
|
-
var IMPORT_RE3 = /^(?:import|from)\s+(\w+)/gm;
|
|
810
|
-
var CLASS_RE = /\bclass\s+(\w+)(?:\s*\(([^)]+)\))?/g;
|
|
811
|
-
var FUNC_RE3 = /\bdef\s+(\w+)\s*\(([^)]*)\)/g;
|
|
812
|
-
var DOCSTRING_RE = /"""([^"]{20,300})"""/g;
|
|
813
|
-
function parsePythonFile(filepath, content) {
|
|
814
|
-
const result = {
|
|
815
|
-
imports: [],
|
|
816
|
-
types: [],
|
|
817
|
-
functions: [],
|
|
818
|
-
docstrings: [],
|
|
819
|
-
error_handling: []
|
|
820
|
-
};
|
|
821
|
-
let match;
|
|
822
|
-
while ((match = IMPORT_RE3.exec(content)) !== null) {
|
|
823
|
-
result.imports.push(match[1]);
|
|
824
|
-
}
|
|
825
|
-
while ((match = DOCSTRING_RE.exec(content)) !== null) {
|
|
826
|
-
const firstSentence = match[1].trim().split("\n")[0].slice(0, 120);
|
|
827
|
-
if (firstSentence.length > 15) {
|
|
828
|
-
result.docstrings.push(firstSentence);
|
|
829
|
-
}
|
|
830
|
-
}
|
|
831
|
-
while ((match = CLASS_RE.exec(content)) !== null) {
|
|
832
|
-
const bases = (match[2] || "").split(",").map((b) => b.trim()).filter(Boolean);
|
|
833
|
-
result.types.push({
|
|
834
|
-
kind: "class",
|
|
835
|
-
name: match[1],
|
|
836
|
-
bases,
|
|
837
|
-
file: filepath
|
|
838
|
-
});
|
|
839
|
-
}
|
|
840
|
-
while ((match = FUNC_RE3.exec(content)) !== null) {
|
|
841
|
-
const name = match[1];
|
|
842
|
-
if (name.startsWith("__") && name.endsWith("__")) continue;
|
|
843
|
-
result.functions.push({
|
|
844
|
-
name,
|
|
845
|
-
params: (match[2] || "").trim(),
|
|
846
|
-
file: filepath
|
|
847
|
-
});
|
|
848
|
-
}
|
|
849
|
-
if (content.includes("try:")) {
|
|
850
|
-
result.error_handling.push("try-except");
|
|
851
|
-
}
|
|
852
|
-
if (content.includes("raise")) {
|
|
853
|
-
result.error_handling.push("raise");
|
|
854
|
-
}
|
|
855
|
-
return result;
|
|
856
|
-
}
|
|
857
|
-
function mergePythonResults(results) {
|
|
858
|
-
const merged = {
|
|
859
|
-
imports: [],
|
|
860
|
-
types: [],
|
|
861
|
-
functions: [],
|
|
862
|
-
docstrings: [],
|
|
863
|
-
error_handling: []
|
|
864
|
-
};
|
|
865
|
-
const importCounts = /* @__PURE__ */ new Map();
|
|
866
|
-
const errorCounts = /* @__PURE__ */ new Map();
|
|
867
|
-
for (const r of results) {
|
|
868
|
-
for (const imp of r.imports) {
|
|
869
|
-
importCounts.set(imp, (importCounts.get(imp) || 0) + 1);
|
|
870
|
-
}
|
|
871
|
-
merged.types.push(...r.types);
|
|
872
|
-
merged.functions.push(...r.functions);
|
|
873
|
-
merged.docstrings.push(...r.docstrings);
|
|
874
|
-
for (const e of r.error_handling) {
|
|
875
|
-
errorCounts.set(e, (errorCounts.get(e) || 0) + 1);
|
|
876
|
-
}
|
|
877
|
-
}
|
|
878
|
-
merged.imports = Array.from(importCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([mod]) => mod);
|
|
879
|
-
merged.error_handling = Array.from(errorCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([p]) => p);
|
|
880
|
-
merged.types = merged.types.slice(0, 100);
|
|
881
|
-
merged.functions = merged.functions.slice(0, 100);
|
|
882
|
-
merged.docstrings = merged.docstrings.slice(0, 20);
|
|
883
|
-
return merged;
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
// src/parsers/cpp.ts
|
|
887
|
-
var INCLUDE_RE = /#include\s*[<"]([^>"]+)[>"]/g;
|
|
888
|
-
var TYPE_RE3 = /\b(?:class|struct|enum)\s+(\w+)/g;
|
|
889
|
-
var FUNC_RE4 = /\b(?:void|int|float|double|char|bool|String|uint8_t|uint16_t|uint32_t)\s+(\w+)\s*\(/g;
|
|
890
|
-
function parseCppFile(filepath, content) {
|
|
891
|
-
const result = {
|
|
892
|
-
imports: [],
|
|
893
|
-
types: [],
|
|
894
|
-
functions: [],
|
|
895
|
-
error_handling: []
|
|
896
|
-
};
|
|
897
|
-
let match;
|
|
898
|
-
while ((match = INCLUDE_RE.exec(content)) !== null) {
|
|
899
|
-
result.imports.push(match[1]);
|
|
900
|
-
}
|
|
901
|
-
while ((match = TYPE_RE3.exec(content)) !== null) {
|
|
902
|
-
result.types.push({
|
|
903
|
-
kind: "cpp",
|
|
904
|
-
name: match[1],
|
|
905
|
-
file: filepath
|
|
906
|
-
});
|
|
907
|
-
}
|
|
908
|
-
while ((match = FUNC_RE4.exec(content)) !== null) {
|
|
909
|
-
result.functions.push({
|
|
910
|
-
name: match[1],
|
|
911
|
-
file: filepath
|
|
912
|
-
});
|
|
913
|
-
}
|
|
914
|
-
if (/\btry\b|\bthrow\b|\bcatch\b/.test(content)) {
|
|
915
|
-
result.error_handling.push("try/throw/catch");
|
|
916
|
-
}
|
|
917
|
-
return result;
|
|
918
|
-
}
|
|
919
|
-
function mergeCppResults(results) {
|
|
920
|
-
const merged = {
|
|
921
|
-
imports: [],
|
|
922
|
-
types: [],
|
|
923
|
-
functions: [],
|
|
924
|
-
error_handling: []
|
|
925
|
-
};
|
|
926
|
-
const importCounts = /* @__PURE__ */ new Map();
|
|
927
|
-
for (const r of results) {
|
|
928
|
-
for (const imp of r.imports) {
|
|
929
|
-
importCounts.set(imp, (importCounts.get(imp) || 0) + 1);
|
|
930
|
-
}
|
|
931
|
-
merged.types.push(...r.types);
|
|
932
|
-
merged.functions.push(...r.functions);
|
|
933
|
-
merged.error_handling.push(...r.error_handling);
|
|
313
|
+
merged.error_handling.push(...r.error_handling);
|
|
934
314
|
}
|
|
935
315
|
merged.imports = Array.from(importCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([mod]) => mod);
|
|
936
316
|
merged.error_handling = [...new Set(merged.error_handling)];
|
|
@@ -1147,6 +527,17 @@ function extractKeywords(sourceContext, basicAnalysis, repoDir, options) {
|
|
|
1147
527
|
}
|
|
1148
528
|
|
|
1149
529
|
// src/utils/llmClient.ts
|
|
530
|
+
function summarizeProvenance(callLog) {
|
|
531
|
+
const providers = [];
|
|
532
|
+
const models = [];
|
|
533
|
+
let failed = 0;
|
|
534
|
+
for (const r of callLog) {
|
|
535
|
+
if (!providers.includes(r.provider)) providers.push(r.provider);
|
|
536
|
+
if (!models.includes(r.model)) models.push(r.model);
|
|
537
|
+
if (!r.ok) failed++;
|
|
538
|
+
}
|
|
539
|
+
return { providers, models, calls: callLog.length, failed_calls: failed, degraded: failed > 0 };
|
|
540
|
+
}
|
|
1150
541
|
var LlmClientError = class extends Error {
|
|
1151
542
|
constructor(message, status, cause) {
|
|
1152
543
|
super(message);
|
|
@@ -1179,9 +570,10 @@ function resolveProviderChain(config) {
|
|
|
1179
570
|
return chain;
|
|
1180
571
|
}
|
|
1181
572
|
var MIN_REQUEST_INTERVAL_MS = 2500;
|
|
573
|
+
var requestIntervalMs = Number(process.env.LLM_MIN_REQUEST_INTERVAL_MS || MIN_REQUEST_INTERVAL_MS);
|
|
1182
574
|
var lastRequestAt = 0;
|
|
1183
575
|
async function pacedDelay() {
|
|
1184
|
-
const wait = lastRequestAt +
|
|
576
|
+
const wait = lastRequestAt + requestIntervalMs - Date.now();
|
|
1185
577
|
if (wait > 0) await new Promise((resolve2) => setTimeout(resolve2, wait));
|
|
1186
578
|
lastRequestAt = Date.now();
|
|
1187
579
|
}
|
|
@@ -1196,6 +588,10 @@ function createLlmClient(config) {
|
|
|
1196
588
|
primary.apiKey;
|
|
1197
589
|
primary.baseUrl;
|
|
1198
590
|
const model = primary.model;
|
|
591
|
+
if (config?.minRequestIntervalMs !== void 0) {
|
|
592
|
+
requestIntervalMs = Math.max(0, config.minRequestIntervalMs);
|
|
593
|
+
}
|
|
594
|
+
const callLog = [];
|
|
1199
595
|
async function chat(messages, options) {
|
|
1200
596
|
const temperature = options?.temperature ?? config?.temperature ?? 0.1;
|
|
1201
597
|
const maxTokens = options?.maxTokens ?? config?.maxTokens ?? parseInt(process.env.LLM_MAX_TOKENS || "65536", 10);
|
|
@@ -1210,6 +606,7 @@ function createLlmClient(config) {
|
|
|
1210
606
|
await new Promise((resolve2) => setTimeout(resolve2, 2e4));
|
|
1211
607
|
}
|
|
1212
608
|
for (const provider of chain) {
|
|
609
|
+
const recIdx = callLog.push({ provider: provider.label, model: provider.model, ok: false }) - 1;
|
|
1213
610
|
try {
|
|
1214
611
|
const headers = {
|
|
1215
612
|
"Content-Type": "application/json",
|
|
@@ -1270,6 +667,7 @@ function createLlmClient(config) {
|
|
|
1270
667
|
const retryData = await retry.json();
|
|
1271
668
|
const retryContent2 = retryData.choices?.[0]?.message?.content || "";
|
|
1272
669
|
if (!retryContent2.trim()) throw err;
|
|
670
|
+
callLog[recIdx] = { ...callLog[recIdx], ok: true };
|
|
1273
671
|
return {
|
|
1274
672
|
content: retryContent2,
|
|
1275
673
|
model: retryData.model || provider.model,
|
|
@@ -1298,6 +696,7 @@ function createLlmClient(config) {
|
|
|
1298
696
|
if (!retry.ok) throw err;
|
|
1299
697
|
const retryData = await retry.json();
|
|
1300
698
|
const retryContent = retryData.choices?.[0]?.message?.content || "";
|
|
699
|
+
callLog[recIdx] = { ...callLog[recIdx], ok: true };
|
|
1301
700
|
return {
|
|
1302
701
|
content: retryContent,
|
|
1303
702
|
model: retryData.model || provider.model,
|
|
@@ -1328,6 +727,7 @@ function createLlmClient(config) {
|
|
|
1328
727
|
};
|
|
1329
728
|
} catch (err) {
|
|
1330
729
|
const msg = err instanceof Error ? err.message : String(err);
|
|
730
|
+
callLog[recIdx] = { ...callLog[recIdx], ok: false, error: msg.slice(0, 200) };
|
|
1331
731
|
attempts.push(`${provider.label}: ${msg}`);
|
|
1332
732
|
lastError = err instanceof LlmClientError ? err : new LlmClientError(`[${provider.label}] ${msg}`);
|
|
1333
733
|
}
|
|
@@ -1337,7 +737,7 @@ function createLlmClient(config) {
|
|
|
1337
737
|
}
|
|
1338
738
|
throw lastError ?? new LlmClientError("All LLM providers failed after " + MAX_CHAIN_ROUNDS + " rounds.");
|
|
1339
739
|
}
|
|
1340
|
-
return { chat, model };
|
|
740
|
+
return { chat, model, callLog };
|
|
1341
741
|
}
|
|
1342
742
|
function parseJsonFromLlm(text) {
|
|
1343
743
|
let cleaned = text.trim();
|
|
@@ -1401,8 +801,8 @@ async function llmChatJson(client, systemPrompt, userPrompt, options) {
|
|
|
1401
801
|
|
|
1402
802
|
// src/graph/scaffoldExtractor.ts
|
|
1403
803
|
async function extractScaffold(options) {
|
|
1404
|
-
const { goal, techStack, fileList, llmConfig } = options;
|
|
1405
|
-
const client = createLlmClient(llmConfig);
|
|
804
|
+
const { goal, techStack, fileList, llmConfig, sharedClient } = options;
|
|
805
|
+
const client = sharedClient ?? createLlmClient(llmConfig);
|
|
1406
806
|
const fileListStr = fileList.length > 0 ? fileList.join("\n") : "(empty)";
|
|
1407
807
|
const systemPrompt = "You are a programming pedagogy expert. For ONE concrete project, extract the 'FOUNDATION & SETUP' feature (id F0) \u2014 the COMPLETE set of 'MUST-KNOW' steps the learner needs before studying the first feature. Break it into concrete ACTION steps (5-8 steps), each with completion_level='base'. Include:\n1. Tools required for this tech stack (IDE, simulator/emulator, terminal, git, package manager) + basic operations (open project, build, run, debug, commit).\n2. Create the initial project: from a template OR clone/open the base-project (the provided repo).\n3. MINIMAL programming knowledge of the language (variables, types, functions, if/for, view declaration) + build one small demo (e.g. a single-screen Hello World app) BEFORE touching the real project.\n4. Repo map of the actual project: folder structure, entry point, how to build/run, main architecture (MVVM/Flux...), important packages/modules.\n5. Development loop (build->run->see result->debug), how to read/fix basic compile errors, minimal git workflow (clone/branch/commit/push).\nkeywords[]: real tool/language terms (e.g. 'Xcode', 'Simulator', 'Git', 'Swift', '@main', 'MVVM'). files[]: leave [] or use real paths if the step touches specific files. description: ONE concise sentence, MAX 140 characters. intent: WHY this step matters. outcome: {user_visible: string, technical: string}. acceptance[]: 2 verifiable criteria. effort: {estimated_minutes: number, complexity: low|medium|high}. NEVER invent. ALL text must be in ENGLISH (technical terms stay as-is). Return JSON containing only feature F0.";
|
|
1408
808
|
const userPrompt = `App goal: ${goal}
|
|
@@ -1487,8 +887,8 @@ ${content}`);
|
|
|
1487
887
|
return blocks.join("\n\n");
|
|
1488
888
|
}
|
|
1489
889
|
async function extractProjectOverview(options) {
|
|
1490
|
-
const { goal, techStack, sdkApiIndex, fileContentsMap, astKeywords, llmConfig } = options;
|
|
1491
|
-
const client = createLlmClient(llmConfig);
|
|
890
|
+
const { goal, techStack, sdkApiIndex, fileContentsMap, astKeywords, llmConfig, sharedClient } = options;
|
|
891
|
+
const client = sharedClient ?? createLlmClient(llmConfig);
|
|
1492
892
|
const usedSdkApis = sdkApiIndex.sdk_apis.filter((api) => api.used_in_demo).map((api) => api.name).slice(0, 2e3);
|
|
1493
893
|
const allFilesStr = buildSourceText(fileContentsMap);
|
|
1494
894
|
let keywordsAnchor = "";
|
|
@@ -1557,8 +957,8 @@ ${content}`);
|
|
|
1557
957
|
return blocks.join("\n\n");
|
|
1558
958
|
}
|
|
1559
959
|
async function extractFeatureSteps(options) {
|
|
1560
|
-
const { goal, techStack, sdkApiIndex, fileContentsMap, feature, llmConfig } = options;
|
|
1561
|
-
const client = createLlmClient(llmConfig);
|
|
960
|
+
const { goal, techStack, sdkApiIndex, fileContentsMap, feature, llmConfig, sharedClient } = options;
|
|
961
|
+
const client = sharedClient ?? createLlmClient(llmConfig);
|
|
1562
962
|
const usedSdkApis = sdkApiIndex.sdk_apis.filter((api) => api.used_in_demo).map((api) => api.name).slice(0, 2e3);
|
|
1563
963
|
const allFilesStr = buildSourceText2(fileContentsMap);
|
|
1564
964
|
const fid = feature.id || "F_UNK";
|
|
@@ -1625,8 +1025,8 @@ Split this feature into 2-6 action steps. Return JSON:
|
|
|
1625
1025
|
concept_codes: []
|
|
1626
1026
|
}));
|
|
1627
1027
|
}
|
|
1628
|
-
async function extractFeatureStepsBatched(goal, techStack, sdkApiIndex, fileContentsMap, featuresMeta, llmConfig) {
|
|
1629
|
-
const client = createLlmClient(llmConfig);
|
|
1028
|
+
async function extractFeatureStepsBatched(goal, techStack, sdkApiIndex, fileContentsMap, featuresMeta, llmConfig, sharedClient) {
|
|
1029
|
+
const client = sharedClient ?? createLlmClient(llmConfig);
|
|
1630
1030
|
const usedSdkApis = sdkApiIndex.sdk_apis.filter((api) => api.used_in_demo).map((api) => api.name).slice(0, 2e3);
|
|
1631
1031
|
const sourceText = buildSourceText2(fileContentsMap);
|
|
1632
1032
|
const featuresListing = featuresMeta.map(
|
|
@@ -1893,7 +1293,7 @@ function inferDepthFromContext(keyword, fileContentsMap) {
|
|
|
1893
1293
|
return { depth: "sio", rationale: `Default: treating '${keyword}' as SIO (implementation-level)` };
|
|
1894
1294
|
}
|
|
1895
1295
|
async function escalateAndMapConcepts(options) {
|
|
1896
|
-
const { projectGraph, fileContentsMap, availableConcepts, conceptMapOverride, llmConfig } = options;
|
|
1296
|
+
const { projectGraph, fileContentsMap, availableConcepts, conceptMapOverride, llmConfig, sharedClient } = options;
|
|
1897
1297
|
const features = projectGraph.features || [];
|
|
1898
1298
|
const allTerms = /* @__PURE__ */ new Set();
|
|
1899
1299
|
for (const feature of features) {
|
|
@@ -1909,7 +1309,7 @@ async function escalateAndMapConcepts(options) {
|
|
|
1909
1309
|
llmResults = conceptMapOverride;
|
|
1910
1310
|
} else {
|
|
1911
1311
|
try {
|
|
1912
|
-
const client = createLlmClient(llmConfig);
|
|
1312
|
+
const client = sharedClient ?? createLlmClient(llmConfig);
|
|
1913
1313
|
const conceptBank = (availableConcepts || []).sort().join(", ") || "(empty)";
|
|
1914
1314
|
const termEntries = termList.map((term) => {
|
|
1915
1315
|
const snippets = extractTermSnippets(term, fileContentsMap);
|
|
@@ -1962,13 +1362,16 @@ Return JSON: {"results": {"<keyword>": {"concept_code": "...", "concept_name": "
|
|
|
1962
1362
|
const cName = info.concept_name || kw;
|
|
1963
1363
|
let depth = "sio";
|
|
1964
1364
|
let depthRationale = "Default: SIO";
|
|
1365
|
+
let depthSource = "override";
|
|
1965
1366
|
if (info.depth && ["ulo", "cio", "sio"].includes(info.depth)) {
|
|
1966
1367
|
depth = info.depth;
|
|
1967
1368
|
depthRationale = `LLM classified as ${depth}`;
|
|
1369
|
+
depthSource = "llm";
|
|
1968
1370
|
} else {
|
|
1969
1371
|
const inferred = inferDepthFromContext(kw, fileContentsMap);
|
|
1970
1372
|
depth = inferred.depth;
|
|
1971
1373
|
depthRationale = inferred.rationale;
|
|
1374
|
+
depthSource = "heuristic";
|
|
1972
1375
|
}
|
|
1973
1376
|
const kwKey = `${cCode}::${kw}`;
|
|
1974
1377
|
if (seen.has(kwKey)) continue;
|
|
@@ -1986,6 +1389,7 @@ Return JSON: {"results": {"<keyword>": {"concept_code": "...", "concept_name": "
|
|
|
1986
1389
|
keyword: kw,
|
|
1987
1390
|
depth,
|
|
1988
1391
|
depth_rationale: depthRationale,
|
|
1392
|
+
depth_source: depthSource,
|
|
1989
1393
|
evidence_files: evidenceFiles
|
|
1990
1394
|
});
|
|
1991
1395
|
}
|
|
@@ -2353,11 +1757,11 @@ function assembleRoadmap(options) {
|
|
|
2353
1757
|
}
|
|
2354
1758
|
|
|
2355
1759
|
// src/extractors/fileSelector.ts
|
|
2356
|
-
async function selectFilesWithLlm(filePaths, goal, techStack, llmConfig) {
|
|
1760
|
+
async function selectFilesWithLlm(filePaths, goal, techStack, llmConfig, sharedClient) {
|
|
2357
1761
|
if (filePaths.length <= 15) {
|
|
2358
1762
|
return { selected: filePaths, reasoning: "Too few files to filter" };
|
|
2359
1763
|
}
|
|
2360
|
-
const client = createLlmClient(llmConfig);
|
|
1764
|
+
const client = sharedClient ?? createLlmClient(llmConfig);
|
|
2361
1765
|
const treeLines = buildFileTree(filePaths);
|
|
2362
1766
|
const systemPrompt = 'You are a software architect analyzing a project repository.\nGiven a file tree and a project goal, select which files are MOST IMPORTANT to read for understanding the project architecture and implementation.\n\nRULES:\n1. Prioritize source code files (Swift, TS, Python, C++) over config/docs\n2. Include entry points (App.swift, main.py, index.ts)\n3. Include core domain files (models, services, views, controllers)\n4. EXCLUDE test files, mock files, generated files\n5. EXCLUDE config files (package.json, Podfile, etc.) unless they reveal architecture\n6. Include files that directly implement the features mentioned in the goal\n7. Return 20-40 files maximum (fewer is better if the project is small)\n\nReturn JSON: {"selected": ["path/to/file1", "path/to/file2"], "reasoning": "brief explanation"}';
|
|
2363
1767
|
const userPrompt = `Project goal: ${goal}
|
|
@@ -2519,6 +1923,7 @@ function detectPrimaryLanguage(filePaths) {
|
|
|
2519
1923
|
}
|
|
2520
1924
|
async function runProjectGraphPipeline(options) {
|
|
2521
1925
|
const { repoDir, goal, techStack, llmConfig, embeddings, onProgress } = options;
|
|
1926
|
+
const client = createLlmClient(llmConfig);
|
|
2522
1927
|
const log = (step, msg) => {
|
|
2523
1928
|
console.log(`[${step}] ${msg}`);
|
|
2524
1929
|
onProgress?.(step, msg);
|
|
@@ -2532,7 +1937,8 @@ async function runProjectGraphPipeline(options) {
|
|
|
2532
1937
|
allRelPaths,
|
|
2533
1938
|
goal,
|
|
2534
1939
|
techStack,
|
|
2535
|
-
llmConfig
|
|
1940
|
+
llmConfig,
|
|
1941
|
+
client
|
|
2536
1942
|
);
|
|
2537
1943
|
log("STEP_1", `LLM selected ${selectedPaths.length}/${allRelPaths.length} files: ${reasoning.slice(0, 100)}`);
|
|
2538
1944
|
log("STEP_1", "Reading content for selected files...");
|
|
@@ -2581,7 +1987,8 @@ async function runProjectGraphPipeline(options) {
|
|
|
2581
1987
|
goal,
|
|
2582
1988
|
techStack,
|
|
2583
1989
|
fileList: filePaths,
|
|
2584
|
-
llmConfig
|
|
1990
|
+
llmConfig,
|
|
1991
|
+
sharedClient: client
|
|
2585
1992
|
});
|
|
2586
1993
|
log("STEP_4_C1", "Extracting project overview (features meta)...");
|
|
2587
1994
|
const overview = await extractProjectOverview({
|
|
@@ -2590,7 +1997,8 @@ async function runProjectGraphPipeline(options) {
|
|
|
2590
1997
|
sdkApiIndex,
|
|
2591
1998
|
fileContentsMap,
|
|
2592
1999
|
astKeywords,
|
|
2593
|
-
llmConfig
|
|
2000
|
+
llmConfig,
|
|
2001
|
+
sharedClient: client
|
|
2594
2002
|
});
|
|
2595
2003
|
const product = overview.product;
|
|
2596
2004
|
const pblStep = {
|
|
@@ -2643,7 +2051,8 @@ async function runProjectGraphPipeline(options) {
|
|
|
2643
2051
|
sdkApiIndex,
|
|
2644
2052
|
fileContentsMap,
|
|
2645
2053
|
nonF0Features,
|
|
2646
|
-
llmConfig
|
|
2054
|
+
llmConfig,
|
|
2055
|
+
client
|
|
2647
2056
|
);
|
|
2648
2057
|
for (const feature of nonF0Features) {
|
|
2649
2058
|
const steps = batchedSteps.get(feature.id || "");
|
|
@@ -2659,7 +2068,8 @@ async function runProjectGraphPipeline(options) {
|
|
|
2659
2068
|
sdkApiIndex,
|
|
2660
2069
|
fileContentsMap,
|
|
2661
2070
|
feature,
|
|
2662
|
-
llmConfig
|
|
2071
|
+
llmConfig,
|
|
2072
|
+
sharedClient: client
|
|
2663
2073
|
});
|
|
2664
2074
|
feature.steps = steps;
|
|
2665
2075
|
} catch (e) {
|
|
@@ -2712,7 +2122,8 @@ async function runProjectGraphPipeline(options) {
|
|
|
2712
2122
|
const featureConcepts = await escalateAndMapConcepts({
|
|
2713
2123
|
projectGraph: verifiedGraph,
|
|
2714
2124
|
fileContentsMap,
|
|
2715
|
-
llmConfig
|
|
2125
|
+
llmConfig,
|
|
2126
|
+
sharedClient: client
|
|
2716
2127
|
});
|
|
2717
2128
|
log("STEP_6", `Concept mapping complete: ${featureConcepts.size} features mapped`);
|
|
2718
2129
|
for (const feature of verifiedGraph.features || []) {
|
|
@@ -2779,7 +2190,8 @@ async function runProjectGraphPipeline(options) {
|
|
|
2779
2190
|
roadmap,
|
|
2780
2191
|
hallucinations,
|
|
2781
2192
|
keywords: astKeywords,
|
|
2782
|
-
featureConcepts
|
|
2193
|
+
featureConcepts,
|
|
2194
|
+
provenance: client.callLog
|
|
2783
2195
|
};
|
|
2784
2196
|
}
|
|
2785
2197
|
|
|
@@ -2899,6 +2311,13 @@ var ULO_CODE_RE = /^ULO-[A-Z][A-Z0-9_]*-\d{2}$/;
|
|
|
2899
2311
|
var CIO_CODE_RE = /^CIO-[A-Z][A-Z0-9_]*-\d{2}-[A-Z][A-Z0-9_]*$/;
|
|
2900
2312
|
var SIO_CODE_RE = /^SIO-[A-Z0-9]+-[A-Z][A-Z0-9_]*-\d{2}$/;
|
|
2901
2313
|
|
|
2314
|
+
// src/utils/diacritics.ts
|
|
2315
|
+
var COMBINING_MARKS = /[\u0300-\u036f]/g;
|
|
2316
|
+
var D_WITH_STROKE = /đ/gi;
|
|
2317
|
+
function stripDiacritics(s) {
|
|
2318
|
+
return s.normalize("NFD").replace(COMBINING_MARKS, "").replace(D_WITH_STROKE, "d").toLowerCase();
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2902
2321
|
// src/parsers/syllabusParser.ts
|
|
2903
2322
|
var STOP_WORDS = /* @__PURE__ */ new Set([
|
|
2904
2323
|
"and",
|
|
@@ -3661,7 +3080,7 @@ function loadKnowledgeTreeCatalog(tsvPath) {
|
|
|
3661
3080
|
}
|
|
3662
3081
|
function tokens(s) {
|
|
3663
3082
|
return new Set(
|
|
3664
|
-
s
|
|
3083
|
+
stripDiacritics(s).replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length >= 4)
|
|
3665
3084
|
);
|
|
3666
3085
|
}
|
|
3667
3086
|
function filterBySelection(catalog, selectedCategories, selectedTopics, analysisConcepts, maxConcepts = 120) {
|
|
@@ -3799,326 +3218,1221 @@ async function standardizeConcepts(opts) {
|
|
|
3799
3218
|
if (tc.keywords.length > 0 && adopted.length === 0) {
|
|
3800
3219
|
throw new Error("Concept " + c.id + " standardized to " + tc.code + " without adopted_keywords \u2014 fail-closed");
|
|
3801
3220
|
}
|
|
3802
|
-
finalDecisions.push({ ...d, id: c.id, tree_code: tc.code, adopted_keywords: adopted });
|
|
3803
|
-
warnings.push("Concept " + c.id + " standardized as " + tc.code + " (" + tc.name + "), adopted keywords: " + adopted.join(", ") + (d.note ? " \u2014 " + d.note : ""));
|
|
3804
|
-
} else {
|
|
3805
|
-
const code = (d.proposed_code || "").toUpperCase().replace(/[^A-Z0-9_]/g, "_");
|
|
3806
|
-
if (!/^[A-Z][A-Z0-9_]*$/.test(code)) {
|
|
3807
|
-
throw new Error("Concept " + c.id + ' proposed invalid code "' + d.proposed_code + '" \u2014 fail-closed');
|
|
3221
|
+
finalDecisions.push({ ...d, id: c.id, tree_code: tc.code, adopted_keywords: adopted });
|
|
3222
|
+
warnings.push("Concept " + c.id + " standardized as " + tc.code + " (" + tc.name + "), adopted keywords: " + adopted.join(", ") + (d.note ? " \u2014 " + d.note : ""));
|
|
3223
|
+
} else {
|
|
3224
|
+
const code = (d.proposed_code || "").toUpperCase().replace(/[^A-Z0-9_]/g, "_");
|
|
3225
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(code)) {
|
|
3226
|
+
throw new Error("Concept " + c.id + ' proposed invalid code "' + d.proposed_code + '" \u2014 fail-closed');
|
|
3227
|
+
}
|
|
3228
|
+
finalDecisions.push({ ...d, id: c.id, proposed_code: code });
|
|
3229
|
+
proposals.push({
|
|
3230
|
+
code,
|
|
3231
|
+
name: c.name,
|
|
3232
|
+
description: d.proposed_definition || c.description.slice(0, 300),
|
|
3233
|
+
keywords: c.keywords,
|
|
3234
|
+
subject_codes: "PY",
|
|
3235
|
+
category_codes: "",
|
|
3236
|
+
topic_codes: "",
|
|
3237
|
+
prerequisite_concept_codes: [],
|
|
3238
|
+
status: "proposed",
|
|
3239
|
+
source_concept_id: c.id,
|
|
3240
|
+
possible_tree_matches: (d.possible_tree_matches || []).filter(Boolean)
|
|
3241
|
+
});
|
|
3242
|
+
warnings.push("Concept " + c.id + " proposed as NEW: " + code + (d.possible_tree_matches?.length ? " (near matches: " + d.possible_tree_matches.join(", ") + ")" : ""));
|
|
3243
|
+
}
|
|
3244
|
+
}
|
|
3245
|
+
const outPath = proposalsPath || path.join(os.tmpdir(), "domain-kit-debug", "concept-proposals.json");
|
|
3246
|
+
try {
|
|
3247
|
+
fs.mkdirSync(outPath.slice(0, outPath.lastIndexOf("/")), { recursive: true });
|
|
3248
|
+
fs.writeFileSync(outPath, JSON.stringify({ generated_at: (/* @__PURE__ */ new Date()).toISOString(), proposals }, null, 2));
|
|
3249
|
+
log("STEP_2_5", "Proposals written: " + proposals.length + " -> " + outPath);
|
|
3250
|
+
} catch (e) {
|
|
3251
|
+
warnings.push("Could not write proposals artifact: " + (e instanceof Error ? e.message : String(e)));
|
|
3252
|
+
}
|
|
3253
|
+
return { decisions: finalDecisions, proposals, warnings, shortlistSize: shortlist.length, catalogSize: catalog.concepts.length };
|
|
3254
|
+
}
|
|
3255
|
+
|
|
3256
|
+
// src/graph/knowledgeGraphPipeline.ts
|
|
3257
|
+
var MIN_CALIBRATION_MINUTES = 15;
|
|
3258
|
+
var MAX_CALIBRATION_MINUTES = 60;
|
|
3259
|
+
var DEFAULT_CONCEPT_MINUTES = 30;
|
|
3260
|
+
var MIN_CALIBRATION_DIVERGENCE = 0.5;
|
|
3261
|
+
function calibrateConceptMinutes(sioCount, problemTypeCount, prereqCount, hasHighBloom) {
|
|
3262
|
+
const ratio = (value, cap) => Math.min(1, Math.max(0, value) / cap);
|
|
3263
|
+
const score = 0.3 * ratio(sioCount, 4) + 0.25 * ratio(problemTypeCount, 4) + 0.2 * ratio(prereqCount, 4) + 0.25 * (hasHighBloom ? 1 : 0);
|
|
3264
|
+
return Math.round(MIN_CALIBRATION_MINUTES + score * (MAX_CALIBRATION_MINUTES - MIN_CALIBRATION_MINUTES));
|
|
3265
|
+
}
|
|
3266
|
+
function topologicalSort(concepts) {
|
|
3267
|
+
const inDegree = /* @__PURE__ */ new Map();
|
|
3268
|
+
const adjacency = /* @__PURE__ */ new Map();
|
|
3269
|
+
for (const concept of concepts) {
|
|
3270
|
+
inDegree.set(concept.id, 0);
|
|
3271
|
+
adjacency.set(concept.id, []);
|
|
3272
|
+
}
|
|
3273
|
+
for (const concept of concepts) {
|
|
3274
|
+
for (const prereq of concept.prerequisites || []) {
|
|
3275
|
+
adjacency.get(prereq)?.push(concept.id);
|
|
3276
|
+
inDegree.set(concept.id, (inDegree.get(concept.id) || 0) + 1);
|
|
3277
|
+
}
|
|
3278
|
+
}
|
|
3279
|
+
const queue = [];
|
|
3280
|
+
for (const [id, degree] of inDegree) {
|
|
3281
|
+
if (degree === 0) queue.push(id);
|
|
3282
|
+
}
|
|
3283
|
+
const sorted = [];
|
|
3284
|
+
while (queue.length > 0) {
|
|
3285
|
+
const current = queue.shift();
|
|
3286
|
+
sorted.push(current);
|
|
3287
|
+
for (const neighbor of adjacency.get(current) || []) {
|
|
3288
|
+
const newDegree = (inDegree.get(neighbor) || 1) - 1;
|
|
3289
|
+
inDegree.set(neighbor, newDegree);
|
|
3290
|
+
if (newDegree === 0) queue.push(neighbor);
|
|
3291
|
+
}
|
|
3292
|
+
}
|
|
3293
|
+
if (sorted.length !== concepts.length) {
|
|
3294
|
+
console.warn(`[WARN] Topological sort incomplete: ${sorted.length}/${concepts.length} concepts sorted`);
|
|
3295
|
+
}
|
|
3296
|
+
return sorted;
|
|
3297
|
+
}
|
|
3298
|
+
async function generateKnowledgeGraph(options) {
|
|
3299
|
+
const { subject, description, syllabusText, gradeBand, domain, techStack, llmConfig, onProgress } = options;
|
|
3300
|
+
const warnings = [];
|
|
3301
|
+
const log = (step, msg) => {
|
|
3302
|
+
console.log(`[KG-${step}] ${msg}`);
|
|
3303
|
+
onProgress?.(step, msg);
|
|
3304
|
+
};
|
|
3305
|
+
log("STEP_1", "Decomposing subject into concepts...");
|
|
3306
|
+
let parsedSyllabus = options.parsedSyllabus;
|
|
3307
|
+
if (!parsedSyllabus && syllabusText) {
|
|
3308
|
+
parsedSyllabus = parseSyllabus(syllabusText);
|
|
3309
|
+
log("STEP_1", `Parsed syllabus: ${parsedSyllabus.units.length} units, ${parsedSyllabus.allTopics.length} topics`);
|
|
3310
|
+
}
|
|
3311
|
+
const client = createLlmClient(llmConfig);
|
|
3312
|
+
const maxSyllabusChars = parseInt(process.env.KG_MAX_SYLLABUS_CHARS || "16000", 10);
|
|
3313
|
+
let truncatedSyllabus = "";
|
|
3314
|
+
if (syllabusText) {
|
|
3315
|
+
if (syllabusText.length > maxSyllabusChars) {
|
|
3316
|
+
truncatedSyllabus = syllabusText.slice(0, maxSyllabusChars);
|
|
3317
|
+
warnings.push(`Syllabus truncated from ${syllabusText.length} to ${maxSyllabusChars} chars \u2014 may miss some concepts`);
|
|
3318
|
+
} else {
|
|
3319
|
+
truncatedSyllabus = syllabusText;
|
|
3320
|
+
}
|
|
3321
|
+
}
|
|
3322
|
+
const structuredTopicsGuide = parsedSyllabus && parsedSyllabus.allTopics.length > 0 ? `
|
|
3323
|
+
STRUCTURED SYLLABUS TOPICS (Every topic MUST map to at least one concept to ensure 100% syllabus coverage):
|
|
3324
|
+
` + parsedSyllabus.allTopics.map((t) => `- [${t.unitTitle}] ${t.id}: ${t.title} (Keywords: ${t.keywords.join(", ")})`).join("\n") + "\n" : "";
|
|
3325
|
+
const systemPrompt = `You are an expert curriculum designer. Decompose the given subject into a structured knowledge graph with concepts, prerequisites, and problem types.
|
|
3326
|
+
|
|
3327
|
+
CRITICAL RULES:
|
|
3328
|
+
1. Each concept MUST have the three depth layers, authored against this CONTRACT:
|
|
3329
|
+
- ulo (WHAT + WHY): bound to the CONCEPT's intrinsic NATURE. It explains what the
|
|
3330
|
+
concept is and why it exists \u2014 the problem it solves in principle. It must be
|
|
3331
|
+
TECHNOLOGY-AGNOSTIC: never name a concrete library, API, function, file format,
|
|
3332
|
+
tool or language feature. Typical assessment level: Remember / Understand.
|
|
3333
|
+
- cio (HOW): the MECHANISM \u2014 how the concept works and solves that problem, step
|
|
3334
|
+
by step. Still bound to the concept's nature: more concrete than the ULO, but
|
|
3335
|
+
NOT tied to one specific technology (no concrete API names). CIO capability is
|
|
3336
|
+
assessed INDIRECTLY, through completing contextual work, not in isolation.
|
|
3337
|
+
- sio (IMPLEMENTATION): an array of 2-4 items. EVERY item MUST reference concrete
|
|
3338
|
+
KEYWORDS/technologies (a real API, function, module, file, or named pattern) and
|
|
3339
|
+
describe a completable, context-specific implementation task. Application,
|
|
3340
|
+
Analysis and Creation skills are assessed INDIRECTLY through completing these
|
|
3341
|
+
items. There is NO fixed Bloom ceiling for a layer \u2014 but concreteness MUST
|
|
3342
|
+
strictly increase: SIO more technology-bound than CIO, CIO more concrete than ULO.
|
|
3343
|
+
- prerequisites: concept IDs that must be learned first (MUST NOT create cycles)
|
|
3344
|
+
- problem_types: 2-4 problem types with bloom_level and difficulty. Remember and
|
|
3345
|
+
Understand problem types test the ULO/CIO directly; Apply-and-above types are
|
|
3346
|
+
proxies for SIO completion in context.
|
|
3347
|
+
- techniques: methods, formulas, tools
|
|
3348
|
+
2. Prerequisite chains MUST be acyclic (no concept can depend on itself or create a loop)
|
|
3349
|
+
3. Concepts should be ordered by prerequisite dependency (topological sort)
|
|
3350
|
+
4. Problem types should range from Remember to Apply (not all at same level)
|
|
3351
|
+
5. Group concepts into categories (chapters/sections)
|
|
3352
|
+
6. ALL text in ENGLISH (even if input is Vietnamese)
|
|
3353
|
+
7. NEVER invent concepts not present in the syllabus
|
|
3354
|
+
8. KEYWORDS: Each concept MUST include 5-10 domain-specific keywords that:
|
|
3355
|
+
- Are concrete terms a student encounters when studying THIS concept
|
|
3356
|
+
- Include at least 2 technology-specific terms (APIs, functions, types, tools)
|
|
3357
|
+
- Are DISTINCT from keywords of other concepts (minimal overlap)
|
|
3358
|
+
- NEVER generic English words (e.g. "view", "data", "code" are TOO generic)
|
|
3359
|
+
- Example for "SwiftUI Layout": ["VStack", "HStack", "ZStack", ".padding()", ".frame()", "alignment", "Spacer", "LazyVGrid"]
|
|
3360
|
+
9. Return JSON matching the KnowledgeGraphSchema`;
|
|
3361
|
+
const userPrompt = `Subject: ${subject}
|
|
3362
|
+
${description ? `Description: ${description}` : ""}
|
|
3363
|
+
${structuredTopicsGuide}${truncatedSyllabus ? `Syllabus:
|
|
3364
|
+
${truncatedSyllabus}` : ""}
|
|
3365
|
+
${gradeBand ? `Grade band: ${gradeBand[0]}-${gradeBand[1]}` : ""}
|
|
3366
|
+
${domain ? `Domain: ${domain}` : ""}
|
|
3367
|
+
|
|
3368
|
+
Return JSON:
|
|
3369
|
+
{
|
|
3370
|
+
"subject": {
|
|
3371
|
+
"id": "${subject.toLowerCase().replace(/\s+/g, "-").slice(0, 30)}",
|
|
3372
|
+
"name": "${subject}",
|
|
3373
|
+
"description": "...",
|
|
3374
|
+
"domain": "${domain || "other"}",
|
|
3375
|
+
"grade_band": ${gradeBand ? `[${gradeBand[0]}, ${gradeBand[1]}]` : "[6, 12]"}
|
|
3376
|
+
},
|
|
3377
|
+
"concepts": [
|
|
3378
|
+
{
|
|
3379
|
+
"id": "C1",
|
|
3380
|
+
"name": "Concept name",
|
|
3381
|
+
"description": "2-3 sentence description: what this concept covers, why a learner encounters it, and what mastering it enables. Must be specific enough that a student reading only this description understands the concept's scope.",
|
|
3382
|
+
"category": "category_id",
|
|
3383
|
+
"keywords": ["specific_term_1", "concrete_api_2", "method_call_3", "tool_or_type_4", "syntax_keyword_5", "concept_term_6"],
|
|
3384
|
+
"ulo": "WHAT + WHY in technology-agnostic language: what the concept is and the problem it exists to solve. No library/API/tool names.",
|
|
3385
|
+
"cio": "HOW the mechanism works, still technology-agnostic but more concrete than the ULO.",
|
|
3386
|
+
"sio": ["Concrete task naming a REAL API/function/module/file from the target technology, completable end to end", "Another technology-bound task using a different concrete keyword"],
|
|
3387
|
+
"prerequisites": [],
|
|
3388
|
+
"estimated_minutes": 30,
|
|
3389
|
+
"problem_types": [
|
|
3390
|
+
{
|
|
3391
|
+
"id": "PT-C1-01",
|
|
3392
|
+
"name": "Problem type name",
|
|
3393
|
+
"description": "What this problem type tests",
|
|
3394
|
+
"bloom_level": "Remember",
|
|
3395
|
+
"difficulty": "easy",
|
|
3396
|
+
"estimated_minutes": 10
|
|
3397
|
+
}
|
|
3398
|
+
],
|
|
3399
|
+
"techniques": ["technique 1", "technique 2"],
|
|
3400
|
+
"common_mistakes": ["mistake 1"]
|
|
3401
|
+
}
|
|
3402
|
+
],
|
|
3403
|
+
"categories": [
|
|
3404
|
+
{
|
|
3405
|
+
"id": "cat1",
|
|
3406
|
+
"name": "Category name",
|
|
3407
|
+
"description": "Category description",
|
|
3408
|
+
"concept_ids": ["C1", "C2"],
|
|
3409
|
+
"order": 1
|
|
3410
|
+
}
|
|
3411
|
+
],
|
|
3412
|
+
"learning_path": [
|
|
3413
|
+
{"concept_id": "C1", "order": 1, "depends_on": []},
|
|
3414
|
+
{"concept_id": "C2", "order": 2, "depends_on": ["C1"]}
|
|
3415
|
+
]
|
|
3416
|
+
}`;
|
|
3417
|
+
const result = await llmChatJson(client, systemPrompt, userPrompt, {
|
|
3418
|
+
temperature: 0.1,
|
|
3419
|
+
maxTokens: parseInt(process.env.KG_MAX_TOKENS || "65536", 10)
|
|
3420
|
+
});
|
|
3421
|
+
log("STEP_2", "Validating and cleaning knowledge graph...");
|
|
3422
|
+
const concepts = result.concepts || [];
|
|
3423
|
+
const categories = result.categories || [];
|
|
3424
|
+
for (const c of concepts) {
|
|
3425
|
+
if ((c.description || "").length < 50) {
|
|
3426
|
+
warnings.push("Concept " + c.id + ": description too short (" + (c.description || "").length + " chars) \u2014 may produce shallow KX narratives");
|
|
3427
|
+
}
|
|
3428
|
+
}
|
|
3429
|
+
{
|
|
3430
|
+
const STOP = /* @__PURE__ */ new Set(["and", "the", "for", "with", "from", "that", "this", "into", "when", "while", "using", "their", "your", "each", "how", "why", "what", "between", "through", "about"]);
|
|
3431
|
+
const tokensFrom = (text) => stripDiacritics(text).replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length >= 4 && !STOP.has(w));
|
|
3432
|
+
for (const c of concepts) {
|
|
3433
|
+
const existing = (c.keywords || []).map((k) => String(k).trim()).filter(Boolean);
|
|
3434
|
+
if (existing.length === 0) {
|
|
3435
|
+
const derived = [.../* @__PURE__ */ new Set([...tokensFrom(c.name), ...tokensFrom(c.description || "")])].slice(0, 8);
|
|
3436
|
+
if (derived.length === 0) {
|
|
3437
|
+
throw new Error("Concept " + c.id + " (" + c.name + ") has no keywords and none could be derived from name/description \u2014 fail-closed");
|
|
3438
|
+
}
|
|
3439
|
+
c.keywords = derived;
|
|
3440
|
+
warnings.push("Concept " + c.id + ": empty keyword set - derived " + derived.join(", ") + " from name/description (WARNING: LLM returned no keywords)");
|
|
3441
|
+
} else {
|
|
3442
|
+
c.keywords = existing;
|
|
3443
|
+
}
|
|
3444
|
+
}
|
|
3445
|
+
}
|
|
3446
|
+
const rawPath = result.learning_path;
|
|
3447
|
+
let learningPath = (rawPath || []).map((s) => ({
|
|
3448
|
+
concept_id: String(s.concept_id || ""),
|
|
3449
|
+
order: Number(s.order || 0),
|
|
3450
|
+
depends_on: Array.isArray(s.depends_on) ? s.depends_on.map(String) : [],
|
|
3451
|
+
milestone_name: String(s.milestone_name || "")
|
|
3452
|
+
}));
|
|
3453
|
+
const conceptIds = new Set(concepts.map((c) => c.id));
|
|
3454
|
+
for (const concept of concepts) {
|
|
3455
|
+
for (const prereq of concept.prerequisites || []) {
|
|
3456
|
+
if (!conceptIds.has(prereq)) {
|
|
3457
|
+
warnings.push(`Concept ${concept.id} references non-existent prerequisite ${prereq} \u2014 removed`);
|
|
3458
|
+
concept.prerequisites = (concept.prerequisites || []).filter((p) => p !== prereq);
|
|
3459
|
+
}
|
|
3460
|
+
}
|
|
3461
|
+
}
|
|
3462
|
+
if (options.knowledgeTreePath) {
|
|
3463
|
+
const catalog = loadKnowledgeTreeCatalog(options.knowledgeTreePath);
|
|
3464
|
+
log("STEP_2_5", "Knowledge tree loaded: " + catalog.concepts.length + " concepts from " + catalog.path.split("/").pop());
|
|
3465
|
+
const std = await standardizeConcepts({
|
|
3466
|
+
client,
|
|
3467
|
+
concepts: concepts.map((c) => ({
|
|
3468
|
+
id: c.id,
|
|
3469
|
+
name: c.name,
|
|
3470
|
+
description: c.description || "",
|
|
3471
|
+
keywords: c.keywords || []
|
|
3472
|
+
})),
|
|
3473
|
+
catalog,
|
|
3474
|
+
techStack,
|
|
3475
|
+
proposalsPath: options.proposalsPath,
|
|
3476
|
+
onProgress: log
|
|
3477
|
+
});
|
|
3478
|
+
warnings.push(...std.warnings);
|
|
3479
|
+
const codeByStd = new Map(std.decisions.map((d) => [d.id, d]));
|
|
3480
|
+
for (const c of concepts) {
|
|
3481
|
+
const d = codeByStd.get(c.id);
|
|
3482
|
+
if (!d) throw new Error("Concept " + c.id + " missing standardization decision \u2014 fail-closed");
|
|
3483
|
+
if (d.decision === "standardized") {
|
|
3484
|
+
c.standardization = "standardized";
|
|
3485
|
+
c.tree_code = d.tree_code;
|
|
3486
|
+
c.code = d.tree_code;
|
|
3487
|
+
const tc = catalog.concepts.find((x) => x.code === d.tree_code);
|
|
3488
|
+
if (tc) {
|
|
3489
|
+
for (const k of d.adopted_keywords || []) {
|
|
3490
|
+
if (!(c.keywords || []).some((x) => x.toLowerCase() === k.toLowerCase())) {
|
|
3491
|
+
c.keywords = [...c.keywords || [], k];
|
|
3492
|
+
}
|
|
3493
|
+
}
|
|
3494
|
+
c.tree_prerequisites = tc.prerequisiteCodes;
|
|
3495
|
+
}
|
|
3496
|
+
} else {
|
|
3497
|
+
c.standardization = "proposed_new";
|
|
3498
|
+
c.code = d.proposed_code;
|
|
3499
|
+
}
|
|
3500
|
+
}
|
|
3501
|
+
const idByTreeCode = new Map(concepts.filter((c) => c.tree_code).map((c) => [c.tree_code, c.id]));
|
|
3502
|
+
for (const c of concepts) {
|
|
3503
|
+
for (const tp of c.tree_prerequisites || []) {
|
|
3504
|
+
const target = idByTreeCode.get(tp);
|
|
3505
|
+
if (target && target !== c.id && !(c.prerequisites || []).includes(target)) {
|
|
3506
|
+
c.prerequisites = [...c.prerequisites || [], target];
|
|
3507
|
+
warnings.push("Prereq seeded from tree: " + c.id + " <- " + tp + " (" + target + ")");
|
|
3508
|
+
}
|
|
3509
|
+
}
|
|
3510
|
+
}
|
|
3511
|
+
log("STEP_2_5", "Standardized: " + std.decisions.filter((d) => d.decision === "standardized").length + ", proposed new: " + std.decisions.filter((d) => d.decision === "proposed_new").length);
|
|
3512
|
+
}
|
|
3513
|
+
log("STEP_3", "Checking for prerequisite cycles...");
|
|
3514
|
+
const cycles = detectPrerequisiteCycles(concepts);
|
|
3515
|
+
if (cycles.length > 0) {
|
|
3516
|
+
log("STEP_3", `Found ${cycles.length} cycle(s) in prerequisite chain \u2014 breaking cycles`);
|
|
3517
|
+
for (const cycle of cycles) {
|
|
3518
|
+
warnings.push(`Cycle detected: ${cycle.join(" \u2192 ")} \u2014 breaking at weakest edge`);
|
|
3519
|
+
}
|
|
3520
|
+
const { fixed, removed } = breakPrerequisiteCycles(concepts, cycles);
|
|
3521
|
+
for (let i = 0; i < concepts.length; i++) {
|
|
3522
|
+
const fixedConcept = fixed.find((f) => f.id === concepts[i].id);
|
|
3523
|
+
if (fixedConcept) {
|
|
3524
|
+
concepts[i] = { ...concepts[i], prerequisites: fixedConcept.prerequisites };
|
|
3525
|
+
}
|
|
3526
|
+
}
|
|
3527
|
+
for (const { from, to } of removed) {
|
|
3528
|
+
warnings.push(`Removed prerequisite edge: ${from} \u2192 ${to} (cycle breaker)`);
|
|
3529
|
+
}
|
|
3530
|
+
}
|
|
3531
|
+
log("STEP_4", "Topological sort of concepts...");
|
|
3532
|
+
const sortedIds = topologicalSort(concepts);
|
|
3533
|
+
learningPath = sortedIds.map((id, idx) => {
|
|
3534
|
+
const concept = concepts.find((c) => c.id === id);
|
|
3535
|
+
return {
|
|
3536
|
+
concept_id: id,
|
|
3537
|
+
order: idx + 1,
|
|
3538
|
+
depends_on: concept?.prerequisites || [],
|
|
3539
|
+
milestone_name: ""
|
|
3540
|
+
};
|
|
3541
|
+
});
|
|
3542
|
+
log("STEP_5", "Authoring ULO/CIO/SIO depth layers hierarchically...");
|
|
3543
|
+
{
|
|
3544
|
+
const lo = await authorDepthLayersHierarchically(
|
|
3545
|
+
client,
|
|
3546
|
+
concepts.map((c) => ({ id: c.id, name: c.name, description: c.description, keywords: c.keywords })),
|
|
3547
|
+
techStack || (domain === "software" || domain === "technology" ? "python" : domain || "general"),
|
|
3548
|
+
warnings
|
|
3549
|
+
);
|
|
3550
|
+
const codeByConceptId = assignConceptCodes(concepts.map((c) => ({ id: c.id, name: c.name })));
|
|
3551
|
+
const techTag = techTagFor(techStack || (domain === "software" || domain === "technology" ? "python" : domain || "general"));
|
|
3552
|
+
for (const concept of concepts) {
|
|
3553
|
+
concept.ulo = lo.uloByConcept[concept.id] || concept.ulo || "";
|
|
3554
|
+
concept.cio = lo.cioByConcept[concept.id] || concept.cio || "";
|
|
3555
|
+
if (lo.sioByConcept[concept.id] && lo.sioByConcept[concept.id].length > 0) {
|
|
3556
|
+
concept.sio = lo.sioByConcept[concept.id];
|
|
3557
|
+
}
|
|
3558
|
+
concept.code = concept.code || codeByConceptId[concept.id];
|
|
3559
|
+
concept.ulo_code = concept.ulo ? uloCode(concept.code) : concept.ulo_code;
|
|
3560
|
+
concept.cio_code = concept.cio ? cioCode(concept.code, 1, cioActionSlug(concept.cio)) : concept.cio_code;
|
|
3561
|
+
concept.sio_codes = concept.sio.length > 0 ? concept.sio.map((_, i) => sioCode(techTag, concept.code, i + 1)) : concept.sio_codes;
|
|
3562
|
+
}
|
|
3563
|
+
log("STEP_5", "Depth layers authored: " + concepts.length + " concepts, audit " + lo.audits.filter((a) => a.passed).length + "/" + concepts.length + " passed");
|
|
3564
|
+
}
|
|
3565
|
+
{
|
|
3566
|
+
for (const concept of concepts) {
|
|
3567
|
+
const sioText = (concept.sio || []).join(" ");
|
|
3568
|
+
const techTokens = [];
|
|
3569
|
+
let m;
|
|
3570
|
+
const techTokenRe = /`([^`]+)`/g;
|
|
3571
|
+
while ((m = techTokenRe.exec(sioText)) !== null) {
|
|
3572
|
+
if (m[1].length >= 3 && m[1].length <= 40) techTokens.push(m[1]);
|
|
3573
|
+
}
|
|
3574
|
+
const annotationRe = /(?:^|[\s(])(@[A-Za-z]\w*)/g;
|
|
3575
|
+
while ((m = annotationRe.exec(sioText)) !== null) techTokens.push(m[1]);
|
|
3576
|
+
const dotRe = /\b\w+\.\w+\(/g;
|
|
3577
|
+
while ((m = dotRe.exec(sioText)) !== null) techTokens.push(m[0].replace("(", ""));
|
|
3578
|
+
const modifierRe = /(?:^|[\s(])(\.[a-z]\w*)\b/g;
|
|
3579
|
+
while ((m = modifierRe.exec(sioText)) !== null) techTokens.push(m[1]);
|
|
3580
|
+
const camelRe = /\b[a-z]+[A-Z][A-Za-z]+\b/g;
|
|
3581
|
+
while ((m = camelRe.exec(sioText)) !== null) techTokens.push(m[0]);
|
|
3582
|
+
const existing = new Set((concept.keywords || []).map((k) => k.toLowerCase()));
|
|
3583
|
+
const newTokens = [...new Set(techTokens)].filter((t) => !existing.has(t.toLowerCase())).slice(0, 8);
|
|
3584
|
+
if (newTokens.length > 0) {
|
|
3585
|
+
concept.keywords = [...concept.keywords || [], ...newTokens];
|
|
3586
|
+
warnings.push("Concept " + concept.id + ": enriched keywords with " + newTokens.length + " SIO-derived tokens");
|
|
3587
|
+
}
|
|
3588
|
+
}
|
|
3589
|
+
log("STEP_5b", "SIO-keyword enrichment complete");
|
|
3590
|
+
}
|
|
3591
|
+
{
|
|
3592
|
+
for (const concept of concepts) {
|
|
3593
|
+
const sioCount = (concept.sio || []).length;
|
|
3594
|
+
const ptCount = (concept.problem_types || []).length;
|
|
3595
|
+
const prereqCount = (concept.prerequisites || []).length;
|
|
3596
|
+
const hasHighBloom = (concept.problem_types || []).some(
|
|
3597
|
+
(pt) => ["Analyze", "Evaluate", "Create"].includes(pt.bloom_level || "")
|
|
3598
|
+
);
|
|
3599
|
+
const calibrated = calibrateConceptMinutes(sioCount, ptCount, prereqCount, hasHighBloom);
|
|
3600
|
+
const llmEstimate = concept.estimated_minutes || DEFAULT_CONCEPT_MINUTES;
|
|
3601
|
+
if (Math.abs(calibrated - llmEstimate) / llmEstimate > MIN_CALIBRATION_DIVERGENCE) {
|
|
3602
|
+
concept.estimated_minutes = calibrated;
|
|
3603
|
+
warnings.push("Concept " + concept.id + ": minutes calibrated " + llmEstimate + "m \u2192 " + calibrated + "m");
|
|
3604
|
+
}
|
|
3605
|
+
}
|
|
3606
|
+
log("STEP_5c", "Minute calibration complete");
|
|
3607
|
+
}
|
|
3608
|
+
for (const step of learningPath) {
|
|
3609
|
+
if (!conceptIds.has(step.concept_id)) {
|
|
3610
|
+
warnings.push(`Learning path references non-existent concept ${step.concept_id}`);
|
|
3611
|
+
}
|
|
3612
|
+
}
|
|
3613
|
+
log("STEP_2", `Validated ${concepts.length} concepts, ${categories.length} categories, ${learningPath.length} learning path steps`);
|
|
3614
|
+
const rawGraph = {
|
|
3615
|
+
schema_version: 1,
|
|
3616
|
+
type: "knowledge_graph",
|
|
3617
|
+
subject: result.subject,
|
|
3618
|
+
concepts,
|
|
3619
|
+
categories,
|
|
3620
|
+
learning_path: learningPath
|
|
3621
|
+
};
|
|
3622
|
+
log("STEP_6", "Running Knowledge Graph verification & reverse coverage audit...");
|
|
3623
|
+
const { verifiedGraph, report } = verifyKnowledgeGraph(rawGraph, parsedSyllabus);
|
|
3624
|
+
warnings.push(...report.warnings);
|
|
3625
|
+
return {
|
|
3626
|
+
knowledgeGraph: verifiedGraph,
|
|
3627
|
+
warnings,
|
|
3628
|
+
verificationReport: report,
|
|
3629
|
+
provenance: client.callLog
|
|
3630
|
+
};
|
|
3631
|
+
}
|
|
3632
|
+
var FeedKeywordsSchema = zod.z.object({
|
|
3633
|
+
all: zod.z.array(zod.z.string()).default([]),
|
|
3634
|
+
new: zod.z.array(zod.z.string()).default([]),
|
|
3635
|
+
prerequisite: zod.z.array(zod.z.string()).default([])
|
|
3636
|
+
});
|
|
3637
|
+
var FeedScaffoldCandidateSchema = zod.z.object({
|
|
3638
|
+
item: zod.z.string(),
|
|
3639
|
+
reason: zod.z.string().default(""),
|
|
3640
|
+
minutes_saved: zod.z.number().int().nonnegative().default(0),
|
|
3641
|
+
suggest: zod.z.enum(["provide_full", "provide_partial", "provide_reference"]).default("provide_full")
|
|
3642
|
+
});
|
|
3643
|
+
var FeedNodeSchema = zod.z.object({
|
|
3644
|
+
id: zod.z.string().min(1),
|
|
3645
|
+
kind: zod.z.enum(["skill", "concept", "product_step"]),
|
|
3646
|
+
name: zod.z.string().min(1),
|
|
3647
|
+
concept_codes: zod.z.array(zod.z.string()).default([]),
|
|
3648
|
+
keywords: FeedKeywordsSchema,
|
|
3649
|
+
estimated_minutes: zod.z.number().int().nonnegative(),
|
|
3650
|
+
bloom_hint: zod.z.enum(["Remember", "Understand", "Apply", "Analyze", "Evaluate", "Create"]).nullable().default(null),
|
|
3651
|
+
depth_hint: zod.z.enum(["ulo", "cio", "sio"]).nullable().default(null),
|
|
3652
|
+
completion_level: zod.z.enum(["mvp", "standard", "stretch"]).default("standard"),
|
|
3653
|
+
scaffolding: zod.z.object({
|
|
3654
|
+
scaffold_candidates: zod.z.array(FeedScaffoldCandidateSchema).default([]),
|
|
3655
|
+
core_value_minutes: zod.z.number().int().nonnegative().default(0)
|
|
3656
|
+
}).default({ scaffold_candidates: [], core_value_minutes: 0 }),
|
|
3657
|
+
references: zod.z.array(zod.z.object({ file: zod.z.string().min(1), evidence: zod.z.string().default("") })).default([]),
|
|
3658
|
+
feature_id: zod.z.string().nullable().default(null),
|
|
3659
|
+
concept_ref: zod.z.string().nullable().default(null),
|
|
3660
|
+
// P52/T2.1: outcome.user_visible projected from the ProjectGraph step — the
|
|
3661
|
+
// tangible checkpoint the session-cutting planner aims for (empty when the
|
|
3662
|
+
// step declares no user-visible result). Concepts carry '' by default.
|
|
3663
|
+
user_visible_deliverable: zod.z.string().default(""),
|
|
3664
|
+
// P52/T3.1 — depth-reconcile inputs (optional/defaulted so old feeds
|
|
3665
|
+
// validate unchanged):
|
|
3666
|
+
// • depth_variants: minutes to teach this node at each depth level.
|
|
3667
|
+
// ULO (know-it-exists) ≤ CIO (mechanism) ≤ SIO (implementation). All 3
|
|
3668
|
+
// required so a downgrade never turns an anchor teach into a zero.
|
|
3669
|
+
// • depth_scaffold_candidates: parallel to scaffold_candidates but
|
|
3670
|
+
// acts on DEPTH (e.g. SIO→CIO) instead of lesson time.
|
|
3671
|
+
// • is_core: concept is a Master-Tree core concept — reconciler must
|
|
3672
|
+
// never downgrade it; escalate instead.
|
|
3673
|
+
depth_variants: zod.z.object({ ulo: zod.z.number().int().nonnegative(), cio: zod.z.number().int().nonnegative(), sio: zod.z.number().int().nonnegative() }).optional(),
|
|
3674
|
+
depth_scaffold_candidates: zod.z.array(zod.z.object({
|
|
3675
|
+
from_depth: zod.z.enum(["ulo", "cio", "sio"]),
|
|
3676
|
+
to_depth: zod.z.enum(["ulo", "cio", "sio"]),
|
|
3677
|
+
minutes_saved: zod.z.number().int().nonnegative(),
|
|
3678
|
+
reason: zod.z.string().default("")
|
|
3679
|
+
})).optional(),
|
|
3680
|
+
is_core: zod.z.boolean().default(false),
|
|
3681
|
+
/**
|
|
3682
|
+
* Prerequisite concept ids this node needs that NO node in this feed provides —
|
|
3683
|
+
* i.e. the learner must already know them before starting. Previously these were
|
|
3684
|
+
* silently dropped (knowledge path) or crashed emission (both hybrid paths), so a
|
|
3685
|
+
* course silently lost its entry requirements. Kept on the node so the mapping
|
|
3686
|
+
* "concept X needs prior knowledge Y" survives; a course-level roll-up is derivable.
|
|
3687
|
+
*/
|
|
3688
|
+
external_prerequisites: zod.z.array(zod.z.string()).default([]),
|
|
3689
|
+
// Depth layer text (optional, additive — old feeds validate unchanged).
|
|
3690
|
+
// Carries the ULO/CIO/SIO authored text from the KnowledgeGraph concept
|
|
3691
|
+
// so downstream consumers (KX generator) can inject depth-appropriate
|
|
3692
|
+
// material into their prompts without a second LLM call.
|
|
3693
|
+
depth_context: zod.z.object({
|
|
3694
|
+
ulo: zod.z.string().default(""),
|
|
3695
|
+
cio: zod.z.string().default(""),
|
|
3696
|
+
sio: zod.z.array(zod.z.string()).default([])
|
|
3697
|
+
}).optional(),
|
|
3698
|
+
// Progressive-completion bookkeeping (feed v2, additive):
|
|
3699
|
+
// phase_id = development phase that introduces/uses this node;
|
|
3700
|
+
// introduce_aspect = how the phase teaches a concept node (first intro, advanced
|
|
3701
|
+
// revisit, or preview — a light ULO awareness pass that is NOT the introduction).
|
|
3702
|
+
phase_id: zod.z.string().default(""),
|
|
3703
|
+
introduce_aspect: zod.z.enum(["new", "advanced", "preview"]).nullable().default(null)
|
|
3704
|
+
});
|
|
3705
|
+
var FeedEdgeSchema = zod.z.object({
|
|
3706
|
+
from: zod.z.string().min(1),
|
|
3707
|
+
to: zod.z.string().min(1),
|
|
3708
|
+
kind: zod.z.enum(["knowledge", "task"]),
|
|
3709
|
+
reason: zod.z.string().default("")
|
|
3710
|
+
});
|
|
3711
|
+
var FeedGroupingSchema = zod.z.object({
|
|
3712
|
+
id: zod.z.string().min(1),
|
|
3713
|
+
name: zod.z.string().min(1),
|
|
3714
|
+
node_ids: zod.z.array(zod.z.string()).min(1),
|
|
3715
|
+
rationale: zod.z.string().default("")
|
|
3716
|
+
});
|
|
3717
|
+
var FeedPhaseSchema = zod.z.object({
|
|
3718
|
+
id: zod.z.string().min(1),
|
|
3719
|
+
order: zod.z.number().int().positive(),
|
|
3720
|
+
name: zod.z.string().min(1),
|
|
3721
|
+
product_completion: zod.z.string().min(1),
|
|
3722
|
+
node_ids: zod.z.array(zod.z.string()).min(1)
|
|
3723
|
+
});
|
|
3724
|
+
var FeedDiagnosticSchema = zod.z.object({
|
|
3725
|
+
code: zod.z.string().min(1),
|
|
3726
|
+
severity: zod.z.enum(["info", "warning", "error"]),
|
|
3727
|
+
message: zod.z.string(),
|
|
3728
|
+
node_ids: zod.z.array(zod.z.string()).default([])
|
|
3729
|
+
});
|
|
3730
|
+
var FeedProvenanceSchema = zod.z.object({
|
|
3731
|
+
providers: zod.z.array(zod.z.string()).default([]),
|
|
3732
|
+
models: zod.z.array(zod.z.string()).default([]),
|
|
3733
|
+
calls: zod.z.number().int().nonnegative().default(0),
|
|
3734
|
+
failed_calls: zod.z.number().int().nonnegative().default(0),
|
|
3735
|
+
degraded: zod.z.boolean().default(false)
|
|
3736
|
+
});
|
|
3737
|
+
var CurriculumFeedSchema = zod.z.object({
|
|
3738
|
+
schema_version: zod.z.literal(2),
|
|
3739
|
+
source: zod.z.object({
|
|
3740
|
+
graph_type: zod.z.enum(["project_graph", "knowledge_graph", "hybrid_graph"]),
|
|
3741
|
+
/** DERIVED from diagnostics — do not write to it directly. */
|
|
3742
|
+
warnings: zod.z.array(zod.z.string()).default([]),
|
|
3743
|
+
hallucination_count: zod.z.number().int().nonnegative().default(0),
|
|
3744
|
+
diagnostics: zod.z.array(FeedDiagnosticSchema).default([]),
|
|
3745
|
+
provenance: FeedProvenanceSchema.optional()
|
|
3746
|
+
}),
|
|
3747
|
+
learning_nodes: zod.z.array(FeedNodeSchema).min(1, "curriculum feed must contain at least one learning node"),
|
|
3748
|
+
dependency_edges: zod.z.array(FeedEdgeSchema).default([]),
|
|
3749
|
+
suggested_groupings: zod.z.array(FeedGroupingSchema).default([]),
|
|
3750
|
+
// Progressive completion phases (cross-feature). When non-empty, these — not
|
|
3751
|
+
// suggested_groupings — define the teaching sequence and unit structure.
|
|
3752
|
+
phases: zod.z.array(FeedPhaseSchema).default([])
|
|
3753
|
+
});
|
|
3754
|
+
function validateCurriculumFeed(feed) {
|
|
3755
|
+
const parsed = CurriculumFeedSchema.parse(feed);
|
|
3756
|
+
const ids = new Set(parsed.learning_nodes.map((n) => n.id));
|
|
3757
|
+
if (ids.size !== parsed.learning_nodes.length) {
|
|
3758
|
+
throw new Error("curriculum feed contains duplicate node ids");
|
|
3759
|
+
}
|
|
3760
|
+
for (const e of parsed.dependency_edges) {
|
|
3761
|
+
if (!ids.has(e.from) || !ids.has(e.to)) {
|
|
3762
|
+
throw new Error("curriculum feed edge " + e.from + " -> " + e.to + " references unknown node");
|
|
3763
|
+
}
|
|
3764
|
+
if (e.from === e.to) throw new Error("curriculum feed self-loop edge on " + e.from);
|
|
3765
|
+
}
|
|
3766
|
+
for (const g of parsed.suggested_groupings) {
|
|
3767
|
+
for (const id of g.node_ids) {
|
|
3768
|
+
if (!ids.has(id)) throw new Error("grouping " + g.id + " references unknown node " + id);
|
|
3769
|
+
}
|
|
3770
|
+
}
|
|
3771
|
+
for (const ph of parsed.phases) {
|
|
3772
|
+
for (const id of ph.node_ids) {
|
|
3773
|
+
if (!ids.has(id)) throw new Error("phase " + ph.id + " references unknown node " + id);
|
|
3774
|
+
}
|
|
3775
|
+
}
|
|
3776
|
+
const phaseIds = parsed.phases.map((p) => p.id);
|
|
3777
|
+
if (new Set(phaseIds).size !== phaseIds.length) throw new Error("curriculum feed contains duplicate phase ids");
|
|
3778
|
+
const phaseOrders = parsed.phases.map((p) => p.order);
|
|
3779
|
+
if (new Set(phaseOrders).size !== phaseOrders.length) {
|
|
3780
|
+
throw new Error("curriculum feed contains duplicate phase orders: " + phaseOrders.join(", "));
|
|
3781
|
+
}
|
|
3782
|
+
for (let i = 1; i < phaseOrders.length; i++) {
|
|
3783
|
+
if (phaseOrders[i] <= phaseOrders[i - 1]) {
|
|
3784
|
+
throw new Error("curriculum feed phase orders are not ascending: " + phaseOrders.join(", "));
|
|
3785
|
+
}
|
|
3786
|
+
}
|
|
3787
|
+
if (parsed.phases.length > 0) {
|
|
3788
|
+
const phaseIndexOf = /* @__PURE__ */ new Map();
|
|
3789
|
+
parsed.phases.forEach((p, i) => {
|
|
3790
|
+
for (const id of p.node_ids) phaseIndexOf.set(id, i);
|
|
3791
|
+
});
|
|
3792
|
+
for (const e of parsed.dependency_edges) {
|
|
3793
|
+
const fromIdx = phaseIndexOf.get(e.from);
|
|
3794
|
+
const toIdx = phaseIndexOf.get(e.to);
|
|
3795
|
+
if (fromIdx === void 0 || toIdx === void 0) continue;
|
|
3796
|
+
if (fromIdx > toIdx) {
|
|
3797
|
+
throw new Error(
|
|
3798
|
+
"curriculum feed teaching-order violation: prerequisite " + e.from + " (phase " + (fromIdx + 1) + ") is scheduled AFTER its consumer " + e.to + " (phase " + (toIdx + 1) + ")"
|
|
3799
|
+
);
|
|
3800
|
+
}
|
|
3801
|
+
}
|
|
3802
|
+
}
|
|
3803
|
+
return parsed;
|
|
3804
|
+
}
|
|
3805
|
+
|
|
3806
|
+
// src/feed/curriculumFeedEmitter.ts
|
|
3807
|
+
var DEPTH_MINUTE_FRACTIONS = {
|
|
3808
|
+
/** Awareness-only pass before the anchor teach. */
|
|
3809
|
+
preview: 0.25,
|
|
3810
|
+
ulo: 0.35,
|
|
3811
|
+
cio: 0.65,
|
|
3812
|
+
/** An advanced revisit re-teaches the concept, not the whole thing again. */
|
|
3813
|
+
advancedRevisit: 0.5,
|
|
3814
|
+
/** Minutes saved when an advanced revisit drops from SIO practice to a CIO recap. */
|
|
3815
|
+
advancedRecapSave: 0.3,
|
|
3816
|
+
/** Floor so a tiny concept still gets a teachable awareness pass. */
|
|
3817
|
+
floor: 5,
|
|
3818
|
+
/** Floor for an advanced revisit — it is a real teach, not an awareness pass. */
|
|
3819
|
+
advancedFloor: 15
|
|
3820
|
+
};
|
|
3821
|
+
var DEFAULT_STEP_MINUTES = 15;
|
|
3822
|
+
var BLOOM_RANK = { Remember: 0, Understand: 1, Apply: 2, Analyze: 3, Evaluate: 4, Create: 5 };
|
|
3823
|
+
var pad22 = (n) => String(n).padStart(2, "0");
|
|
3824
|
+
var steplessStepIds = [];
|
|
3825
|
+
var takeSteplessStepIds = () => steplessStepIds.splice(0, steplessStepIds.length);
|
|
3826
|
+
var arr = (v) => Array.isArray(v) ? v : [];
|
|
3827
|
+
var str = (v, d = "") => typeof v === "string" ? v : d;
|
|
3828
|
+
var num = (v, d) => typeof v === "number" && Number.isFinite(v) ? v : d;
|
|
3829
|
+
var strs = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
|
|
3830
|
+
function stepMinutes(step) {
|
|
3831
|
+
const effort = step.effort ?? {};
|
|
3832
|
+
return Math.max(0, Math.round(num(effort.estimated_minutes, num(step.estimated_minutes, DEFAULT_STEP_MINUTES))));
|
|
3833
|
+
}
|
|
3834
|
+
function diagnosticsOf(opts) {
|
|
3835
|
+
const list = opts.diagnostics ?? (opts.diagnostics = []);
|
|
3836
|
+
for (const message of opts.warnings ?? []) {
|
|
3837
|
+
if (!list.some((d) => d.code === "upstream_warning" && d.message === message)) {
|
|
3838
|
+
list.push({ code: "upstream_warning", severity: "warning", message, node_ids: [] });
|
|
3839
|
+
}
|
|
3840
|
+
}
|
|
3841
|
+
return list;
|
|
3842
|
+
}
|
|
3843
|
+
function pushDiagnostic(opts, code, severity, message, nodeIds = []) {
|
|
3844
|
+
diagnosticsOf(opts).push({ code, severity, message, node_ids: nodeIds });
|
|
3845
|
+
}
|
|
3846
|
+
function splitPrerequisites(concept, known) {
|
|
3847
|
+
const internal = [];
|
|
3848
|
+
const external = [];
|
|
3849
|
+
for (const p of strs(concept.prerequisites)) {
|
|
3850
|
+
(known.has(p) ? internal : external).push(p);
|
|
3851
|
+
}
|
|
3852
|
+
return { internal, external };
|
|
3853
|
+
}
|
|
3854
|
+
function noteExternalPrerequisites(opts, byNode) {
|
|
3855
|
+
if (byNode.size === 0) return;
|
|
3856
|
+
const entries = [];
|
|
3857
|
+
for (const [nodeId, prereqs] of byNode) {
|
|
3858
|
+
for (const prereqId of prereqs) entries.push({ nodeId, prereqId });
|
|
3859
|
+
}
|
|
3860
|
+
entries.sort((a, b) => a.nodeId === b.nodeId ? a.prereqId.localeCompare(b.prereqId) : a.nodeId.localeCompare(b.nodeId));
|
|
3861
|
+
pushDiagnostic(
|
|
3862
|
+
opts,
|
|
3863
|
+
"external_prerequisite",
|
|
3864
|
+
"info",
|
|
3865
|
+
"Prerequisite(s) outside this analysis \u2014 the learner must already know them: " + entries.map((e) => e.nodeId + " \u2190 " + e.prereqId).join(", "),
|
|
3866
|
+
entries.map((e) => e.nodeId)
|
|
3867
|
+
);
|
|
3868
|
+
}
|
|
3869
|
+
function buildSource(opts, graphType) {
|
|
3870
|
+
const diagnostics = diagnosticsOf(opts);
|
|
3871
|
+
return {
|
|
3872
|
+
graph_type: graphType,
|
|
3873
|
+
// Derived view — 'info' diagnostics are deliberately not surfaced as warnings.
|
|
3874
|
+
warnings: diagnostics.filter((d) => d.severity !== "info").map((d) => d.message),
|
|
3875
|
+
hallucination_count: opts.hallucinationCount ?? 0,
|
|
3876
|
+
diagnostics,
|
|
3877
|
+
provenance: opts.provenance
|
|
3878
|
+
};
|
|
3879
|
+
}
|
|
3880
|
+
function keywordKey(k) {
|
|
3881
|
+
return k.normalize("NFC").toLowerCase();
|
|
3882
|
+
}
|
|
3883
|
+
function newKeywordsInOrder(nodes) {
|
|
3884
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3885
|
+
for (const n of nodes) {
|
|
3886
|
+
const all = n.keywords.all;
|
|
3887
|
+
n.keywords.new = [];
|
|
3888
|
+
n.keywords.prerequisite = [];
|
|
3889
|
+
for (const k of all) {
|
|
3890
|
+
if (seen.has(keywordKey(k))) n.keywords.prerequisite.push(k);
|
|
3891
|
+
else n.keywords.new.push(k);
|
|
3892
|
+
seen.add(keywordKey(k));
|
|
3893
|
+
}
|
|
3894
|
+
}
|
|
3895
|
+
}
|
|
3896
|
+
function feedNodeFromStep(step, featureId) {
|
|
3897
|
+
const id = str(step.id);
|
|
3898
|
+
return {
|
|
3899
|
+
id,
|
|
3900
|
+
kind: "product_step",
|
|
3901
|
+
name: str(step.name, id),
|
|
3902
|
+
concept_codes: strs(step.concept_codes),
|
|
3903
|
+
keywords: { all: strs(step.keywords), new: [], prerequisite: [] },
|
|
3904
|
+
estimated_minutes: stepMinutes(step),
|
|
3905
|
+
bloom_hint: "Apply",
|
|
3906
|
+
depth_hint: null,
|
|
3907
|
+
completion_level: "standard",
|
|
3908
|
+
scaffolding: { scaffold_candidates: [], core_value_minutes: stepMinutes(step) },
|
|
3909
|
+
references: arr(step.files).map((f) => ({ file: str(f), evidence: "step file" })).filter((f) => f.file),
|
|
3910
|
+
feature_id: featureId,
|
|
3911
|
+
concept_ref: null,
|
|
3912
|
+
// P52/T2.1: carry the step's tangible outcome so the planner can cut
|
|
3913
|
+
// sessions at user-visible checkpoints instead of raw minute counts.
|
|
3914
|
+
user_visible_deliverable: str(step.outcome?.user_visible),
|
|
3915
|
+
is_core: false,
|
|
3916
|
+
external_prerequisites: [],
|
|
3917
|
+
phase_id: "",
|
|
3918
|
+
introduce_aspect: null
|
|
3919
|
+
};
|
|
3920
|
+
}
|
|
3921
|
+
function deriveConceptDepthHint(concept) {
|
|
3922
|
+
const explicit = str(concept.depth_hint);
|
|
3923
|
+
if (explicit === "ulo" || explicit === "cio" || explicit === "sio") return explicit;
|
|
3924
|
+
const sio = arr(concept.sio);
|
|
3925
|
+
const prereqs = strs(concept.prerequisites);
|
|
3926
|
+
const pts = arr(concept.problem_types);
|
|
3927
|
+
const hasHighBloom = pts.some((p) => {
|
|
3928
|
+
const b = str(p.bloom_level);
|
|
3929
|
+
return b === "Apply" || b === "Analyze" || b === "Evaluate" || b === "Create";
|
|
3930
|
+
});
|
|
3931
|
+
if (sio.length >= 2 || hasHighBloom) return "sio";
|
|
3932
|
+
if (str(concept.cio) || prereqs.length >= 1) return "cio";
|
|
3933
|
+
return "ulo";
|
|
3934
|
+
}
|
|
3935
|
+
function deriveDepthInputs(minutes, depth) {
|
|
3936
|
+
const ulo = Math.max(DEPTH_MINUTE_FRACTIONS.floor, Math.round(minutes * DEPTH_MINUTE_FRACTIONS.ulo));
|
|
3937
|
+
const cio = Math.max(ulo + DEPTH_MINUTE_FRACTIONS.floor, Math.round(minutes * DEPTH_MINUTE_FRACTIONS.cio));
|
|
3938
|
+
const sio = Math.max(cio + DEPTH_MINUTE_FRACTIONS.floor, minutes);
|
|
3939
|
+
const variants = { ulo, cio, sio };
|
|
3940
|
+
const candidates = [];
|
|
3941
|
+
if (depth === "sio") {
|
|
3942
|
+
candidates.push({ from_depth: "sio", to_depth: "cio", minutes_saved: sio - cio, reason: "SIO implementation practice deferred \u2014 CIO mechanism suffices for downstream consumers" });
|
|
3943
|
+
}
|
|
3944
|
+
if (depth === "cio" || depth === "sio") {
|
|
3945
|
+
candidates.push({ from_depth: depth, to_depth: "ulo", minutes_saved: (depth === "sio" ? sio : cio) - ulo, reason: "Mechanism deferred \u2014 ULO awareness suffices (know-it-exists); never below the ULO floor" });
|
|
3946
|
+
}
|
|
3947
|
+
return { depth_variants: variants, depth_scaffold_candidates: candidates };
|
|
3948
|
+
}
|
|
3949
|
+
function feedNodeFromConcept(concept) {
|
|
3950
|
+
const id = str(concept.id);
|
|
3951
|
+
const pts = arr(concept.problem_types).map((p) => str(p.bloom_level, "Understand")).filter((b) => b in BLOOM_RANK).sort((a, b) => BLOOM_RANK[a] - BLOOM_RANK[b]);
|
|
3952
|
+
const topBloom = pts[pts.length - 1] ?? "Understand";
|
|
3953
|
+
const rawRefs = arr(concept.references);
|
|
3954
|
+
const references = rawRefs.length > 0 ? rawRefs.map((r) => ({ file: str(r.file, "syllabus.md"), evidence: str(r.evidence, str(concept.name)) })).filter((r) => r.file) : [{ file: "syllabus.md", evidence: str(concept.category ? `Topic under category ${str(concept.category)}` : str(concept.name), id) }];
|
|
3955
|
+
const problemScaffolds = arr(concept.problem_types).map((pt) => ({
|
|
3956
|
+
item: str(pt.name, "Problem type"),
|
|
3957
|
+
reason: str(pt.description, "Formative practice check"),
|
|
3958
|
+
minutes_saved: num(pt.estimated_minutes, 10),
|
|
3959
|
+
suggest: "provide_partial"
|
|
3960
|
+
}));
|
|
3961
|
+
return {
|
|
3962
|
+
id,
|
|
3963
|
+
kind: "concept",
|
|
3964
|
+
name: str(concept.name, id),
|
|
3965
|
+
concept_codes: [str(concept.code) || id].filter(Boolean),
|
|
3966
|
+
keywords: { all: strs(concept.keywords), new: [], prerequisite: [] },
|
|
3967
|
+
estimated_minutes: Math.max(0, Math.round(num(concept.estimated_minutes, 15))),
|
|
3968
|
+
bloom_hint: topBloom,
|
|
3969
|
+
depth_hint: deriveConceptDepthHint(concept),
|
|
3970
|
+
completion_level: "standard",
|
|
3971
|
+
scaffolding: {
|
|
3972
|
+
scaffold_candidates: problemScaffolds,
|
|
3973
|
+
core_value_minutes: Math.max(0, Math.round(num(concept.estimated_minutes, 15)))
|
|
3974
|
+
},
|
|
3975
|
+
references,
|
|
3976
|
+
feature_id: null,
|
|
3977
|
+
concept_ref: id,
|
|
3978
|
+
user_visible_deliverable: "",
|
|
3979
|
+
// concepts are taught, not shipped — checkpoints live on product steps
|
|
3980
|
+
...deriveDepthInputs(Math.max(0, Math.round(num(concept.estimated_minutes, 15))), deriveConceptDepthHint(concept)),
|
|
3981
|
+
// Knowledge-Tree concepts are the spine of the course — the reconciler must
|
|
3982
|
+
// escalate them, never downgrade them. The emitter hardcoded `false`, so the
|
|
3983
|
+
// flag the schema documents never reached the planner for any concept.
|
|
3984
|
+
is_core: concept.is_core === true || str(concept.standardization) === "standardized",
|
|
3985
|
+
external_prerequisites: [],
|
|
3986
|
+
depth_context: {
|
|
3987
|
+
ulo: str(concept.ulo),
|
|
3988
|
+
cio: str(concept.cio),
|
|
3989
|
+
sio: strs(concept.sio)
|
|
3990
|
+
},
|
|
3991
|
+
phase_id: "",
|
|
3992
|
+
introduce_aspect: null
|
|
3993
|
+
};
|
|
3994
|
+
}
|
|
3995
|
+
function addFeatureGroup(f, fid, nodes, edges, groupings) {
|
|
3996
|
+
const steps = arr(f.steps);
|
|
3997
|
+
const stepIds = [];
|
|
3998
|
+
let prev = "";
|
|
3999
|
+
for (let i = 0; i < steps.length; i++) {
|
|
4000
|
+
const stepNode = feedNodeFromStep(steps[i], fid);
|
|
4001
|
+
if (!stepNode.id) {
|
|
4002
|
+
steplessStepIds.push(fid + "#" + i);
|
|
4003
|
+
continue;
|
|
4004
|
+
}
|
|
4005
|
+
stepIds.push(stepNode.id);
|
|
4006
|
+
nodes.push(stepNode);
|
|
4007
|
+
if (prev) edges.push({ from: prev, to: stepNode.id, kind: "task", reason: "feature build order" });
|
|
4008
|
+
prev = stepNode.id;
|
|
4009
|
+
}
|
|
4010
|
+
for (const dep of strs(f.depends_on)) {
|
|
4011
|
+
const depFirst = nodes.find((n) => n.feature_id === dep);
|
|
4012
|
+
if (depFirst && stepIds[0]) edges.push({ from: depFirst.id, to: stepIds[0], kind: "task", reason: "feature depends_on " + dep });
|
|
4013
|
+
}
|
|
4014
|
+
if (stepIds.length > 0) groupings.push({ id: "G-" + fid, name: str(f.name, fid), node_ids: stepIds, rationale: str(f.description, "") });
|
|
4015
|
+
return stepIds;
|
|
4016
|
+
}
|
|
4017
|
+
function emitFromHybrid(hybrid, opts) {
|
|
4018
|
+
const nodes = [];
|
|
4019
|
+
const edges = [];
|
|
4020
|
+
const groupings = [];
|
|
4021
|
+
const pg = hybrid.project_graph ?? {};
|
|
4022
|
+
const kg = hybrid.knowledge_graph ?? {};
|
|
4023
|
+
const rawPhases = arr(hybrid.phases).filter((p) => str(p.id));
|
|
4024
|
+
const features = arr(pg.features);
|
|
4025
|
+
const conceptRecs = arr(kg.concepts);
|
|
4026
|
+
const conceptById = new Map(conceptRecs.map((c) => [str(c.id), c]));
|
|
4027
|
+
const knownConceptIds = new Set(conceptById.keys());
|
|
4028
|
+
if (rawPhases.length === 0) {
|
|
4029
|
+
const externalByNode2 = /* @__PURE__ */ new Map();
|
|
4030
|
+
for (const c of conceptRecs) {
|
|
4031
|
+
const node = feedNodeFromConcept(c);
|
|
4032
|
+
nodes.push(node);
|
|
4033
|
+
const { internal, external } = splitPrerequisites(c, knownConceptIds);
|
|
4034
|
+
if (external.length > 0) {
|
|
4035
|
+
node.external_prerequisites = external;
|
|
4036
|
+
externalByNode2.set(node.id, external);
|
|
4037
|
+
}
|
|
4038
|
+
for (const p of internal) edges.push({ from: p, to: node.id, kind: "knowledge", reason: "concept prerequisite" });
|
|
4039
|
+
}
|
|
4040
|
+
noteExternalPrerequisites(opts, externalByNode2);
|
|
4041
|
+
features.forEach((f, fi) => addFeatureGroup(f, str(f.id, "F" + pad22(fi + 1)), nodes, edges, groupings));
|
|
4042
|
+
linkHybridKnowledgeEdges(hybrid, nodes, edges);
|
|
4043
|
+
newKeywordsInOrder(nodes);
|
|
4044
|
+
return validateCurriculumFeed({
|
|
4045
|
+
schema_version: 2,
|
|
4046
|
+
source: buildSource(opts, "hybrid_graph"),
|
|
4047
|
+
learning_nodes: nodes,
|
|
4048
|
+
dependency_edges: edges,
|
|
4049
|
+
suggested_groupings: groupings,
|
|
4050
|
+
phases: []
|
|
4051
|
+
});
|
|
4052
|
+
}
|
|
4053
|
+
const featureOrder = /* @__PURE__ */ new Map();
|
|
4054
|
+
features.forEach((f, fi) => featureOrder.set(str(f.id, "F" + pad22(fi + 1)), fi));
|
|
4055
|
+
const stepIdsByFeature = /* @__PURE__ */ new Map();
|
|
4056
|
+
for (const f of features) {
|
|
4057
|
+
const fid = str(f.id);
|
|
4058
|
+
stepIdsByFeature.set(fid, arr(f.steps).map((s) => str(s.id)).filter(Boolean));
|
|
4059
|
+
}
|
|
4060
|
+
const emitCtx = [];
|
|
4061
|
+
const emittedConcepts = /* @__PURE__ */ new Set();
|
|
4062
|
+
const previewsEmitted = /* @__PURE__ */ new Set();
|
|
4063
|
+
const previewNodeIdByConcept = /* @__PURE__ */ new Map();
|
|
4064
|
+
const externalByNode = /* @__PURE__ */ new Map();
|
|
4065
|
+
const linkList = arr(hybrid.links);
|
|
4066
|
+
for (const rp of rawPhases) {
|
|
4067
|
+
const pid = str(rp.id);
|
|
4068
|
+
const order = num(rp.order, emitCtx.length + 1);
|
|
4069
|
+
const conceptOrder = [];
|
|
4070
|
+
const intros = arr(rp.introduces);
|
|
4071
|
+
const news = intros.filter((i) => str(i.aspect, "new") === "new");
|
|
4072
|
+
const advanced = intros.filter((i) => str(i.aspect) === "advanced");
|
|
4073
|
+
const previews = intros.filter((i) => str(i.aspect) === "preview");
|
|
4074
|
+
for (const intro of [...news, ...advanced, ...previews]) {
|
|
4075
|
+
const cid = str(intro.concept_id);
|
|
4076
|
+
const c = conceptById.get(cid);
|
|
4077
|
+
if (!c) {
|
|
4078
|
+
throw new Error("Phase " + pid + " introduces unknown concept " + cid + " - fail-closed");
|
|
4079
|
+
}
|
|
4080
|
+
if (str(intro.aspect, "new") === "advanced" && !emittedConcepts.has(cid)) {
|
|
4081
|
+
throw new Error("Phase " + pid + " advanced-revisits concept " + cid + " that was never emitted - fail-closed");
|
|
4082
|
+
}
|
|
4083
|
+
if (str(intro.aspect, "new") === "preview") {
|
|
4084
|
+
if (emittedConcepts.has(cid)) {
|
|
4085
|
+
throw new Error("Phase " + pid + " previews concept " + cid + " that was already introduced - preview must precede the anchor teach - fail-closed");
|
|
4086
|
+
}
|
|
4087
|
+
if (previewsEmitted.has(cid)) {
|
|
4088
|
+
throw new Error("Phase " + pid + " duplicate preview of concept " + cid + " - fail-closed");
|
|
4089
|
+
}
|
|
4090
|
+
const pidIdx = rawPhases.findIndex((rp2) => str(rp2.id) === pid);
|
|
4091
|
+
const anchorIdx = rawPhases.findIndex((rp2) => arr(rp2.introduces).some((i2) => str(i2.concept_id) === cid && str(i2.aspect, "new") !== "preview"));
|
|
4092
|
+
if (anchorIdx < 0) {
|
|
4093
|
+
throw new Error("Phase " + pid + " previews concept " + cid + " that is never fully introduced in any phase - fail-closed");
|
|
4094
|
+
}
|
|
4095
|
+
if (pidIdx >= anchorIdx) {
|
|
4096
|
+
throw new Error("Phase " + pid + " previews concept " + cid + " whose anchor introduction is not in a LATER phase - fail-closed");
|
|
4097
|
+
}
|
|
4098
|
+
previewsEmitted.add(cid);
|
|
4099
|
+
const prevNode = feedNodeFromConcept(c);
|
|
4100
|
+
prevNode.id = cid + "__PREV" + pad22(order);
|
|
4101
|
+
prevNode.name = str(c.name, cid) + " (preview)";
|
|
4102
|
+
prevNode.phase_id = pid;
|
|
4103
|
+
prevNode.introduce_aspect = "preview";
|
|
4104
|
+
prevNode.estimated_minutes = Math.max(DEPTH_MINUTE_FRACTIONS.floor, Math.round(prevNode.estimated_minutes * DEPTH_MINUTE_FRACTIONS.preview));
|
|
4105
|
+
prevNode.depth_hint = "ulo";
|
|
4106
|
+
prevNode.bloom_hint = "Understand";
|
|
4107
|
+
prevNode.concept_codes = [str(c.code) || cid];
|
|
4108
|
+
prevNode.keywords = { all: strs(c.keywords), new: [], prerequisite: [] };
|
|
4109
|
+
prevNode.scaffolding = { scaffold_candidates: [], core_value_minutes: 0 };
|
|
4110
|
+
prevNode.depth_variants = { ulo: prevNode.estimated_minutes, cio: prevNode.estimated_minutes, sio: prevNode.estimated_minutes };
|
|
4111
|
+
prevNode.depth_scaffold_candidates = [];
|
|
4112
|
+
nodes.push(prevNode);
|
|
4113
|
+
conceptOrder.push(prevNode.id);
|
|
4114
|
+
previewNodeIdByConcept.set(cid, prevNode.id);
|
|
4115
|
+
continue;
|
|
4116
|
+
}
|
|
4117
|
+
if (!emittedConcepts.has(cid)) {
|
|
4118
|
+
const node = feedNodeFromConcept(c);
|
|
4119
|
+
node.phase_id = pid;
|
|
4120
|
+
node.introduce_aspect = "new";
|
|
4121
|
+
nodes.push(node);
|
|
4122
|
+
for (const p of strs(c.prerequisites)) {
|
|
4123
|
+
if (!conceptById.has(p)) {
|
|
4124
|
+
if (!externalByNode.has(cid)) externalByNode.set(cid, []);
|
|
4125
|
+
externalByNode.get(cid).push(p);
|
|
4126
|
+
node.external_prerequisites.push(p);
|
|
4127
|
+
continue;
|
|
4128
|
+
}
|
|
4129
|
+
const previewOfPrereq = previewNodeIdByConcept.get(p);
|
|
4130
|
+
if (!emittedConcepts.has(p) && !previewOfPrereq) {
|
|
4131
|
+
throw new Error("Concept " + cid + " (phase " + pid + ") has prerequisite " + p + " that is neither introduced nor previewed in an EARLIER phase - fail-closed");
|
|
4132
|
+
}
|
|
4133
|
+
edges.push({ from: emittedConcepts.has(p) ? p : previewOfPrereq, to: cid, kind: "knowledge", reason: "concept prerequisite" });
|
|
4134
|
+
}
|
|
4135
|
+
emittedConcepts.add(cid);
|
|
4136
|
+
conceptOrder.push(cid);
|
|
4137
|
+
} else if (str(intro.aspect, "new") === "new") {
|
|
4138
|
+
throw new Error("Phase " + pid + " introduces " + cid + " as new but it was already introduced in an earlier phase - fail-closed");
|
|
4139
|
+
} else {
|
|
4140
|
+
const revisitId = cid + "__ADV" + pad22(order);
|
|
4141
|
+
const node = feedNodeFromConcept(c);
|
|
4142
|
+
node.id = revisitId;
|
|
4143
|
+
node.name = str(c.name, cid) + " (advanced)";
|
|
4144
|
+
node.phase_id = pid;
|
|
4145
|
+
node.introduce_aspect = "advanced";
|
|
4146
|
+
node.estimated_minutes = Math.max(
|
|
4147
|
+
DEPTH_MINUTE_FRACTIONS.advancedFloor,
|
|
4148
|
+
Math.round(node.estimated_minutes * DEPTH_MINUTE_FRACTIONS.advancedRevisit)
|
|
4149
|
+
);
|
|
4150
|
+
node.depth_scaffold_candidates = [{
|
|
4151
|
+
from_depth: "sio",
|
|
4152
|
+
to_depth: "cio",
|
|
4153
|
+
minutes_saved: Math.round(node.estimated_minutes * DEPTH_MINUTE_FRACTIONS.advancedRecapSave),
|
|
4154
|
+
reason: "advanced revisit may drop to CIO recap without breaking first-teach chain"
|
|
4155
|
+
}];
|
|
4156
|
+
node.concept_codes = [str(c.code) || cid];
|
|
4157
|
+
edges.push({ from: cid, to: revisitId, kind: "knowledge", reason: "advanced revisit of " + cid + " at phase " + pid });
|
|
4158
|
+
nodes.push(node);
|
|
4159
|
+
conceptOrder.push(revisitId);
|
|
3808
4160
|
}
|
|
3809
|
-
finalDecisions.push({ ...d, id: c.id, proposed_code: code });
|
|
3810
|
-
proposals.push({
|
|
3811
|
-
code,
|
|
3812
|
-
name: c.name,
|
|
3813
|
-
description: d.proposed_definition || c.description.slice(0, 300),
|
|
3814
|
-
keywords: c.keywords,
|
|
3815
|
-
subject_codes: "PY",
|
|
3816
|
-
category_codes: "",
|
|
3817
|
-
topic_codes: "",
|
|
3818
|
-
prerequisite_concept_codes: [],
|
|
3819
|
-
status: "proposed",
|
|
3820
|
-
source_concept_id: c.id,
|
|
3821
|
-
possible_tree_matches: (d.possible_tree_matches || []).filter(Boolean)
|
|
3822
|
-
});
|
|
3823
|
-
warnings.push("Concept " + c.id + " proposed as NEW: " + code + (d.possible_tree_matches?.length ? " (near matches: " + d.possible_tree_matches.join(", ") + ")" : ""));
|
|
3824
|
-
}
|
|
3825
|
-
}
|
|
3826
|
-
const outPath = proposalsPath || path.join(os.tmpdir(), "domain-kit-debug", "concept-proposals.json");
|
|
3827
|
-
try {
|
|
3828
|
-
fs.mkdirSync(outPath.slice(0, outPath.lastIndexOf("/")), { recursive: true });
|
|
3829
|
-
fs.writeFileSync(outPath, JSON.stringify({ generated_at: (/* @__PURE__ */ new Date()).toISOString(), proposals }, null, 2));
|
|
3830
|
-
log("STEP_2_5", "Proposals written: " + proposals.length + " -> " + outPath);
|
|
3831
|
-
} catch (e) {
|
|
3832
|
-
warnings.push("Could not write proposals artifact: " + (e instanceof Error ? e.message : String(e)));
|
|
3833
|
-
}
|
|
3834
|
-
return { decisions: finalDecisions, proposals, warnings, shortlistSize: shortlist.length, catalogSize: catalog.concepts.length };
|
|
3835
|
-
}
|
|
3836
|
-
|
|
3837
|
-
// src/graph/knowledgeGraphPipeline.ts
|
|
3838
|
-
function topologicalSort(concepts) {
|
|
3839
|
-
const inDegree = /* @__PURE__ */ new Map();
|
|
3840
|
-
const adjacency = /* @__PURE__ */ new Map();
|
|
3841
|
-
for (const concept of concepts) {
|
|
3842
|
-
inDegree.set(concept.id, 0);
|
|
3843
|
-
adjacency.set(concept.id, []);
|
|
3844
|
-
}
|
|
3845
|
-
for (const concept of concepts) {
|
|
3846
|
-
for (const prereq of concept.prerequisites || []) {
|
|
3847
|
-
adjacency.get(prereq)?.push(concept.id);
|
|
3848
|
-
inDegree.set(concept.id, (inDegree.get(concept.id) || 0) + 1);
|
|
3849
4161
|
}
|
|
4162
|
+
const featureIds = strs(rp.feature_ids).filter((fid) => featureOrder.has(fid)).sort((a, b) => featureOrder.get(a) - featureOrder.get(b));
|
|
4163
|
+
emitCtx.push({ id: pid, order, name: str(rp.name, pid), productCompletion: str(rp.product_completion), conceptOrder, featureIds });
|
|
3850
4164
|
}
|
|
3851
|
-
|
|
3852
|
-
for (const
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
while (queue.length > 0) {
|
|
3857
|
-
const current = queue.shift();
|
|
3858
|
-
sorted.push(current);
|
|
3859
|
-
for (const neighbor of adjacency.get(current) || []) {
|
|
3860
|
-
const newDegree = (inDegree.get(neighbor) || 1) - 1;
|
|
3861
|
-
inDegree.set(neighbor, newDegree);
|
|
3862
|
-
if (newDegree === 0) queue.push(neighbor);
|
|
4165
|
+
noteExternalPrerequisites(opts, externalByNode);
|
|
4166
|
+
for (const l of linkList) {
|
|
4167
|
+
const cid = str(l.concept_id);
|
|
4168
|
+
if (cid && conceptById.has(cid) && !emittedConcepts.has(cid)) {
|
|
4169
|
+
throw new Error("Concept " + cid + " is consumed by links but never introduced in any phase - fail-closed");
|
|
3863
4170
|
}
|
|
3864
4171
|
}
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
}
|
|
3870
|
-
async function generateKnowledgeGraph(options) {
|
|
3871
|
-
const { subject, description, syllabusText, gradeBand, domain, techStack, llmConfig, onProgress } = options;
|
|
3872
|
-
const warnings = [];
|
|
3873
|
-
const log = (step, msg) => {
|
|
3874
|
-
console.log(`[KG-${step}] ${msg}`);
|
|
3875
|
-
onProgress?.(step, msg);
|
|
3876
|
-
};
|
|
3877
|
-
log("STEP_1", "Decomposing subject into concepts...");
|
|
3878
|
-
let parsedSyllabus = options.parsedSyllabus;
|
|
3879
|
-
if (!parsedSyllabus && syllabusText) {
|
|
3880
|
-
parsedSyllabus = parseSyllabus(syllabusText);
|
|
3881
|
-
log("STEP_1", `Parsed syllabus: ${parsedSyllabus.units.length} units, ${parsedSyllabus.allTopics.length} topics`);
|
|
4172
|
+
const seenFeatures = new Set(emitCtx.flatMap((e) => e.featureIds));
|
|
4173
|
+
const missingFeatures = features.map((f) => str(f.id)).filter((fid) => fid && !seenFeatures.has(fid));
|
|
4174
|
+
if (missingFeatures.length > 0) {
|
|
4175
|
+
throw new Error("Features not covered by any phase: " + missingFeatures.join(", ") + " - fail-closed");
|
|
3882
4176
|
}
|
|
3883
|
-
const
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
3891
|
-
|
|
3892
|
-
}
|
|
4177
|
+
const orphans = Array.from(conceptById.keys()).filter((cid) => !emittedConcepts.has(cid));
|
|
4178
|
+
if (orphans.length > 0) {
|
|
4179
|
+
pushDiagnostic(
|
|
4180
|
+
opts,
|
|
4181
|
+
"orphan_concept",
|
|
4182
|
+
"error",
|
|
4183
|
+
"Knowledge graph has " + orphans.length + " concept(s) no phase introduces and no link consumes \u2014 they will not be taught: " + orphans.join(", "),
|
|
4184
|
+
orphans
|
|
4185
|
+
);
|
|
3893
4186
|
}
|
|
3894
|
-
const
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
|
|
3899
|
-
|
|
3900
|
-
|
|
3901
|
-
|
|
3902
|
-
|
|
3903
|
-
|
|
3904
|
-
|
|
3905
|
-
Return JSON:
|
|
3906
|
-
{
|
|
3907
|
-
"subject": {
|
|
3908
|
-
"id": "${subject.toLowerCase().replace(/\s+/g, "-").slice(0, 30)}",
|
|
3909
|
-
"name": "${subject}",
|
|
3910
|
-
"description": "...",
|
|
3911
|
-
"domain": "${domain || "other"}",
|
|
3912
|
-
"grade_band": ${gradeBand ? `[${gradeBand[0]}, ${gradeBand[1]}]` : "[6, 12]"}
|
|
3913
|
-
},
|
|
3914
|
-
"concepts": [
|
|
3915
|
-
{
|
|
3916
|
-
"id": "C1",
|
|
3917
|
-
"name": "Concept name",
|
|
3918
|
-
"description": "Brief description",
|
|
3919
|
-
"category": "category_id",
|
|
3920
|
-
"ulo": "WHAT + WHY in technology-agnostic language: what the concept is and the problem it exists to solve. No library/API/tool names.",
|
|
3921
|
-
"cio": "HOW the mechanism works, still technology-agnostic but more concrete than the ULO.",
|
|
3922
|
-
"sio": ["Concrete task naming a REAL API/function/module/file from the target technology, completable end to end", "Another technology-bound task using a different concrete keyword"],
|
|
3923
|
-
"prerequisites": [],
|
|
3924
|
-
"estimated_minutes": 30,
|
|
3925
|
-
"problem_types": [
|
|
3926
|
-
{
|
|
3927
|
-
"id": "PT-C1-01",
|
|
3928
|
-
"name": "Problem type name",
|
|
3929
|
-
"description": "What this problem type tests",
|
|
3930
|
-
"bloom_level": "Remember",
|
|
3931
|
-
"difficulty": "easy",
|
|
3932
|
-
"estimated_minutes": 10
|
|
3933
|
-
}
|
|
3934
|
-
],
|
|
3935
|
-
"techniques": ["technique 1", "technique 2"],
|
|
3936
|
-
"common_mistakes": ["mistake 1"]
|
|
4187
|
+
const phaseNodeIds = [];
|
|
4188
|
+
for (const ctx of emitCtx) {
|
|
4189
|
+
const ids = [...ctx.conceptOrder];
|
|
4190
|
+
for (const fid of ctx.featureIds) {
|
|
4191
|
+
const f = features.find((x) => str(x.id) === fid);
|
|
4192
|
+
const stepIds = addFeatureGroup(f, fid, nodes, edges, groupings);
|
|
4193
|
+
for (const sid of stepIds) {
|
|
4194
|
+
const node = nodes.find((x) => x.id === sid);
|
|
4195
|
+
node.phase_id = ctx.id;
|
|
4196
|
+
}
|
|
4197
|
+
ids.push(...stepIds);
|
|
3937
4198
|
}
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
{
|
|
3941
|
-
"id": "cat1",
|
|
3942
|
-
"name": "Category name",
|
|
3943
|
-
"description": "Category description",
|
|
3944
|
-
"concept_ids": ["C1", "C2"],
|
|
3945
|
-
"order": 1
|
|
4199
|
+
if (ids.length === 0) {
|
|
4200
|
+
throw new Error("Phase " + ctx.id + " contains no teaching nodes (no concepts introduced and no feature steps) - drop it or give it work - fail-closed");
|
|
3946
4201
|
}
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
const
|
|
3960
|
-
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
if (existing.length === 0) {
|
|
3966
|
-
const derived = [.../* @__PURE__ */ new Set([...tokensFrom(c.name), ...tokensFrom(c.description || "")])].slice(0, 5);
|
|
3967
|
-
if (derived.length === 0) {
|
|
3968
|
-
throw new Error("Concept " + c.id + " (" + c.name + ") has no keywords and none could be derived from name/description \u2014 fail-closed");
|
|
3969
|
-
}
|
|
3970
|
-
c.keywords = derived;
|
|
3971
|
-
warnings.push("Concept " + c.id + ": empty keyword set - derived " + derived.join(", ") + " from name/description");
|
|
3972
|
-
} else {
|
|
3973
|
-
c.keywords = existing;
|
|
3974
|
-
}
|
|
4202
|
+
phaseNodeIds.push(ids);
|
|
4203
|
+
}
|
|
4204
|
+
const idlessInHybrid = takeSteplessStepIds();
|
|
4205
|
+
if (idlessInHybrid.length > 0) {
|
|
4206
|
+
pushDiagnostic(
|
|
4207
|
+
opts,
|
|
4208
|
+
"step_without_id",
|
|
4209
|
+
"warning",
|
|
4210
|
+
"Step(s) carrying no id cannot become planning nodes and were dropped (feature#index): " + idlessInHybrid.join(", "),
|
|
4211
|
+
idlessInHybrid
|
|
4212
|
+
);
|
|
4213
|
+
}
|
|
4214
|
+
const placed = new Set(phaseNodeIds.flat());
|
|
4215
|
+
const orderedNodes = [];
|
|
4216
|
+
for (const ids of phaseNodeIds) {
|
|
4217
|
+
for (const id of ids) {
|
|
4218
|
+
const n = nodes.find((x) => x.id === id);
|
|
4219
|
+
if (n) orderedNodes.push(n);
|
|
3975
4220
|
}
|
|
3976
4221
|
}
|
|
3977
|
-
const
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
|
|
3989
|
-
|
|
4222
|
+
for (const n of nodes) if (!placed.has(n.id)) orderedNodes.push(n);
|
|
4223
|
+
nodes.length = 0;
|
|
4224
|
+
nodes.push(...orderedNodes);
|
|
4225
|
+
const edgeSeen = new Set(edges.map((e) => e.from + ">" + e.to));
|
|
4226
|
+
const pushEdge = (from, to, reason) => {
|
|
4227
|
+
const key = from + ">" + to;
|
|
4228
|
+
if (edgeSeen.has(key)) return;
|
|
4229
|
+
edgeSeen.add(key);
|
|
4230
|
+
edges.push({ from, to, kind: "knowledge", reason });
|
|
4231
|
+
};
|
|
4232
|
+
for (const l of linkList) {
|
|
4233
|
+
const cid = str(l.concept_id);
|
|
4234
|
+
const fid = str(l.feature_id);
|
|
4235
|
+
if (!conceptById.has(cid)) continue;
|
|
4236
|
+
const firstStep = (stepIdsByFeature.get(fid) || [])[0];
|
|
4237
|
+
if (!firstStep) continue;
|
|
4238
|
+
const depth = str(l.depth, "cio");
|
|
4239
|
+
const anchorNode = nodes.find((n2) => n2.kind === "concept" && n2.concept_ref === cid && n2.introduce_aspect === "new");
|
|
4240
|
+
const stepNode = nodes.find((n2) => n2.id === firstStep);
|
|
4241
|
+
const anchorPhaseIdx = anchorNode ? emitCtx.findIndex((c2) => c2.id === anchorNode.phase_id) : -1;
|
|
4242
|
+
const stepPhaseIdx = stepNode ? emitCtx.findIndex((c2) => c2.id === stepNode.phase_id) : -1;
|
|
4243
|
+
if (emitCtx.length > 0 && anchorPhaseIdx > stepPhaseIdx) {
|
|
4244
|
+
pushDiagnostic(
|
|
4245
|
+
opts,
|
|
4246
|
+
"hybrid_link_drift",
|
|
4247
|
+
"warning",
|
|
4248
|
+
"hybrid link drift: concept " + cid + " is fully taught in a later phase than consumer " + firstStep + " (a preview covers the dependency)",
|
|
4249
|
+
[cid, firstStep]
|
|
4250
|
+
);
|
|
4251
|
+
continue;
|
|
4252
|
+
}
|
|
4253
|
+
pushEdge(cid, firstStep, str(l.relationship, "hybrid link") + " [" + depth + "]");
|
|
4254
|
+
for (const n of nodes) {
|
|
4255
|
+
if (n.kind === "concept" && n.concept_ref === cid && n.introduce_aspect === "advanced" && n.phase_id === str(l.phase_id)) {
|
|
4256
|
+
pushEdge(n.id, firstStep, "advanced application [" + depth + "]");
|
|
3990
4257
|
}
|
|
3991
4258
|
}
|
|
3992
4259
|
}
|
|
3993
|
-
|
|
3994
|
-
|
|
3995
|
-
|
|
3996
|
-
|
|
3997
|
-
|
|
3998
|
-
|
|
3999
|
-
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
4009
|
-
|
|
4010
|
-
|
|
4011
|
-
|
|
4012
|
-
|
|
4013
|
-
|
|
4014
|
-
|
|
4015
|
-
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
|
-
|
|
4019
|
-
|
|
4020
|
-
|
|
4021
|
-
if (!(c.keywords || []).some((x) => x.toLowerCase() === k.toLowerCase())) {
|
|
4022
|
-
c.keywords = [...c.keywords || [], k];
|
|
4023
|
-
}
|
|
4024
|
-
}
|
|
4025
|
-
c.tree_prerequisites = tc.prerequisiteCodes;
|
|
4026
|
-
}
|
|
4027
|
-
} else {
|
|
4028
|
-
c.standardization = "proposed_new";
|
|
4029
|
-
c.code = d.proposed_code;
|
|
4260
|
+
newKeywordsInOrder(nodes);
|
|
4261
|
+
const feedOrder = new Map(nodes.map((n, i) => [n.id, i]));
|
|
4262
|
+
const introducer = /* @__PURE__ */ new Map();
|
|
4263
|
+
for (const n of nodes) for (const k of n.keywords?.new ?? []) {
|
|
4264
|
+
const key = keywordKey(k);
|
|
4265
|
+
if (!introducer.has(key)) introducer.set(key, n.id);
|
|
4266
|
+
}
|
|
4267
|
+
for (const n of nodes) {
|
|
4268
|
+
for (const k of n.keywords?.prerequisite ?? []) {
|
|
4269
|
+
const src = introducer.get(keywordKey(k));
|
|
4270
|
+
if (!src || src === n.id) continue;
|
|
4271
|
+
const srcNode = nodes.find((x) => x.id === src);
|
|
4272
|
+
const phaseOf = (id) => {
|
|
4273
|
+
const n2 = nodes.find((x) => x.id === id);
|
|
4274
|
+
return n2 ? emitCtx.findIndex((c) => c.id === n2.phase_id) : -1;
|
|
4275
|
+
};
|
|
4276
|
+
const conceptOvertaken = n.kind === "concept" && srcNode?.kind === "product_step";
|
|
4277
|
+
const fromPhase = phaseOf(src);
|
|
4278
|
+
const toPhase = phaseOf(n.id);
|
|
4279
|
+
if (conceptOvertaken || emitCtx.length > 0 && fromPhase > toPhase) {
|
|
4280
|
+
pushDiagnostic(
|
|
4281
|
+
opts,
|
|
4282
|
+
conceptOvertaken ? "concept_overtaken_by_step" : "keyword_order_drift",
|
|
4283
|
+
"warning",
|
|
4284
|
+
"keyword-order drift: introducer " + src + " sits in a later phase than consumer " + n.id + ' (keyword "' + k + '") \u2014 host should move the introducer earlier',
|
|
4285
|
+
[src, n.id]
|
|
4286
|
+
);
|
|
4287
|
+
continue;
|
|
4030
4288
|
}
|
|
4289
|
+
if (feedOrder.get(src) < feedOrder.get(n.id)) pushEdge(src, n.id, "keyword prerequisite [" + k + "]");
|
|
4031
4290
|
}
|
|
4032
|
-
|
|
4033
|
-
|
|
4034
|
-
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4039
|
-
|
|
4291
|
+
}
|
|
4292
|
+
const phases = emitCtx.map((ctx, i) => ({
|
|
4293
|
+
id: ctx.id,
|
|
4294
|
+
order: ctx.order,
|
|
4295
|
+
name: ctx.name,
|
|
4296
|
+
product_completion: ctx.productCompletion || "Phase " + (i + 1) + " deliverable",
|
|
4297
|
+
node_ids: phaseNodeIds[i]
|
|
4298
|
+
}));
|
|
4299
|
+
return validateCurriculumFeed({
|
|
4300
|
+
schema_version: 2,
|
|
4301
|
+
source: buildSource(opts, "hybrid_graph"),
|
|
4302
|
+
learning_nodes: nodes,
|
|
4303
|
+
dependency_edges: edges,
|
|
4304
|
+
suggested_groupings: groupings,
|
|
4305
|
+
phases
|
|
4306
|
+
});
|
|
4307
|
+
}
|
|
4308
|
+
function linkHybridKnowledgeEdges(hybrid, nodes, edges, setStepHints) {
|
|
4309
|
+
const conceptIds = new Set(nodes.filter((n) => n.kind === "concept").map((n) => n.concept_ref || n.id));
|
|
4310
|
+
for (const l of arr(hybrid.links)) {
|
|
4311
|
+
const conceptId = str(l.concept_id);
|
|
4312
|
+
const featureId = str(l.feature_id);
|
|
4313
|
+
if (!conceptIds.has(conceptId)) continue;
|
|
4314
|
+
const depth = str(l.depth, "cio");
|
|
4315
|
+
const firstStep = nodes.find((n) => n.feature_id === featureId);
|
|
4316
|
+
if (firstStep) {
|
|
4317
|
+
edges.push({ from: conceptId, to: firstStep.id, kind: "knowledge", reason: str(l.relationship, "hybrid link") + " [" + depth + "]" });
|
|
4318
|
+
if (!firstStep.depth_hint) {
|
|
4319
|
+
firstStep.depth_hint = ["ulo", "cio", "sio"].includes(depth) ? depth : "cio";
|
|
4040
4320
|
}
|
|
4041
4321
|
}
|
|
4042
|
-
log("STEP_2_5", "Standardized: " + std.decisions.filter((d) => d.decision === "standardized").length + ", proposed new: " + std.decisions.filter((d) => d.decision === "proposed_new").length);
|
|
4043
4322
|
}
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4323
|
+
}
|
|
4324
|
+
function emitFromKnowledge(kg, opts) {
|
|
4325
|
+
const nodes = [];
|
|
4326
|
+
const edges = [];
|
|
4327
|
+
const groupings = [];
|
|
4328
|
+
const conceptIds = new Set(arr(kg.concepts).map((c) => str(c.id)));
|
|
4329
|
+
const externalByNode = /* @__PURE__ */ new Map();
|
|
4330
|
+
for (const c of arr(kg.concepts)) {
|
|
4331
|
+
const node = feedNodeFromConcept(c);
|
|
4332
|
+
nodes.push(node);
|
|
4333
|
+
const { internal, external } = splitPrerequisites(c, conceptIds);
|
|
4334
|
+
if (external.length > 0) {
|
|
4335
|
+
node.external_prerequisites = external;
|
|
4336
|
+
externalByNode.set(node.id, external);
|
|
4050
4337
|
}
|
|
4051
|
-
const
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4338
|
+
for (const p of internal) {
|
|
4339
|
+
if (p !== node.id) {
|
|
4340
|
+
edges.push({
|
|
4341
|
+
from: p,
|
|
4342
|
+
to: node.id,
|
|
4343
|
+
kind: "knowledge",
|
|
4344
|
+
reason: `concept prerequisite: ${p} must be understood before ${node.id}`
|
|
4345
|
+
});
|
|
4056
4346
|
}
|
|
4057
4347
|
}
|
|
4058
|
-
|
|
4059
|
-
|
|
4348
|
+
}
|
|
4349
|
+
noteExternalPrerequisites(opts, externalByNode);
|
|
4350
|
+
if (arr(kg.learning_path).length > 0) {
|
|
4351
|
+
const orderMap = /* @__PURE__ */ new Map();
|
|
4352
|
+
arr(kg.learning_path).forEach((s, idx) => {
|
|
4353
|
+
const cid = str(s.concept_id);
|
|
4354
|
+
if (cid) orderMap.set(cid, num(s.order, idx + 1));
|
|
4355
|
+
});
|
|
4356
|
+
nodes.sort((a, b) => (orderMap.get(a.id) ?? 999) - (orderMap.get(b.id) ?? 999));
|
|
4357
|
+
}
|
|
4358
|
+
let ci = 0;
|
|
4359
|
+
for (const cat of arr(kg.categories)) {
|
|
4360
|
+
ci += 1;
|
|
4361
|
+
const ids = strs(cat.concept_ids).filter((id) => nodes.some((n) => n.id === id));
|
|
4362
|
+
if (ids.length > 0) {
|
|
4363
|
+
groupings.push({
|
|
4364
|
+
id: str(cat.id, "cat" + ci),
|
|
4365
|
+
name: str(cat.name, "Category " + ci),
|
|
4366
|
+
node_ids: ids,
|
|
4367
|
+
rationale: str(cat.description, "")
|
|
4368
|
+
});
|
|
4060
4369
|
}
|
|
4061
4370
|
}
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
milestone_name: ""
|
|
4071
|
-
};
|
|
4371
|
+
newKeywordsInOrder(nodes);
|
|
4372
|
+
return validateCurriculumFeed({
|
|
4373
|
+
schema_version: 2,
|
|
4374
|
+
source: buildSource(opts, "knowledge_graph"),
|
|
4375
|
+
learning_nodes: nodes,
|
|
4376
|
+
dependency_edges: edges,
|
|
4377
|
+
suggested_groupings: groupings,
|
|
4378
|
+
phases: []
|
|
4072
4379
|
});
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4380
|
+
}
|
|
4381
|
+
function emitFromProject(pg, opts) {
|
|
4382
|
+
const nodes = [];
|
|
4383
|
+
const edges = [];
|
|
4384
|
+
const groupings = [];
|
|
4385
|
+
const allFeatures = arr(pg.features);
|
|
4386
|
+
allFeatures.forEach((f, fi) => addFeatureGroup(f, str(f.id, "F" + pad22(fi + 1)), nodes, edges, groupings));
|
|
4387
|
+
const stepless = allFeatures.filter((f) => arr(f.steps).length === 0).map((f) => str(f.id)).filter(Boolean);
|
|
4388
|
+
if (stepless.length > 0) {
|
|
4389
|
+
pushDiagnostic(
|
|
4390
|
+
opts,
|
|
4391
|
+
"feature_without_steps",
|
|
4392
|
+
"warning",
|
|
4393
|
+
"Feature(s) declared but carrying no steps \u2014 they will be absent from the feed: " + stepless.join(", "),
|
|
4394
|
+
stepless
|
|
4080
4395
|
);
|
|
4081
|
-
const codeByConceptId = assignConceptCodes(concepts.map((c) => ({ id: c.id, name: c.name })));
|
|
4082
|
-
const techTag = techTagFor(techStack || (domain === "software" || domain === "technology" ? "python" : domain || "general"));
|
|
4083
|
-
for (const concept of concepts) {
|
|
4084
|
-
concept.ulo = lo.uloByConcept[concept.id] || concept.ulo || "";
|
|
4085
|
-
concept.cio = lo.cioByConcept[concept.id] || concept.cio || "";
|
|
4086
|
-
if (lo.sioByConcept[concept.id] && lo.sioByConcept[concept.id].length > 0) {
|
|
4087
|
-
concept.sio = lo.sioByConcept[concept.id];
|
|
4088
|
-
}
|
|
4089
|
-
concept.code = concept.code || codeByConceptId[concept.id];
|
|
4090
|
-
concept.ulo_code = concept.ulo ? uloCode(concept.code) : concept.ulo_code;
|
|
4091
|
-
concept.cio_code = concept.cio ? cioCode(concept.code, 1, cioActionSlug(concept.cio)) : concept.cio_code;
|
|
4092
|
-
concept.sio_codes = concept.sio.length > 0 ? concept.sio.map((_, i) => sioCode(techTag, concept.code, i + 1)) : concept.sio_codes;
|
|
4093
|
-
}
|
|
4094
|
-
log("STEP_5", "Depth layers authored: " + concepts.length + " concepts, audit " + lo.audits.filter((a) => a.passed).length + "/" + concepts.length + " passed");
|
|
4095
4396
|
}
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
|
|
4397
|
+
const idless = takeSteplessStepIds();
|
|
4398
|
+
if (idless.length > 0) {
|
|
4399
|
+
pushDiagnostic(
|
|
4400
|
+
opts,
|
|
4401
|
+
"step_without_id",
|
|
4402
|
+
"warning",
|
|
4403
|
+
"Step(s) carrying no id cannot become planning nodes and were dropped (feature#index): " + idless.join(", "),
|
|
4404
|
+
idless
|
|
4405
|
+
);
|
|
4100
4406
|
}
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4407
|
+
if (nodes.length === 0 && allFeatures.length > 0) {
|
|
4408
|
+
const hasTasks = arr(pg.implementation ?? {}).length > 0 || arr(pg.implementation?.tasks).length > 0;
|
|
4409
|
+
throw new Error(
|
|
4410
|
+
"Project graph has " + allFeatures.length + " features but NO features[].steps - the curriculum feed cannot emit planning nodes." + (hasTasks ? " This looks like a legacy knowledge-tree graph (implementation.tasks). Regenerate it with the domain-kit v3 pipeline (runProjectGraphPipeline / hybridGraphPipeline), then re-run." : " Every feature must carry at least one step with id/name/keywords/effort for curriculum planning.")
|
|
4411
|
+
);
|
|
4412
|
+
}
|
|
4413
|
+
newKeywordsInOrder(nodes);
|
|
4414
|
+
return validateCurriculumFeed({
|
|
4415
|
+
schema_version: 2,
|
|
4416
|
+
source: buildSource(opts, "project_graph"),
|
|
4417
|
+
learning_nodes: nodes,
|
|
4418
|
+
dependency_edges: edges,
|
|
4419
|
+
suggested_groupings: groupings,
|
|
4420
|
+
phases: []
|
|
4421
|
+
});
|
|
4422
|
+
}
|
|
4423
|
+
function emitCurriculumFeed(graph, opts = {}) {
|
|
4424
|
+
const g = graph;
|
|
4425
|
+
if (arr(g.learning_nodes).length > 0) {
|
|
4426
|
+
return validateCurriculumFeed(g);
|
|
4427
|
+
}
|
|
4428
|
+
if (g.project_graph || g.knowledge_graph || arr(g.links).length > 0 || arr(g.phases).length > 0) {
|
|
4429
|
+
return emitFromHybrid(g, opts);
|
|
4430
|
+
}
|
|
4431
|
+
if (arr(g.concepts).length > 0 || str(g.type) === "knowledge_graph") return emitFromKnowledge(g, opts);
|
|
4432
|
+
return emitFromProject(g, opts);
|
|
4118
4433
|
}
|
|
4119
4434
|
|
|
4120
4435
|
// src/pipeline/knowledgeGraphPipeline.ts
|
|
4121
|
-
init_curriculumFeedEmitter();
|
|
4122
4436
|
async function runKnowledgeGraphPipeline(options) {
|
|
4123
4437
|
const warnings = [];
|
|
4124
4438
|
let parsedSyllabus = options.parsedSyllabus;
|
|
@@ -4139,7 +4453,8 @@ async function runKnowledgeGraphPipeline(options) {
|
|
|
4139
4453
|
});
|
|
4140
4454
|
warnings.push(...result.warnings);
|
|
4141
4455
|
const curriculumFeed = emitCurriculumFeed(result.knowledgeGraph, {
|
|
4142
|
-
warnings
|
|
4456
|
+
warnings,
|
|
4457
|
+
provenance: summarizeProvenance(result.provenance)
|
|
4143
4458
|
});
|
|
4144
4459
|
return {
|
|
4145
4460
|
knowledgeGraph: result.knowledgeGraph,
|
|
@@ -4173,9 +4488,10 @@ async function generateHybridGraph(options) {
|
|
|
4173
4488
|
description: "",
|
|
4174
4489
|
total_project_minutes: 0,
|
|
4175
4490
|
total_concept_minutes: 0,
|
|
4176
|
-
|
|
4491
|
+
linked_prereq_minutes: 0
|
|
4177
4492
|
},
|
|
4178
|
-
warnings
|
|
4493
|
+
warnings,
|
|
4494
|
+
provenance: []
|
|
4179
4495
|
};
|
|
4180
4496
|
}
|
|
4181
4497
|
const client = createLlmClient(llmConfig);
|
|
@@ -4256,9 +4572,14 @@ async function generateHybridGraph(options) {
|
|
|
4256
4572
|
description: "Progressive completion path: " + phases.length + " phases, " + features.length + " features, " + concepts.length + " concepts",
|
|
4257
4573
|
total_project_minutes: totalProjectMinutes,
|
|
4258
4574
|
total_concept_minutes: totalConceptMinutes,
|
|
4259
|
-
|
|
4575
|
+
// HG-1 (2026-10-05): renamed from estimated_total_minutes. It counts only
|
|
4576
|
+
// concepts that HAVE a feature link (max per concept, not summed), so it
|
|
4577
|
+
// is NOT the course total — see HybridGraphSchema.linked_prereq_minutes.
|
|
4578
|
+
// The phase planner below commits to totalProjectMinutes + totalConceptMinutes.
|
|
4579
|
+
linked_prereq_minutes: totalProjectMinutes + totalPrereqMinutes
|
|
4260
4580
|
},
|
|
4261
|
-
warnings
|
|
4581
|
+
warnings,
|
|
4582
|
+
provenance: client.callLog
|
|
4262
4583
|
};
|
|
4263
4584
|
}
|
|
4264
4585
|
function auditPhasePlan(plan, features, concepts, links, warnings, log) {
|
|
@@ -4334,12 +4655,20 @@ function auditPhasePlan(plan, features, concepts, links, warnings, log) {
|
|
|
4334
4655
|
} else {
|
|
4335
4656
|
newIntroPhase.set(cid, order);
|
|
4336
4657
|
}
|
|
4658
|
+
introductions.push({ concept_id: cid, aspect, note: String(ri.note || "") });
|
|
4659
|
+
}
|
|
4660
|
+
for (const intro of introductions) {
|
|
4661
|
+
const cid = intro.concept_id;
|
|
4662
|
+
if (intro.aspect === "advanced") continue;
|
|
4663
|
+
const introOrder = newIntroPhase.get(cid);
|
|
4664
|
+
if (introOrder === void 0 || introOrder !== order) continue;
|
|
4337
4665
|
for (const pre of prereqOf.get(cid) || []) {
|
|
4338
|
-
|
|
4339
|
-
|
|
4666
|
+
const previewOrder = previewPhase.get(pre);
|
|
4667
|
+
const covered = newIntroPhase.has(pre) || previewOrder !== void 0 && previewOrder < order;
|
|
4668
|
+
if (!covered) {
|
|
4669
|
+
errors.push("Phase " + id + ": concept " + cid + " introduced before its prerequisite " + pre + " is introduced (or previewed in an earlier phase) - violates the prerequisite chain");
|
|
4340
4670
|
}
|
|
4341
4671
|
}
|
|
4342
|
-
introductions.push({ concept_id: cid, aspect, note: String(ri.note || "") });
|
|
4343
4672
|
}
|
|
4344
4673
|
phases.push({
|
|
4345
4674
|
id,
|
|
@@ -4377,6 +4706,9 @@ function auditPhasePlan(plan, features, concepts, links, warnings, log) {
|
|
|
4377
4706
|
if (anchor !== void 0 && anchor <= pOrder) {
|
|
4378
4707
|
errors.push("Concept " + cid + " previewed in phase " + pOrder + " but fully introduced in phase " + anchor + " - preview must precede the anchor introduction");
|
|
4379
4708
|
}
|
|
4709
|
+
if (anchor === void 0) {
|
|
4710
|
+
errors.push("Concept " + cid + " is previewed in phase " + pOrder + " but never fully introduced (aspect new) in any phase - a preview must have an anchor teach");
|
|
4711
|
+
}
|
|
4380
4712
|
}
|
|
4381
4713
|
return { phases, errors };
|
|
4382
4714
|
}
|
|
@@ -4391,8 +4723,12 @@ async function decomposePhases(args) {
|
|
|
4391
4723
|
const linkList = links.map(
|
|
4392
4724
|
(l) => "- " + l.feature_id + " -> " + l.concept_id + " (" + l.depth + ")"
|
|
4393
4725
|
).join("\n") || "(none)";
|
|
4394
|
-
const systemPrompt = 'You decompose a software project build into PROGRESSIVE COMPLETION PHASES for learning purposes.\n\nA phase is NOT a feature and NOT a milestone list: it is a cross-feature slice of the\nproduct whose completion yields a DEMONSTRABLY more complete product. Build up gradually:\nthe product grows feature by feature across phases, and each phase adds its features to\nwhat previous phases already built.\n\nRULES:\n1. Every feature must be assigned to EXACTLY ONE phase (no feature reused, none skipped).\n2. Phases are ordered; later phases depend on earlier ones and yield a more complete product.\n3. product_completion describes concretely what the product can DO once the phase is done.\n4. introduces lists which concepts the phase must teach BEFORE its feature work starts:\n - aspect new: first time the concept appears in the course\n - aspect advanced: a concept introduced in an EARLIER phase, revisited here at a\n higher depth (an advanced aspect of the same concept, e.g. validation rules on top\n of basic data modeling). Use the note field to say which aspect.\n - aspect preview: a light awareness pass ("know it exists", ULO depth only, ~5 min)\n placed in an EARLIER phase when its feature work needs the vocabulary before the\n full teach. The anchor phase still owns the full new introduction.\n5. A concept MUST be introduced (new) no later than the first phase that consumes it.\n6. A concept must not be introduced before its own prerequisites are introduced.\n7. Typically 3-6 phases for a small project; keep phases balanced.\n8. Phase 1 usually covers project setup/foundation features plus the first minimal slice.\
|
|
4395
|
-
const
|
|
4726
|
+
const systemPrompt = 'You decompose a software project build into PROGRESSIVE COMPLETION PHASES for learning purposes.\n\nA phase is NOT a feature and NOT a milestone list: it is a cross-feature slice of the\nproduct whose completion yields a DEMONSTRABLY more complete product. Build up gradually:\nthe product grows feature by feature across phases, and each phase adds its features to\nwhat previous phases already built.\n\nRULES:\n1. Every feature must be assigned to EXACTLY ONE phase (no feature reused, none skipped).\n2. Phases are ordered; later phases depend on earlier ones and yield a more complete product.\n3. product_completion describes concretely what the product can DO once the phase is done.\n4. introduces lists which concepts the phase must teach BEFORE its feature work starts:\n - aspect new: first time the concept appears in the course\n - aspect advanced: a concept introduced in an EARLIER phase, revisited here at a\n higher depth (an advanced aspect of the same concept, e.g. validation rules on top\n of basic data modeling). Use the note field to say which aspect.\n - aspect preview: a light awareness pass ("know it exists", ULO depth only, ~5 min)\n placed in an EARLIER phase when its feature work needs the vocabulary before the\n full teach. The anchor phase still owns the full new introduction.\n5. A concept MUST be introduced (new) no later than the first phase that consumes it.\n6. A concept must not be introduced before its own prerequisites are introduced.\n7. Typically 3-6 phases for a small project; keep phases balanced.\n8. MINUTE BALANCE: Each phase should contain approximately 120-240 total minutes of\n concept + feature work (about 3-5 sessions of 90 minutes each). If a phase\n would contain < 60 minutes (too thin) or > 400 minutes (too heavy), rebalance.\n9. Phase 1 usually covers project setup/foundation features plus the first minimal slice.\n10. Return JSON only.';
|
|
4727
|
+
const totalFeatureMinutes = features.reduce((s, f) => s + (f.steps || []).reduce((ss, st) => ss + (st.effort?.estimated_minutes || 15), 0), 0);
|
|
4728
|
+
const totalConceptMinutes = concepts.reduce((s, c) => s + (c.estimated_minutes || 30), 0);
|
|
4729
|
+
const totalMinutes = totalFeatureMinutes + totalConceptMinutes;
|
|
4730
|
+
const targetPhaseCount = Math.max(3, Math.round(totalMinutes / 180));
|
|
4731
|
+
const baseUserPrompt = "FEATURES:\n" + featureList + "\n\nCONCEPTS:\n" + conceptList + "\n\nFEATURE-CONCEPT LINKS:\n" + linkList + "\n\nTOTAL MINUTES: ~" + totalMinutes + "m (" + totalFeatureMinutes + "m features + " + totalConceptMinutes + "m concepts)\nTARGET: ~" + targetPhaseCount + " phases of ~180m each\n\nReturn JSON with a phases array. Each phase: { id: PH1, name, product_completion, feature_ids: [F0, F1], introduces: [{ concept_id: C1, aspect: new or advanced, note }] }.\n";
|
|
4396
4732
|
const MAX_DECOMPOSE_ATTEMPTS = 3;
|
|
4397
4733
|
let auditErrors = [];
|
|
4398
4734
|
let phases = [];
|
|
@@ -4430,13 +4766,13 @@ async function decomposePhases(args) {
|
|
|
4430
4766
|
}
|
|
4431
4767
|
|
|
4432
4768
|
// src/pipeline/hybridGraphPipeline.ts
|
|
4433
|
-
init_curriculumFeedEmitter();
|
|
4434
4769
|
async function runHybridGraphPipeline(options) {
|
|
4435
4770
|
const warnings = [];
|
|
4436
4771
|
const result = await generateHybridGraph(options);
|
|
4437
4772
|
warnings.push(...result.warnings);
|
|
4438
4773
|
const curriculumFeed = emitCurriculumFeed(result.hybridGraph, {
|
|
4439
|
-
warnings
|
|
4774
|
+
warnings,
|
|
4775
|
+
provenance: summarizeProvenance(result.provenance)
|
|
4440
4776
|
});
|
|
4441
4777
|
return {
|
|
4442
4778
|
hybridGraph: result.hybridGraph,
|
|
@@ -5019,6 +5355,9 @@ function detectSyllabusLanguage(text) {
|
|
|
5019
5355
|
}
|
|
5020
5356
|
return "en";
|
|
5021
5357
|
}
|
|
5358
|
+
function normalizeText(s) {
|
|
5359
|
+
return s.normalize("NFC").toLowerCase();
|
|
5360
|
+
}
|
|
5022
5361
|
function detectDomain(options) {
|
|
5023
5362
|
const evidence = [];
|
|
5024
5363
|
const detectedTechStack = [];
|
|
@@ -5075,15 +5414,19 @@ function detectDomain(options) {
|
|
|
5075
5414
|
}
|
|
5076
5415
|
}
|
|
5077
5416
|
}
|
|
5078
|
-
const textContent = [
|
|
5417
|
+
const textContent = normalizeText([
|
|
5079
5418
|
options.description || "",
|
|
5080
5419
|
options.goal || "",
|
|
5081
5420
|
options.syllabusText || ""
|
|
5082
|
-
].join(" ")
|
|
5421
|
+
].join(" "));
|
|
5422
|
+
const textNoMarks = stripDiacritics(textContent);
|
|
5083
5423
|
for (const [domain, keywords] of Object.entries(DOMAIN_KEYWORDS)) {
|
|
5084
5424
|
const cat = domain;
|
|
5085
5425
|
for (const kw of keywords) {
|
|
5086
|
-
|
|
5426
|
+
const kwNorm = normalizeText(kw);
|
|
5427
|
+
const kwPlain = stripDiacritics(kw);
|
|
5428
|
+
const matched = textContent.includes(kwNorm) || kwPlain !== kwNorm && textNoMarks.includes(kwPlain);
|
|
5429
|
+
if (matched) {
|
|
5087
5430
|
scores[cat] += 0.1;
|
|
5088
5431
|
if (!evidence.some((e) => e.includes(`"${kw}"`))) {
|
|
5089
5432
|
evidence.push(`Keyword "${kw}" \u2192 ${cat}`);
|
|
@@ -5122,11 +5465,12 @@ function detectDomain(options) {
|
|
|
5122
5465
|
}
|
|
5123
5466
|
if (options.syllabusText) {
|
|
5124
5467
|
const syllabusLang = detectSyllabusLanguage(options.syllabusText);
|
|
5125
|
-
const syllabusLower = options.syllabusText
|
|
5126
|
-
|
|
5468
|
+
const syllabusLower = normalizeText(options.syllabusText);
|
|
5469
|
+
const syllabusPlain = stripDiacritics(options.syllabusText);
|
|
5470
|
+
if (syllabusLower.includes("h\xECnh h\u1ECDc") || syllabusLower.includes("\u0111\u1EA1i s\u1ED1") || syllabusPlain.includes("hinh hoc") || syllabusPlain.includes("dai so") || syllabusLower.includes("geometry") || syllabusLower.includes("algebra") || syllabusLower.includes("\u51E0\u4F55") || syllabusLower.includes("\u4EE3\u6570") || syllabusLower.includes("\u5E7E\u4F55") || syllabusLower.includes("\u4EE3\u6570") || syllabusLower.includes("g\xE9om\xE9trie") || syllabusLower.includes("alg\xE8bre")) {
|
|
5127
5471
|
scores.math += 0.5;
|
|
5128
5472
|
detectedSubject = "Mathematics";
|
|
5129
|
-
} else if (syllabusLower.includes("v\u1EADt l\xFD") || syllabusLower.includes("h\xF3a") || syllabusLower.includes("sinh") || syllabusLower.includes("physics") || syllabusLower.includes("chemistry") || syllabusLower.includes("biology") || syllabusLower.includes("\u7269\u7406") || syllabusLower.includes("\u5316\u5B66") || syllabusLower.includes("\u7269\u7406") || syllabusLower.includes("\u5316\u5B66") || syllabusLower.includes("physique") || syllabusLower.includes("chimie")) {
|
|
5473
|
+
} else if (syllabusLower.includes("v\u1EADt l\xFD") || syllabusLower.includes("h\xF3a") || syllabusPlain.includes("vat ly") || syllabusPlain.includes("hoa") || syllabusLower.includes("sinh") || syllabusLower.includes("physics") || syllabusLower.includes("chemistry") || syllabusLower.includes("biology") || syllabusLower.includes("\u7269\u7406") || syllabusLower.includes("\u5316\u5B66") || syllabusLower.includes("\u7269\u7406") || syllabusLower.includes("\u5316\u5B66") || syllabusLower.includes("physique") || syllabusLower.includes("chimie")) {
|
|
5130
5474
|
scores.science += 0.5;
|
|
5131
5475
|
detectedSubject = "Natural Science";
|
|
5132
5476
|
} else if (syllabusLower.includes("ti\u1EBFng anh") || syllabusLower.includes("english") || syllabusLower.includes("grammar") || syllabusLower.includes("\u82F1\u8BED") || syllabusLower.includes("\u82F1\u8A9E") || syllabusLower.includes("anglais")) {
|
|
@@ -5367,6 +5711,9 @@ async function runDomainPipeline(options) {
|
|
|
5367
5711
|
}
|
|
5368
5712
|
if (effectiveMode === "hybrid") {
|
|
5369
5713
|
log("LAYER_3", "Executing Hybrid Graph Pipeline...");
|
|
5714
|
+
if (!options.repoDir && options.forcedLearningMode === "hybrid") {
|
|
5715
|
+
throw new Error('forcedLearningMode "hybrid" requires repoDir \u2014 refusing to silently degrade to a concept-only feed (fail-closed)');
|
|
5716
|
+
}
|
|
5370
5717
|
if (options.repoDir) {
|
|
5371
5718
|
const projectResult2 = await runProjectGraphPipeline({
|
|
5372
5719
|
repoDir: options.repoDir,
|
|
@@ -5384,7 +5731,9 @@ async function runDomainPipeline(options) {
|
|
|
5384
5731
|
domain: domainProfile.domainCategory,
|
|
5385
5732
|
techStack: options.techStack,
|
|
5386
5733
|
llmConfig: options.llmConfig,
|
|
5387
|
-
onProgress: options.onProgress
|
|
5734
|
+
onProgress: options.onProgress,
|
|
5735
|
+
knowledgeTreePath: options.knowledgeTreePath,
|
|
5736
|
+
proposalsPath: options.proposalsPath
|
|
5388
5737
|
});
|
|
5389
5738
|
const hybridResult = await runHybridGraphPipeline({
|
|
5390
5739
|
projectGraph: projectResult2.projectGraph,
|
|
@@ -5403,7 +5752,8 @@ async function runDomainPipeline(options) {
|
|
|
5403
5752
|
warnings
|
|
5404
5753
|
};
|
|
5405
5754
|
} else {
|
|
5406
|
-
|
|
5755
|
+
const downgradeReason = "Hybrid learning mode requested but repoDir was not provided \u2014 falling back to concept-driven knowledge graph pipeline";
|
|
5756
|
+
warnings.push(downgradeReason);
|
|
5407
5757
|
const knowledgeResult = await runKnowledgeGraphPipeline({
|
|
5408
5758
|
subject: options.subject || domainProfile.detectedSubject || options.goal || "Subject Theory",
|
|
5409
5759
|
description: options.description,
|
|
@@ -5412,7 +5762,9 @@ async function runDomainPipeline(options) {
|
|
|
5412
5762
|
domain: domainProfile.domainCategory,
|
|
5413
5763
|
techStack: options.techStack,
|
|
5414
5764
|
llmConfig: options.llmConfig,
|
|
5415
|
-
onProgress: options.onProgress
|
|
5765
|
+
onProgress: options.onProgress,
|
|
5766
|
+
knowledgeTreePath: options.knowledgeTreePath,
|
|
5767
|
+
proposalsPath: options.proposalsPath
|
|
5416
5768
|
});
|
|
5417
5769
|
warnings.push(...knowledgeResult.warnings);
|
|
5418
5770
|
return {
|
|
@@ -5420,7 +5772,8 @@ async function runDomainPipeline(options) {
|
|
|
5420
5772
|
graphType: "knowledge_graph",
|
|
5421
5773
|
curriculumFeed: knowledgeResult.curriculumFeed,
|
|
5422
5774
|
knowledgeResult,
|
|
5423
|
-
warnings
|
|
5775
|
+
warnings,
|
|
5776
|
+
modeDowngrade: { from: "hybrid_graph", to: "knowledge_graph", reason: downgradeReason }
|
|
5424
5777
|
};
|
|
5425
5778
|
}
|
|
5426
5779
|
}
|
|
@@ -5436,9 +5789,10 @@ async function runDomainPipeline(options) {
|
|
|
5436
5789
|
embeddings: options.embeddings,
|
|
5437
5790
|
onProgress: options.onProgress
|
|
5438
5791
|
});
|
|
5439
|
-
const
|
|
5440
|
-
|
|
5441
|
-
|
|
5792
|
+
const curriculumFeed = emitCurriculumFeed(projectResult.projectGraph, {
|
|
5793
|
+
warnings,
|
|
5794
|
+
hallucinationCount: projectResult.hallucinations.length,
|
|
5795
|
+
provenance: summarizeProvenance(projectResult.provenance)
|
|
5442
5796
|
});
|
|
5443
5797
|
return {
|
|
5444
5798
|
domainProfile,
|