@thanh01.pmt/domain-kit 0.5.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-TYXSJUQU.mjs → chunk-2OGNQUXC.mjs} +81 -32
- package/dist/chunk-2OGNQUXC.mjs.map +1 -0
- package/dist/{chunk-G7HLFMD2.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-E4GCCWJO.mjs → chunk-CGNXGVHV.mjs} +37 -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-TWKIVUTQ.mjs → chunk-WKTH3JPA.mjs} +197 -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-DUFwFyr2.d.ts → curriculumFeedSchema-DYayelAA.d.cts} +156 -1
- package/dist/{curriculumFeedSchema-DUFwFyr2.d.cts → curriculumFeedSchema-DYayelAA.d.ts} +156 -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 +229 -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 +73 -29
- 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 +2634 -2359
- 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 +2207 -1958
- 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 +55 -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 +10 -9
- package/LICENSE +0 -21
- package/dist/chunk-BL5NBKPH.mjs.map +0 -1
- package/dist/chunk-E4GCCWJO.mjs.map +0 -1
- package/dist/chunk-G7HLFMD2.mjs.map +0 -1
- package/dist/chunk-TWKIVUTQ.mjs.map +0 -1
- package/dist/chunk-TYXSJUQU.mjs.map +0 -1
- package/dist/chunk-VCPIWJ7R.mjs.map +0 -1
- package/dist/curriculumFeedEmitter-T7NUBLAM.mjs +0 -4
- package/dist/curriculumFeedEmitter-T7NUBLAM.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,1215 +1,597 @@
|
|
|
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
|
-
// Depth layer text (optional, additive — old feeds validate unchanged).
|
|
113
|
-
// Carries the ULO/CIO/SIO authored text from the KnowledgeGraph concept
|
|
114
|
-
// so downstream consumers (KX generator) can inject depth-appropriate
|
|
115
|
-
// material into their prompts without a second LLM call.
|
|
116
|
-
depth_context: zod.z.object({
|
|
117
|
-
ulo: zod.z.string().default(""),
|
|
118
|
-
cio: zod.z.string().default(""),
|
|
119
|
-
sio: zod.z.array(zod.z.string()).default([])
|
|
120
|
-
}).optional(),
|
|
121
|
-
// Progressive-completion bookkeeping (feed v2, additive):
|
|
122
|
-
// phase_id = development phase that introduces/uses this node;
|
|
123
|
-
// introduce_aspect = how the phase teaches a concept node (first intro, advanced
|
|
124
|
-
// revisit, or preview — a light ULO awareness pass that is NOT the introduction).
|
|
125
|
-
phase_id: zod.z.string().default(""),
|
|
126
|
-
introduce_aspect: zod.z.enum(["new", "advanced", "preview"]).nullable().default(null)
|
|
127
|
-
});
|
|
128
|
-
FeedEdgeSchema = zod.z.object({
|
|
129
|
-
from: zod.z.string().min(1),
|
|
130
|
-
to: zod.z.string().min(1),
|
|
131
|
-
kind: zod.z.enum(["knowledge", "task"]),
|
|
132
|
-
reason: zod.z.string().default("")
|
|
133
|
-
});
|
|
134
|
-
FeedGroupingSchema = zod.z.object({
|
|
135
|
-
id: zod.z.string().min(1),
|
|
136
|
-
name: zod.z.string().min(1),
|
|
137
|
-
node_ids: zod.z.array(zod.z.string()).min(1),
|
|
138
|
-
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
|
|
139
135
|
});
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
name:
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
146
142
|
});
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
}),
|
|
154
|
-
learning_nodes: zod.z.array(FeedNodeSchema).min(1, "curriculum feed must contain at least one learning node"),
|
|
155
|
-
dependency_edges: zod.z.array(FeedEdgeSchema).default([]),
|
|
156
|
-
suggested_groupings: zod.z.array(FeedGroupingSchema).default([]),
|
|
157
|
-
// Progressive completion phases (cross-feature). When non-empty, these — not
|
|
158
|
-
// suggested_groupings — define the teaching sequence and unit structure.
|
|
159
|
-
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
|
|
160
149
|
});
|
|
161
150
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
return
|
|
172
|
-
}
|
|
173
|
-
function pushWarning(opts, message) {
|
|
174
|
-
(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;
|
|
175
161
|
}
|
|
176
|
-
function
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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);
|
|
186
179
|
}
|
|
187
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;
|
|
188
186
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
references: arr(step.files).map((f) => ({ file: str(f), evidence: "step file" })).filter((f) => f.file),
|
|
203
|
-
feature_id: featureId,
|
|
204
|
-
concept_ref: null,
|
|
205
|
-
// P52/T2.1: carry the step's tangible outcome so the planner can cut
|
|
206
|
-
// sessions at user-visible checkpoints instead of raw minute counts.
|
|
207
|
-
user_visible_deliverable: str(step.outcome?.user_visible),
|
|
208
|
-
is_core: false,
|
|
209
|
-
phase_id: "",
|
|
210
|
-
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: []
|
|
211
200
|
};
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
if (explicit === "ulo" || explicit === "cio" || explicit === "sio") return explicit;
|
|
216
|
-
const sio = arr(concept.sio);
|
|
217
|
-
const prereqs = strs(concept.prerequisites);
|
|
218
|
-
const pts = arr(concept.problem_types);
|
|
219
|
-
const hasHighBloom = pts.some((p) => {
|
|
220
|
-
const b = str(p.bloom_level);
|
|
221
|
-
return b === "Apply" || b === "Analyze" || b === "Evaluate" || b === "Create";
|
|
222
|
-
});
|
|
223
|
-
if (sio.length >= 2 || hasHighBloom) return "sio";
|
|
224
|
-
if (str(concept.cio) || prereqs.length >= 1) return "cio";
|
|
225
|
-
return "ulo";
|
|
226
|
-
}
|
|
227
|
-
function deriveDepthInputs(minutes, depth) {
|
|
228
|
-
const ulo = Math.max(5, Math.round(minutes * 0.35));
|
|
229
|
-
const cio = Math.max(ulo + 5, Math.round(minutes * 0.65));
|
|
230
|
-
const sio = Math.max(cio + 5, minutes);
|
|
231
|
-
const variants = { ulo, cio, sio };
|
|
232
|
-
const candidates = [];
|
|
233
|
-
if (depth === "sio") {
|
|
234
|
-
candidates.push({ from_depth: "sio", to_depth: "cio", minutes_saved: sio - cio, reason: "SIO implementation practice deferred \u2014 CIO mechanism suffices for downstream consumers" });
|
|
201
|
+
let match;
|
|
202
|
+
while ((match = IMPORT_RE3.exec(content)) !== null) {
|
|
203
|
+
result.imports.push(match[1]);
|
|
235
204
|
}
|
|
236
|
-
|
|
237
|
-
|
|
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
|
+
}
|
|
238
210
|
}
|
|
239
|
-
|
|
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
|
+
});
|
|
219
|
+
}
|
|
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
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
if (content.includes("try:")) {
|
|
230
|
+
result.error_handling.push("try-except");
|
|
231
|
+
}
|
|
232
|
+
if (content.includes("raise")) {
|
|
233
|
+
result.error_handling.push("raise");
|
|
234
|
+
}
|
|
235
|
+
return result;
|
|
240
236
|
}
|
|
241
|
-
function
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
item: str(pt.name, "Problem type"),
|
|
249
|
-
reason: str(pt.description, "Formative practice check"),
|
|
250
|
-
minutes_saved: num(pt.estimated_minutes, 10),
|
|
251
|
-
suggest: "provide_partial"
|
|
252
|
-
}));
|
|
253
|
-
return {
|
|
254
|
-
id,
|
|
255
|
-
kind: "concept",
|
|
256
|
-
name: str(concept.name, id),
|
|
257
|
-
concept_codes: [str(concept.code) || id].filter(Boolean),
|
|
258
|
-
keywords: { all: strs(concept.keywords), new: [], prerequisite: [] },
|
|
259
|
-
estimated_minutes: Math.max(0, Math.round(num(concept.estimated_minutes, 15))),
|
|
260
|
-
bloom_hint: topBloom,
|
|
261
|
-
depth_hint: deriveConceptDepthHint(concept),
|
|
262
|
-
completion_level: "standard",
|
|
263
|
-
scaffolding: {
|
|
264
|
-
scaffold_candidates: problemScaffolds,
|
|
265
|
-
core_value_minutes: Math.max(0, Math.round(num(concept.estimated_minutes, 15)))
|
|
266
|
-
},
|
|
267
|
-
references,
|
|
268
|
-
feature_id: null,
|
|
269
|
-
concept_ref: id,
|
|
270
|
-
user_visible_deliverable: "",
|
|
271
|
-
// concepts are taught, not shipped — checkpoints live on product steps
|
|
272
|
-
...deriveDepthInputs(Math.max(0, Math.round(num(concept.estimated_minutes, 15))), deriveConceptDepthHint(concept)),
|
|
273
|
-
is_core: false,
|
|
274
|
-
depth_context: {
|
|
275
|
-
ulo: str(concept.ulo),
|
|
276
|
-
cio: str(concept.cio),
|
|
277
|
-
sio: strs(concept.sio)
|
|
278
|
-
},
|
|
279
|
-
phase_id: "",
|
|
280
|
-
introduce_aspect: null
|
|
237
|
+
function mergePythonResults(results) {
|
|
238
|
+
const merged = {
|
|
239
|
+
imports: [],
|
|
240
|
+
types: [],
|
|
241
|
+
functions: [],
|
|
242
|
+
docstrings: [],
|
|
243
|
+
error_handling: []
|
|
281
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);
|
|
250
|
+
}
|
|
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);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
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;
|
|
282
264
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
265
|
+
|
|
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) {
|
|
271
|
+
const result = {
|
|
272
|
+
imports: [],
|
|
273
|
+
types: [],
|
|
274
|
+
functions: [],
|
|
275
|
+
error_handling: []
|
|
276
|
+
};
|
|
277
|
+
let match;
|
|
278
|
+
while ((match = INCLUDE_RE.exec(content)) !== null) {
|
|
279
|
+
result.imports.push(match[1]);
|
|
294
280
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
281
|
+
while ((match = TYPE_RE3.exec(content)) !== null) {
|
|
282
|
+
result.types.push({
|
|
283
|
+
kind: "cpp",
|
|
284
|
+
name: match[1],
|
|
285
|
+
file: filepath
|
|
286
|
+
});
|
|
298
287
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
const nodes = [];
|
|
304
|
-
const edges = [];
|
|
305
|
-
const groupings = [];
|
|
306
|
-
const pg = hybrid.project_graph ?? {};
|
|
307
|
-
const kg = hybrid.knowledge_graph ?? {};
|
|
308
|
-
const rawPhases = arr(hybrid.phases).filter((p) => str(p.id));
|
|
309
|
-
const features = arr(pg.features);
|
|
310
|
-
const conceptRecs = arr(kg.concepts);
|
|
311
|
-
const conceptById = new Map(conceptRecs.map((c) => [str(c.id), c]));
|
|
312
|
-
if (rawPhases.length === 0) {
|
|
313
|
-
for (const c of conceptRecs) {
|
|
314
|
-
const node = feedNodeFromConcept(c);
|
|
315
|
-
nodes.push(node);
|
|
316
|
-
for (const p of strs(c.prerequisites)) edges.push({ from: p, to: node.id, kind: "knowledge", reason: "concept prerequisite" });
|
|
317
|
-
}
|
|
318
|
-
features.forEach((f, fi) => addFeatureGroup(f, str(f.id, "F" + pad22(fi + 1)), nodes, edges, groupings));
|
|
319
|
-
linkHybridKnowledgeEdges(hybrid, nodes, edges);
|
|
320
|
-
newKeywordsInOrder(nodes);
|
|
321
|
-
return validateCurriculumFeed({
|
|
322
|
-
schema_version: 2,
|
|
323
|
-
source: { graph_type: "hybrid_graph", warnings: opts.warnings ?? [], hallucination_count: opts.hallucinationCount ?? 0 },
|
|
324
|
-
learning_nodes: nodes,
|
|
325
|
-
dependency_edges: edges,
|
|
326
|
-
suggested_groupings: groupings,
|
|
327
|
-
phases: []
|
|
288
|
+
while ((match = FUNC_RE4.exec(content)) !== null) {
|
|
289
|
+
result.functions.push({
|
|
290
|
+
name: match[1],
|
|
291
|
+
file: filepath
|
|
328
292
|
});
|
|
329
293
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
const stepIdsByFeature = /* @__PURE__ */ new Map();
|
|
333
|
-
for (const f of features) {
|
|
334
|
-
const fid = str(f.id);
|
|
335
|
-
stepIdsByFeature.set(fid, arr(f.steps).map((s) => str(s.id)).filter(Boolean));
|
|
294
|
+
if (/\btry\b|\bthrow\b|\bcatch\b/.test(content)) {
|
|
295
|
+
result.error_handling.push("try/throw/catch");
|
|
336
296
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
297
|
+
return result;
|
|
298
|
+
}
|
|
299
|
+
function mergeCppResults(results) {
|
|
300
|
+
const merged = {
|
|
301
|
+
imports: [],
|
|
302
|
+
types: [],
|
|
303
|
+
functions: [],
|
|
304
|
+
error_handling: []
|
|
305
|
+
};
|
|
306
|
+
const importCounts = /* @__PURE__ */ new Map();
|
|
307
|
+
for (const r of results) {
|
|
308
|
+
for (const imp of r.imports) {
|
|
309
|
+
importCounts.set(imp, (importCounts.get(imp) || 0) + 1);
|
|
310
|
+
}
|
|
311
|
+
merged.types.push(...r.types);
|
|
312
|
+
merged.functions.push(...r.functions);
|
|
313
|
+
merged.error_handling.push(...r.error_handling);
|
|
314
|
+
}
|
|
315
|
+
merged.imports = Array.from(importCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([mod]) => mod);
|
|
316
|
+
merged.error_handling = [...new Set(merged.error_handling)];
|
|
317
|
+
return merged;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// src/extractors/keywordExtractor.ts
|
|
321
|
+
var STDLIB_MODULES = /* @__PURE__ */ new Set([
|
|
322
|
+
// Python
|
|
323
|
+
"os",
|
|
324
|
+
"sys",
|
|
325
|
+
"typing",
|
|
326
|
+
"json",
|
|
327
|
+
"re",
|
|
328
|
+
"math",
|
|
329
|
+
"random",
|
|
330
|
+
"time",
|
|
331
|
+
"datetime",
|
|
332
|
+
"pathlib",
|
|
333
|
+
"dataclasses",
|
|
334
|
+
"enum",
|
|
335
|
+
"collections",
|
|
336
|
+
"itertools",
|
|
337
|
+
"functools",
|
|
338
|
+
"logging",
|
|
339
|
+
"argparse",
|
|
340
|
+
"subprocess",
|
|
341
|
+
"threading",
|
|
342
|
+
"multiprocessing",
|
|
343
|
+
"asyncio",
|
|
344
|
+
"socket",
|
|
345
|
+
"http",
|
|
346
|
+
"urllib",
|
|
347
|
+
"ssl",
|
|
348
|
+
"hashlib",
|
|
349
|
+
"base64",
|
|
350
|
+
"csv",
|
|
351
|
+
"sqlite3",
|
|
352
|
+
"pickle",
|
|
353
|
+
"tempfile",
|
|
354
|
+
"shutil",
|
|
355
|
+
"glob",
|
|
356
|
+
"io",
|
|
357
|
+
"string",
|
|
358
|
+
"struct",
|
|
359
|
+
"uuid",
|
|
360
|
+
"abc",
|
|
361
|
+
"copy",
|
|
362
|
+
"decimal",
|
|
363
|
+
"fractions",
|
|
364
|
+
"statistics",
|
|
365
|
+
"queue",
|
|
366
|
+
"signal",
|
|
367
|
+
"traceback",
|
|
368
|
+
"warnings",
|
|
369
|
+
"weakref",
|
|
370
|
+
"contextlib",
|
|
371
|
+
"unittest",
|
|
372
|
+
"pytest",
|
|
373
|
+
"tkinter",
|
|
374
|
+
"tk",
|
|
375
|
+
"ttk",
|
|
376
|
+
"types",
|
|
377
|
+
"inspect",
|
|
378
|
+
"platform",
|
|
379
|
+
// Common third-party generic plumbing
|
|
380
|
+
"requests",
|
|
381
|
+
"urllib3",
|
|
382
|
+
"pydantic",
|
|
383
|
+
"dotenv",
|
|
384
|
+
"yaml",
|
|
385
|
+
"tomllib"
|
|
386
|
+
]);
|
|
387
|
+
function platformFromPath(filePath) {
|
|
388
|
+
if (!filePath) return "app";
|
|
389
|
+
const fp = filePath.toLowerCase();
|
|
390
|
+
if (fp.endsWith(".ino") || fp.endsWith(".cpp") || fp.endsWith(".h") || fp.endsWith(".hpp") || fp.endsWith(".c")) {
|
|
391
|
+
if (fp.endsWith(".ino")) return "esp32";
|
|
392
|
+
if (fp.includes("firmware") || fp.includes("esp")) return "esp32";
|
|
393
|
+
return "app";
|
|
394
|
+
}
|
|
395
|
+
return "app";
|
|
396
|
+
}
|
|
397
|
+
function extractKeywords(sourceContext, basicAnalysis, repoDir, options) {
|
|
398
|
+
const keywords = [];
|
|
399
|
+
const effectiveStdlib = new Set(STDLIB_MODULES);
|
|
400
|
+
if ("imports" in sourceContext) {
|
|
401
|
+
for (const imp of sourceContext.imports) {
|
|
402
|
+
let module;
|
|
403
|
+
let count = 1;
|
|
404
|
+
let platform = "app";
|
|
405
|
+
if (typeof imp === "string") {
|
|
406
|
+
module = imp;
|
|
407
|
+
} else if (imp && typeof imp === "object" && "module" in imp) {
|
|
408
|
+
const impObj = imp;
|
|
409
|
+
module = impObj.module;
|
|
410
|
+
count = impObj.count || 1;
|
|
411
|
+
platform = platformFromPath(impObj.file || "");
|
|
404
412
|
} else {
|
|
405
|
-
|
|
406
|
-
const node = feedNodeFromConcept(c);
|
|
407
|
-
node.id = revisitId;
|
|
408
|
-
node.name = str(c.name, cid) + " (advanced)";
|
|
409
|
-
node.phase_id = pid;
|
|
410
|
-
node.introduce_aspect = "advanced";
|
|
411
|
-
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" }];
|
|
412
|
-
node.estimated_minutes = Math.max(15, Math.round(node.estimated_minutes * 0.5));
|
|
413
|
-
node.concept_codes = [str(c.code) || cid];
|
|
414
|
-
edges.push({ from: cid, to: revisitId, kind: "knowledge", reason: "advanced revisit of " + cid + " at phase " + pid });
|
|
415
|
-
nodes.push(node);
|
|
416
|
-
conceptOrder.push(revisitId);
|
|
413
|
+
continue;
|
|
417
414
|
}
|
|
415
|
+
if (effectiveStdlib.has(module.toLowerCase())) continue;
|
|
416
|
+
keywords.push({
|
|
417
|
+
keyword: module,
|
|
418
|
+
source: "import",
|
|
419
|
+
platform,
|
|
420
|
+
weight: 1 + (count - 1) * 0.1,
|
|
421
|
+
context: `Imported ${count} times`
|
|
422
|
+
});
|
|
418
423
|
}
|
|
419
|
-
const featureIds = strs(rp.feature_ids).filter((fid) => featureOrder.has(fid)).sort((a, b) => featureOrder.get(a) - featureOrder.get(b));
|
|
420
|
-
emitCtx.push({ id: pid, order, name: str(rp.name, pid), productCompletion: str(rp.product_completion), conceptOrder, featureIds });
|
|
421
424
|
}
|
|
422
|
-
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
|
|
425
|
+
if ("types" in sourceContext) {
|
|
426
|
+
for (const typeDecl of sourceContext.types) {
|
|
427
|
+
const name = typeDecl.name;
|
|
428
|
+
const kind = typeDecl.kind;
|
|
429
|
+
const platform = platformFromPath(typeDecl.file || "");
|
|
430
|
+
if (["View", "Model", "ViewModel", "Controller", "Service", "Manager"].includes(name)) continue;
|
|
431
|
+
keywords.push({
|
|
432
|
+
keyword: name,
|
|
433
|
+
source: "type_declaration",
|
|
434
|
+
platform,
|
|
435
|
+
weight: 1.5,
|
|
436
|
+
context: `${kind} declaration`
|
|
437
|
+
});
|
|
426
438
|
}
|
|
427
439
|
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
440
|
+
if ("functions" in sourceContext) {
|
|
441
|
+
for (const func of sourceContext.functions) {
|
|
442
|
+
const name = func.name;
|
|
443
|
+
const platform = platformFromPath(func.file || "");
|
|
444
|
+
if (["init", "setup", "configure", "initialize"].includes(name)) continue;
|
|
445
|
+
keywords.push({
|
|
446
|
+
keyword: name,
|
|
447
|
+
source: "function_signature",
|
|
448
|
+
platform,
|
|
449
|
+
weight: 1.2,
|
|
450
|
+
context: "Function signature"
|
|
451
|
+
});
|
|
452
|
+
}
|
|
432
453
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
454
|
+
if ("docstrings" in sourceContext && Array.isArray(sourceContext.docstrings)) {
|
|
455
|
+
for (const doc of sourceContext.docstrings) {
|
|
456
|
+
const firstSentence = doc.trim().split("\n")[0].slice(0, 120);
|
|
457
|
+
if (firstSentence.length > 15) {
|
|
458
|
+
keywords.push({
|
|
459
|
+
keyword: firstSentence,
|
|
460
|
+
source: "docstring",
|
|
461
|
+
platform: "app",
|
|
462
|
+
weight: 1.8,
|
|
463
|
+
context: "Docstring (domain intent)"
|
|
464
|
+
});
|
|
442
465
|
}
|
|
443
|
-
ids.push(...stepIds);
|
|
444
466
|
}
|
|
445
|
-
phaseNodeIds.push(ids);
|
|
446
467
|
}
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
468
|
+
if ("frameworks_used" in sourceContext && Array.isArray(sourceContext.frameworks_used)) {
|
|
469
|
+
for (const fw of sourceContext.frameworks_used) {
|
|
470
|
+
const fwName = typeof fw === "string" ? fw : fw.framework || "";
|
|
471
|
+
if (fwName) {
|
|
472
|
+
keywords.push({
|
|
473
|
+
keyword: fwName,
|
|
474
|
+
source: "framework_usage",
|
|
475
|
+
platform: "app",
|
|
476
|
+
weight: 1.4,
|
|
477
|
+
context: "Framework usage (real practice)"
|
|
478
|
+
});
|
|
479
|
+
}
|
|
453
480
|
}
|
|
454
481
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
const cid = str(l.concept_id);
|
|
467
|
-
const fid = str(l.feature_id);
|
|
468
|
-
if (!conceptById.has(cid)) continue;
|
|
469
|
-
const firstStep = (stepIdsByFeature.get(fid) || [])[0];
|
|
470
|
-
if (!firstStep) continue;
|
|
471
|
-
const depth = str(l.depth, "cio");
|
|
472
|
-
const anchorNode = nodes.find((n2) => n2.kind === "concept" && n2.concept_ref === cid && n2.introduce_aspect === "new");
|
|
473
|
-
const stepNode = nodes.find((n2) => n2.id === firstStep);
|
|
474
|
-
const anchorPhaseIdx = anchorNode ? emitCtx.findIndex((c2) => c2.id === anchorNode.phase_id) : -1;
|
|
475
|
-
const stepPhaseIdx = stepNode ? emitCtx.findIndex((c2) => c2.id === stepNode.phase_id) : -1;
|
|
476
|
-
if (emitCtx.length > 0 && anchorPhaseIdx > stepPhaseIdx) {
|
|
477
|
-
pushWarning(opts, "hybrid link drift: concept " + cid + " is fully taught in a later phase than consumer " + firstStep + " (a preview covers the dependency)");
|
|
478
|
-
continue;
|
|
479
|
-
}
|
|
480
|
-
pushEdge(cid, firstStep, str(l.relationship, "hybrid link") + " [" + depth + "]");
|
|
481
|
-
for (const n of nodes) {
|
|
482
|
-
if (n.kind === "concept" && n.concept_ref === cid && n.introduce_aspect === "advanced" && n.phase_id === str(l.phase_id)) {
|
|
483
|
-
pushEdge(n.id, firstStep, "advanced application [" + depth + "]");
|
|
482
|
+
if ("property_wrappers" in sourceContext && Array.isArray(sourceContext.property_wrappers)) {
|
|
483
|
+
for (const wrapper of sourceContext.property_wrappers) {
|
|
484
|
+
const wrapperName = typeof wrapper === "string" ? wrapper : wrapper.wrapper || "";
|
|
485
|
+
if (wrapperName) {
|
|
486
|
+
keywords.push({
|
|
487
|
+
keyword: `@${wrapperName}`,
|
|
488
|
+
source: "property_wrapper",
|
|
489
|
+
platform: "app",
|
|
490
|
+
weight: 2,
|
|
491
|
+
context: "Property wrapper"
|
|
492
|
+
});
|
|
484
493
|
}
|
|
485
494
|
}
|
|
486
495
|
}
|
|
487
|
-
|
|
488
|
-
const
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
const phaseOf = (id) => {
|
|
500
|
-
const n2 = nodes.find((x) => x.id === id);
|
|
501
|
-
return n2 ? emitCtx.findIndex((c) => c.id === n2.phase_id) : -1;
|
|
502
|
-
};
|
|
503
|
-
const conceptOvertaken = n.kind === "concept" && srcNode?.kind === "product_step";
|
|
504
|
-
const fromPhase = phaseOf(src);
|
|
505
|
-
const toPhase = phaseOf(n.id);
|
|
506
|
-
if (conceptOvertaken || emitCtx.length > 0 && fromPhase > toPhase) {
|
|
507
|
-
pushWarning(
|
|
508
|
-
opts,
|
|
509
|
-
"keyword-order drift: introducer " + src + " sits in a later phase than consumer " + n.id + ' (keyword "' + k + '") \u2014 host should move the introducer earlier'
|
|
510
|
-
);
|
|
511
|
-
continue;
|
|
512
|
-
}
|
|
513
|
-
if (feedOrder.get(src) < feedOrder.get(n.id)) pushEdge(src, n.id, "keyword prerequisite [" + k + "]");
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
const phases = emitCtx.map((ctx, i) => ({
|
|
517
|
-
id: ctx.id,
|
|
518
|
-
order: ctx.order,
|
|
519
|
-
name: ctx.name,
|
|
520
|
-
product_completion: ctx.productCompletion || "Phase " + (i + 1) + " deliverable",
|
|
521
|
-
node_ids: phaseNodeIds[i]
|
|
522
|
-
}));
|
|
523
|
-
return validateCurriculumFeed({
|
|
524
|
-
schema_version: 2,
|
|
525
|
-
source: { graph_type: "hybrid_graph", warnings: opts.warnings ?? [], hallucination_count: opts.hallucinationCount ?? 0 },
|
|
526
|
-
learning_nodes: nodes,
|
|
527
|
-
dependency_edges: edges,
|
|
528
|
-
suggested_groupings: groupings,
|
|
529
|
-
phases
|
|
530
|
-
});
|
|
531
|
-
}
|
|
532
|
-
function linkHybridKnowledgeEdges(hybrid, nodes, edges, setStepHints) {
|
|
533
|
-
const conceptIds = new Set(nodes.filter((n) => n.kind === "concept").map((n) => n.concept_ref || n.id));
|
|
534
|
-
for (const l of arr(hybrid.links)) {
|
|
535
|
-
const conceptId = str(l.concept_id);
|
|
536
|
-
const featureId = str(l.feature_id);
|
|
537
|
-
if (!conceptIds.has(conceptId)) continue;
|
|
538
|
-
const depth = str(l.depth, "cio");
|
|
539
|
-
const firstStep = nodes.find((n) => n.feature_id === featureId);
|
|
540
|
-
if (firstStep) {
|
|
541
|
-
edges.push({ from: conceptId, to: firstStep.id, kind: "knowledge", reason: str(l.relationship, "hybrid link") + " [" + depth + "]" });
|
|
542
|
-
if (!firstStep.depth_hint) {
|
|
543
|
-
firstStep.depth_hint = ["ulo", "cio", "sio"].includes(depth) ? depth : "cio";
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
function emitFromKnowledge(kg, opts) {
|
|
549
|
-
const nodes = [];
|
|
550
|
-
const edges = [];
|
|
551
|
-
const groupings = [];
|
|
552
|
-
const conceptIds = new Set(arr(kg.concepts).map((c) => str(c.id)));
|
|
553
|
-
for (const c of arr(kg.concepts)) {
|
|
554
|
-
const node = feedNodeFromConcept(c);
|
|
555
|
-
nodes.push(node);
|
|
556
|
-
for (const p of strs(c.prerequisites)) {
|
|
557
|
-
if (p !== node.id && conceptIds.has(p)) {
|
|
558
|
-
edges.push({
|
|
559
|
-
from: p,
|
|
560
|
-
to: node.id,
|
|
561
|
-
kind: "knowledge",
|
|
562
|
-
reason: `concept prerequisite: ${p} must be understood before ${node.id}`
|
|
563
|
-
});
|
|
564
|
-
}
|
|
496
|
+
const errorPatterns = "error_handling" in sourceContext ? sourceContext.error_handling : "error_handling_patterns" in sourceContext ? sourceContext.error_handling_patterns || [] : [];
|
|
497
|
+
for (const error of errorPatterns) {
|
|
498
|
+
let pattern;
|
|
499
|
+
let platform = "app";
|
|
500
|
+
if (typeof error === "string") {
|
|
501
|
+
pattern = error;
|
|
502
|
+
} else if (error && typeof error === "object" && "pattern" in error) {
|
|
503
|
+
const errObj = error;
|
|
504
|
+
pattern = errObj.pattern;
|
|
505
|
+
platform = platformFromPath(errObj.file || "");
|
|
506
|
+
} else {
|
|
507
|
+
continue;
|
|
565
508
|
}
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
509
|
+
keywords.push({
|
|
510
|
+
keyword: pattern,
|
|
511
|
+
source: "error_handling",
|
|
512
|
+
platform,
|
|
513
|
+
weight: 1.3,
|
|
514
|
+
context: "Error handling pattern"
|
|
572
515
|
});
|
|
573
|
-
nodes.sort((a, b) => (orderMap.get(a.id) ?? 999) - (orderMap.get(b.id) ?? 999));
|
|
574
516
|
}
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
const
|
|
579
|
-
if (
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
name: str(cat.name, "Category " + ci),
|
|
583
|
-
node_ids: ids,
|
|
584
|
-
rationale: str(cat.description, "")
|
|
585
|
-
});
|
|
517
|
+
const seen = /* @__PURE__ */ new Set();
|
|
518
|
+
const uniqueKeywords = [];
|
|
519
|
+
for (const kw of keywords) {
|
|
520
|
+
const key = kw.keyword.toLowerCase();
|
|
521
|
+
if (!seen.has(key)) {
|
|
522
|
+
seen.add(key);
|
|
523
|
+
uniqueKeywords.push(kw);
|
|
586
524
|
}
|
|
587
525
|
}
|
|
588
|
-
|
|
589
|
-
return validateCurriculumFeed({
|
|
590
|
-
schema_version: 2,
|
|
591
|
-
source: { graph_type: "knowledge_graph", warnings: opts.warnings ?? [], hallucination_count: opts.hallucinationCount ?? 0 },
|
|
592
|
-
learning_nodes: nodes,
|
|
593
|
-
dependency_edges: edges,
|
|
594
|
-
suggested_groupings: groupings,
|
|
595
|
-
phases: []
|
|
596
|
-
});
|
|
597
|
-
}
|
|
598
|
-
function emitFromProject(pg, opts) {
|
|
599
|
-
const nodes = [];
|
|
600
|
-
const edges = [];
|
|
601
|
-
const groupings = [];
|
|
602
|
-
arr(pg.features).forEach((f, fi) => addFeatureGroup(f, str(f.id, "F" + pad22(fi + 1)), nodes, edges, groupings));
|
|
603
|
-
if (nodes.length === 0 && arr(pg.features).length > 0) {
|
|
604
|
-
const hasTasks = arr(pg.implementation ?? {}).length > 0 || arr(pg.implementation?.tasks).length > 0;
|
|
605
|
-
throw new Error(
|
|
606
|
-
"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.")
|
|
607
|
-
);
|
|
608
|
-
}
|
|
609
|
-
newKeywordsInOrder(nodes);
|
|
610
|
-
return validateCurriculumFeed({
|
|
611
|
-
schema_version: 2,
|
|
612
|
-
source: { graph_type: "project_graph", warnings: opts.warnings ?? [], hallucination_count: opts.hallucinationCount ?? 0 },
|
|
613
|
-
learning_nodes: nodes,
|
|
614
|
-
dependency_edges: edges,
|
|
615
|
-
suggested_groupings: groupings,
|
|
616
|
-
phases: []
|
|
617
|
-
});
|
|
526
|
+
return uniqueKeywords;
|
|
618
527
|
}
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
528
|
+
|
|
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 };
|
|
629
540
|
}
|
|
630
|
-
var
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
arr = (v) => Array.isArray(v) ? v : [];
|
|
637
|
-
str = (v, d = "") => typeof v === "string" ? v : d;
|
|
638
|
-
num = (v, d) => typeof v === "number" && Number.isFinite(v) ? v : d;
|
|
639
|
-
strs = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
|
|
541
|
+
var LlmClientError = class extends Error {
|
|
542
|
+
constructor(message, status, cause) {
|
|
543
|
+
super(message);
|
|
544
|
+
this.status = status;
|
|
545
|
+
this.cause = cause;
|
|
546
|
+
this.name = "LlmClientError";
|
|
640
547
|
}
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
var FRAMEWORK_USAGE_RE = /\b(URLSession|URLRequest|JSONSerialization|JSONDecoder|JSONEncoder|Task|DispatchQueue|Timer|NotificationCenter|UserDefaults|FileManager|Bundle)\b/g;
|
|
649
|
-
function parseSwiftFile(filepath, content) {
|
|
650
|
-
const result = {
|
|
651
|
-
imports: [],
|
|
652
|
-
types: [],
|
|
653
|
-
functions: [],
|
|
654
|
-
property_wrappers: [],
|
|
655
|
-
error_handling: [],
|
|
656
|
-
frameworks_used: []
|
|
548
|
+
};
|
|
549
|
+
function resolveProviderChain(config) {
|
|
550
|
+
const chain = [];
|
|
551
|
+
const push = (label, baseUrl, apiKey, model) => {
|
|
552
|
+
if (apiKey && baseUrl && model && !chain.some((c) => c.baseUrl === baseUrl && c.model === model)) {
|
|
553
|
+
chain.push({ label, baseUrl, apiKey, model });
|
|
554
|
+
}
|
|
657
555
|
};
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
result.imports.push(match[1]);
|
|
556
|
+
if (config?.apiKey) {
|
|
557
|
+
push("config", config.baseUrl || "https://api.openai.com/v1", config.apiKey, config.model || "gpt-4o-mini");
|
|
661
558
|
}
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
559
|
+
push("legacy", process.env.LLM_BASE_URL || process.env.OPENAI_BASE_URL || "", process.env.LLM_API_KEY || process.env.OPENAI_API_KEY || "", process.env.LLM_MODEL || process.env.LLM_TIER_FAST || "");
|
|
560
|
+
push("dashscope", process.env.DASHSCOPE_BASE_URL || process.env.ALIBABA_BASE_URL || "", process.env.DASHSCOPE_API_KEY || process.env.ALIBABA_API_KEY || "", process.env.DASHSCOPE_MODEL || process.env.DEFAULT_AI_MODEL || "");
|
|
561
|
+
push("nvidia", "https://integrate.api.nvidia.com/v1", process.env.NVIDIA_API_KEY || "", process.env.NVIDIA_MODEL || "nvidia/nemotron-3-ultra-550b-a55b:free");
|
|
562
|
+
push("openrouter", "https://openrouter.ai/api/v1", process.env.OPENROUTER_API_KEY || "", process.env.OPENROUTER_MODEL || "nvidia/nemotron-3-ultra-550b-a55b:free");
|
|
563
|
+
if (process.env.OPENROUTER_MODEL) {
|
|
564
|
+
const or = chain.find((c) => c.label === "openrouter");
|
|
565
|
+
if (or) {
|
|
566
|
+
const rest = chain.filter((c) => c !== or);
|
|
567
|
+
return [or, ...rest];
|
|
667
568
|
}
|
|
668
569
|
}
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
});
|
|
687
|
-
}
|
|
688
|
-
const wrapperSet = /* @__PURE__ */ new Set();
|
|
689
|
-
while ((match = WRAPPER_RE.exec(content)) !== null) {
|
|
690
|
-
wrapperSet.add(match[1]);
|
|
691
|
-
}
|
|
692
|
-
result.property_wrappers = Array.from(wrapperSet);
|
|
693
|
-
if (content.includes("do {") || content.includes("do{")) {
|
|
694
|
-
result.error_handling.push("do-catch");
|
|
695
|
-
}
|
|
696
|
-
if (content.includes("throws")) {
|
|
697
|
-
result.error_handling.push("throws");
|
|
698
|
-
}
|
|
699
|
-
if (content.includes("Result<")) {
|
|
700
|
-
result.error_handling.push("Result type");
|
|
701
|
-
}
|
|
702
|
-
if (content.includes("try?")) {
|
|
703
|
-
result.error_handling.push("try?");
|
|
570
|
+
return chain;
|
|
571
|
+
}
|
|
572
|
+
var MIN_REQUEST_INTERVAL_MS = 2500;
|
|
573
|
+
var requestIntervalMs = Number(process.env.LLM_MIN_REQUEST_INTERVAL_MS || MIN_REQUEST_INTERVAL_MS);
|
|
574
|
+
var lastRequestAt = 0;
|
|
575
|
+
async function pacedDelay() {
|
|
576
|
+
const wait = lastRequestAt + requestIntervalMs - Date.now();
|
|
577
|
+
if (wait > 0) await new Promise((resolve2) => setTimeout(resolve2, wait));
|
|
578
|
+
lastRequestAt = Date.now();
|
|
579
|
+
}
|
|
580
|
+
function createLlmClient(config) {
|
|
581
|
+
const chain = resolveProviderChain(config);
|
|
582
|
+
const primary = chain[0];
|
|
583
|
+
if (!primary) {
|
|
584
|
+
throw new LlmClientError(
|
|
585
|
+
"No LLM provider configured. Set NVIDIA_API_KEY (primary) and/or OPENROUTER_API_KEY (fallback), or pass llmConfig."
|
|
586
|
+
);
|
|
704
587
|
}
|
|
705
|
-
|
|
706
|
-
|
|
588
|
+
primary.apiKey;
|
|
589
|
+
primary.baseUrl;
|
|
590
|
+
const model = primary.model;
|
|
591
|
+
if (config?.minRequestIntervalMs !== void 0) {
|
|
592
|
+
requestIntervalMs = Math.max(0, config.minRequestIntervalMs);
|
|
707
593
|
}
|
|
708
|
-
|
|
709
|
-
}
|
|
710
|
-
function mergeSwiftResults(results) {
|
|
711
|
-
const merged = {
|
|
712
|
-
imports: [],
|
|
713
|
-
types: [],
|
|
714
|
-
functions: [],
|
|
715
|
-
property_wrappers: [],
|
|
716
|
-
error_handling: [],
|
|
717
|
-
frameworks_used: []
|
|
718
|
-
};
|
|
719
|
-
const importCounts = /* @__PURE__ */ new Map();
|
|
720
|
-
const wrapperCounts = /* @__PURE__ */ new Map();
|
|
721
|
-
const errorCounts = /* @__PURE__ */ new Map();
|
|
722
|
-
const frameworkSet = /* @__PURE__ */ new Set();
|
|
723
|
-
for (const r of results) {
|
|
724
|
-
for (const imp of r.imports) {
|
|
725
|
-
importCounts.set(imp, (importCounts.get(imp) || 0) + 1);
|
|
726
|
-
}
|
|
727
|
-
merged.types.push(...r.types);
|
|
728
|
-
merged.functions.push(...r.functions);
|
|
729
|
-
for (const w of r.property_wrappers) {
|
|
730
|
-
wrapperCounts.set(w, (wrapperCounts.get(w) || 0) + 1);
|
|
731
|
-
}
|
|
732
|
-
for (const e of r.error_handling) {
|
|
733
|
-
errorCounts.set(e, (errorCounts.get(e) || 0) + 1);
|
|
734
|
-
}
|
|
735
|
-
for (const fw of r.frameworks_used) {
|
|
736
|
-
frameworkSet.add(fw);
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
merged.imports = Array.from(importCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([mod, count]) => mod);
|
|
740
|
-
merged.property_wrappers = Array.from(wrapperCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([w]) => w);
|
|
741
|
-
merged.error_handling = Array.from(errorCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([p]) => p);
|
|
742
|
-
merged.frameworks_used = Array.from(frameworkSet).sort();
|
|
743
|
-
merged.types = merged.types.slice(0, 100);
|
|
744
|
-
merged.functions = merged.functions.slice(0, 100);
|
|
745
|
-
return merged;
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
// src/parsers/typescript.ts
|
|
749
|
-
var IMPORT_RE2 = /import\s+.*?from\s+['"]([^'"]+)['"]/g;
|
|
750
|
-
var TYPE_RE2 = /\b(interface|type|class|enum)\s+(\w+)/g;
|
|
751
|
-
var FUNC_RE2 = /\bfunction\s+(\w+)\s*\(([^)]*)\)/g;
|
|
752
|
-
var ARROW_RE = /\bconst\s+(\w+)\s*=\s*\([^)]*\)\s*(?::\s*\w+)?\s*=>/g;
|
|
753
|
-
function parseTsFile(filepath, content) {
|
|
754
|
-
const result = {
|
|
755
|
-
imports: [],
|
|
756
|
-
types: [],
|
|
757
|
-
functions: [],
|
|
758
|
-
error_handling: []
|
|
759
|
-
};
|
|
760
|
-
let match;
|
|
761
|
-
while ((match = IMPORT_RE2.exec(content)) !== null) {
|
|
762
|
-
result.imports.push(match[1]);
|
|
763
|
-
}
|
|
764
|
-
while ((match = TYPE_RE2.exec(content)) !== null) {
|
|
765
|
-
result.types.push({
|
|
766
|
-
kind: match[1],
|
|
767
|
-
name: match[2],
|
|
768
|
-
file: filepath
|
|
769
|
-
});
|
|
770
|
-
}
|
|
771
|
-
while ((match = FUNC_RE2.exec(content)) !== null) {
|
|
772
|
-
result.functions.push({
|
|
773
|
-
name: match[1],
|
|
774
|
-
params: (match[2] || "").trim(),
|
|
775
|
-
file: filepath
|
|
776
|
-
});
|
|
777
|
-
}
|
|
778
|
-
while ((match = ARROW_RE.exec(content)) !== null) {
|
|
779
|
-
result.functions.push({
|
|
780
|
-
name: match[1],
|
|
781
|
-
params: "",
|
|
782
|
-
file: filepath
|
|
783
|
-
});
|
|
784
|
-
}
|
|
785
|
-
if (content.includes("try {") || content.includes("try{")) {
|
|
786
|
-
result.error_handling.push("try-catch");
|
|
787
|
-
}
|
|
788
|
-
if (content.includes("Promise")) {
|
|
789
|
-
result.error_handling.push("Promise");
|
|
790
|
-
}
|
|
791
|
-
if (content.includes(".catch(")) {
|
|
792
|
-
result.error_handling.push(".catch()");
|
|
793
|
-
}
|
|
794
|
-
return result;
|
|
795
|
-
}
|
|
796
|
-
function mergeTsResults(results) {
|
|
797
|
-
const merged = {
|
|
798
|
-
imports: [],
|
|
799
|
-
types: [],
|
|
800
|
-
functions: [],
|
|
801
|
-
error_handling: []
|
|
802
|
-
};
|
|
803
|
-
const importCounts = /* @__PURE__ */ new Map();
|
|
804
|
-
const errorCounts = /* @__PURE__ */ new Map();
|
|
805
|
-
for (const r of results) {
|
|
806
|
-
for (const imp of r.imports) {
|
|
807
|
-
importCounts.set(imp, (importCounts.get(imp) || 0) + 1);
|
|
808
|
-
}
|
|
809
|
-
merged.types.push(...r.types);
|
|
810
|
-
merged.functions.push(...r.functions);
|
|
811
|
-
for (const e of r.error_handling) {
|
|
812
|
-
errorCounts.set(e, (errorCounts.get(e) || 0) + 1);
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
merged.imports = Array.from(importCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([mod]) => mod);
|
|
816
|
-
merged.error_handling = Array.from(errorCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([p]) => p);
|
|
817
|
-
merged.types = merged.types.slice(0, 100);
|
|
818
|
-
merged.functions = merged.functions.slice(0, 100);
|
|
819
|
-
return merged;
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
// src/parsers/python.ts
|
|
823
|
-
var IMPORT_RE3 = /^(?:import|from)\s+(\w+)/gm;
|
|
824
|
-
var CLASS_RE = /\bclass\s+(\w+)(?:\s*\(([^)]+)\))?/g;
|
|
825
|
-
var FUNC_RE3 = /\bdef\s+(\w+)\s*\(([^)]*)\)/g;
|
|
826
|
-
var DOCSTRING_RE = /"""([^"]{20,300})"""/g;
|
|
827
|
-
function parsePythonFile(filepath, content) {
|
|
828
|
-
const result = {
|
|
829
|
-
imports: [],
|
|
830
|
-
types: [],
|
|
831
|
-
functions: [],
|
|
832
|
-
docstrings: [],
|
|
833
|
-
error_handling: []
|
|
834
|
-
};
|
|
835
|
-
let match;
|
|
836
|
-
while ((match = IMPORT_RE3.exec(content)) !== null) {
|
|
837
|
-
result.imports.push(match[1]);
|
|
838
|
-
}
|
|
839
|
-
while ((match = DOCSTRING_RE.exec(content)) !== null) {
|
|
840
|
-
const firstSentence = match[1].trim().split("\n")[0].slice(0, 120);
|
|
841
|
-
if (firstSentence.length > 15) {
|
|
842
|
-
result.docstrings.push(firstSentence);
|
|
843
|
-
}
|
|
844
|
-
}
|
|
845
|
-
while ((match = CLASS_RE.exec(content)) !== null) {
|
|
846
|
-
const bases = (match[2] || "").split(",").map((b) => b.trim()).filter(Boolean);
|
|
847
|
-
result.types.push({
|
|
848
|
-
kind: "class",
|
|
849
|
-
name: match[1],
|
|
850
|
-
bases,
|
|
851
|
-
file: filepath
|
|
852
|
-
});
|
|
853
|
-
}
|
|
854
|
-
while ((match = FUNC_RE3.exec(content)) !== null) {
|
|
855
|
-
const name = match[1];
|
|
856
|
-
if (name.startsWith("__") && name.endsWith("__")) continue;
|
|
857
|
-
result.functions.push({
|
|
858
|
-
name,
|
|
859
|
-
params: (match[2] || "").trim(),
|
|
860
|
-
file: filepath
|
|
861
|
-
});
|
|
862
|
-
}
|
|
863
|
-
if (content.includes("try:")) {
|
|
864
|
-
result.error_handling.push("try-except");
|
|
865
|
-
}
|
|
866
|
-
if (content.includes("raise")) {
|
|
867
|
-
result.error_handling.push("raise");
|
|
868
|
-
}
|
|
869
|
-
return result;
|
|
870
|
-
}
|
|
871
|
-
function mergePythonResults(results) {
|
|
872
|
-
const merged = {
|
|
873
|
-
imports: [],
|
|
874
|
-
types: [],
|
|
875
|
-
functions: [],
|
|
876
|
-
docstrings: [],
|
|
877
|
-
error_handling: []
|
|
878
|
-
};
|
|
879
|
-
const importCounts = /* @__PURE__ */ new Map();
|
|
880
|
-
const errorCounts = /* @__PURE__ */ new Map();
|
|
881
|
-
for (const r of results) {
|
|
882
|
-
for (const imp of r.imports) {
|
|
883
|
-
importCounts.set(imp, (importCounts.get(imp) || 0) + 1);
|
|
884
|
-
}
|
|
885
|
-
merged.types.push(...r.types);
|
|
886
|
-
merged.functions.push(...r.functions);
|
|
887
|
-
merged.docstrings.push(...r.docstrings);
|
|
888
|
-
for (const e of r.error_handling) {
|
|
889
|
-
errorCounts.set(e, (errorCounts.get(e) || 0) + 1);
|
|
890
|
-
}
|
|
891
|
-
}
|
|
892
|
-
merged.imports = Array.from(importCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([mod]) => mod);
|
|
893
|
-
merged.error_handling = Array.from(errorCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([p]) => p);
|
|
894
|
-
merged.types = merged.types.slice(0, 100);
|
|
895
|
-
merged.functions = merged.functions.slice(0, 100);
|
|
896
|
-
merged.docstrings = merged.docstrings.slice(0, 20);
|
|
897
|
-
return merged;
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
// src/parsers/cpp.ts
|
|
901
|
-
var INCLUDE_RE = /#include\s*[<"]([^>"]+)[>"]/g;
|
|
902
|
-
var TYPE_RE3 = /\b(?:class|struct|enum)\s+(\w+)/g;
|
|
903
|
-
var FUNC_RE4 = /\b(?:void|int|float|double|char|bool|String|uint8_t|uint16_t|uint32_t)\s+(\w+)\s*\(/g;
|
|
904
|
-
function parseCppFile(filepath, content) {
|
|
905
|
-
const result = {
|
|
906
|
-
imports: [],
|
|
907
|
-
types: [],
|
|
908
|
-
functions: [],
|
|
909
|
-
error_handling: []
|
|
910
|
-
};
|
|
911
|
-
let match;
|
|
912
|
-
while ((match = INCLUDE_RE.exec(content)) !== null) {
|
|
913
|
-
result.imports.push(match[1]);
|
|
914
|
-
}
|
|
915
|
-
while ((match = TYPE_RE3.exec(content)) !== null) {
|
|
916
|
-
result.types.push({
|
|
917
|
-
kind: "cpp",
|
|
918
|
-
name: match[1],
|
|
919
|
-
file: filepath
|
|
920
|
-
});
|
|
921
|
-
}
|
|
922
|
-
while ((match = FUNC_RE4.exec(content)) !== null) {
|
|
923
|
-
result.functions.push({
|
|
924
|
-
name: match[1],
|
|
925
|
-
file: filepath
|
|
926
|
-
});
|
|
927
|
-
}
|
|
928
|
-
if (/\btry\b|\bthrow\b|\bcatch\b/.test(content)) {
|
|
929
|
-
result.error_handling.push("try/throw/catch");
|
|
930
|
-
}
|
|
931
|
-
return result;
|
|
932
|
-
}
|
|
933
|
-
function mergeCppResults(results) {
|
|
934
|
-
const merged = {
|
|
935
|
-
imports: [],
|
|
936
|
-
types: [],
|
|
937
|
-
functions: [],
|
|
938
|
-
error_handling: []
|
|
939
|
-
};
|
|
940
|
-
const importCounts = /* @__PURE__ */ new Map();
|
|
941
|
-
for (const r of results) {
|
|
942
|
-
for (const imp of r.imports) {
|
|
943
|
-
importCounts.set(imp, (importCounts.get(imp) || 0) + 1);
|
|
944
|
-
}
|
|
945
|
-
merged.types.push(...r.types);
|
|
946
|
-
merged.functions.push(...r.functions);
|
|
947
|
-
merged.error_handling.push(...r.error_handling);
|
|
948
|
-
}
|
|
949
|
-
merged.imports = Array.from(importCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([mod]) => mod);
|
|
950
|
-
merged.error_handling = [...new Set(merged.error_handling)];
|
|
951
|
-
return merged;
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
// src/extractors/keywordExtractor.ts
|
|
955
|
-
var STDLIB_MODULES = /* @__PURE__ */ new Set([
|
|
956
|
-
// Python
|
|
957
|
-
"os",
|
|
958
|
-
"sys",
|
|
959
|
-
"typing",
|
|
960
|
-
"json",
|
|
961
|
-
"re",
|
|
962
|
-
"math",
|
|
963
|
-
"random",
|
|
964
|
-
"time",
|
|
965
|
-
"datetime",
|
|
966
|
-
"pathlib",
|
|
967
|
-
"dataclasses",
|
|
968
|
-
"enum",
|
|
969
|
-
"collections",
|
|
970
|
-
"itertools",
|
|
971
|
-
"functools",
|
|
972
|
-
"logging",
|
|
973
|
-
"argparse",
|
|
974
|
-
"subprocess",
|
|
975
|
-
"threading",
|
|
976
|
-
"multiprocessing",
|
|
977
|
-
"asyncio",
|
|
978
|
-
"socket",
|
|
979
|
-
"http",
|
|
980
|
-
"urllib",
|
|
981
|
-
"ssl",
|
|
982
|
-
"hashlib",
|
|
983
|
-
"base64",
|
|
984
|
-
"csv",
|
|
985
|
-
"sqlite3",
|
|
986
|
-
"pickle",
|
|
987
|
-
"tempfile",
|
|
988
|
-
"shutil",
|
|
989
|
-
"glob",
|
|
990
|
-
"io",
|
|
991
|
-
"string",
|
|
992
|
-
"struct",
|
|
993
|
-
"uuid",
|
|
994
|
-
"abc",
|
|
995
|
-
"copy",
|
|
996
|
-
"decimal",
|
|
997
|
-
"fractions",
|
|
998
|
-
"statistics",
|
|
999
|
-
"queue",
|
|
1000
|
-
"signal",
|
|
1001
|
-
"traceback",
|
|
1002
|
-
"warnings",
|
|
1003
|
-
"weakref",
|
|
1004
|
-
"contextlib",
|
|
1005
|
-
"unittest",
|
|
1006
|
-
"pytest",
|
|
1007
|
-
"tkinter",
|
|
1008
|
-
"tk",
|
|
1009
|
-
"ttk",
|
|
1010
|
-
"types",
|
|
1011
|
-
"inspect",
|
|
1012
|
-
"platform",
|
|
1013
|
-
// Common third-party generic plumbing
|
|
1014
|
-
"requests",
|
|
1015
|
-
"urllib3",
|
|
1016
|
-
"pydantic",
|
|
1017
|
-
"dotenv",
|
|
1018
|
-
"yaml",
|
|
1019
|
-
"tomllib"
|
|
1020
|
-
]);
|
|
1021
|
-
function platformFromPath(filePath) {
|
|
1022
|
-
if (!filePath) return "app";
|
|
1023
|
-
const fp = filePath.toLowerCase();
|
|
1024
|
-
if (fp.endsWith(".ino") || fp.endsWith(".cpp") || fp.endsWith(".h") || fp.endsWith(".hpp") || fp.endsWith(".c")) {
|
|
1025
|
-
if (fp.endsWith(".ino")) return "esp32";
|
|
1026
|
-
if (fp.includes("firmware") || fp.includes("esp")) return "esp32";
|
|
1027
|
-
return "app";
|
|
1028
|
-
}
|
|
1029
|
-
return "app";
|
|
1030
|
-
}
|
|
1031
|
-
function extractKeywords(sourceContext, basicAnalysis, repoDir, options) {
|
|
1032
|
-
const keywords = [];
|
|
1033
|
-
const effectiveStdlib = new Set(STDLIB_MODULES);
|
|
1034
|
-
if ("imports" in sourceContext) {
|
|
1035
|
-
for (const imp of sourceContext.imports) {
|
|
1036
|
-
let module;
|
|
1037
|
-
let count = 1;
|
|
1038
|
-
let platform = "app";
|
|
1039
|
-
if (typeof imp === "string") {
|
|
1040
|
-
module = imp;
|
|
1041
|
-
} else if (imp && typeof imp === "object" && "module" in imp) {
|
|
1042
|
-
const impObj = imp;
|
|
1043
|
-
module = impObj.module;
|
|
1044
|
-
count = impObj.count || 1;
|
|
1045
|
-
platform = platformFromPath(impObj.file || "");
|
|
1046
|
-
} else {
|
|
1047
|
-
continue;
|
|
1048
|
-
}
|
|
1049
|
-
if (effectiveStdlib.has(module.toLowerCase())) continue;
|
|
1050
|
-
keywords.push({
|
|
1051
|
-
keyword: module,
|
|
1052
|
-
source: "import",
|
|
1053
|
-
platform,
|
|
1054
|
-
weight: 1 + (count - 1) * 0.1,
|
|
1055
|
-
context: `Imported ${count} times`
|
|
1056
|
-
});
|
|
1057
|
-
}
|
|
1058
|
-
}
|
|
1059
|
-
if ("types" in sourceContext) {
|
|
1060
|
-
for (const typeDecl of sourceContext.types) {
|
|
1061
|
-
const name = typeDecl.name;
|
|
1062
|
-
const kind = typeDecl.kind;
|
|
1063
|
-
const platform = platformFromPath(typeDecl.file || "");
|
|
1064
|
-
if (["View", "Model", "ViewModel", "Controller", "Service", "Manager"].includes(name)) continue;
|
|
1065
|
-
keywords.push({
|
|
1066
|
-
keyword: name,
|
|
1067
|
-
source: "type_declaration",
|
|
1068
|
-
platform,
|
|
1069
|
-
weight: 1.5,
|
|
1070
|
-
context: `${kind} declaration`
|
|
1071
|
-
});
|
|
1072
|
-
}
|
|
1073
|
-
}
|
|
1074
|
-
if ("functions" in sourceContext) {
|
|
1075
|
-
for (const func of sourceContext.functions) {
|
|
1076
|
-
const name = func.name;
|
|
1077
|
-
const platform = platformFromPath(func.file || "");
|
|
1078
|
-
if (["init", "setup", "configure", "initialize"].includes(name)) continue;
|
|
1079
|
-
keywords.push({
|
|
1080
|
-
keyword: name,
|
|
1081
|
-
source: "function_signature",
|
|
1082
|
-
platform,
|
|
1083
|
-
weight: 1.2,
|
|
1084
|
-
context: "Function signature"
|
|
1085
|
-
});
|
|
1086
|
-
}
|
|
1087
|
-
}
|
|
1088
|
-
if ("docstrings" in sourceContext && Array.isArray(sourceContext.docstrings)) {
|
|
1089
|
-
for (const doc of sourceContext.docstrings) {
|
|
1090
|
-
const firstSentence = doc.trim().split("\n")[0].slice(0, 120);
|
|
1091
|
-
if (firstSentence.length > 15) {
|
|
1092
|
-
keywords.push({
|
|
1093
|
-
keyword: firstSentence,
|
|
1094
|
-
source: "docstring",
|
|
1095
|
-
platform: "app",
|
|
1096
|
-
weight: 1.8,
|
|
1097
|
-
context: "Docstring (domain intent)"
|
|
1098
|
-
});
|
|
1099
|
-
}
|
|
1100
|
-
}
|
|
1101
|
-
}
|
|
1102
|
-
if ("frameworks_used" in sourceContext && Array.isArray(sourceContext.frameworks_used)) {
|
|
1103
|
-
for (const fw of sourceContext.frameworks_used) {
|
|
1104
|
-
const fwName = typeof fw === "string" ? fw : fw.framework || "";
|
|
1105
|
-
if (fwName) {
|
|
1106
|
-
keywords.push({
|
|
1107
|
-
keyword: fwName,
|
|
1108
|
-
source: "framework_usage",
|
|
1109
|
-
platform: "app",
|
|
1110
|
-
weight: 1.4,
|
|
1111
|
-
context: "Framework usage (real practice)"
|
|
1112
|
-
});
|
|
1113
|
-
}
|
|
1114
|
-
}
|
|
1115
|
-
}
|
|
1116
|
-
if ("property_wrappers" in sourceContext && Array.isArray(sourceContext.property_wrappers)) {
|
|
1117
|
-
for (const wrapper of sourceContext.property_wrappers) {
|
|
1118
|
-
const wrapperName = typeof wrapper === "string" ? wrapper : wrapper.wrapper || "";
|
|
1119
|
-
if (wrapperName) {
|
|
1120
|
-
keywords.push({
|
|
1121
|
-
keyword: `@${wrapperName}`,
|
|
1122
|
-
source: "property_wrapper",
|
|
1123
|
-
platform: "app",
|
|
1124
|
-
weight: 2,
|
|
1125
|
-
context: "Property wrapper"
|
|
1126
|
-
});
|
|
1127
|
-
}
|
|
1128
|
-
}
|
|
1129
|
-
}
|
|
1130
|
-
const errorPatterns = "error_handling" in sourceContext ? sourceContext.error_handling : "error_handling_patterns" in sourceContext ? sourceContext.error_handling_patterns || [] : [];
|
|
1131
|
-
for (const error of errorPatterns) {
|
|
1132
|
-
let pattern;
|
|
1133
|
-
let platform = "app";
|
|
1134
|
-
if (typeof error === "string") {
|
|
1135
|
-
pattern = error;
|
|
1136
|
-
} else if (error && typeof error === "object" && "pattern" in error) {
|
|
1137
|
-
const errObj = error;
|
|
1138
|
-
pattern = errObj.pattern;
|
|
1139
|
-
platform = platformFromPath(errObj.file || "");
|
|
1140
|
-
} else {
|
|
1141
|
-
continue;
|
|
1142
|
-
}
|
|
1143
|
-
keywords.push({
|
|
1144
|
-
keyword: pattern,
|
|
1145
|
-
source: "error_handling",
|
|
1146
|
-
platform,
|
|
1147
|
-
weight: 1.3,
|
|
1148
|
-
context: "Error handling pattern"
|
|
1149
|
-
});
|
|
1150
|
-
}
|
|
1151
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1152
|
-
const uniqueKeywords = [];
|
|
1153
|
-
for (const kw of keywords) {
|
|
1154
|
-
const key = kw.keyword.toLowerCase();
|
|
1155
|
-
if (!seen.has(key)) {
|
|
1156
|
-
seen.add(key);
|
|
1157
|
-
uniqueKeywords.push(kw);
|
|
1158
|
-
}
|
|
1159
|
-
}
|
|
1160
|
-
return uniqueKeywords;
|
|
1161
|
-
}
|
|
1162
|
-
|
|
1163
|
-
// src/utils/llmClient.ts
|
|
1164
|
-
var LlmClientError = class extends Error {
|
|
1165
|
-
constructor(message, status, cause) {
|
|
1166
|
-
super(message);
|
|
1167
|
-
this.status = status;
|
|
1168
|
-
this.cause = cause;
|
|
1169
|
-
this.name = "LlmClientError";
|
|
1170
|
-
}
|
|
1171
|
-
};
|
|
1172
|
-
function resolveProviderChain(config) {
|
|
1173
|
-
const chain = [];
|
|
1174
|
-
const push = (label, baseUrl, apiKey, model) => {
|
|
1175
|
-
if (apiKey && baseUrl && model && !chain.some((c) => c.baseUrl === baseUrl && c.model === model)) {
|
|
1176
|
-
chain.push({ label, baseUrl, apiKey, model });
|
|
1177
|
-
}
|
|
1178
|
-
};
|
|
1179
|
-
if (config?.apiKey) {
|
|
1180
|
-
push("config", config.baseUrl || "https://api.openai.com/v1", config.apiKey, config.model || "gpt-4o-mini");
|
|
1181
|
-
}
|
|
1182
|
-
push("legacy", process.env.LLM_BASE_URL || process.env.OPENAI_BASE_URL || "", process.env.LLM_API_KEY || process.env.OPENAI_API_KEY || "", process.env.LLM_MODEL || process.env.LLM_TIER_FAST || "");
|
|
1183
|
-
push("dashscope", process.env.DASHSCOPE_BASE_URL || process.env.ALIBABA_BASE_URL || "", process.env.DASHSCOPE_API_KEY || process.env.ALIBABA_API_KEY || "", process.env.DASHSCOPE_MODEL || process.env.DEFAULT_AI_MODEL || "");
|
|
1184
|
-
push("nvidia", "https://integrate.api.nvidia.com/v1", process.env.NVIDIA_API_KEY || "", process.env.NVIDIA_MODEL || "nvidia/nemotron-3-ultra-550b-a55b:free");
|
|
1185
|
-
push("openrouter", "https://openrouter.ai/api/v1", process.env.OPENROUTER_API_KEY || "", process.env.OPENROUTER_MODEL || "nvidia/nemotron-3-ultra-550b-a55b:free");
|
|
1186
|
-
if (process.env.OPENROUTER_MODEL) {
|
|
1187
|
-
const or = chain.find((c) => c.label === "openrouter");
|
|
1188
|
-
if (or) {
|
|
1189
|
-
const rest = chain.filter((c) => c !== or);
|
|
1190
|
-
return [or, ...rest];
|
|
1191
|
-
}
|
|
1192
|
-
}
|
|
1193
|
-
return chain;
|
|
1194
|
-
}
|
|
1195
|
-
var MIN_REQUEST_INTERVAL_MS = 2500;
|
|
1196
|
-
var lastRequestAt = 0;
|
|
1197
|
-
async function pacedDelay() {
|
|
1198
|
-
const wait = lastRequestAt + MIN_REQUEST_INTERVAL_MS - Date.now();
|
|
1199
|
-
if (wait > 0) await new Promise((resolve2) => setTimeout(resolve2, wait));
|
|
1200
|
-
lastRequestAt = Date.now();
|
|
1201
|
-
}
|
|
1202
|
-
function createLlmClient(config) {
|
|
1203
|
-
const chain = resolveProviderChain(config);
|
|
1204
|
-
const primary = chain[0];
|
|
1205
|
-
if (!primary) {
|
|
1206
|
-
throw new LlmClientError(
|
|
1207
|
-
"No LLM provider configured. Set NVIDIA_API_KEY (primary) and/or OPENROUTER_API_KEY (fallback), or pass llmConfig."
|
|
1208
|
-
);
|
|
1209
|
-
}
|
|
1210
|
-
primary.apiKey;
|
|
1211
|
-
primary.baseUrl;
|
|
1212
|
-
const model = primary.model;
|
|
594
|
+
const callLog = [];
|
|
1213
595
|
async function chat(messages, options) {
|
|
1214
596
|
const temperature = options?.temperature ?? config?.temperature ?? 0.1;
|
|
1215
597
|
const maxTokens = options?.maxTokens ?? config?.maxTokens ?? parseInt(process.env.LLM_MAX_TOKENS || "65536", 10);
|
|
@@ -1224,6 +606,7 @@ function createLlmClient(config) {
|
|
|
1224
606
|
await new Promise((resolve2) => setTimeout(resolve2, 2e4));
|
|
1225
607
|
}
|
|
1226
608
|
for (const provider of chain) {
|
|
609
|
+
const recIdx = callLog.push({ provider: provider.label, model: provider.model, ok: false }) - 1;
|
|
1227
610
|
try {
|
|
1228
611
|
const headers = {
|
|
1229
612
|
"Content-Type": "application/json",
|
|
@@ -1284,6 +667,7 @@ function createLlmClient(config) {
|
|
|
1284
667
|
const retryData = await retry.json();
|
|
1285
668
|
const retryContent2 = retryData.choices?.[0]?.message?.content || "";
|
|
1286
669
|
if (!retryContent2.trim()) throw err;
|
|
670
|
+
callLog[recIdx] = { ...callLog[recIdx], ok: true };
|
|
1287
671
|
return {
|
|
1288
672
|
content: retryContent2,
|
|
1289
673
|
model: retryData.model || provider.model,
|
|
@@ -1312,6 +696,7 @@ function createLlmClient(config) {
|
|
|
1312
696
|
if (!retry.ok) throw err;
|
|
1313
697
|
const retryData = await retry.json();
|
|
1314
698
|
const retryContent = retryData.choices?.[0]?.message?.content || "";
|
|
699
|
+
callLog[recIdx] = { ...callLog[recIdx], ok: true };
|
|
1315
700
|
return {
|
|
1316
701
|
content: retryContent,
|
|
1317
702
|
model: retryData.model || provider.model,
|
|
@@ -1342,6 +727,7 @@ function createLlmClient(config) {
|
|
|
1342
727
|
};
|
|
1343
728
|
} catch (err) {
|
|
1344
729
|
const msg = err instanceof Error ? err.message : String(err);
|
|
730
|
+
callLog[recIdx] = { ...callLog[recIdx], ok: false, error: msg.slice(0, 200) };
|
|
1345
731
|
attempts.push(`${provider.label}: ${msg}`);
|
|
1346
732
|
lastError = err instanceof LlmClientError ? err : new LlmClientError(`[${provider.label}] ${msg}`);
|
|
1347
733
|
}
|
|
@@ -1351,7 +737,7 @@ function createLlmClient(config) {
|
|
|
1351
737
|
}
|
|
1352
738
|
throw lastError ?? new LlmClientError("All LLM providers failed after " + MAX_CHAIN_ROUNDS + " rounds.");
|
|
1353
739
|
}
|
|
1354
|
-
return { chat, model };
|
|
740
|
+
return { chat, model, callLog };
|
|
1355
741
|
}
|
|
1356
742
|
function parseJsonFromLlm(text) {
|
|
1357
743
|
let cleaned = text.trim();
|
|
@@ -1415,8 +801,8 @@ async function llmChatJson(client, systemPrompt, userPrompt, options) {
|
|
|
1415
801
|
|
|
1416
802
|
// src/graph/scaffoldExtractor.ts
|
|
1417
803
|
async function extractScaffold(options) {
|
|
1418
|
-
const { goal, techStack, fileList, llmConfig } = options;
|
|
1419
|
-
const client = createLlmClient(llmConfig);
|
|
804
|
+
const { goal, techStack, fileList, llmConfig, sharedClient } = options;
|
|
805
|
+
const client = sharedClient ?? createLlmClient(llmConfig);
|
|
1420
806
|
const fileListStr = fileList.length > 0 ? fileList.join("\n") : "(empty)";
|
|
1421
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.";
|
|
1422
808
|
const userPrompt = `App goal: ${goal}
|
|
@@ -1501,8 +887,8 @@ ${content}`);
|
|
|
1501
887
|
return blocks.join("\n\n");
|
|
1502
888
|
}
|
|
1503
889
|
async function extractProjectOverview(options) {
|
|
1504
|
-
const { goal, techStack, sdkApiIndex, fileContentsMap, astKeywords, llmConfig } = options;
|
|
1505
|
-
const client = createLlmClient(llmConfig);
|
|
890
|
+
const { goal, techStack, sdkApiIndex, fileContentsMap, astKeywords, llmConfig, sharedClient } = options;
|
|
891
|
+
const client = sharedClient ?? createLlmClient(llmConfig);
|
|
1506
892
|
const usedSdkApis = sdkApiIndex.sdk_apis.filter((api) => api.used_in_demo).map((api) => api.name).slice(0, 2e3);
|
|
1507
893
|
const allFilesStr = buildSourceText(fileContentsMap);
|
|
1508
894
|
let keywordsAnchor = "";
|
|
@@ -1571,8 +957,8 @@ ${content}`);
|
|
|
1571
957
|
return blocks.join("\n\n");
|
|
1572
958
|
}
|
|
1573
959
|
async function extractFeatureSteps(options) {
|
|
1574
|
-
const { goal, techStack, sdkApiIndex, fileContentsMap, feature, llmConfig } = options;
|
|
1575
|
-
const client = createLlmClient(llmConfig);
|
|
960
|
+
const { goal, techStack, sdkApiIndex, fileContentsMap, feature, llmConfig, sharedClient } = options;
|
|
961
|
+
const client = sharedClient ?? createLlmClient(llmConfig);
|
|
1576
962
|
const usedSdkApis = sdkApiIndex.sdk_apis.filter((api) => api.used_in_demo).map((api) => api.name).slice(0, 2e3);
|
|
1577
963
|
const allFilesStr = buildSourceText2(fileContentsMap);
|
|
1578
964
|
const fid = feature.id || "F_UNK";
|
|
@@ -1639,8 +1025,8 @@ Split this feature into 2-6 action steps. Return JSON:
|
|
|
1639
1025
|
concept_codes: []
|
|
1640
1026
|
}));
|
|
1641
1027
|
}
|
|
1642
|
-
async function extractFeatureStepsBatched(goal, techStack, sdkApiIndex, fileContentsMap, featuresMeta, llmConfig) {
|
|
1643
|
-
const client = createLlmClient(llmConfig);
|
|
1028
|
+
async function extractFeatureStepsBatched(goal, techStack, sdkApiIndex, fileContentsMap, featuresMeta, llmConfig, sharedClient) {
|
|
1029
|
+
const client = sharedClient ?? createLlmClient(llmConfig);
|
|
1644
1030
|
const usedSdkApis = sdkApiIndex.sdk_apis.filter((api) => api.used_in_demo).map((api) => api.name).slice(0, 2e3);
|
|
1645
1031
|
const sourceText = buildSourceText2(fileContentsMap);
|
|
1646
1032
|
const featuresListing = featuresMeta.map(
|
|
@@ -1907,7 +1293,7 @@ function inferDepthFromContext(keyword, fileContentsMap) {
|
|
|
1907
1293
|
return { depth: "sio", rationale: `Default: treating '${keyword}' as SIO (implementation-level)` };
|
|
1908
1294
|
}
|
|
1909
1295
|
async function escalateAndMapConcepts(options) {
|
|
1910
|
-
const { projectGraph, fileContentsMap, availableConcepts, conceptMapOverride, llmConfig } = options;
|
|
1296
|
+
const { projectGraph, fileContentsMap, availableConcepts, conceptMapOverride, llmConfig, sharedClient } = options;
|
|
1911
1297
|
const features = projectGraph.features || [];
|
|
1912
1298
|
const allTerms = /* @__PURE__ */ new Set();
|
|
1913
1299
|
for (const feature of features) {
|
|
@@ -1923,7 +1309,7 @@ async function escalateAndMapConcepts(options) {
|
|
|
1923
1309
|
llmResults = conceptMapOverride;
|
|
1924
1310
|
} else {
|
|
1925
1311
|
try {
|
|
1926
|
-
const client = createLlmClient(llmConfig);
|
|
1312
|
+
const client = sharedClient ?? createLlmClient(llmConfig);
|
|
1927
1313
|
const conceptBank = (availableConcepts || []).sort().join(", ") || "(empty)";
|
|
1928
1314
|
const termEntries = termList.map((term) => {
|
|
1929
1315
|
const snippets = extractTermSnippets(term, fileContentsMap);
|
|
@@ -1976,13 +1362,16 @@ Return JSON: {"results": {"<keyword>": {"concept_code": "...", "concept_name": "
|
|
|
1976
1362
|
const cName = info.concept_name || kw;
|
|
1977
1363
|
let depth = "sio";
|
|
1978
1364
|
let depthRationale = "Default: SIO";
|
|
1365
|
+
let depthSource = "override";
|
|
1979
1366
|
if (info.depth && ["ulo", "cio", "sio"].includes(info.depth)) {
|
|
1980
1367
|
depth = info.depth;
|
|
1981
1368
|
depthRationale = `LLM classified as ${depth}`;
|
|
1369
|
+
depthSource = "llm";
|
|
1982
1370
|
} else {
|
|
1983
1371
|
const inferred = inferDepthFromContext(kw, fileContentsMap);
|
|
1984
1372
|
depth = inferred.depth;
|
|
1985
1373
|
depthRationale = inferred.rationale;
|
|
1374
|
+
depthSource = "heuristic";
|
|
1986
1375
|
}
|
|
1987
1376
|
const kwKey = `${cCode}::${kw}`;
|
|
1988
1377
|
if (seen.has(kwKey)) continue;
|
|
@@ -2000,6 +1389,7 @@ Return JSON: {"results": {"<keyword>": {"concept_code": "...", "concept_name": "
|
|
|
2000
1389
|
keyword: kw,
|
|
2001
1390
|
depth,
|
|
2002
1391
|
depth_rationale: depthRationale,
|
|
1392
|
+
depth_source: depthSource,
|
|
2003
1393
|
evidence_files: evidenceFiles
|
|
2004
1394
|
});
|
|
2005
1395
|
}
|
|
@@ -2367,11 +1757,11 @@ function assembleRoadmap(options) {
|
|
|
2367
1757
|
}
|
|
2368
1758
|
|
|
2369
1759
|
// src/extractors/fileSelector.ts
|
|
2370
|
-
async function selectFilesWithLlm(filePaths, goal, techStack, llmConfig) {
|
|
1760
|
+
async function selectFilesWithLlm(filePaths, goal, techStack, llmConfig, sharedClient) {
|
|
2371
1761
|
if (filePaths.length <= 15) {
|
|
2372
1762
|
return { selected: filePaths, reasoning: "Too few files to filter" };
|
|
2373
1763
|
}
|
|
2374
|
-
const client = createLlmClient(llmConfig);
|
|
1764
|
+
const client = sharedClient ?? createLlmClient(llmConfig);
|
|
2375
1765
|
const treeLines = buildFileTree(filePaths);
|
|
2376
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"}';
|
|
2377
1767
|
const userPrompt = `Project goal: ${goal}
|
|
@@ -2533,6 +1923,7 @@ function detectPrimaryLanguage(filePaths) {
|
|
|
2533
1923
|
}
|
|
2534
1924
|
async function runProjectGraphPipeline(options) {
|
|
2535
1925
|
const { repoDir, goal, techStack, llmConfig, embeddings, onProgress } = options;
|
|
1926
|
+
const client = createLlmClient(llmConfig);
|
|
2536
1927
|
const log = (step, msg) => {
|
|
2537
1928
|
console.log(`[${step}] ${msg}`);
|
|
2538
1929
|
onProgress?.(step, msg);
|
|
@@ -2546,7 +1937,8 @@ async function runProjectGraphPipeline(options) {
|
|
|
2546
1937
|
allRelPaths,
|
|
2547
1938
|
goal,
|
|
2548
1939
|
techStack,
|
|
2549
|
-
llmConfig
|
|
1940
|
+
llmConfig,
|
|
1941
|
+
client
|
|
2550
1942
|
);
|
|
2551
1943
|
log("STEP_1", `LLM selected ${selectedPaths.length}/${allRelPaths.length} files: ${reasoning.slice(0, 100)}`);
|
|
2552
1944
|
log("STEP_1", "Reading content for selected files...");
|
|
@@ -2595,7 +1987,8 @@ async function runProjectGraphPipeline(options) {
|
|
|
2595
1987
|
goal,
|
|
2596
1988
|
techStack,
|
|
2597
1989
|
fileList: filePaths,
|
|
2598
|
-
llmConfig
|
|
1990
|
+
llmConfig,
|
|
1991
|
+
sharedClient: client
|
|
2599
1992
|
});
|
|
2600
1993
|
log("STEP_4_C1", "Extracting project overview (features meta)...");
|
|
2601
1994
|
const overview = await extractProjectOverview({
|
|
@@ -2604,7 +1997,8 @@ async function runProjectGraphPipeline(options) {
|
|
|
2604
1997
|
sdkApiIndex,
|
|
2605
1998
|
fileContentsMap,
|
|
2606
1999
|
astKeywords,
|
|
2607
|
-
llmConfig
|
|
2000
|
+
llmConfig,
|
|
2001
|
+
sharedClient: client
|
|
2608
2002
|
});
|
|
2609
2003
|
const product = overview.product;
|
|
2610
2004
|
const pblStep = {
|
|
@@ -2657,7 +2051,8 @@ async function runProjectGraphPipeline(options) {
|
|
|
2657
2051
|
sdkApiIndex,
|
|
2658
2052
|
fileContentsMap,
|
|
2659
2053
|
nonF0Features,
|
|
2660
|
-
llmConfig
|
|
2054
|
+
llmConfig,
|
|
2055
|
+
client
|
|
2661
2056
|
);
|
|
2662
2057
|
for (const feature of nonF0Features) {
|
|
2663
2058
|
const steps = batchedSteps.get(feature.id || "");
|
|
@@ -2673,7 +2068,8 @@ async function runProjectGraphPipeline(options) {
|
|
|
2673
2068
|
sdkApiIndex,
|
|
2674
2069
|
fileContentsMap,
|
|
2675
2070
|
feature,
|
|
2676
|
-
llmConfig
|
|
2071
|
+
llmConfig,
|
|
2072
|
+
sharedClient: client
|
|
2677
2073
|
});
|
|
2678
2074
|
feature.steps = steps;
|
|
2679
2075
|
} catch (e) {
|
|
@@ -2726,7 +2122,8 @@ async function runProjectGraphPipeline(options) {
|
|
|
2726
2122
|
const featureConcepts = await escalateAndMapConcepts({
|
|
2727
2123
|
projectGraph: verifiedGraph,
|
|
2728
2124
|
fileContentsMap,
|
|
2729
|
-
llmConfig
|
|
2125
|
+
llmConfig,
|
|
2126
|
+
sharedClient: client
|
|
2730
2127
|
});
|
|
2731
2128
|
log("STEP_6", `Concept mapping complete: ${featureConcepts.size} features mapped`);
|
|
2732
2129
|
for (const feature of verifiedGraph.features || []) {
|
|
@@ -2793,7 +2190,8 @@ async function runProjectGraphPipeline(options) {
|
|
|
2793
2190
|
roadmap,
|
|
2794
2191
|
hallucinations,
|
|
2795
2192
|
keywords: astKeywords,
|
|
2796
|
-
featureConcepts
|
|
2193
|
+
featureConcepts,
|
|
2194
|
+
provenance: client.callLog
|
|
2797
2195
|
};
|
|
2798
2196
|
}
|
|
2799
2197
|
|
|
@@ -2913,6 +2311,13 @@ var ULO_CODE_RE = /^ULO-[A-Z][A-Z0-9_]*-\d{2}$/;
|
|
|
2913
2311
|
var CIO_CODE_RE = /^CIO-[A-Z][A-Z0-9_]*-\d{2}-[A-Z][A-Z0-9_]*$/;
|
|
2914
2312
|
var SIO_CODE_RE = /^SIO-[A-Z0-9]+-[A-Z][A-Z0-9_]*-\d{2}$/;
|
|
2915
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
|
+
|
|
2916
2321
|
// src/parsers/syllabusParser.ts
|
|
2917
2322
|
var STOP_WORDS = /* @__PURE__ */ new Set([
|
|
2918
2323
|
"and",
|
|
@@ -3370,856 +2775,1664 @@ function auditDepthLayers(concepts) {
|
|
|
3370
2775
|
};
|
|
3371
2776
|
});
|
|
3372
2777
|
}
|
|
3373
|
-
|
|
3374
|
-
// src/graph/hierarchicalLoAuthoring.ts
|
|
3375
|
-
function excerpt(text, max) {
|
|
3376
|
-
const t = (text || "").trim();
|
|
3377
|
-
return t.length > max ? t.slice(0, max) + "..." : t;
|
|
2778
|
+
|
|
2779
|
+
// src/graph/hierarchicalLoAuthoring.ts
|
|
2780
|
+
function excerpt(text, max) {
|
|
2781
|
+
const t = (text || "").trim();
|
|
2782
|
+
return t.length > max ? t.slice(0, max) + "..." : t;
|
|
2783
|
+
}
|
|
2784
|
+
async function chatJson(client, system, user) {
|
|
2785
|
+
const r = await client.chat(
|
|
2786
|
+
[{ role: "system", content: system }, { role: "user", content: user }],
|
|
2787
|
+
{ temperature: 0.1, maxTokens: parseInt(process.env.KG_MAX_TOKENS || "65536", 10) }
|
|
2788
|
+
);
|
|
2789
|
+
return parseJsonFromLlm(r.content);
|
|
2790
|
+
}
|
|
2791
|
+
var LO_BATCH_SIZE = 6;
|
|
2792
|
+
function chunkConcepts(items, size) {
|
|
2793
|
+
const out = [];
|
|
2794
|
+
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
|
|
2795
|
+
return out;
|
|
2796
|
+
}
|
|
2797
|
+
var JSON_ONLY = "Return EXACTLY JSON. No markdown, no explanations.";
|
|
2798
|
+
async function authorDepthLayersHierarchically(client, concepts, techStack, warnings) {
|
|
2799
|
+
const uloByConcept = {};
|
|
2800
|
+
const cioByConcept = {};
|
|
2801
|
+
const sioByConcept = {};
|
|
2802
|
+
concepts.map((c) => "- " + c.id + ": " + c.name + (c.description ? " \u2014 " + excerpt(c.description, 160) : "")).join("\n");
|
|
2803
|
+
const uloSystem = [
|
|
2804
|
+
"You are a Curriculum Design Expert writing Universal Learning Objectives (ULO).",
|
|
2805
|
+
"",
|
|
2806
|
+
"ULO = Computational Tier: the CORE COMPETENCY \u2014 WHAT the concept is and WHY it exists \u2014",
|
|
2807
|
+
"completely independent of any technology, language, framework, library, file format or tool.",
|
|
2808
|
+
"",
|
|
2809
|
+
"Rules:",
|
|
2810
|
+
"1. NEVER mention a concrete technology, API, library, file name or syntax in a ULO.",
|
|
2811
|
+
"2. Bind the ULO to the concept intrinsic nature: the problem it solves in principle.",
|
|
2812
|
+
"3. Typical assessment level: Remember or Understand. Use pragmatic, measurable verbs",
|
|
2813
|
+
" (Explain, Differentiate, Identify, Describe) instead of Know/Understand/Summarize.",
|
|
2814
|
+
"4. Cognitive Ceiling: do NOT force Analyze/Evaluate/Create ULOs \u2014 if the concept is",
|
|
2815
|
+
" foundational, its core competency is low-level. Higher levels are exercised later",
|
|
2816
|
+
" INDIRECTLY through implementation work, not in the ULO itself.",
|
|
2817
|
+
"5. One ULO per concept; 1-2 sentences covering WHAT + WHY.",
|
|
2818
|
+
"6. Statement form (mandatory): every ULO MUST read 'The learner will be able to ...' \u2014",
|
|
2819
|
+
" a lowercase actionable verb follows the clause (explain ..., differentiate ...).",
|
|
2820
|
+
" Do not prefix any other phrasing.",
|
|
2821
|
+
"",
|
|
2822
|
+
"Approved style exemplars (production bank):",
|
|
2823
|
+
ULO_EXEMPLARS.map((e) => "- " + e).join("\n"),
|
|
2824
|
+
"",
|
|
2825
|
+
JSON_ONLY
|
|
2826
|
+
].join("\n");
|
|
2827
|
+
for (const batch of chunkConcepts(concepts, LO_BATCH_SIZE)) {
|
|
2828
|
+
const batchList = batch.map((c) => "- " + c.id + " (" + c.name + ")" + (c.description ? ": " + excerpt(c.description, 160) : "")).join("\n");
|
|
2829
|
+
const batchUser = "Concepts:\n" + batchList + '\n\nReturn JSON:\n{"ulos":[{"concept_id":"C1","ulo":"..."}]}';
|
|
2830
|
+
const uloRes = await chatJson(client, uloSystem, batchUser);
|
|
2831
|
+
for (const u of uloRes.ulos ?? []) {
|
|
2832
|
+
if (u.concept_id && u.ulo) uloByConcept[u.concept_id] = standardStatement(u.ulo);
|
|
2833
|
+
}
|
|
2834
|
+
}
|
|
2835
|
+
for (const c of concepts) {
|
|
2836
|
+
if (!uloByConcept[c.id]) {
|
|
2837
|
+
warnings.push("Phase A: no ULO generated for " + c.id + " \u2014 kept empty (audit will fail)");
|
|
2838
|
+
uloByConcept[c.id] = "";
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
concepts.map((c) => "- " + c.id + " (" + c.name + "): " + uloByConcept[c.id]).join("\n");
|
|
2842
|
+
const cioSystem = [
|
|
2843
|
+
"You are a Curriculum Design Expert writing Concept-level Instructional Objectives (CIO).",
|
|
2844
|
+
"",
|
|
2845
|
+
"CIO = Algorithmic Tier: the MECHANISM \u2014 HOW the concept works, step by step, at a",
|
|
2846
|
+
"procedural level. CIOs are derived from approved ULOs.",
|
|
2847
|
+
"",
|
|
2848
|
+
"Rules:",
|
|
2849
|
+
"1. Still technology-agnostic: describe the neutral pattern/approach WITHOUT concrete",
|
|
2850
|
+
" API names, libraries, file formats or syntax.",
|
|
2851
|
+
"2. Marr 2-Language Test: the CIO must read as true and implementable regardless of",
|
|
2852
|
+
" whether the learner uses Python, Swift, JavaScript or any other language. If it",
|
|
2853
|
+
" only makes sense in one language, rewrite it more neutrally.",
|
|
2854
|
+
"3. More concrete than the ULO: reference stages, conditions, ordering or trade-offs",
|
|
2855
|
+
" of the underlying process.",
|
|
2856
|
+
"4. One CIO per concept; 1-3 sentences describing the mechanism.",
|
|
2857
|
+
"5. Statement form (mandatory): every CIO MUST read 'The learner will be able to ...' \u2014",
|
|
2858
|
+
" the mechanism is described after the clause.",
|
|
2859
|
+
"",
|
|
2860
|
+
"Approved style exemplars (production bank):",
|
|
2861
|
+
CIO_EXEMPLARS.map((e) => "- " + e).join("\n"),
|
|
2862
|
+
"",
|
|
2863
|
+
JSON_ONLY
|
|
2864
|
+
].join("\n");
|
|
2865
|
+
for (const batch of chunkConcepts(concepts, LO_BATCH_SIZE)) {
|
|
2866
|
+
new Set(batch.map((c) => c.id));
|
|
2867
|
+
const batchUloContext = batch.map((c) => "- " + c.id + " (" + c.name + "): " + (uloByConcept[c.id] || "(missing)")).join("\n");
|
|
2868
|
+
const batchCioUser = "Approved ULOs:\n" + batchUloContext + '\n\nReturn JSON:\n{"cios":[{"concept_id":"C1","cio":"..."}]}';
|
|
2869
|
+
const cioRes = await chatJson(client, cioSystem, batchCioUser);
|
|
2870
|
+
for (const x of cioRes.cios ?? []) {
|
|
2871
|
+
if (x.concept_id && x.cio) cioByConcept[x.concept_id] = standardStatement(x.cio);
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2874
|
+
for (const c of concepts) {
|
|
2875
|
+
if (!cioByConcept[c.id]) {
|
|
2876
|
+
warnings.push("Phase B: no CIO generated for " + c.id + " \u2014 kept empty (audit will fail)");
|
|
2877
|
+
cioByConcept[c.id] = "";
|
|
2878
|
+
}
|
|
2879
|
+
}
|
|
2880
|
+
concepts.map((c) => "- " + c.id + " (" + c.name + "): CIO=" + cioByConcept[c.id] + " | ULO=" + uloByConcept[c.id]).join("\n");
|
|
2881
|
+
const sioSystem = [
|
|
2882
|
+
"You are a Curriculum Design Expert writing Specific Instructional Objectives (SIO).",
|
|
2883
|
+
"",
|
|
2884
|
+
"SIO = Implementation Tier: completable, context-specific implementation tasks derived",
|
|
2885
|
+
"from approved CIOs, bound to the TARGET technology of the course.",
|
|
2886
|
+
"",
|
|
2887
|
+
"Rules:",
|
|
2888
|
+
"1. EVERY SIO item MUST name concrete keywords/technologies: a real API, function,",
|
|
2889
|
+
" decorator, module, class, file or command of the target technology.",
|
|
2890
|
+
"2. Each item is a completable task: an actionable verb + the concrete construct + the",
|
|
2891
|
+
" context where it is used. Application/Analysis/Creation skills are assessed",
|
|
2892
|
+
" INDIRECTLY through completing these tasks \u2014 there is no fixed Bloom ceiling.",
|
|
2893
|
+
" MACHINE CHECK: every item MUST contain at least one ACTION VERB from this whitelist",
|
|
2894
|
+
" (build, create, implement, write, configure, add, use, apply, display, store, load,",
|
|
2895
|
+
" save, handle, parse, validate, test, register, compute, generate, print, format,",
|
|
2896
|
+
" convert, extend, define, execute, run, read, fetch, wrap, log, call, raise, install,",
|
|
2897
|
+
" import, export, declare, embed, model, represent, override, customize, enforce, mock,",
|
|
2898
|
+
" assert, query, index, cache, stream, split, join, strip, encode, decode, traverse,",
|
|
2899
|
+
" measure, evaluate, benchmark, diagnose, demonstrate, verify, organize, structure,",
|
|
2900
|
+
" choose, select, compare, distinguish, attach, annotate, expose, accept, manage, group,",
|
|
2901
|
+
" sort, filter, map, reduce, catch, throw, migrate, inspect, trace, parameterize,",
|
|
2902
|
+
" construct, design, capture) \u2014 OR wrap the concrete construct in backticks.",
|
|
2903
|
+
" Items with abstract verbs (understand, learn, know, grasp, appreciate, explore) and",
|
|
2904
|
+
" no backtick construct FAIL the audit. Prefer starting the task phrase with the verb.",
|
|
2905
|
+
"3. 2-3 items per concept, each referencing a DIFFERENT concrete construct.",
|
|
2906
|
+
"4. Statement form (mandatory): every SIO MUST read 'The learner will be able to ...' \u2014",
|
|
2907
|
+
" the concrete task follows the clause.",
|
|
2908
|
+
"",
|
|
2909
|
+
"Approved style exemplars (production bank):",
|
|
2910
|
+
SIO_EXEMPLARS.map((e) => "- " + e).join("\n"),
|
|
2911
|
+
"",
|
|
2912
|
+
JSON_ONLY
|
|
2913
|
+
].join("\n");
|
|
2914
|
+
concepts.map((c) => "- " + c.id + " (" + c.name + "): " + ((c.keywords ?? []).slice(0, 10).join(", ") || "(none listed \u2014 use concrete constructs of " + techStack + ")")).join("\n");
|
|
2915
|
+
for (const batch of chunkConcepts(concepts, LO_BATCH_SIZE)) {
|
|
2916
|
+
const batchKw = batch.map((c) => "- " + c.id + " (" + c.name + "): " + ((c.keywords ?? []).slice(0, 10).join(", ") || "(none listed \u2014 use concrete constructs of " + techStack + ")")).join("\n");
|
|
2917
|
+
const batchCioCtx = batch.map((c) => "- " + c.id + " (" + c.name + "): CIO=" + (cioByConcept[c.id] || "(missing)") + " | ULO=" + (uloByConcept[c.id] || "(missing)")).join("\n");
|
|
2918
|
+
const batchSioUser = [
|
|
2919
|
+
"Target technology: " + techStack,
|
|
2920
|
+
"",
|
|
2921
|
+
"Concept keywords (each SIO item MUST contain at least one of its concept's keywords):",
|
|
2922
|
+
batchKw,
|
|
2923
|
+
"",
|
|
2924
|
+
"Approved CIOs and ULOs:",
|
|
2925
|
+
batchCioCtx,
|
|
2926
|
+
"",
|
|
2927
|
+
"Return JSON:",
|
|
2928
|
+
'{"sios":[{"concept_id":"C1","sio":["...","..."]}]}'
|
|
2929
|
+
].join("\n");
|
|
2930
|
+
const sioRes = await chatJson(client, sioSystem, batchSioUser);
|
|
2931
|
+
for (const x of sioRes.sios ?? []) {
|
|
2932
|
+
if (x.concept_id && Array.isArray(x.sio)) sioByConcept[x.concept_id] = x.sio.map((item) => standardStatement(String(item)));
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2935
|
+
for (const c of concepts) {
|
|
2936
|
+
if (!sioByConcept[c.id] || sioByConcept[c.id].length === 0) {
|
|
2937
|
+
warnings.push("Phase C: no SIO generated for " + c.id + " \u2014 kept empty (audit will fail)");
|
|
2938
|
+
sioByConcept[c.id] = [];
|
|
2939
|
+
}
|
|
2940
|
+
}
|
|
2941
|
+
concepts.map((c) => c.id);
|
|
2942
|
+
const auditOnce = () => auditDepthLayers(concepts.map((c) => ({
|
|
2943
|
+
id: c.id,
|
|
2944
|
+
name: c.name,
|
|
2945
|
+
keywords: c.keywords ?? [],
|
|
2946
|
+
ulo: uloByConcept[c.id],
|
|
2947
|
+
cio: cioByConcept[c.id],
|
|
2948
|
+
sio: sioByConcept[c.id]
|
|
2949
|
+
})));
|
|
2950
|
+
let audits = auditOnce();
|
|
2951
|
+
const repairRound = async (round) => {
|
|
2952
|
+
const failingNow = audits.filter((a) => !a.passed);
|
|
2953
|
+
if (failingNow.length === 0) return false;
|
|
2954
|
+
if (round > 1) warnings.push("Depth audit repair round " + round + ": " + failingNow.length + " concept(s) still failing");
|
|
2955
|
+
const failing = failingNow;
|
|
2956
|
+
{
|
|
2957
|
+
warnings.push("Depth audit: " + failing.length + "/" + concepts.length + " concepts failed first audit \u2014 targeted repair");
|
|
2958
|
+
const repairTargets = failing.map((a) => {
|
|
2959
|
+
const c = concepts.find((x) => x.id === a.conceptId);
|
|
2960
|
+
const issues = [];
|
|
2961
|
+
if (a.uloHasTechSignals) issues.push("ULO names concrete technology (remove: " + a.uloTechSignals.join(", ") + ")");
|
|
2962
|
+
if (a.uloHasImplementationVerbs) issues.push("ULO reads as an implementation task (rewrite as WHAT+WHY competency)");
|
|
2963
|
+
for (const item of a.sioUnboundItems) issues.push("SIO not keyword-bound: " + excerpt(item, 120));
|
|
2964
|
+
for (const item of a.sioOffConceptItems) issues.push("SIO does not reference any of this concept's keywords (" + (c?.keywords ?? []).join(", ") + "): " + excerpt(item, 120));
|
|
2965
|
+
for (const item of a.sioNotCompletable) issues.push("SIO not a completable task: " + excerpt(item, 120));
|
|
2966
|
+
if (!a.concretenessIncreases) issues.push("SIO is not more concrete than the ULO \u2014 bind it to concrete constructs");
|
|
2967
|
+
return {
|
|
2968
|
+
concept_id: a.conceptId,
|
|
2969
|
+
name: c?.name ?? a.conceptId,
|
|
2970
|
+
keywords: c?.keywords ?? [],
|
|
2971
|
+
current: { ulo: uloByConcept[a.conceptId], cio: cioByConcept[a.conceptId], sio: sioByConcept[a.conceptId] },
|
|
2972
|
+
issues
|
|
2973
|
+
};
|
|
2974
|
+
});
|
|
2975
|
+
const repairSystem = [
|
|
2976
|
+
"You repair depth-layer learning objectives that failed a deterministic contract audit.",
|
|
2977
|
+
"For each concept, rewrite ONLY the layers mentioned in issues, keeping the others as-is.",
|
|
2978
|
+
"Contract:",
|
|
2979
|
+
"- ULO: WHAT+WHY, concept-bound, technology-agnostic, Remember/Understand verbs.",
|
|
2980
|
+
"- CIO: HOW mechanism, technology-agnostic, more concrete than ULO, Marr 2-Language neutral.",
|
|
2981
|
+
"- SIO: keyword-bound completable implementation tasks naming real constructs of " + techStack + ".",
|
|
2982
|
+
"- BINDING RULE: every SIO item must contain at least one of the concept's listed keywords VERBATIM (copy the exact keyword text into the sentence).",
|
|
2983
|
+
"- COMPLETABILITY RULE: every SIO item must contain an actionable verb (build, create, implement, write, configure, parse, validate, handle, store, load, run, read, fetch, display, generate, format, convert, test, debug, trace, measure, design, structure, select, filter, sort, map, reduce, catch, throw, migrate, deploy, query, index, cache, stream, inspect) or wrap a concrete construct in backticks \u2014 abstract verbs (understand, learn, know, grasp, explore, appreciate) always fail.",
|
|
2984
|
+
"",
|
|
2985
|
+
JSON_ONLY
|
|
2986
|
+
].join("\n");
|
|
2987
|
+
const repairUser = [
|
|
2988
|
+
"Failing concepts:",
|
|
2989
|
+
JSON.stringify(repairTargets, null, 2),
|
|
2990
|
+
"",
|
|
2991
|
+
"Return the FULL repaired set for these concepts in the same 3-layer JSON shape:",
|
|
2992
|
+
'{"repairs":[{"concept_id":"C1","ulo":"...","cio":"...","sio":["..."]}]}'
|
|
2993
|
+
].join("\n");
|
|
2994
|
+
try {
|
|
2995
|
+
const repairRes = await chatJson(client, repairSystem, repairUser);
|
|
2996
|
+
for (const r of repairRes.repairs ?? []) {
|
|
2997
|
+
if (!r.concept_id) continue;
|
|
2998
|
+
if (r.ulo) uloByConcept[r.concept_id] = standardStatement(r.ulo);
|
|
2999
|
+
if (r.cio) cioByConcept[r.concept_id] = standardStatement(r.cio);
|
|
3000
|
+
if (Array.isArray(r.sio) && r.sio.length > 0) sioByConcept[r.concept_id] = r.sio.map((t) => standardStatement(String(t)));
|
|
3001
|
+
}
|
|
3002
|
+
audits = auditOnce();
|
|
3003
|
+
} catch (err) {
|
|
3004
|
+
warnings.push("Depth repair round failed: " + (err instanceof Error ? err.message : String(err)));
|
|
3005
|
+
}
|
|
3006
|
+
}
|
|
3007
|
+
return audits.every((a) => a.passed);
|
|
3008
|
+
};
|
|
3009
|
+
for (let round = 1; round <= 2; round++) {
|
|
3010
|
+
const allPass = await repairRound(round);
|
|
3011
|
+
if (allPass) break;
|
|
3012
|
+
}
|
|
3013
|
+
const stillFailing = audits.filter((a) => !a.passed);
|
|
3014
|
+
if (stillFailing.length > 0) {
|
|
3015
|
+
try {
|
|
3016
|
+
const { mkdirSync: mkdirSync3, writeFileSync: writeFileSync3 } = await import('fs');
|
|
3017
|
+
const dumpDir = (await import('os')).tmpdir() + "/domain-kit-debug/";
|
|
3018
|
+
mkdirSync3(dumpDir, { recursive: true });
|
|
3019
|
+
writeFileSync3(dumpDir + "depth-audit-debug.json", JSON.stringify(
|
|
3020
|
+
stillFailing.map((a) => ({ audit: a, ulo: uloByConcept[a.conceptId], cio: cioByConcept[a.conceptId], sio: sioByConcept[a.conceptId] })),
|
|
3021
|
+
null,
|
|
3022
|
+
2
|
|
3023
|
+
));
|
|
3024
|
+
} catch {
|
|
3025
|
+
}
|
|
3026
|
+
const detail = stillFailing.map((a) => a.conceptId + "[" + (a.uloHasTechSignals ? "ulo-tech " : "") + (a.uloHasImplementationVerbs ? "ulo-impl " : "") + (a.sioUnboundItems.length ? "sio-unbound " : "") + (a.sioNotCompletable.length ? "sio-incomplete" : "") + "]").join(", ");
|
|
3027
|
+
throw new Error(
|
|
3028
|
+
"Depth-layer contract audit failed after repair for: " + detail + " \u2014 regenerate or fix the prompt contract (fail-closed, no silent fallback)"
|
|
3029
|
+
);
|
|
3030
|
+
}
|
|
3031
|
+
return { uloByConcept, cioByConcept, sioByConcept, audits, warnings };
|
|
3032
|
+
}
|
|
3033
|
+
function cell(v) {
|
|
3034
|
+
return (v || "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
3378
3035
|
}
|
|
3379
|
-
|
|
3380
|
-
const
|
|
3381
|
-
|
|
3382
|
-
|
|
3036
|
+
function loadKnowledgeTreeCatalog(tsvPath) {
|
|
3037
|
+
const text = fs.readFileSync(tsvPath, "utf8");
|
|
3038
|
+
const lines = text.split("\n").filter((l) => l.trim().length > 0);
|
|
3039
|
+
if (lines.length === 0) throw new Error("Knowledge tree TSV is empty: " + tsvPath);
|
|
3040
|
+
const header = lines[0].split(" ").map((h) => h.trim());
|
|
3041
|
+
const col = (name) => header.indexOf(name);
|
|
3042
|
+
const iT = col("item_type");
|
|
3043
|
+
const iC = col("code");
|
|
3044
|
+
const iN = col("name");
|
|
3045
|
+
const iD = col("description");
|
|
3046
|
+
const iCat = col("category_codes");
|
|
3047
|
+
const iTpc = col("topic_codes");
|
|
3048
|
+
const iPre = col("prerequisite_concept_codes");
|
|
3049
|
+
const iKw = col("keywords");
|
|
3050
|
+
for (const [name, idx] of [["item_type", iT], ["code", iC], ["name", iN]]) {
|
|
3051
|
+
if (idx < 0) throw new Error("Knowledge tree TSV missing column: " + name);
|
|
3052
|
+
}
|
|
3053
|
+
const categories = /* @__PURE__ */ new Map();
|
|
3054
|
+
const topics = /* @__PURE__ */ new Map();
|
|
3055
|
+
const concepts = [];
|
|
3056
|
+
for (const line of lines.slice(1)) {
|
|
3057
|
+
const c = line.split(" ");
|
|
3058
|
+
const type = (c[iT] || "").trim();
|
|
3059
|
+
const code = (c[iC] || "").trim();
|
|
3060
|
+
const name = (c[iN] || "").trim();
|
|
3061
|
+
if (!code || !name) continue;
|
|
3062
|
+
if (type === "CATEGORY") categories.set(code, name);
|
|
3063
|
+
if (type === "TOPIC") topics.set(code, { name, categoryCodes: cell(c[iCat]) });
|
|
3064
|
+
if (type !== "CONCEPT") continue;
|
|
3065
|
+
const topicCodes = cell(c[iTpc]);
|
|
3066
|
+
const categoryCodes = /* @__PURE__ */ new Set();
|
|
3067
|
+
for (const t of topicCodes) for (const cc of topics.get(t)?.categoryCodes || []) categoryCodes.add(cc);
|
|
3068
|
+
concepts.push({
|
|
3069
|
+
code,
|
|
3070
|
+
name,
|
|
3071
|
+
description: (c[iD] || "").trim(),
|
|
3072
|
+
keywords: cell(c[iKw]),
|
|
3073
|
+
topicCodes,
|
|
3074
|
+
categoryCodes: [...categoryCodes],
|
|
3075
|
+
prerequisiteCodes: cell(c[iPre])
|
|
3076
|
+
});
|
|
3077
|
+
}
|
|
3078
|
+
if (concepts.length === 0) throw new Error("Knowledge tree TSV has no CONCEPT rows: " + tsvPath);
|
|
3079
|
+
return { path: tsvPath, concepts, categories, topics };
|
|
3080
|
+
}
|
|
3081
|
+
function tokens(s) {
|
|
3082
|
+
return new Set(
|
|
3083
|
+
stripDiacritics(s).replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length >= 4)
|
|
3383
3084
|
);
|
|
3384
|
-
return parseJsonFromLlm(r.content);
|
|
3385
3085
|
}
|
|
3386
|
-
|
|
3387
|
-
|
|
3086
|
+
function filterBySelection(catalog, selectedCategories, selectedTopics, analysisConcepts, maxConcepts = 120) {
|
|
3087
|
+
const tps = new Set(selectedTopics.map((c) => c.trim().toUpperCase()).filter(Boolean));
|
|
3088
|
+
const cats = new Set(selectedCategories.map((c) => c.trim().toUpperCase()).filter(Boolean));
|
|
3089
|
+
const probe = analysisConcepts.map((c) => tokens(c.name + " " + c.keywords.join(" ")));
|
|
3090
|
+
const probeAll = /* @__PURE__ */ new Set();
|
|
3091
|
+
for (const p of probe) for (const w of p) probeAll.add(w);
|
|
3092
|
+
const inSelection = (tc) => tc.topicCodes.some((t) => tps.has(t)) || tc.categoryCodes.some((c) => cats.has(c));
|
|
3093
|
+
const overlapScore = (tc) => {
|
|
3094
|
+
const tcTok = tokens(tc.name + " " + tc.description.slice(0, 300) + " " + tc.keywords.join(" "));
|
|
3095
|
+
let overlap = 0;
|
|
3096
|
+
for (const w of tcTok) if (probeAll.has(w)) overlap++;
|
|
3097
|
+
const kwDirect = tc.keywords.filter((k) => {
|
|
3098
|
+
const kl = k.toLowerCase();
|
|
3099
|
+
return [...probeAll].some((w) => w === kl || w.length >= 5 && kl.includes(w));
|
|
3100
|
+
}).length;
|
|
3101
|
+
return overlap * 2 + kwDirect * 3;
|
|
3102
|
+
};
|
|
3103
|
+
const kept = catalog.concepts.filter((tc) => inSelection(tc) || overlapScore(tc) >= 6);
|
|
3104
|
+
return kept.map((tc) => ({ tc, s: overlapScore(tc) })).sort((a, b) => b.s - a.s).slice(0, maxConcepts).map((x) => x.tc);
|
|
3105
|
+
}
|
|
3106
|
+
function renderShortlist(shortlist) {
|
|
3107
|
+
return shortlist.map((tc) => "- " + tc.code + " | " + tc.name + " | kw: " + tc.keywords.slice(0, 8).join(", ") + (tc.prerequisiteCodes.length ? " | prereqs: " + tc.prerequisiteCodes.join(",") : "")).join("\n");
|
|
3108
|
+
}
|
|
3109
|
+
function renderCategoryTopicIndex(catalog) {
|
|
3110
|
+
const topicToConcepts = /* @__PURE__ */ new Map();
|
|
3111
|
+
for (const tc of catalog.concepts) for (const t of tc.topicCodes) topicToConcepts.set(t, (topicToConcepts.get(t) || 0) + 1);
|
|
3112
|
+
const byCat = /* @__PURE__ */ new Map();
|
|
3113
|
+
for (const [code, info] of catalog.topics) {
|
|
3114
|
+
for (const cat of info.categoryCodes) {
|
|
3115
|
+
const arr2 = byCat.get(cat) || [];
|
|
3116
|
+
arr2.push(code + " " + info.name + " (" + (topicToConcepts.get(code) || 0) + " concepts)");
|
|
3117
|
+
byCat.set(cat, arr2);
|
|
3118
|
+
}
|
|
3119
|
+
}
|
|
3388
3120
|
const out = [];
|
|
3389
|
-
for (
|
|
3390
|
-
|
|
3121
|
+
for (const [code, name] of catalog.categories) {
|
|
3122
|
+
out.push("CATEGORY " + code + " \u2014 " + name);
|
|
3123
|
+
for (const t of byCat.get(code) || []) out.push(" TOPIC: " + t);
|
|
3124
|
+
}
|
|
3125
|
+
return out.join("\n");
|
|
3391
3126
|
}
|
|
3392
|
-
var
|
|
3393
|
-
|
|
3394
|
-
const
|
|
3395
|
-
const
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3127
|
+
var JSON_ONLY2 = "Return ONLY a JSON object, no markdown fences, no commentary.";
|
|
3128
|
+
function parseJsonBlock(text) {
|
|
3129
|
+
const start = text.indexOf("{");
|
|
3130
|
+
const end = text.lastIndexOf("}");
|
|
3131
|
+
if (start < 0 || end <= start) throw new Error("LLM standardization response has no JSON object");
|
|
3132
|
+
return JSON.parse(text.slice(start, end + 1));
|
|
3133
|
+
}
|
|
3134
|
+
async function standardizeConcepts(opts) {
|
|
3135
|
+
const { client, catalog, techStack = "python", proposalsPath } = opts;
|
|
3136
|
+
const log = opts.onProgress ?? (() => {
|
|
3137
|
+
});
|
|
3138
|
+
const warnings = [];
|
|
3139
|
+
const hasTopics = catalog.topics.size > 0;
|
|
3140
|
+
const selSystem = [
|
|
3141
|
+
"You pre-select relevant areas of an institutional knowledge tree before a detailed concept alignment pass.",
|
|
3142
|
+
hasTopics ? "Given the analysis concepts, pick the TOPICS that could contain matching concepts. Concepts are filed under topics, so topics are the filter dimension." : "Given the analysis concepts, pick the CATEGORIES that could contain matching concepts.",
|
|
3143
|
+
"- Be generous: missing a relevant topic corrupts the alignment; an extra topic only costs attention.",
|
|
3144
|
+
"- Only codes that appear in the index are valid.",
|
|
3145
|
+
JSON_ONLY2
|
|
3146
|
+
].join("\n");
|
|
3147
|
+
const selUser = [
|
|
3148
|
+
"ANALYSIS CONCEPTS:",
|
|
3149
|
+
opts.concepts.map((c) => "- " + c.name + ": " + c.description.slice(0, 160)).join("\n"),
|
|
3400
3150
|
"",
|
|
3401
|
-
"
|
|
3402
|
-
|
|
3151
|
+
"KNOWLEDGE TREE INDEX:",
|
|
3152
|
+
renderCategoryTopicIndex(catalog),
|
|
3403
3153
|
"",
|
|
3154
|
+
hasTopics ? 'Return JSON: {"relevant_topics":["TOPIC_CODE"]}' : 'Return JSON: {"relevant_categories":["CATEGORY_CODE"]}'
|
|
3155
|
+
].join("\n");
|
|
3156
|
+
const selRes = await client.chat(
|
|
3157
|
+
[{ role: "system", content: selSystem }, { role: "user", content: selUser }],
|
|
3158
|
+
{ temperature: 0, maxTokens: 4096 }
|
|
3159
|
+
);
|
|
3160
|
+
const sel = parseJsonBlock(selRes.content);
|
|
3161
|
+
const selTps = hasTopics ? (sel.relevant_topics || []).map(String).filter(Boolean) : [];
|
|
3162
|
+
const selCats = hasTopics ? [] : (sel.relevant_categories || []).map(String).filter(Boolean);
|
|
3163
|
+
log("STEP_2_5", "LLM relevance pre-selection (" + (hasTopics ? "topics" : "categories") + "): " + (hasTopics ? selTps.join(",") : selCats.join(",")));
|
|
3164
|
+
const shortlist = filterBySelection(catalog, selCats, selTps, opts.concepts);
|
|
3165
|
+
log("STEP_2_5", "Filtered tree concepts: " + shortlist.length + "/" + catalog.concepts.length + " shortlisted for alignment");
|
|
3166
|
+
if (shortlist.length === 0) throw new Error("Topic/category pre-selection eliminated every tree concept \u2014 fail-closed");
|
|
3167
|
+
const system = [
|
|
3168
|
+
"You align auto-derived learning concepts with an institutional knowledge tree.",
|
|
3169
|
+
"For EACH input concept decide exactly one of:",
|
|
3170
|
+
` 1. "standardized" - the concept teaches the SAME underlying knowledge as one tree concept. Applying a tree concept inside a specific product context (e.g. "File-based persistence in a CLI app") is STILL the tree concept if the core knowledge matches. Set tree_code to that concept's code.`,
|
|
3171
|
+
" Adopt the tree's terminology. Only propose new when the tree genuinely has NO concept teaching that knowledge (broader AND narrower both fail).",
|
|
3172
|
+
' 2. "proposed_new" - no tree concept covers it at the same granularity. Propose an UPPER_SNAKE_CASE code (descriptive, stable, max 5 words) and a one-sentence definition.',
|
|
3173
|
+
" If between 1 and 3 tree concepts are CLOSE but not exact, list their codes in possible_tree_matches for human review.",
|
|
3174
|
+
`For a "standardized" decision ALSO return "adopted_keywords": the subset of that tree concept's keywords (from the shortlist) that make sense for the course TECH STACK. Drop keywords that name tooling from other ecosystems (e.g. JS test runners for a Python course) \u2014 keep conceptual keywords even when spelled generically. Maximum 8, ordered most-relevant first.`,
|
|
3404
3175
|
"Rules:",
|
|
3405
|
-
"
|
|
3406
|
-
"
|
|
3407
|
-
"
|
|
3408
|
-
"
|
|
3409
|
-
"
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
"
|
|
3414
|
-
" a lowercase actionable verb follows the clause (explain ..., differentiate ...).",
|
|
3415
|
-
" Do not prefix any other phrasing.",
|
|
3176
|
+
"- Judge by MEANING (definition + keywords), not by string similarity.",
|
|
3177
|
+
'- COVERAGE RULE: a "standardized" match must cover ALL primary dimensions of the input concept \u2014 every distinct aspect named in its title or description. A tree concept covering only ONE of several aspects (e.g. "interpreting error messages" for a concept about error handling AND user experience) is a PARTIAL match: decide "proposed_new" and list that tree code in possible_tree_matches.',
|
|
3178
|
+
"- Context-specific wording does NOT make a new concept: new concept = knowledge absent from the tree, not knowledge applied elsewhere.",
|
|
3179
|
+
"- Every input concept MUST receive exactly one decision. Never merge two input concepts into one tree code.",
|
|
3180
|
+
"- Prefer proposed_new over a wrong standardized match: a false match corrupts the curriculum.",
|
|
3181
|
+
JSON_ONLY2
|
|
3182
|
+
].join("\n");
|
|
3183
|
+
const user = [
|
|
3184
|
+
"TECH STACK: " + techStack,
|
|
3416
3185
|
"",
|
|
3417
|
-
"
|
|
3418
|
-
|
|
3186
|
+
"ANALYSIS CONCEPTS (to standardize):",
|
|
3187
|
+
JSON.stringify(opts.concepts.map((c) => ({ id: c.id, name: c.name, description: c.description.slice(0, 300), keywords: c.keywords.slice(0, 12) })), null, 1),
|
|
3419
3188
|
"",
|
|
3420
|
-
|
|
3189
|
+
"KNOWLEDGE TREE SHORTLIST (pre-filtered, " + shortlist.length + " candidate concepts):",
|
|
3190
|
+
renderShortlist(shortlist),
|
|
3191
|
+
"",
|
|
3192
|
+
'Return JSON: {"decisions":[{"id":"<input id>","decision":"standardized","tree_code":"TREE_CODE","adopted_keywords":["kw1","kw2"],"note":"one sentence"},',
|
|
3193
|
+
' {"id":"<input id>","decision":"proposed_new","proposed_code":"PROPOSED_CODE","proposed_definition":"...","possible_tree_matches":["CODE"]}]}'
|
|
3421
3194
|
].join("\n");
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3195
|
+
const res = await client.chat(
|
|
3196
|
+
[{ role: "system", content: system }, { role: "user", content: user }],
|
|
3197
|
+
{ temperature: 0, maxTokens: 8192 }
|
|
3198
|
+
);
|
|
3199
|
+
log("STEP_2_5", "Standardization response received (provider " + res.provider + ")");
|
|
3200
|
+
const parsed = parseJsonBlock(res.content);
|
|
3201
|
+
const decisions = parsed.decisions ?? [];
|
|
3202
|
+
const byId = new Map(decisions.map((d) => [String(d.id), d]));
|
|
3203
|
+
const finalDecisions = [];
|
|
3204
|
+
const proposals = [];
|
|
3205
|
+
for (const c of opts.concepts) {
|
|
3206
|
+
const d = byId.get(c.id);
|
|
3207
|
+
if (!d || d.decision !== "standardized" && d.decision !== "proposed_new") {
|
|
3208
|
+
throw new Error("Concept " + c.id + " (" + c.name + ") has no standardization decision \u2014 fail-closed");
|
|
3209
|
+
}
|
|
3210
|
+
if (d.decision === "standardized") {
|
|
3211
|
+
const tc = catalog.concepts.find((x) => x.code === d.tree_code);
|
|
3212
|
+
if (!tc) throw new Error("Concept " + c.id + " standardized to unknown tree code " + d.tree_code + " \u2014 fail-closed");
|
|
3213
|
+
const treeKw = new Set(tc.keywords.map((k) => k.toLowerCase()));
|
|
3214
|
+
const adopted = (d.adopted_keywords || []).map((k) => String(k)).filter((k) => {
|
|
3215
|
+
if (treeKw.has(k.toLowerCase())) return true;
|
|
3216
|
+
throw new Error("Concept " + c.id + ' adopted keyword "' + k + '" not in tree concept ' + tc.code + " \u2014 fail-closed");
|
|
3217
|
+
});
|
|
3218
|
+
if (tc.keywords.length > 0 && adopted.length === 0) {
|
|
3219
|
+
throw new Error("Concept " + c.id + " standardized to " + tc.code + " without adopted_keywords \u2014 fail-closed");
|
|
3220
|
+
}
|
|
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);
|
|
3428
3277
|
}
|
|
3429
3278
|
}
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
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);
|
|
3434
3291
|
}
|
|
3435
3292
|
}
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
"
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
const batchCioUser = "Approved ULOs:\n" + batchUloContext + '\n\nReturn JSON:\n{"cios":[{"concept_id":"C1","cio":"..."}]}';
|
|
3464
|
-
const cioRes = await chatJson(client, cioSystem, batchCioUser);
|
|
3465
|
-
for (const x of cioRes.cios ?? []) {
|
|
3466
|
-
if (x.concept_id && x.cio) cioByConcept[x.concept_id] = standardStatement(x.cio);
|
|
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;
|
|
3467
3320
|
}
|
|
3468
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 || [];
|
|
3469
3424
|
for (const c of concepts) {
|
|
3470
|
-
if (
|
|
3471
|
-
warnings.push("
|
|
3472
|
-
cioByConcept[c.id] = "";
|
|
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");
|
|
3473
3427
|
}
|
|
3474
3428
|
}
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
" save, handle, parse, validate, test, register, compute, generate, print, format,",
|
|
3491
|
-
" convert, extend, define, execute, run, read, fetch, wrap, log, call, raise, install,",
|
|
3492
|
-
" import, export, declare, embed, model, represent, override, customize, enforce, mock,",
|
|
3493
|
-
" assert, query, index, cache, stream, split, join, strip, encode, decode, traverse,",
|
|
3494
|
-
" measure, evaluate, benchmark, diagnose, demonstrate, verify, organize, structure,",
|
|
3495
|
-
" choose, select, compare, distinguish, attach, annotate, expose, accept, manage, group,",
|
|
3496
|
-
" sort, filter, map, reduce, catch, throw, migrate, inspect, trace, parameterize,",
|
|
3497
|
-
" construct, design, capture) \u2014 OR wrap the concrete construct in backticks.",
|
|
3498
|
-
" Items with abstract verbs (understand, learn, know, grasp, appreciate, explore) and",
|
|
3499
|
-
" no backtick construct FAIL the audit. Prefer starting the task phrase with the verb.",
|
|
3500
|
-
"3. 2-3 items per concept, each referencing a DIFFERENT concrete construct.",
|
|
3501
|
-
"4. Statement form (mandatory): every SIO MUST read 'The learner will be able to ...' \u2014",
|
|
3502
|
-
" the concrete task follows the clause.",
|
|
3503
|
-
"",
|
|
3504
|
-
"Approved style exemplars (production bank):",
|
|
3505
|
-
SIO_EXEMPLARS.map((e) => "- " + e).join("\n"),
|
|
3506
|
-
"",
|
|
3507
|
-
JSON_ONLY
|
|
3508
|
-
].join("\n");
|
|
3509
|
-
concepts.map((c) => "- " + c.id + " (" + c.name + "): " + ((c.keywords ?? []).slice(0, 10).join(", ") || "(none listed \u2014 use concrete constructs of " + techStack + ")")).join("\n");
|
|
3510
|
-
for (const batch of chunkConcepts(concepts, LO_BATCH_SIZE)) {
|
|
3511
|
-
const batchKw = batch.map((c) => "- " + c.id + " (" + c.name + "): " + ((c.keywords ?? []).slice(0, 10).join(", ") || "(none listed \u2014 use concrete constructs of " + techStack + ")")).join("\n");
|
|
3512
|
-
const batchCioCtx = batch.map((c) => "- " + c.id + " (" + c.name + "): CIO=" + (cioByConcept[c.id] || "(missing)") + " | ULO=" + (uloByConcept[c.id] || "(missing)")).join("\n");
|
|
3513
|
-
const batchSioUser = [
|
|
3514
|
-
"Target technology: " + techStack,
|
|
3515
|
-
"",
|
|
3516
|
-
"Concept keywords (each SIO item MUST contain at least one of its concept's keywords):",
|
|
3517
|
-
batchKw,
|
|
3518
|
-
"",
|
|
3519
|
-
"Approved CIOs and ULOs:",
|
|
3520
|
-
batchCioCtx,
|
|
3521
|
-
"",
|
|
3522
|
-
"Return JSON:",
|
|
3523
|
-
'{"sios":[{"concept_id":"C1","sio":["...","..."]}]}'
|
|
3524
|
-
].join("\n");
|
|
3525
|
-
const sioRes = await chatJson(client, sioSystem, batchSioUser);
|
|
3526
|
-
for (const x of sioRes.sios ?? []) {
|
|
3527
|
-
if (x.concept_id && Array.isArray(x.sio)) sioByConcept[x.concept_id] = x.sio.map((item) => standardStatement(String(item)));
|
|
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
|
+
}
|
|
3528
3444
|
}
|
|
3529
3445
|
}
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
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
|
+
}
|
|
3534
3460
|
}
|
|
3535
3461
|
}
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
});
|
|
3570
|
-
const repairSystem = [
|
|
3571
|
-
"You repair depth-layer learning objectives that failed a deterministic contract audit.",
|
|
3572
|
-
"For each concept, rewrite ONLY the layers mentioned in issues, keeping the others as-is.",
|
|
3573
|
-
"Contract:",
|
|
3574
|
-
"- ULO: WHAT+WHY, concept-bound, technology-agnostic, Remember/Understand verbs.",
|
|
3575
|
-
"- CIO: HOW mechanism, technology-agnostic, more concrete than ULO, Marr 2-Language neutral.",
|
|
3576
|
-
"- SIO: keyword-bound completable implementation tasks naming real constructs of " + techStack + ".",
|
|
3577
|
-
"- BINDING RULE: every SIO item must contain at least one of the concept's listed keywords VERBATIM (copy the exact keyword text into the sentence).",
|
|
3578
|
-
"- COMPLETABILITY RULE: every SIO item must contain an actionable verb (build, create, implement, write, configure, parse, validate, handle, store, load, run, read, fetch, display, generate, format, convert, test, debug, trace, measure, design, structure, select, filter, sort, map, reduce, catch, throw, migrate, deploy, query, index, cache, stream, inspect) or wrap a concrete construct in backticks \u2014 abstract verbs (understand, learn, know, grasp, explore, appreciate) always fail.",
|
|
3579
|
-
"",
|
|
3580
|
-
JSON_ONLY
|
|
3581
|
-
].join("\n");
|
|
3582
|
-
const repairUser = [
|
|
3583
|
-
"Failing concepts:",
|
|
3584
|
-
JSON.stringify(repairTargets, null, 2),
|
|
3585
|
-
"",
|
|
3586
|
-
"Return the FULL repaired set for these concepts in the same 3-layer JSON shape:",
|
|
3587
|
-
'{"repairs":[{"concept_id":"C1","ulo":"...","cio":"...","sio":["..."]}]}'
|
|
3588
|
-
].join("\n");
|
|
3589
|
-
try {
|
|
3590
|
-
const repairRes = await chatJson(client, repairSystem, repairUser);
|
|
3591
|
-
for (const r of repairRes.repairs ?? []) {
|
|
3592
|
-
if (!r.concept_id) continue;
|
|
3593
|
-
if (r.ulo) uloByConcept[r.concept_id] = standardStatement(r.ulo);
|
|
3594
|
-
if (r.cio) cioByConcept[r.concept_id] = standardStatement(r.cio);
|
|
3595
|
-
if (Array.isArray(r.sio) && r.sio.length > 0) sioByConcept[r.concept_id] = r.sio.map((t) => standardStatement(String(t)));
|
|
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;
|
|
3596
3495
|
}
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3496
|
+
} else {
|
|
3497
|
+
c.standardization = "proposed_new";
|
|
3498
|
+
c.code = d.proposed_code;
|
|
3600
3499
|
}
|
|
3601
3500
|
}
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
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);
|
|
3607
3512
|
}
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
writeFileSync3(dumpDir + "depth-audit-debug.json", JSON.stringify(
|
|
3615
|
-
stillFailing.map((a) => ({ audit: a, ulo: uloByConcept[a.conceptId], cio: cioByConcept[a.conceptId], sio: sioByConcept[a.conceptId] })),
|
|
3616
|
-
null,
|
|
3617
|
-
2
|
|
3618
|
-
));
|
|
3619
|
-
} catch {
|
|
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`);
|
|
3620
3519
|
}
|
|
3621
|
-
const
|
|
3622
|
-
|
|
3623
|
-
|
|
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
|
|
3624
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");
|
|
3625
3564
|
}
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
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");
|
|
3647
3607
|
}
|
|
3648
|
-
const
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
const c = line.split(" ");
|
|
3653
|
-
const type = (c[iT] || "").trim();
|
|
3654
|
-
const code = (c[iC] || "").trim();
|
|
3655
|
-
const name = (c[iN] || "").trim();
|
|
3656
|
-
if (!code || !name) continue;
|
|
3657
|
-
if (type === "CATEGORY") categories.set(code, name);
|
|
3658
|
-
if (type === "TOPIC") topics.set(code, { name, categoryCodes: cell(c[iCat]) });
|
|
3659
|
-
if (type !== "CONCEPT") continue;
|
|
3660
|
-
const topicCodes = cell(c[iTpc]);
|
|
3661
|
-
const categoryCodes = /* @__PURE__ */ new Set();
|
|
3662
|
-
for (const t of topicCodes) for (const cc of topics.get(t)?.categoryCodes || []) categoryCodes.add(cc);
|
|
3663
|
-
concepts.push({
|
|
3664
|
-
code,
|
|
3665
|
-
name,
|
|
3666
|
-
description: (c[iD] || "").trim(),
|
|
3667
|
-
keywords: cell(c[iKw]),
|
|
3668
|
-
topicCodes,
|
|
3669
|
-
categoryCodes: [...categoryCodes],
|
|
3670
|
-
prerequisiteCodes: cell(c[iPre])
|
|
3671
|
-
});
|
|
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
|
+
}
|
|
3672
3612
|
}
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
const
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
let overlap = 0;
|
|
3691
|
-
for (const w of tcTok) if (probeAll.has(w)) overlap++;
|
|
3692
|
-
const kwDirect = tc.keywords.filter((k) => {
|
|
3693
|
-
const kl = k.toLowerCase();
|
|
3694
|
-
return [...probeAll].some((w) => w === kl || w.length >= 5 && kl.includes(w));
|
|
3695
|
-
}).length;
|
|
3696
|
-
return overlap * 2 + kwDirect * 3;
|
|
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
|
|
3697
3630
|
};
|
|
3698
|
-
const kept = catalog.concepts.filter((tc) => inSelection(tc) || overlapScore(tc) >= 6);
|
|
3699
|
-
return kept.map((tc) => ({ tc, s: overlapScore(tc) })).sort((a, b) => b.s - a.s).slice(0, maxConcepts).map((x) => x.tc);
|
|
3700
|
-
}
|
|
3701
|
-
function renderShortlist(shortlist) {
|
|
3702
|
-
return shortlist.map((tc) => "- " + tc.code + " | " + tc.name + " | kw: " + tc.keywords.slice(0, 8).join(", ") + (tc.prerequisiteCodes.length ? " | prereqs: " + tc.prerequisiteCodes.join(",") : "")).join("\n");
|
|
3703
3631
|
}
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
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");
|
|
3713
3763
|
}
|
|
3764
|
+
if (e.from === e.to) throw new Error("curriculum feed self-loop edge on " + e.from);
|
|
3714
3765
|
}
|
|
3715
|
-
const
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
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
|
+
}
|
|
3719
3770
|
}
|
|
3720
|
-
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
function parseJsonBlock(text) {
|
|
3724
|
-
const start = text.indexOf("{");
|
|
3725
|
-
const end = text.lastIndexOf("}");
|
|
3726
|
-
if (start < 0 || end <= start) throw new Error("LLM standardization response has no JSON object");
|
|
3727
|
-
return JSON.parse(text.slice(start, end + 1));
|
|
3728
|
-
}
|
|
3729
|
-
async function standardizeConcepts(opts) {
|
|
3730
|
-
const { client, catalog, techStack = "python", proposalsPath } = opts;
|
|
3731
|
-
const log = opts.onProgress ?? (() => {
|
|
3732
|
-
});
|
|
3733
|
-
const warnings = [];
|
|
3734
|
-
const hasTopics = catalog.topics.size > 0;
|
|
3735
|
-
const selSystem = [
|
|
3736
|
-
"You pre-select relevant areas of an institutional knowledge tree before a detailed concept alignment pass.",
|
|
3737
|
-
hasTopics ? "Given the analysis concepts, pick the TOPICS that could contain matching concepts. Concepts are filed under topics, so topics are the filter dimension." : "Given the analysis concepts, pick the CATEGORIES that could contain matching concepts.",
|
|
3738
|
-
"- Be generous: missing a relevant topic corrupts the alignment; an extra topic only costs attention.",
|
|
3739
|
-
"- Only codes that appear in the index are valid.",
|
|
3740
|
-
JSON_ONLY2
|
|
3741
|
-
].join("\n");
|
|
3742
|
-
const selUser = [
|
|
3743
|
-
"ANALYSIS CONCEPTS:",
|
|
3744
|
-
opts.concepts.map((c) => "- " + c.name + ": " + c.description.slice(0, 160)).join("\n"),
|
|
3745
|
-
"",
|
|
3746
|
-
"KNOWLEDGE TREE INDEX:",
|
|
3747
|
-
renderCategoryTopicIndex(catalog),
|
|
3748
|
-
"",
|
|
3749
|
-
hasTopics ? 'Return JSON: {"relevant_topics":["TOPIC_CODE"]}' : 'Return JSON: {"relevant_categories":["CATEGORY_CODE"]}'
|
|
3750
|
-
].join("\n");
|
|
3751
|
-
const selRes = await client.chat(
|
|
3752
|
-
[{ role: "system", content: selSystem }, { role: "user", content: selUser }],
|
|
3753
|
-
{ temperature: 0, maxTokens: 4096 }
|
|
3754
|
-
);
|
|
3755
|
-
const sel = parseJsonBlock(selRes.content);
|
|
3756
|
-
const selTps = hasTopics ? (sel.relevant_topics || []).map(String).filter(Boolean) : [];
|
|
3757
|
-
const selCats = hasTopics ? [] : (sel.relevant_categories || []).map(String).filter(Boolean);
|
|
3758
|
-
log("STEP_2_5", "LLM relevance pre-selection (" + (hasTopics ? "topics" : "categories") + "): " + (hasTopics ? selTps.join(",") : selCats.join(",")));
|
|
3759
|
-
const shortlist = filterBySelection(catalog, selCats, selTps, opts.concepts);
|
|
3760
|
-
log("STEP_2_5", "Filtered tree concepts: " + shortlist.length + "/" + catalog.concepts.length + " shortlisted for alignment");
|
|
3761
|
-
if (shortlist.length === 0) throw new Error("Topic/category pre-selection eliminated every tree concept \u2014 fail-closed");
|
|
3762
|
-
const system = [
|
|
3763
|
-
"You align auto-derived learning concepts with an institutional knowledge tree.",
|
|
3764
|
-
"For EACH input concept decide exactly one of:",
|
|
3765
|
-
` 1. "standardized" - the concept teaches the SAME underlying knowledge as one tree concept. Applying a tree concept inside a specific product context (e.g. "File-based persistence in a CLI app") is STILL the tree concept if the core knowledge matches. Set tree_code to that concept's code.`,
|
|
3766
|
-
" Adopt the tree's terminology. Only propose new when the tree genuinely has NO concept teaching that knowledge (broader AND narrower both fail).",
|
|
3767
|
-
' 2. "proposed_new" - no tree concept covers it at the same granularity. Propose an UPPER_SNAKE_CASE code (descriptive, stable, max 5 words) and a one-sentence definition.',
|
|
3768
|
-
" If between 1 and 3 tree concepts are CLOSE but not exact, list their codes in possible_tree_matches for human review.",
|
|
3769
|
-
`For a "standardized" decision ALSO return "adopted_keywords": the subset of that tree concept's keywords (from the shortlist) that make sense for the course TECH STACK. Drop keywords that name tooling from other ecosystems (e.g. JS test runners for a Python course) \u2014 keep conceptual keywords even when spelled generically. Maximum 8, ordered most-relevant first.`,
|
|
3770
|
-
"Rules:",
|
|
3771
|
-
"- Judge by MEANING (definition + keywords), not by string similarity.",
|
|
3772
|
-
'- COVERAGE RULE: a "standardized" match must cover ALL primary dimensions of the input concept \u2014 every distinct aspect named in its title or description. A tree concept covering only ONE of several aspects (e.g. "interpreting error messages" for a concept about error handling AND user experience) is a PARTIAL match: decide "proposed_new" and list that tree code in possible_tree_matches.',
|
|
3773
|
-
"- Context-specific wording does NOT make a new concept: new concept = knowledge absent from the tree, not knowledge applied elsewhere.",
|
|
3774
|
-
"- Every input concept MUST receive exactly one decision. Never merge two input concepts into one tree code.",
|
|
3775
|
-
"- Prefer proposed_new over a wrong standardized match: a false match corrupts the curriculum.",
|
|
3776
|
-
JSON_ONLY2
|
|
3777
|
-
].join("\n");
|
|
3778
|
-
const user = [
|
|
3779
|
-
"TECH STACK: " + techStack,
|
|
3780
|
-
"",
|
|
3781
|
-
"ANALYSIS CONCEPTS (to standardize):",
|
|
3782
|
-
JSON.stringify(opts.concepts.map((c) => ({ id: c.id, name: c.name, description: c.description.slice(0, 300), keywords: c.keywords.slice(0, 12) })), null, 1),
|
|
3783
|
-
"",
|
|
3784
|
-
"KNOWLEDGE TREE SHORTLIST (pre-filtered, " + shortlist.length + " candidate concepts):",
|
|
3785
|
-
renderShortlist(shortlist),
|
|
3786
|
-
"",
|
|
3787
|
-
'Return JSON: {"decisions":[{"id":"<input id>","decision":"standardized","tree_code":"TREE_CODE","adopted_keywords":["kw1","kw2"],"note":"one sentence"},',
|
|
3788
|
-
' {"id":"<input id>","decision":"proposed_new","proposed_code":"PROPOSED_CODE","proposed_definition":"...","possible_tree_matches":["CODE"]}]}'
|
|
3789
|
-
].join("\n");
|
|
3790
|
-
const res = await client.chat(
|
|
3791
|
-
[{ role: "system", content: system }, { role: "user", content: user }],
|
|
3792
|
-
{ temperature: 0, maxTokens: 8192 }
|
|
3793
|
-
);
|
|
3794
|
-
log("STEP_2_5", "Standardization response received (provider " + res.provider + ")");
|
|
3795
|
-
const parsed = parseJsonBlock(res.content);
|
|
3796
|
-
const decisions = parsed.decisions ?? [];
|
|
3797
|
-
const byId = new Map(decisions.map((d) => [String(d.id), d]));
|
|
3798
|
-
const finalDecisions = [];
|
|
3799
|
-
const proposals = [];
|
|
3800
|
-
for (const c of opts.concepts) {
|
|
3801
|
-
const d = byId.get(c.id);
|
|
3802
|
-
if (!d || d.decision !== "standardized" && d.decision !== "proposed_new") {
|
|
3803
|
-
throw new Error("Concept " + c.id + " (" + c.name + ") has no standardization decision \u2014 fail-closed");
|
|
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);
|
|
3804
3774
|
}
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
}
|
|
3816
|
-
finalDecisions.push({ ...d, id: c.id, tree_code: tc.code, adopted_keywords: adopted });
|
|
3817
|
-
warnings.push("Concept " + c.id + " standardized as " + tc.code + " (" + tc.name + "), adopted keywords: " + adopted.join(", ") + (d.note ? " \u2014 " + d.note : ""));
|
|
3818
|
-
} else {
|
|
3819
|
-
const code = (d.proposed_code || "").toUpperCase().replace(/[^A-Z0-9_]/g, "_");
|
|
3820
|
-
if (!/^[A-Z][A-Z0-9_]*$/.test(code)) {
|
|
3821
|
-
throw new Error("Concept " + c.id + ' proposed invalid code "' + d.proposed_code + '" \u2014 fail-closed');
|
|
3822
|
-
}
|
|
3823
|
-
finalDecisions.push({ ...d, id: c.id, proposed_code: code });
|
|
3824
|
-
proposals.push({
|
|
3825
|
-
code,
|
|
3826
|
-
name: c.name,
|
|
3827
|
-
description: d.proposed_definition || c.description.slice(0, 300),
|
|
3828
|
-
keywords: c.keywords,
|
|
3829
|
-
subject_codes: "PY",
|
|
3830
|
-
category_codes: "",
|
|
3831
|
-
topic_codes: "",
|
|
3832
|
-
prerequisite_concept_codes: [],
|
|
3833
|
-
status: "proposed",
|
|
3834
|
-
source_concept_id: c.id,
|
|
3835
|
-
possible_tree_matches: (d.possible_tree_matches || []).filter(Boolean)
|
|
3836
|
-
});
|
|
3837
|
-
warnings.push("Concept " + c.id + " proposed as NEW: " + code + (d.possible_tree_matches?.length ? " (near matches: " + d.possible_tree_matches.join(", ") + ")" : ""));
|
|
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(", "));
|
|
3838
3785
|
}
|
|
3839
3786
|
}
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
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
|
+
}
|
|
3847
3802
|
}
|
|
3848
|
-
return
|
|
3803
|
+
return parsed;
|
|
3849
3804
|
}
|
|
3850
3805
|
|
|
3851
|
-
// src/
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
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: [] });
|
|
3863
3839
|
}
|
|
3864
3840
|
}
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
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);
|
|
3868
3851
|
}
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
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));
|
|
3877
3893
|
}
|
|
3878
3894
|
}
|
|
3879
|
-
if (sorted.length !== concepts.length) {
|
|
3880
|
-
console.warn(`[WARN] Topological sort incomplete: ${sorted.length}/${concepts.length} concepts sorted`);
|
|
3881
|
-
}
|
|
3882
|
-
return sorted;
|
|
3883
3895
|
}
|
|
3884
|
-
|
|
3885
|
-
const
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
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
|
|
3890
3919
|
};
|
|
3891
|
-
|
|
3892
|
-
|
|
3893
|
-
|
|
3894
|
-
|
|
3895
|
-
|
|
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" });
|
|
3896
3943
|
}
|
|
3897
|
-
|
|
3898
|
-
|
|
3899
|
-
let truncatedSyllabus = "";
|
|
3900
|
-
if (syllabusText) {
|
|
3901
|
-
if (syllabusText.length > maxSyllabusChars) {
|
|
3902
|
-
truncatedSyllabus = syllabusText.slice(0, maxSyllabusChars);
|
|
3903
|
-
warnings.push(`Syllabus truncated from ${syllabusText.length} to ${maxSyllabusChars} chars \u2014 may miss some concepts`);
|
|
3904
|
-
} else {
|
|
3905
|
-
truncatedSyllabus = syllabusText;
|
|
3906
|
-
}
|
|
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" });
|
|
3907
3946
|
}
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
const
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
|
|
3918
|
-
|
|
3919
|
-
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
"id": "C1",
|
|
3966
|
-
"name": "Concept name",
|
|
3967
|
-
"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.",
|
|
3968
|
-
"category": "category_id",
|
|
3969
|
-
"keywords": ["specific_term_1", "concrete_api_2", "method_call_3", "tool_or_type_4", "syntax_keyword_5", "concept_term_6"],
|
|
3970
|
-
"ulo": "WHAT + WHY in technology-agnostic language: what the concept is and the problem it exists to solve. No library/API/tool names.",
|
|
3971
|
-
"cio": "HOW the mechanism works, still technology-agnostic but more concrete than the ULO.",
|
|
3972
|
-
"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"],
|
|
3973
|
-
"prerequisites": [],
|
|
3974
|
-
"estimated_minutes": 30,
|
|
3975
|
-
"problem_types": [
|
|
3976
|
-
{
|
|
3977
|
-
"id": "PT-C1-01",
|
|
3978
|
-
"name": "Problem type name",
|
|
3979
|
-
"description": "What this problem type tests",
|
|
3980
|
-
"bloom_level": "Remember",
|
|
3981
|
-
"difficulty": "easy",
|
|
3982
|
-
"estimated_minutes": 10
|
|
3983
|
-
}
|
|
3984
|
-
],
|
|
3985
|
-
"techniques": ["technique 1", "technique 2"],
|
|
3986
|
-
"common_mistakes": ["mistake 1"]
|
|
3987
|
-
}
|
|
3988
|
-
],
|
|
3989
|
-
"categories": [
|
|
3990
|
-
{
|
|
3991
|
-
"id": "cat1",
|
|
3992
|
-
"name": "Category name",
|
|
3993
|
-
"description": "Category description",
|
|
3994
|
-
"concept_ids": ["C1", "C2"],
|
|
3995
|
-
"order": 1
|
|
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;
|
|
3996
4004
|
}
|
|
3997
|
-
|
|
3998
|
-
|
|
3999
|
-
{
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
});
|
|
4007
|
-
|
|
4008
|
-
|
|
4009
|
-
|
|
4010
|
-
|
|
4011
|
-
|
|
4012
|
-
|
|
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" });
|
|
4013
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
|
+
});
|
|
4014
4052
|
}
|
|
4015
|
-
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
|
-
|
|
4019
|
-
|
|
4020
|
-
|
|
4021
|
-
|
|
4022
|
-
|
|
4023
|
-
|
|
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");
|
|
4024
4086
|
}
|
|
4025
|
-
|
|
4026
|
-
|
|
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");
|
|
4027
4139
|
} else {
|
|
4028
|
-
|
|
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);
|
|
4029
4160
|
}
|
|
4030
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 });
|
|
4031
4164
|
}
|
|
4032
|
-
|
|
4033
|
-
|
|
4034
|
-
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
milestone_name: String(s.milestone_name || "")
|
|
4038
|
-
}));
|
|
4039
|
-
const conceptIds = new Set(concepts.map((c) => c.id));
|
|
4040
|
-
for (const concept of concepts) {
|
|
4041
|
-
for (const prereq of concept.prerequisites || []) {
|
|
4042
|
-
if (!conceptIds.has(prereq)) {
|
|
4043
|
-
warnings.push(`Concept ${concept.id} references non-existent prerequisite ${prereq} \u2014 removed`);
|
|
4044
|
-
concept.prerequisites = (concept.prerequisites || []).filter((p) => p !== prereq);
|
|
4045
|
-
}
|
|
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");
|
|
4046
4170
|
}
|
|
4047
4171
|
}
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
const
|
|
4066
|
-
for (const
|
|
4067
|
-
const
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
c.code = d.tree_code;
|
|
4073
|
-
const tc = catalog.concepts.find((x) => x.code === d.tree_code);
|
|
4074
|
-
if (tc) {
|
|
4075
|
-
for (const k of d.adopted_keywords || []) {
|
|
4076
|
-
if (!(c.keywords || []).some((x) => x.toLowerCase() === k.toLowerCase())) {
|
|
4077
|
-
c.keywords = [...c.keywords || [], k];
|
|
4078
|
-
}
|
|
4079
|
-
}
|
|
4080
|
-
c.tree_prerequisites = tc.prerequisiteCodes;
|
|
4081
|
-
}
|
|
4082
|
-
} else {
|
|
4083
|
-
c.standardization = "proposed_new";
|
|
4084
|
-
c.code = d.proposed_code;
|
|
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");
|
|
4176
|
+
}
|
|
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
|
+
);
|
|
4186
|
+
}
|
|
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;
|
|
4085
4196
|
}
|
|
4197
|
+
ids.push(...stepIds);
|
|
4198
|
+
}
|
|
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");
|
|
4201
|
+
}
|
|
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);
|
|
4220
|
+
}
|
|
4221
|
+
}
|
|
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;
|
|
4086
4252
|
}
|
|
4087
|
-
|
|
4088
|
-
for (const
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
if (target && target !== c.id && !(c.prerequisites || []).includes(target)) {
|
|
4092
|
-
c.prerequisites = [...c.prerequisites || [], target];
|
|
4093
|
-
warnings.push("Prereq seeded from tree: " + c.id + " <- " + tp + " (" + target + ")");
|
|
4094
|
-
}
|
|
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 + "]");
|
|
4095
4257
|
}
|
|
4096
4258
|
}
|
|
4097
|
-
log("STEP_2_5", "Standardized: " + std.decisions.filter((d) => d.decision === "standardized").length + ", proposed new: " + std.decisions.filter((d) => d.decision === "proposed_new").length);
|
|
4098
4259
|
}
|
|
4099
|
-
|
|
4100
|
-
const
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
for (
|
|
4108
|
-
const
|
|
4109
|
-
if (
|
|
4110
|
-
|
|
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;
|
|
4111
4288
|
}
|
|
4112
|
-
|
|
4113
|
-
for (const { from, to } of removed) {
|
|
4114
|
-
warnings.push(`Removed prerequisite edge: ${from} \u2192 ${to} (cycle breaker)`);
|
|
4289
|
+
if (feedOrder.get(src) < feedOrder.get(n.id)) pushEdge(src, n.id, "keyword prerequisite [" + k + "]");
|
|
4115
4290
|
}
|
|
4116
4291
|
}
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
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
|
|
4127
4306
|
});
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
);
|
|
4136
|
-
const
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
if (lo.sioByConcept[concept.id] && lo.sioByConcept[concept.id].length > 0) {
|
|
4142
|
-
concept.sio = lo.sioByConcept[concept.id];
|
|
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";
|
|
4143
4320
|
}
|
|
4144
|
-
concept.code = concept.code || codeByConceptId[concept.id];
|
|
4145
|
-
concept.ulo_code = concept.ulo ? uloCode(concept.code) : concept.ulo_code;
|
|
4146
|
-
concept.cio_code = concept.cio ? cioCode(concept.code, 1, cioActionSlug(concept.cio)) : concept.cio_code;
|
|
4147
|
-
concept.sio_codes = concept.sio.length > 0 ? concept.sio.map((_, i) => sioCode(techTag, concept.code, i + 1)) : concept.sio_codes;
|
|
4148
4321
|
}
|
|
4149
|
-
log("STEP_5", "Depth layers authored: " + concepts.length + " concepts, audit " + lo.audits.filter((a) => a.passed).length + "/" + concepts.length + " passed");
|
|
4150
4322
|
}
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
while ((m = modifierRe.exec(sioText)) !== null) techTokens.push(m[1]);
|
|
4166
|
-
const camelRe = /\b[a-z]+[A-Z][A-Za-z]+\b/g;
|
|
4167
|
-
while ((m = camelRe.exec(sioText)) !== null) techTokens.push(m[0]);
|
|
4168
|
-
const existing = new Set((concept.keywords || []).map((k) => k.toLowerCase()));
|
|
4169
|
-
const newTokens = [...new Set(techTokens)].filter((t) => !existing.has(t.toLowerCase())).slice(0, 8);
|
|
4170
|
-
if (newTokens.length > 0) {
|
|
4171
|
-
concept.keywords = [...concept.keywords || [], ...newTokens];
|
|
4172
|
-
warnings.push("Concept " + concept.id + ": enriched keywords with " + newTokens.length + " SIO-derived tokens");
|
|
4173
|
-
}
|
|
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);
|
|
4174
4337
|
}
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
4183
|
-
(pt) => ["Analyze", "Evaluate", "Create"].includes(pt.bloom_level || "")
|
|
4184
|
-
);
|
|
4185
|
-
const score = Math.min(1, sioCount * 0.2 + ptCount * 0.15 + prereqCount * 0.15 + (hasHighBloom ? 0.2 : 0));
|
|
4186
|
-
const calibrated = Math.round(15 + score * 45);
|
|
4187
|
-
const llmEstimate = concept.estimated_minutes || 30;
|
|
4188
|
-
if (llmEstimate === 30 || Math.abs(calibrated - llmEstimate) / llmEstimate > 0.5) {
|
|
4189
|
-
concept.estimated_minutes = calibrated;
|
|
4190
|
-
if (llmEstimate !== calibrated) {
|
|
4191
|
-
warnings.push("Concept " + concept.id + ": minutes calibrated " + llmEstimate + "m \u2192 " + calibrated + "m (score=" + score.toFixed(2) + ")");
|
|
4192
|
-
}
|
|
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
|
+
});
|
|
4193
4346
|
}
|
|
4194
4347
|
}
|
|
4195
|
-
log("STEP_5c", "Minute calibration complete");
|
|
4196
4348
|
}
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
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
|
+
});
|
|
4200
4369
|
}
|
|
4201
4370
|
}
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
schema_version:
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
|
|
4208
|
-
|
|
4209
|
-
|
|
4210
|
-
};
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
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: []
|
|
4379
|
+
});
|
|
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
|
|
4395
|
+
);
|
|
4396
|
+
}
|
|
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
|
+
);
|
|
4406
|
+
}
|
|
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);
|
|
4219
4433
|
}
|
|
4220
4434
|
|
|
4221
4435
|
// src/pipeline/knowledgeGraphPipeline.ts
|
|
4222
|
-
init_curriculumFeedEmitter();
|
|
4223
4436
|
async function runKnowledgeGraphPipeline(options) {
|
|
4224
4437
|
const warnings = [];
|
|
4225
4438
|
let parsedSyllabus = options.parsedSyllabus;
|
|
@@ -4240,7 +4453,8 @@ async function runKnowledgeGraphPipeline(options) {
|
|
|
4240
4453
|
});
|
|
4241
4454
|
warnings.push(...result.warnings);
|
|
4242
4455
|
const curriculumFeed = emitCurriculumFeed(result.knowledgeGraph, {
|
|
4243
|
-
warnings
|
|
4456
|
+
warnings,
|
|
4457
|
+
provenance: summarizeProvenance(result.provenance)
|
|
4244
4458
|
});
|
|
4245
4459
|
return {
|
|
4246
4460
|
knowledgeGraph: result.knowledgeGraph,
|
|
@@ -4274,9 +4488,10 @@ async function generateHybridGraph(options) {
|
|
|
4274
4488
|
description: "",
|
|
4275
4489
|
total_project_minutes: 0,
|
|
4276
4490
|
total_concept_minutes: 0,
|
|
4277
|
-
|
|
4491
|
+
linked_prereq_minutes: 0
|
|
4278
4492
|
},
|
|
4279
|
-
warnings
|
|
4493
|
+
warnings,
|
|
4494
|
+
provenance: []
|
|
4280
4495
|
};
|
|
4281
4496
|
}
|
|
4282
4497
|
const client = createLlmClient(llmConfig);
|
|
@@ -4357,9 +4572,14 @@ async function generateHybridGraph(options) {
|
|
|
4357
4572
|
description: "Progressive completion path: " + phases.length + " phases, " + features.length + " features, " + concepts.length + " concepts",
|
|
4358
4573
|
total_project_minutes: totalProjectMinutes,
|
|
4359
4574
|
total_concept_minutes: totalConceptMinutes,
|
|
4360
|
-
|
|
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
|
|
4361
4580
|
},
|
|
4362
|
-
warnings
|
|
4581
|
+
warnings,
|
|
4582
|
+
provenance: client.callLog
|
|
4363
4583
|
};
|
|
4364
4584
|
}
|
|
4365
4585
|
function auditPhasePlan(plan, features, concepts, links, warnings, log) {
|
|
@@ -4435,12 +4655,20 @@ function auditPhasePlan(plan, features, concepts, links, warnings, log) {
|
|
|
4435
4655
|
} else {
|
|
4436
4656
|
newIntroPhase.set(cid, order);
|
|
4437
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;
|
|
4438
4665
|
for (const pre of prereqOf.get(cid) || []) {
|
|
4439
|
-
|
|
4440
|
-
|
|
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");
|
|
4441
4670
|
}
|
|
4442
4671
|
}
|
|
4443
|
-
introductions.push({ concept_id: cid, aspect, note: String(ri.note || "") });
|
|
4444
4672
|
}
|
|
4445
4673
|
phases.push({
|
|
4446
4674
|
id,
|
|
@@ -4478,6 +4706,9 @@ function auditPhasePlan(plan, features, concepts, links, warnings, log) {
|
|
|
4478
4706
|
if (anchor !== void 0 && anchor <= pOrder) {
|
|
4479
4707
|
errors.push("Concept " + cid + " previewed in phase " + pOrder + " but fully introduced in phase " + anchor + " - preview must precede the anchor introduction");
|
|
4480
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
|
+
}
|
|
4481
4712
|
}
|
|
4482
4713
|
return { phases, errors };
|
|
4483
4714
|
}
|
|
@@ -4535,13 +4766,13 @@ async function decomposePhases(args) {
|
|
|
4535
4766
|
}
|
|
4536
4767
|
|
|
4537
4768
|
// src/pipeline/hybridGraphPipeline.ts
|
|
4538
|
-
init_curriculumFeedEmitter();
|
|
4539
4769
|
async function runHybridGraphPipeline(options) {
|
|
4540
4770
|
const warnings = [];
|
|
4541
4771
|
const result = await generateHybridGraph(options);
|
|
4542
4772
|
warnings.push(...result.warnings);
|
|
4543
4773
|
const curriculumFeed = emitCurriculumFeed(result.hybridGraph, {
|
|
4544
|
-
warnings
|
|
4774
|
+
warnings,
|
|
4775
|
+
provenance: summarizeProvenance(result.provenance)
|
|
4545
4776
|
});
|
|
4546
4777
|
return {
|
|
4547
4778
|
hybridGraph: result.hybridGraph,
|
|
@@ -5124,6 +5355,9 @@ function detectSyllabusLanguage(text) {
|
|
|
5124
5355
|
}
|
|
5125
5356
|
return "en";
|
|
5126
5357
|
}
|
|
5358
|
+
function normalizeText(s) {
|
|
5359
|
+
return s.normalize("NFC").toLowerCase();
|
|
5360
|
+
}
|
|
5127
5361
|
function detectDomain(options) {
|
|
5128
5362
|
const evidence = [];
|
|
5129
5363
|
const detectedTechStack = [];
|
|
@@ -5180,15 +5414,19 @@ function detectDomain(options) {
|
|
|
5180
5414
|
}
|
|
5181
5415
|
}
|
|
5182
5416
|
}
|
|
5183
|
-
const textContent = [
|
|
5417
|
+
const textContent = normalizeText([
|
|
5184
5418
|
options.description || "",
|
|
5185
5419
|
options.goal || "",
|
|
5186
5420
|
options.syllabusText || ""
|
|
5187
|
-
].join(" ")
|
|
5421
|
+
].join(" "));
|
|
5422
|
+
const textNoMarks = stripDiacritics(textContent);
|
|
5188
5423
|
for (const [domain, keywords] of Object.entries(DOMAIN_KEYWORDS)) {
|
|
5189
5424
|
const cat = domain;
|
|
5190
5425
|
for (const kw of keywords) {
|
|
5191
|
-
|
|
5426
|
+
const kwNorm = normalizeText(kw);
|
|
5427
|
+
const kwPlain = stripDiacritics(kw);
|
|
5428
|
+
const matched = textContent.includes(kwNorm) || kwPlain !== kwNorm && textNoMarks.includes(kwPlain);
|
|
5429
|
+
if (matched) {
|
|
5192
5430
|
scores[cat] += 0.1;
|
|
5193
5431
|
if (!evidence.some((e) => e.includes(`"${kw}"`))) {
|
|
5194
5432
|
evidence.push(`Keyword "${kw}" \u2192 ${cat}`);
|
|
@@ -5227,11 +5465,12 @@ function detectDomain(options) {
|
|
|
5227
5465
|
}
|
|
5228
5466
|
if (options.syllabusText) {
|
|
5229
5467
|
const syllabusLang = detectSyllabusLanguage(options.syllabusText);
|
|
5230
|
-
const syllabusLower = options.syllabusText
|
|
5231
|
-
|
|
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")) {
|
|
5232
5471
|
scores.math += 0.5;
|
|
5233
5472
|
detectedSubject = "Mathematics";
|
|
5234
|
-
} 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")) {
|
|
5235
5474
|
scores.science += 0.5;
|
|
5236
5475
|
detectedSubject = "Natural Science";
|
|
5237
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")) {
|
|
@@ -5472,6 +5711,9 @@ async function runDomainPipeline(options) {
|
|
|
5472
5711
|
}
|
|
5473
5712
|
if (effectiveMode === "hybrid") {
|
|
5474
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
|
+
}
|
|
5475
5717
|
if (options.repoDir) {
|
|
5476
5718
|
const projectResult2 = await runProjectGraphPipeline({
|
|
5477
5719
|
repoDir: options.repoDir,
|
|
@@ -5489,7 +5731,9 @@ async function runDomainPipeline(options) {
|
|
|
5489
5731
|
domain: domainProfile.domainCategory,
|
|
5490
5732
|
techStack: options.techStack,
|
|
5491
5733
|
llmConfig: options.llmConfig,
|
|
5492
|
-
onProgress: options.onProgress
|
|
5734
|
+
onProgress: options.onProgress,
|
|
5735
|
+
knowledgeTreePath: options.knowledgeTreePath,
|
|
5736
|
+
proposalsPath: options.proposalsPath
|
|
5493
5737
|
});
|
|
5494
5738
|
const hybridResult = await runHybridGraphPipeline({
|
|
5495
5739
|
projectGraph: projectResult2.projectGraph,
|
|
@@ -5508,7 +5752,8 @@ async function runDomainPipeline(options) {
|
|
|
5508
5752
|
warnings
|
|
5509
5753
|
};
|
|
5510
5754
|
} else {
|
|
5511
|
-
|
|
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);
|
|
5512
5757
|
const knowledgeResult = await runKnowledgeGraphPipeline({
|
|
5513
5758
|
subject: options.subject || domainProfile.detectedSubject || options.goal || "Subject Theory",
|
|
5514
5759
|
description: options.description,
|
|
@@ -5517,7 +5762,9 @@ async function runDomainPipeline(options) {
|
|
|
5517
5762
|
domain: domainProfile.domainCategory,
|
|
5518
5763
|
techStack: options.techStack,
|
|
5519
5764
|
llmConfig: options.llmConfig,
|
|
5520
|
-
onProgress: options.onProgress
|
|
5765
|
+
onProgress: options.onProgress,
|
|
5766
|
+
knowledgeTreePath: options.knowledgeTreePath,
|
|
5767
|
+
proposalsPath: options.proposalsPath
|
|
5521
5768
|
});
|
|
5522
5769
|
warnings.push(...knowledgeResult.warnings);
|
|
5523
5770
|
return {
|
|
@@ -5525,7 +5772,8 @@ async function runDomainPipeline(options) {
|
|
|
5525
5772
|
graphType: "knowledge_graph",
|
|
5526
5773
|
curriculumFeed: knowledgeResult.curriculumFeed,
|
|
5527
5774
|
knowledgeResult,
|
|
5528
|
-
warnings
|
|
5775
|
+
warnings,
|
|
5776
|
+
modeDowngrade: { from: "hybrid_graph", to: "knowledge_graph", reason: downgradeReason }
|
|
5529
5777
|
};
|
|
5530
5778
|
}
|
|
5531
5779
|
}
|
|
@@ -5541,9 +5789,10 @@ async function runDomainPipeline(options) {
|
|
|
5541
5789
|
embeddings: options.embeddings,
|
|
5542
5790
|
onProgress: options.onProgress
|
|
5543
5791
|
});
|
|
5544
|
-
const
|
|
5545
|
-
|
|
5546
|
-
|
|
5792
|
+
const curriculumFeed = emitCurriculumFeed(projectResult.projectGraph, {
|
|
5793
|
+
warnings,
|
|
5794
|
+
hallucinationCount: projectResult.hallucinations.length,
|
|
5795
|
+
provenance: summarizeProvenance(projectResult.provenance)
|
|
5547
5796
|
});
|
|
5548
5797
|
return {
|
|
5549
5798
|
domainProfile,
|