@thanh01.pmt/curriculum-kit 1.0.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.
Files changed (68) hide show
  1. package/README.md +22 -0
  2. package/dist/ai/index.cjs +4980 -0
  3. package/dist/ai/index.cjs.map +1 -0
  4. package/dist/ai/index.d.cts +1287 -0
  5. package/dist/ai/index.d.ts +1287 -0
  6. package/dist/ai/index.mjs +4924 -0
  7. package/dist/ai/index.mjs.map +1 -0
  8. package/dist/index.cjs +15308 -0
  9. package/dist/index.cjs.map +1 -0
  10. package/dist/index.d.cts +747 -0
  11. package/dist/index.d.ts +747 -0
  12. package/dist/index.mjs +14965 -0
  13. package/dist/index.mjs.map +1 -0
  14. package/dist/media/index.cjs +1238 -0
  15. package/dist/media/index.cjs.map +1 -0
  16. package/dist/media/index.d.cts +254 -0
  17. package/dist/media/index.d.ts +254 -0
  18. package/dist/media/index.mjs +1221 -0
  19. package/dist/media/index.mjs.map +1 -0
  20. package/dist/milestoneBundleGenerator-CbXhs1CP.d.ts +62 -0
  21. package/dist/milestoneBundleGenerator-DrBtHzBO.d.cts +62 -0
  22. package/dist/pipeline/index.cjs +4894 -0
  23. package/dist/pipeline/index.cjs.map +1 -0
  24. package/dist/pipeline/index.d.cts +161 -0
  25. package/dist/pipeline/index.d.ts +161 -0
  26. package/dist/pipeline/index.mjs +4869 -0
  27. package/dist/pipeline/index.mjs.map +1 -0
  28. package/dist/provider-factory-DH3udYlN.d.cts +25 -0
  29. package/dist/provider-factory-DH3udYlN.d.ts +25 -0
  30. package/dist/publishers/index.cjs +575 -0
  31. package/dist/publishers/index.cjs.map +1 -0
  32. package/dist/publishers/index.d.cts +85 -0
  33. package/dist/publishers/index.d.ts +85 -0
  34. package/dist/publishers/index.mjs +561 -0
  35. package/dist/publishers/index.mjs.map +1 -0
  36. package/dist/schemas/index.cjs +1640 -0
  37. package/dist/schemas/index.cjs.map +1 -0
  38. package/dist/schemas/index.d.cts +14194 -0
  39. package/dist/schemas/index.d.ts +14194 -0
  40. package/dist/schemas/index.mjs +1517 -0
  41. package/dist/schemas/index.mjs.map +1 -0
  42. package/dist/standards/index.cjs +910 -0
  43. package/dist/standards/index.cjs.map +1 -0
  44. package/dist/standards/index.d.cts +130 -0
  45. package/dist/standards/index.d.ts +130 -0
  46. package/dist/standards/index.mjs +890 -0
  47. package/dist/standards/index.mjs.map +1 -0
  48. package/dist/standardsCoverageGate-BWAkXY76.d.cts +767 -0
  49. package/dist/standardsCoverageGate-BWAkXY76.d.ts +767 -0
  50. package/dist/storage/index.cjs +595 -0
  51. package/dist/storage/index.cjs.map +1 -0
  52. package/dist/storage/index.d.cts +77 -0
  53. package/dist/storage/index.d.ts +77 -0
  54. package/dist/storage/index.mjs +583 -0
  55. package/dist/storage/index.mjs.map +1 -0
  56. package/dist/streamRunner-C7j5LKon.d.cts +60 -0
  57. package/dist/streamRunner-C7j5LKon.d.ts +60 -0
  58. package/dist/supabasePublisher-C627qXFT.d.cts +63 -0
  59. package/dist/supabasePublisher-C627qXFT.d.ts +63 -0
  60. package/dist/types-BUJGYiep.d.cts +89 -0
  61. package/dist/types-BUJGYiep.d.ts +89 -0
  62. package/dist/workflow/index.cjs +5266 -0
  63. package/dist/workflow/index.cjs.map +1 -0
  64. package/dist/workflow/index.d.cts +295 -0
  65. package/dist/workflow/index.d.ts +295 -0
  66. package/dist/workflow/index.mjs +5216 -0
  67. package/dist/workflow/index.mjs.map +1 -0
  68. package/package.json +105 -0
@@ -0,0 +1,1238 @@
1
+ 'use strict';
2
+
3
+ var fs = require('fs');
4
+ var path2 = require('path');
5
+
6
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
7
+
8
+ var fs__default = /*#__PURE__*/_interopDefault(fs);
9
+ var path2__default = /*#__PURE__*/_interopDefault(path2);
10
+
11
+ // src/media/types.ts
12
+ var MediaError = class extends Error {
13
+ constructor(message, provider, status) {
14
+ super(message);
15
+ this.provider = provider;
16
+ this.status = status;
17
+ this.name = "MediaError";
18
+ }
19
+ };
20
+ function buildImagePrompt(options) {
21
+ const { prompt, context, style = "educational-diagram" } = options;
22
+ const parts = [];
23
+ const styleMap = {
24
+ "educational-diagram": "clean educational diagram, labeled parts, high contrast, suitable for K-12 textbook",
25
+ "realistic-photo": "realistic photograph, natural lighting, educational context",
26
+ "flat-illustration": "flat vector illustration, friendly shapes, modern educational style",
27
+ "technical-schematic": "precise technical schematic, wiring-style clarity, engineering drawing conventions"
28
+ };
29
+ parts.push(prompt);
30
+ if (context?.concept) parts.push(`Subject concept: ${context.concept}`);
31
+ if (context?.lessonTitle) parts.push(`From lesson: ${context.lessonTitle}`);
32
+ if (context?.gradeBand) {
33
+ const band = Array.isArray(context.gradeBand) ? context.gradeBand : [context.gradeBand, context.gradeBand];
34
+ parts.push(`Audience: grade ${band[0]}-${band[1]} students, age-appropriate, no scary or unsafe imagery`);
35
+ }
36
+ if (context?.styleGuideExcerpt) parts.push(`Visual style guide: ${context.styleGuideExcerpt.slice(0, 200)}`);
37
+ parts.push(styleMap[style] || styleMap["educational-diagram"]);
38
+ if (options.aspectRatio === "16:9") parts.push("wide 16:9 composition");
39
+ return parts.join(". ");
40
+ }
41
+
42
+ // src/media/imageProviders.ts
43
+ function resolveApiKey(options) {
44
+ const provider = options.provider || "openai";
45
+ const key = options.apiKey || (provider === "openai" ? process.env.OPENAI_API_KEY : void 0) || (provider === "gemini" ? process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY : void 0) || "";
46
+ if (!key) {
47
+ throw new MediaError(`API key missing for image provider '${provider}'. Set GEMINI_API_KEY or OPENAI_API_KEY.`, provider);
48
+ }
49
+ return { provider, key };
50
+ }
51
+ async function generateWithGemini(options, key) {
52
+ const finalPrompt = buildImagePrompt(options);
53
+ const model = "gemini-2.0-flash-exp";
54
+ const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${key}`, {
55
+ method: "POST",
56
+ headers: { "Content-Type": "application/json" },
57
+ body: JSON.stringify({
58
+ contents: [{ parts: [{ text: finalPrompt }] }],
59
+ generationConfig: { responseModalities: ["TEXT", "IMAGE"] }
60
+ }),
61
+ signal: AbortSignal.timeout(9e4)
62
+ });
63
+ if (!res.ok) {
64
+ const body = await res.text().catch(() => "");
65
+ throw new MediaError(`Gemini image API ${res.status}: ${body.slice(0, 300)}`, "gemini", res.status);
66
+ }
67
+ const data = await res.json();
68
+ const parts = data?.candidates?.[0]?.content?.parts || [];
69
+ const imagePart = parts.find((p) => p.inlineData?.data);
70
+ if (!imagePart) {
71
+ throw new MediaError("Gemini returned no image part (may have refused prompt).", "gemini");
72
+ }
73
+ const textPart = parts.find((p) => p.text);
74
+ return {
75
+ base64: imagePart.inlineData.data,
76
+ mimeType: imagePart.inlineData.mimeType || "image/png",
77
+ provider: "gemini",
78
+ prompt: finalPrompt,
79
+ revisedPrompt: textPart?.text
80
+ };
81
+ }
82
+ async function generateWithOpenAI(options, key) {
83
+ const finalPrompt = buildImagePrompt(options);
84
+ const sizeMap = { "1:1": "1024x1024", "16:9": "1536x1024", "4:3": "1024x1024", "3:4": "1024x1536" };
85
+ const res = await fetch("https://api.openai.com/v1/images/generations", {
86
+ method: "POST",
87
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
88
+ body: JSON.stringify({
89
+ model: "gpt-image-1",
90
+ prompt: finalPrompt,
91
+ size: sizeMap[options.aspectRatio || "1:1"] || "1024x1024",
92
+ n: 1
93
+ }),
94
+ signal: AbortSignal.timeout(9e4)
95
+ });
96
+ if (!res.ok) {
97
+ const body = await res.text().catch(() => "");
98
+ throw new MediaError(`OpenAI image API ${res.status}: ${body.slice(0, 300)}`, "openai", res.status);
99
+ }
100
+ const data = await res.json();
101
+ const item = data?.data?.[0];
102
+ if (!item?.b64_json) {
103
+ throw new MediaError("OpenAI returned no image data.", "openai");
104
+ }
105
+ return {
106
+ base64: item.b64_json,
107
+ mimeType: "image/png",
108
+ provider: "openai",
109
+ prompt: finalPrompt,
110
+ revisedPrompt: item.revised_prompt
111
+ };
112
+ }
113
+ async function generateEducationalImage(options) {
114
+ const { provider, key } = resolveApiKey(options);
115
+ if (provider === "openai") return generateWithOpenAI(options, key);
116
+ return generateWithGemini(options, key);
117
+ }
118
+
119
+ // src/media/stockSearch.ts
120
+ async function searchEducationalImages(options) {
121
+ const key = options.apiKey || process.env.PEXELS_API_KEY || "";
122
+ if (!key) throw new MediaError("Pexels API key missing. Set PEXELS_API_KEY.", "pexels");
123
+ const params = new URLSearchParams({
124
+ query: options.query,
125
+ per_page: String(options.perPage ?? 6),
126
+ orientation: options.orientation || "landscape"
127
+ });
128
+ const res = await fetch(`https://api.pexels.com/v1/search?${params}`, {
129
+ headers: { Authorization: key },
130
+ signal: AbortSignal.timeout(3e4)
131
+ });
132
+ if (!res.ok) {
133
+ throw new MediaError(`Pexels image search ${res.status}`, "pexels", res.status);
134
+ }
135
+ const data = await res.json();
136
+ return (data?.photos || []).map((p) => ({
137
+ url: p.src?.large2x || p.src?.large || p.src?.original,
138
+ thumbnailUrl: p.src?.medium,
139
+ width: p.width,
140
+ height: p.height,
141
+ photographer: p.photographer,
142
+ source: "pexels",
143
+ license: "Pexels License (free to use, attribution appreciated)",
144
+ pageUrl: p.url,
145
+ altText: p.alt
146
+ }));
147
+ }
148
+ async function searchEducationalVideos(options) {
149
+ const key = options.apiKey || process.env.PEXELS_API_KEY || "";
150
+ if (!key) throw new MediaError("Pexels API key missing. Set PEXELS_API_KEY.", "pexels");
151
+ const params = new URLSearchParams({
152
+ query: options.query,
153
+ per_page: String(options.perPage ?? 4),
154
+ orientation: "landscape"
155
+ });
156
+ const res = await fetch(`https://api.pexels.com/videos/search?${params}`, {
157
+ headers: { Authorization: key },
158
+ signal: AbortSignal.timeout(3e4)
159
+ });
160
+ if (!res.ok) {
161
+ throw new MediaError(`Pexels video search ${res.status}`, "pexels", res.status);
162
+ }
163
+ const data = await res.json();
164
+ return (data?.videos || []).map((v) => {
165
+ const files = v.video_files || [];
166
+ const best = files.filter((f) => f.quality === "hd").sort((a, b) => (a.width || 0) - (b.width || 0))[0] || files[0];
167
+ return {
168
+ url: best?.link,
169
+ thumbnailUrl: v.image,
170
+ durationSeconds: v.duration,
171
+ width: best?.width,
172
+ height: best?.height,
173
+ source: "pexels",
174
+ license: "Pexels License (free to use, attribution appreciated)",
175
+ pageUrl: v.url
176
+ };
177
+ });
178
+ }
179
+ var MediaLedger = class {
180
+ filePath;
181
+ entries = /* @__PURE__ */ new Map();
182
+ constructor(projectDir, lessonId) {
183
+ this.filePath = path2__default.default.join(projectDir, "_media", `ledger-${lessonId.toUpperCase()}.jsonl`);
184
+ this.load();
185
+ }
186
+ load() {
187
+ this.entries.clear();
188
+ if (!fs__default.default.existsSync(this.filePath)) return;
189
+ const lines = fs__default.default.readFileSync(this.filePath, "utf-8").split("\n");
190
+ for (const line of lines) {
191
+ const trimmed = line.trim();
192
+ if (!trimmed) continue;
193
+ try {
194
+ const e = JSON.parse(trimmed);
195
+ this.entries.set(e.slug, e);
196
+ } catch {
197
+ }
198
+ }
199
+ }
200
+ persist() {
201
+ fs__default.default.mkdirSync(path2__default.default.dirname(this.filePath), { recursive: true });
202
+ fs__default.default.writeFileSync(this.filePath, Array.from(this.entries.values()).map((e) => JSON.stringify(e)).join("\n") + "\n", "utf-8");
203
+ }
204
+ /** Agent tạo artifact ghi entry mới (hoặc cập nhật nếu slug đã có và chưa done) */
205
+ upsert(input) {
206
+ const existing = this.entries.get(input.slug);
207
+ if (existing && existing.status === "done") return existing;
208
+ const entry = { ...input, status: input.status ?? "pending", updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
209
+ this.entries.set(input.slug, entry);
210
+ this.persist();
211
+ return entry;
212
+ }
213
+ get(slug) {
214
+ return this.entries.get(slug);
215
+ }
216
+ list() {
217
+ return Array.from(this.entries.values());
218
+ }
219
+ listByStatus(status) {
220
+ return this.list().filter((e) => e.status === status);
221
+ }
222
+ pending() {
223
+ return this.listByStatus("pending");
224
+ }
225
+ setStatus(slug, status, patch = {}) {
226
+ const e = this.entries.get(slug);
227
+ if (!e) return void 0;
228
+ Object.assign(e, patch, { status, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
229
+ if (status === "failed") e.retryCount = (e.retryCount ?? 0) + 1;
230
+ this.entries.set(slug, e);
231
+ this.persist();
232
+ return e;
233
+ }
234
+ /** Đếm ảnh đã done trong lesson — để Curator tôn trọng maxImages */
235
+ doneCount() {
236
+ return this.listByStatus("done").length;
237
+ }
238
+ };
239
+
240
+ // src/errors/index.ts
241
+ var CurriculumError = class _CurriculumError extends Error {
242
+ detail;
243
+ constructor(detail) {
244
+ super(`[${detail.errorCode}] ${detail.message}`);
245
+ this.name = "CurriculumError";
246
+ this.detail = {
247
+ ...detail,
248
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
249
+ };
250
+ Object.setPrototypeOf(this, _CurriculumError.prototype);
251
+ }
252
+ get errorCode() {
253
+ return this.detail.errorCode;
254
+ }
255
+ get retryable() {
256
+ return this.detail.retryable;
257
+ }
258
+ get suggestedAction() {
259
+ return this.detail.suggestedAction;
260
+ }
261
+ toJSON() {
262
+ return this.detail;
263
+ }
264
+ };
265
+ function createAiInferenceError(params) {
266
+ const {
267
+ errorCode,
268
+ message,
269
+ provider,
270
+ statusCode,
271
+ agent,
272
+ artifactType,
273
+ lessonId,
274
+ rawError,
275
+ suggestedAction = getSuggestedActionForCode(errorCode),
276
+ retryable = isCodeRetryable(errorCode)
277
+ } = params;
278
+ return new CurriculumError({
279
+ errorCode,
280
+ message,
281
+ provider,
282
+ statusCode,
283
+ agent,
284
+ artifactType,
285
+ lessonId,
286
+ suggestedAction,
287
+ retryable,
288
+ rawError: typeof rawError === "string" ? rawError : rawError?.message || JSON.stringify(rawError)
289
+ });
290
+ }
291
+ function getSuggestedActionForCode(code) {
292
+ switch (code) {
293
+ case "ERR_AI_NO_API_KEY":
294
+ return "V\xE0o Settings > API Keys \u0111\u1EC3 c\u1EA5u h\xECnh \xEDt nh\u1EA5t m\u1ED9t kh\xF3a API (Gemini, DeepSeek, OpenRouter, Alibaba ho\u1EB7c NVIDIA).";
295
+ case "ERR_AI_RATE_LIMIT":
296
+ return "API \u0111\xE3 v\u01B0\u1EE3t qu\xE1 gi\u1EDBi h\u1EA1n l\u01B0\u1EE3t g\u1ECDi (Rate Limit HTTP 429). H\xE3y ch\u1EDD 30-60 gi\xE2y r\u1ED3i b\u1EA5m Rerun ho\u1EB7c chuy\u1EC3n sang Provider d\u1EF1 ph\xF2ng.";
297
+ case "ERR_AI_AUTH_FAILED":
298
+ return "API Key kh\xF4ng h\u1EE3p l\u1EC7 ho\u1EB7c \u0111\xE3 h\u1EBFt h\u1EA1n (HTTP 401/403). Vui l\xF2ng ki\u1EC3m tra l\u1EA1i Key trong m\u1EE5c C\xE0i \u0111\u1EB7t.";
299
+ case "ERR_AI_NETWORK_TIMEOUT":
300
+ return "K\u1EBFt n\u1ED1i m\u1EA1ng t\u1EDBi m\xE1y ch\u1EE7 AI b\u1ECB qu\xE1 th\u1EDDi gian ch\u1EDD (Timeout). Vui l\xF2ng ki\u1EC3m tra \u0111\u01B0\u1EDDng truy\u1EC1n v\xE0 b\u1EA5m Rerun.";
301
+ case "ERR_AI_RESPONSE_MALFORMED":
302
+ return "M\xF4 h\xECnh AI tr\u1EA3 v\u1EC1 k\u1EBFt qu\u1EA3 r\u1ED7ng ho\u1EB7c kh\xF4ng \u0111\xFAng \u0111\u1ECBnh d\u1EA1ng markdown. B\u1EA5m Rerun \u0111\u1EC3 y\xEAu c\u1EA7u AI sinh l\u1EA1i.";
303
+ case "ERR_AI_ALL_PROVIDERS_FAILED":
304
+ return "To\xE0n b\u1ED9 c\xE1c nh\xE0 cung c\u1EA5p trong chu\u1ED7i Fallback \u0111\u1EC1u th\u1EA5t b\u1EA1i. Vui l\xF2ng ki\u1EC3m tra k\u1EBFt n\u1ED1i Internet v\xE0 h\u1EA1n m\u1EE9c Token.";
305
+ case "ERR_SOT_MISSING":
306
+ return "Thi\u1EBFu t\xE0i li\u1EC7u n\u1EC1n t\u1EA3ng SOT (CURRICULUM_FRAMEWORK.md ho\u1EB7c LEARNER_PROFILE.md). H\xE3y ch\u1EA1y workflow Kh\u1EDFi t\u1EA1o d\u1EF1 \xE1n tr\u01B0\u1EDBc.";
307
+ case "ERR_TASK_GATE_BLOCKED":
308
+ return "H\u1ECDc li\u1EC7u ph\xEDa sau b\u1ECB ch\u1EB7n do Gi\xE1o \xE1n (LESSON) ph\xEDa tr\u01B0\u1EDBc ch\u01B0a \u0111\u01B0\u1EE3c Ph\xEA duy\u1EC7t (Approval Gate). H\xE3y b\u1EA5m Ph\xEA duy\u1EC7t Gi\xE1o \xE1n \u0111\u1EC3 m\u1EDF kh\xF3a.";
309
+ case "ERR_METERED_LIMIT_EXCEEDED":
310
+ return "Ch\xEDnh s\xE1ch Metered Execution gi\u1EDBi h\u1EA1n t\u1ED1i \u0111a 2 b\xE0i h\u1ECDc m\u1ED7i \u0111\u1EE3t. Vui l\xF2ng ph\xEA duy\u1EC7t c\xE1c b\xE0i hi\u1EC7n t\u1EA1i tr\u01B0\u1EDBc khi t\u1EA1o th\xEAm.";
311
+ case "ERR_SCHEMA_CONTRACT_VIOLATION":
312
+ return "N\u1ED9i dung sinh ra thi\u1EBFu c\xE1c ph\u1EA7n b\u1EAFt bu\u1ED9c [REQUIRED]. H\xE3y k\xEDch ho\u1EA1t ki\u1EC3m th\u1EED ho\u1EB7c b\u1EA5m Rerun.";
313
+ case "ERR_STORAGE_IO_FAILED":
314
+ return "L\u1ED7i \u0111\u1ECDc/ghi t\u1EC7p tin tr\xEAn h\u1EC7 th\u1ED1ng l\u01B0u tr\u1EEF. Ki\u1EC3m tra quy\u1EC1n ghi th\u01B0 m\u1EE5c output/projects.";
315
+ default:
316
+ return "B\u1EA5m Rerun \u0111\u1EC3 th\u1EED l\u1EA1i ho\u1EB7c ki\u1EC3m tra nh\u1EADt k\xFD h\u1EC7 th\u1ED1ng.";
317
+ }
318
+ }
319
+ function isCodeRetryable(code) {
320
+ switch (code) {
321
+ case "ERR_AI_RATE_LIMIT":
322
+ case "ERR_AI_NETWORK_TIMEOUT":
323
+ case "ERR_AI_RESPONSE_MALFORMED":
324
+ case "ERR_STORAGE_IO_FAILED":
325
+ return true;
326
+ case "ERR_AI_NO_API_KEY":
327
+ case "ERR_AI_AUTH_FAILED":
328
+ case "ERR_SOT_MISSING":
329
+ case "ERR_TASK_GATE_BLOCKED":
330
+ case "ERR_METERED_LIMIT_EXCEEDED":
331
+ case "ERR_SCHEMA_CONTRACT_VIOLATION":
332
+ return false;
333
+ default:
334
+ return true;
335
+ }
336
+ }
337
+ function resolveApiKey2(keyName, explicitKey) {
338
+ if (explicitKey && explicitKey.trim()) return explicitKey.trim();
339
+ if (typeof process !== "undefined" && process.env && process.env[keyName]) {
340
+ return process.env[keyName];
341
+ }
342
+ try {
343
+ const currentDir = process.cwd();
344
+ const envPaths = [
345
+ path2__default.default.resolve(currentDir, ".env.local"),
346
+ path2__default.default.resolve(currentDir, ".env"),
347
+ path2__default.default.resolve(currentDir, "apps/web/.env.local"),
348
+ path2__default.default.resolve(currentDir, "packages/learnwell-platform/.env"),
349
+ path2__default.default.resolve(currentDir, "..", ".env.local"),
350
+ path2__default.default.resolve(currentDir, "..", ".env"),
351
+ path2__default.default.resolve(currentDir, "../..", ".env.local"),
352
+ path2__default.default.resolve(currentDir, "../..", ".env")
353
+ ];
354
+ for (const envPath of envPaths) {
355
+ if (fs__default.default.existsSync(envPath)) {
356
+ const content = fs__default.default.readFileSync(envPath, "utf-8");
357
+ for (const line of content.split("\n")) {
358
+ const trimmed = line.trim();
359
+ if (trimmed && !trimmed.startsWith("#") && trimmed.startsWith(keyName + "=")) {
360
+ return trimmed.substring(keyName.length + 1).trim().replace(/^["']|["']$/g, "");
361
+ }
362
+ }
363
+ }
364
+ }
365
+ } catch {
366
+ }
367
+ return "";
368
+ }
369
+ function isProviderEnabled(provider) {
370
+ const p = provider.toLowerCase();
371
+ const explicitVal = resolveApiKey2(`ENABLE_${p.toUpperCase()}`);
372
+ if (explicitVal !== "") {
373
+ return explicitVal === "true" || explicitVal === "1";
374
+ }
375
+ const allowedList = resolveApiKey2("ALLOWED_AI_PROVIDERS");
376
+ if (allowedList && allowedList.trim()) {
377
+ const allowed = allowedList.split(",").map((s) => s.trim().toLowerCase());
378
+ return allowed.includes(p);
379
+ }
380
+ const disabledList = resolveApiKey2("DISABLED_AI_PROVIDERS");
381
+ if (disabledList && disabledList.trim()) {
382
+ const disabled = disabledList.split(",").map((s) => s.trim().toLowerCase());
383
+ if (disabled.includes(p)) return false;
384
+ }
385
+ return true;
386
+ }
387
+ function isModelAllowed(model) {
388
+ const allowedList = resolveApiKey2("ALLOWED_AI_MODELS");
389
+ if (!allowedList || !allowedList.trim()) return true;
390
+ const patterns = allowedList.split(",").map((s) => s.trim());
391
+ return patterns.some((pattern) => {
392
+ if (pattern === "*" || pattern === model) return true;
393
+ if (pattern.endsWith("*") && model.startsWith(pattern.slice(0, -1))) return true;
394
+ if (pattern.startsWith("*") && model.endsWith(pattern.slice(1))) return true;
395
+ return false;
396
+ });
397
+ }
398
+ function getDesignatedFallbackChain() {
399
+ const rawProviders = resolveApiKey2("FALLBACK_AI_PROVIDER") || resolveApiKey2("FALLBACK_AI_PROVIDERS") || resolveApiKey2("DEFAULT_AI_PROVIDER") || "nvidia,openrouter";
400
+ const rawModels = resolveApiKey2("FALLBACK_AI_MODEL") || resolveApiKey2("FALLBACK_AI_MODELS") || resolveApiKey2("DEFAULT_AI_MODEL") || "nvidia/nemotron-3-ultra-550b-a55b,@preset/coding-free,deepseek/deepseek-chat:free";
401
+ const providerList = rawProviders.split(",").map((p) => p.trim().toLowerCase()).filter((p) => p.length > 0 && isProviderEnabled(p));
402
+ const modelList = rawModels.split(",").map((m) => m.trim()).filter((m) => m.length > 0 && isModelAllowed(m));
403
+ const chain = [];
404
+ for (const provider of providerList) {
405
+ let providerModels = [];
406
+ if (provider === "nvidia") {
407
+ providerModels = modelList.filter((m) => m.startsWith("nvidia/") || m.startsWith("meta/") || m.startsWith("mistralai/"));
408
+ if (providerModels.length === 0) {
409
+ providerModels = ["nvidia/nemotron-3-ultra-550b-a55b", "nvidia/nemotron-3.5-lightning-30b-a3b"];
410
+ }
411
+ } else if (provider === "openrouter") {
412
+ providerModels = modelList.filter((m) => m.startsWith("@") || m.includes("/") || m.includes(":"));
413
+ if (providerModels.length === 0) {
414
+ providerModels = ["@preset/coding-free", "deepseek/deepseek-chat:free", "meta-llama/llama-3.3-70b-instruct:free"];
415
+ }
416
+ } else if (provider === "alibaba" || provider === "dashscope") {
417
+ providerModels = modelList.filter((m) => m.startsWith("qwen") || m.includes("deepseek"));
418
+ if (providerModels.length === 0) {
419
+ providerModels = ["deepseek-v4-flash-0731", "qwen-plus", "qwen3.7-plus"];
420
+ }
421
+ } else if (provider === "google" || provider === "gemini") {
422
+ providerModels = modelList.filter((m) => m.startsWith("gemini"));
423
+ if (providerModels.length === 0) {
424
+ providerModels = ["gemini-2.0-flash", "gemini-1.5-flash"];
425
+ }
426
+ } else if (provider === "deepseek") {
427
+ providerModels = modelList.filter((m) => m.startsWith("deepseek"));
428
+ if (providerModels.length === 0) {
429
+ providerModels = ["deepseek-chat", "deepseek-reasoner"];
430
+ }
431
+ } else {
432
+ providerModels = modelList.length > 0 ? modelList : ["default"];
433
+ }
434
+ for (const model of providerModels) {
435
+ if (!chain.some((c) => c.provider === provider && c.model === model)) {
436
+ chain.push({ provider, model });
437
+ }
438
+ }
439
+ }
440
+ return chain;
441
+ }
442
+ function extractThoughtAndContent(rawText) {
443
+ let text = rawText || "";
444
+ let thought = "";
445
+ const thinkMatch = text.match(/<think>([\s\S]*?)<\/think>/i);
446
+ if (thinkMatch) {
447
+ thought = thinkMatch[1].trim();
448
+ text = text.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
449
+ } else if (text.includes("<think>")) {
450
+ const parts = text.split("<think>");
451
+ text = parts[0].trim();
452
+ thought = parts[1].trim();
453
+ }
454
+ if (!thought && (text.startsWith("Here's a thinking process:") || text.startsWith("Thinking Process:"))) {
455
+ const splitRegex = /\n(?=(?:#|##|###|🎯|🧭|📋|🚀|1\.\s+[A-Z\u00C0-\u1EF9]|---))/m;
456
+ const match = text.search(splitRegex);
457
+ if (match > 0) {
458
+ thought = text.slice(0, match).trim();
459
+ text = text.slice(match).trim();
460
+ }
461
+ }
462
+ let content = text.trim();
463
+ if (content.startsWith("```markdown")) {
464
+ content = content.replace(/^```markdown\s*\n/i, "").replace(/\n```\s*$/i, "");
465
+ } else if (content.startsWith("```md")) {
466
+ content = content.replace(/^```md\s*\n/i, "").replace(/\n```\s*$/i, "");
467
+ } else if (content.startsWith("```") && content.endsWith("```") && !content.includes("---")) {
468
+ content = content.replace(/^```\w*\s*\n/i, "").replace(/\n```\s*$/i, "");
469
+ }
470
+ return {
471
+ thought: thought.trim(),
472
+ content: content.trim()
473
+ };
474
+ }
475
+ function createIdleAbortController(idleMs) {
476
+ const controller = new AbortController();
477
+ let timer = null;
478
+ const arm = () => {
479
+ if (timer) clearTimeout(timer);
480
+ timer = setTimeout(
481
+ () => controller.abort(new Error(`Idle timeout: no data for ${Math.round(idleMs / 1e3)}s`)),
482
+ idleMs
483
+ );
484
+ timer?.unref?.();
485
+ };
486
+ arm();
487
+ return {
488
+ signal: controller.signal,
489
+ /** Reset the idle window (call on every received chunk). */
490
+ kick: arm,
491
+ /** Clear the pending timer once the request lifecycle is over. */
492
+ dispose: () => {
493
+ if (timer) clearTimeout(timer);
494
+ timer = null;
495
+ }
496
+ };
497
+ }
498
+ async function processOpenAISSEStream(response, onChunk, idle, toolCallCollector) {
499
+ if (!response.body) return "";
500
+ const reader = response.body.getReader();
501
+ const decoder = new TextDecoder("utf-8");
502
+ let fullRaw = "";
503
+ let buffer = "";
504
+ let insideThinkTag = false;
505
+ while (true) {
506
+ const { done, value } = await reader.read();
507
+ if (done) break;
508
+ idle?.kick();
509
+ buffer += decoder.decode(value, { stream: true });
510
+ const lines = buffer.split("\n");
511
+ buffer = lines.pop() || "";
512
+ for (const line of lines) {
513
+ const trimmed = line.trim();
514
+ if (!trimmed || trimmed.startsWith(":")) continue;
515
+ if (trimmed === "data: [DONE]") continue;
516
+ if (trimmed.startsWith("data: ")) {
517
+ try {
518
+ const json = JSON.parse(trimmed.substring(6));
519
+ if (json.usage) {
520
+ const promptTokens = json.usage.prompt_tokens || json.usage.input_tokens || 0;
521
+ const completionTokens = json.usage.completion_tokens || json.usage.output_tokens || 0;
522
+ const totalTokens = json.usage.total_tokens || promptTokens + completionTokens;
523
+ const reasoningTokens = json.usage.completion_tokens_details?.reasoning_tokens || 0;
524
+ if (onChunk) ;
525
+ }
526
+ const delta = json.choices?.[0]?.delta;
527
+ if (!delta) continue;
528
+ const reasoning = delta.reasoning_content || delta.thought || delta.reasoning || delta.thought_process || "";
529
+ if (reasoning) {
530
+ if (onChunk) ;
531
+ }
532
+ if (delta.tool_calls) {
533
+ const toolStr = JSON.stringify(delta.tool_calls);
534
+ if (onChunk) ;
535
+ if (toolCallCollector) ;
536
+ }
537
+ let content = delta.content || "";
538
+ if (content) {
539
+ fullRaw += content;
540
+ if (content.includes("<think>")) {
541
+ insideThinkTag = true;
542
+ const parts = content.split("<think>");
543
+ if (parts[0] && onChunk) onChunk(parts[0], "content");
544
+ content = parts[1] || "";
545
+ }
546
+ if (content.includes("</think>")) {
547
+ insideThinkTag = false;
548
+ const parts = content.split("</think>");
549
+ if (parts[0] && onChunk) ;
550
+ content = parts[1] || "";
551
+ }
552
+ if (insideThinkTag) {
553
+ if (onChunk && content) ;
554
+ } else if (content) {
555
+ if (onChunk) ;
556
+ }
557
+ }
558
+ } catch {
559
+ }
560
+ }
561
+ }
562
+ }
563
+ idle?.dispose();
564
+ const extracted = extractThoughtAndContent(fullRaw);
565
+ return extracted.content || fullRaw;
566
+ }
567
+ async function processGeminiSSEStream(response, onChunk, idle) {
568
+ if (!response.body) return "";
569
+ const reader = response.body.getReader();
570
+ const decoder = new TextDecoder("utf-8");
571
+ let fullText = "";
572
+ let buffer = "";
573
+ while (true) {
574
+ const { done, value } = await reader.read();
575
+ if (done) break;
576
+ idle?.kick();
577
+ buffer += decoder.decode(value, { stream: true });
578
+ const lines = buffer.split("\n");
579
+ buffer = lines.pop() || "";
580
+ for (const line of lines) {
581
+ const trimmed = line.trim();
582
+ if (!trimmed || trimmed.startsWith(":")) continue;
583
+ if (trimmed.startsWith("data: ")) {
584
+ try {
585
+ const json = JSON.parse(trimmed.substring(6));
586
+ if (json.usageMetadata) {
587
+ const promptTokens = json.usageMetadata.promptTokenCount || 0;
588
+ const completionTokens = json.usageMetadata.candidatesTokenCount || 0;
589
+ const totalTokens = json.usageMetadata.totalTokenCount || promptTokens + completionTokens;
590
+ if (onChunk) ;
591
+ }
592
+ const parts = json.candidates?.[0]?.content?.parts || [];
593
+ for (const part of parts) {
594
+ if (part.thought) {
595
+ if (onChunk) ;
596
+ } else if (part.text) {
597
+ fullText += part.text;
598
+ if (onChunk) ;
599
+ }
600
+ }
601
+ } catch {
602
+ }
603
+ }
604
+ }
605
+ }
606
+ idle?.dispose();
607
+ return fullText;
608
+ }
609
+ async function streamCurriculumAIInference(messages, projectContext, options = {}, onChunk) {
610
+ const openrouterKey = resolveApiKey2("OPENROUTER_API_KEY", options.openrouterKey);
611
+ const deepseekKey = resolveApiKey2("DEEPSEEK_API_KEY", options.deepseekKey);
612
+ const alibabaKey = resolveApiKey2("ALIBABA_API_KEY", options.alibabaKey) || resolveApiKey2("DASHSCOPE_API_KEY");
613
+ const geminiKey = resolveApiKey2("GEMINI_API_KEY", options.geminiKey) || resolveApiKey2("NEXT_PUBLIC_GEMINI_API_KEY");
614
+ const nvidiaKey = resolveApiKey2("NVIDIA_API_KEY", options.nvidiaKey);
615
+ const temperature = options.temperature ?? 0.3;
616
+ const maxTokens = options.maxTokens ?? 32768;
617
+ const basePersona = options.systemPersona || "You are Curriculum OS Assistant \u2014 Senior AI Technical Curriculum Architect & Educational Content Specialist.";
618
+ const systemInstruction = `${basePersona}
619
+
620
+ Project Context:
621
+ ${projectContext}
622
+
623
+ Guidelines:
624
+ 1. You have full context of the project hierarchy, syllabus, lessons, and learning objectives. Answer precisely, deeply, and directly.
625
+ 2. Complete responses fully without truncation. When generating tables or roadmaps, include all sections/milestones.
626
+ 3. Keep technical terminology and official API symbols intact.
627
+ 4. Format output using clean Markdown, clear sections, bullet points, and code blocks where required.
628
+ 5. Do NOT use emojis in headings, tab titles, or UI navigation labels.`;
629
+ const formattedMessages = [
630
+ { role: "system", content: systemInstruction },
631
+ ...messages.map((m) => ({
632
+ role: m.role === "assistant" || m.role === "model" ? "assistant" : "user",
633
+ content: m.content
634
+ }))
635
+ ];
636
+ const providerErrors = {};
637
+ const requestedModel = options.model || "@preset/coding-free";
638
+ const isNvidiaPreferred = requestedModel.startsWith("nvidia/") || Boolean(nvidiaKey && !openrouterKey) && isProviderEnabled("nvidia");
639
+ if (nvidiaKey && isProviderEnabled("nvidia")) {
640
+ const candidateNemotronModels = [
641
+ requestedModel.startsWith("nvidia/") ? requestedModel : "nvidia/nemotron-3-ultra-550b-a55b",
642
+ "nvidia/nemotron-3-ultra-550b-a55b",
643
+ "nvidia/nemotron-3.5-lightning-30b-a3b",
644
+ "nvidia/nemotron-3-super-120b-a12b",
645
+ "nvidia/llama-3.1-nemotron-70b-instruct"
646
+ ].filter((m) => isModelAllowed(m));
647
+ const uniqueNvidiaModels = Array.from(new Set(candidateNemotronModels));
648
+ if (isNvidiaPreferred || uniqueNvidiaModels.length > 0) {
649
+ for (const model of uniqueNvidiaModels) {
650
+ try {
651
+ const idle = createIdleAbortController(18e4);
652
+ const res = await fetch("https://integrate.api.nvidia.com/v1/chat/completions", {
653
+ method: "POST",
654
+ headers: {
655
+ "Authorization": `Bearer ${nvidiaKey}`,
656
+ "Content-Type": "application/json"
657
+ },
658
+ body: JSON.stringify({
659
+ model,
660
+ messages: formattedMessages,
661
+ max_tokens: maxTokens,
662
+ temperature,
663
+ stream: true,
664
+ stream_options: { include_usage: true }
665
+ }),
666
+ signal: idle.signal
667
+ });
668
+ if (res.ok) {
669
+ const text = await processOpenAISSEStream(res, onChunk, idle);
670
+ if (text && text.trim()) return text;
671
+ } else {
672
+ const errText = await res.text().catch(() => "");
673
+ providerErrors[`nvidia_${model}`] = `HTTP ${res.status}: ${errText.slice(0, 120)}`;
674
+ }
675
+ } catch (e) {
676
+ providerErrors[`nvidia_${model}`] = e?.message || "Network error";
677
+ console.warn(`[NVIDIA Stream] Failover for ${model}:`, e?.message || e);
678
+ }
679
+ }
680
+ }
681
+ }
682
+ if (openrouterKey && isProviderEnabled("openrouter")) {
683
+ const openrouterModels = [
684
+ requestedModel,
685
+ "openrouter/free",
686
+ "nvidia/nemotron-3-super-120b-a12b:free",
687
+ "nvidia/nemotron-3-ultra-550b-a55b:free",
688
+ "nvidia/nemotron-3.5-lightning:free",
689
+ "minimax/minimax-m3:free",
690
+ "cohere/north-mini-code:free"
691
+ ].filter((m) => isModelAllowed(m));
692
+ const uniqueOrModels = Array.from(new Set(openrouterModels));
693
+ for (const model of uniqueOrModels) {
694
+ try {
695
+ const idle = createIdleAbortController(18e4);
696
+ const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
697
+ method: "POST",
698
+ headers: {
699
+ "Authorization": `Bearer ${openrouterKey}`,
700
+ "Content-Type": "application/json",
701
+ "HTTP-Referer": "https://orchable.composer",
702
+ "X-Title": "Curriculum OS Studio"
703
+ },
704
+ body: JSON.stringify({
705
+ model,
706
+ messages: formattedMessages,
707
+ max_tokens: maxTokens,
708
+ temperature,
709
+ stream: true,
710
+ stream_options: { include_usage: true }
711
+ }),
712
+ signal: idle.signal
713
+ });
714
+ if (res.ok) {
715
+ const text = await processOpenAISSEStream(res, onChunk, idle);
716
+ if (text && text.trim()) return text;
717
+ } else {
718
+ const errText = await res.text().catch(() => "");
719
+ providerErrors[`openrouter_${model}`] = `HTTP ${res.status}: ${errText.slice(0, 120)}`;
720
+ if (res.status === 429) {
721
+ console.warn(`[OpenRouter] Rate limited (429) for ${model}, trying fallback...`);
722
+ }
723
+ }
724
+ } catch (e) {
725
+ providerErrors[`openrouter_${model}`] = e?.message || "Network error";
726
+ console.warn(`[OpenRouter Stream] Failover for ${model}:`, e?.message || e);
727
+ }
728
+ }
729
+ }
730
+ if (deepseekKey && isProviderEnabled("deepseek")) {
731
+ const dsModel = options.model?.includes("deepseek-reasoner") ? "deepseek-reasoner" : "deepseek-chat";
732
+ if (isModelAllowed(dsModel)) {
733
+ try {
734
+ const idle = createIdleAbortController(18e4);
735
+ const res = await fetch("https://api.deepseek.com/chat/completions", {
736
+ method: "POST",
737
+ headers: {
738
+ "Authorization": `Bearer ${deepseekKey}`,
739
+ "Content-Type": "application/json"
740
+ },
741
+ body: JSON.stringify({
742
+ model: dsModel,
743
+ messages: formattedMessages,
744
+ max_tokens: maxTokens,
745
+ temperature,
746
+ stream: true,
747
+ stream_options: { include_usage: true }
748
+ }),
749
+ signal: idle.signal
750
+ });
751
+ if (res.ok) {
752
+ const text = await processOpenAISSEStream(res, onChunk, idle);
753
+ if (text && text.trim()) return text;
754
+ } else {
755
+ const errText = await res.text().catch(() => "");
756
+ providerErrors["deepseek"] = `HTTP ${res.status}: ${errText.slice(0, 120)}`;
757
+ }
758
+ } catch (e) {
759
+ providerErrors["deepseek"] = e?.message || "Network error";
760
+ console.warn(`[DeepSeek Stream] Failover:`, e?.message || e);
761
+ }
762
+ }
763
+ }
764
+ if (alibabaKey && isProviderEnabled("alibaba")) {
765
+ const qwenModels = (options.model?.startsWith("qwen") ? [options.model, "qwen3.7-plus", "qwen3.6-flash"] : ["qwen3.7-plus", "qwen3.6-flash"]).filter((m) => isModelAllowed(m));
766
+ const baseUrl = alibabaKey.startsWith("sk-sp-") ? "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/chat/completions" : "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions";
767
+ for (const qwenModel of qwenModels) {
768
+ try {
769
+ const idle = createIdleAbortController(18e4);
770
+ const payload = {
771
+ model: qwenModel,
772
+ messages: formattedMessages,
773
+ max_tokens: maxTokens,
774
+ temperature,
775
+ stream: true,
776
+ stream_options: { include_usage: true }
777
+ };
778
+ const res = await fetch(baseUrl, {
779
+ method: "POST",
780
+ headers: {
781
+ "Authorization": `Bearer ${alibabaKey}`,
782
+ "Content-Type": "application/json"
783
+ },
784
+ body: JSON.stringify(payload),
785
+ signal: idle.signal
786
+ });
787
+ if (res.ok) {
788
+ const text = await processOpenAISSEStream(res, onChunk, idle);
789
+ if (text && text.trim()) return text;
790
+ } else {
791
+ const errText = await res.text().catch(() => "");
792
+ providerErrors[`alibaba_${qwenModel}`] = `HTTP ${res.status}: ${errText.slice(0, 120)}`;
793
+ }
794
+ } catch (e) {
795
+ providerErrors[`alibaba_${qwenModel}`] = e?.message || "Network error";
796
+ console.warn(`[Alibaba Stream] Failover for ${qwenModel}:`, e?.message || e);
797
+ }
798
+ }
799
+ }
800
+ if (geminiKey && (isProviderEnabled("gemini") || isProviderEnabled("google"))) {
801
+ const geminiModels = (options.model?.startsWith("gemini") ? [options.model, "gemini-1.5-flash", "gemini-2.0-flash"] : ["gemini-1.5-flash", "gemini-2.0-flash"]).filter((m) => isModelAllowed(m));
802
+ const geminiContents = [
803
+ { role: "user", parts: [{ text: systemInstruction }] },
804
+ { role: "model", parts: [{ text: "T\xF4i \u0111\xE3 hi\u1EC3u r\xF5 to\xE0n b\u1ED9 b\u1ED1i c\u1EA3nh d\u1EF1 \xE1n v\xE0 s\u1EB5n s\xE0ng h\u1ED7 tr\u1EE3 b\u1EA1n m\u1ED9t c\xE1ch chuy\xEAn s\xE2u, ch\xEDnh x\xE1c nh\u1EA5t." }] },
805
+ ...messages.map((m) => ({
806
+ role: m.role === "assistant" || m.role === "model" ? "model" : "user",
807
+ parts: [{ text: m.content }]
808
+ }))
809
+ ];
810
+ for (const gModel of geminiModels) {
811
+ try {
812
+ const idle = createIdleAbortController(18e4);
813
+ const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${gModel}:streamGenerateContent?alt=sse&key=${geminiKey}`, {
814
+ method: "POST",
815
+ headers: { "Content-Type": "application/json" },
816
+ body: JSON.stringify({
817
+ contents: geminiContents,
818
+ generationConfig: {
819
+ temperature,
820
+ maxOutputTokens: maxTokens
821
+ }
822
+ }),
823
+ signal: idle.signal
824
+ });
825
+ if (res.ok) {
826
+ const text = await processGeminiSSEStream(res, onChunk, idle);
827
+ if (text && text.trim()) return text;
828
+ } else {
829
+ const errText = await res.text().catch(() => "");
830
+ providerErrors[`gemini_${gModel}`] = `HTTP ${res.status}: ${errText.slice(0, 120)}`;
831
+ }
832
+ } catch (e) {
833
+ providerErrors[`gemini_${gModel}`] = e?.message || "Network error";
834
+ console.warn(`[Gemini Stream] Failover for ${gModel}:`, e?.message || e);
835
+ }
836
+ }
837
+ }
838
+ const fallbackChain = getDesignatedFallbackChain();
839
+ if (fallbackChain.length > 0) {
840
+ console.log(
841
+ `[streamRunner] \u{1F6E1}\uFE0F Primary routes failed/exhausted. Invoking designated last-resort fallback chain:`,
842
+ fallbackChain.map((c) => `${c.provider}(${c.model})`).join(" -> ")
843
+ );
844
+ for (const target of fallbackChain) {
845
+ const { provider, model } = target;
846
+ if (provider === "nvidia" && nvidiaKey && isProviderEnabled("nvidia")) {
847
+ try {
848
+ const idle = createIdleAbortController(18e4);
849
+ const res = await fetch("https://integrate.api.nvidia.com/v1/chat/completions", {
850
+ method: "POST",
851
+ headers: {
852
+ "Authorization": `Bearer ${nvidiaKey}`,
853
+ "Content-Type": "application/json"
854
+ },
855
+ body: JSON.stringify({
856
+ model,
857
+ messages: formattedMessages,
858
+ max_tokens: maxTokens,
859
+ temperature,
860
+ stream: true,
861
+ stream_options: { include_usage: true }
862
+ }),
863
+ signal: idle.signal
864
+ });
865
+ if (res.ok) {
866
+ const text = await processOpenAISSEStream(res, onChunk, idle);
867
+ if (text && text.trim()) {
868
+ console.log(`[streamRunner] \u2705 Designated fallback NVIDIA (${model}) succeeded!`);
869
+ return text;
870
+ }
871
+ } else {
872
+ const errText = await res.text().catch(() => "");
873
+ providerErrors[`designated_fallback_nvidia_${model}`] = `HTTP ${res.status}: ${errText.slice(0, 120)}`;
874
+ }
875
+ } catch (e) {
876
+ providerErrors[`designated_fallback_nvidia_${model}`] = e?.message || "Network error";
877
+ }
878
+ } else if (provider === "openrouter" && openrouterKey && isProviderEnabled("openrouter")) {
879
+ try {
880
+ const idle = createIdleAbortController(18e4);
881
+ const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
882
+ method: "POST",
883
+ headers: {
884
+ "Authorization": `Bearer ${openrouterKey}`,
885
+ "Content-Type": "application/json",
886
+ "HTTP-Referer": "https://orchable.composer",
887
+ "X-Title": "Curriculum OS Studio"
888
+ },
889
+ body: JSON.stringify({
890
+ model,
891
+ messages: formattedMessages,
892
+ max_tokens: maxTokens,
893
+ temperature,
894
+ stream: true,
895
+ stream_options: { include_usage: true }
896
+ }),
897
+ signal: idle.signal
898
+ });
899
+ if (res.ok) {
900
+ const text = await processOpenAISSEStream(res, onChunk, idle);
901
+ if (text && text.trim()) {
902
+ console.log(`[streamRunner] \u2705 Designated fallback OpenRouter (${model}) succeeded!`);
903
+ return text;
904
+ }
905
+ } else {
906
+ const errText = await res.text().catch(() => "");
907
+ providerErrors[`designated_fallback_openrouter_${model}`] = `HTTP ${res.status}: ${errText.slice(0, 120)}`;
908
+ }
909
+ } catch (e) {
910
+ providerErrors[`designated_fallback_openrouter_${model}`] = e?.message || "Network error";
911
+ }
912
+ } else if ((provider === "alibaba" || provider === "dashscope") && alibabaKey && isProviderEnabled("alibaba")) {
913
+ const baseUrl = alibabaKey.startsWith("sk-sp-") ? "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/chat/completions" : "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions";
914
+ try {
915
+ const idle = createIdleAbortController(18e4);
916
+ const res = await fetch(baseUrl, {
917
+ method: "POST",
918
+ headers: {
919
+ "Authorization": `Bearer ${alibabaKey}`,
920
+ "Content-Type": "application/json"
921
+ },
922
+ body: JSON.stringify({
923
+ model,
924
+ messages: formattedMessages,
925
+ max_tokens: maxTokens,
926
+ temperature,
927
+ stream: true,
928
+ stream_options: { include_usage: true }
929
+ }),
930
+ signal: idle.signal
931
+ });
932
+ if (res.ok) {
933
+ const text = await processOpenAISSEStream(res, onChunk, idle);
934
+ if (text && text.trim()) {
935
+ console.log(`[streamRunner] \u2705 Designated fallback Alibaba (${model}) succeeded!`);
936
+ return text;
937
+ }
938
+ } else {
939
+ const errText = await res.text().catch(() => "");
940
+ providerErrors[`designated_fallback_alibaba_${model}`] = `HTTP ${res.status}: ${errText.slice(0, 120)}`;
941
+ }
942
+ } catch (e) {
943
+ providerErrors[`designated_fallback_alibaba_${model}`] = e?.message || "Network error";
944
+ }
945
+ } else if ((provider === "google" || provider === "gemini") && geminiKey && (isProviderEnabled("gemini") || isProviderEnabled("google"))) {
946
+ try {
947
+ const idle = createIdleAbortController(18e4);
948
+ const res = await fetch(
949
+ `https://generativelanguage.googleapis.com/v1beta/models/${model}:streamGenerateContent?alt=sse&key=${geminiKey}`,
950
+ {
951
+ method: "POST",
952
+ headers: { "Content-Type": "application/json" },
953
+ body: JSON.stringify({
954
+ contents: [
955
+ { role: "user", parts: [{ text: systemInstruction }] },
956
+ { role: "model", parts: [{ text: "Understood. I will follow the project context and guidelines." }] },
957
+ ...messages.map((m) => ({
958
+ role: m.role === "assistant" || m.role === "model" ? "model" : "user",
959
+ parts: [{ text: m.content }]
960
+ }))
961
+ ],
962
+ generationConfig: {
963
+ temperature,
964
+ maxOutputTokens: maxTokens
965
+ }
966
+ }),
967
+ signal: idle.signal
968
+ }
969
+ );
970
+ if (res.ok) {
971
+ const text = await processGeminiSSEStream(res, onChunk, idle);
972
+ if (text && text.trim()) {
973
+ console.log(`[streamRunner] \u2705 Designated fallback Gemini (${model}) succeeded!`);
974
+ return text;
975
+ }
976
+ } else {
977
+ const errText = await res.text().catch(() => "");
978
+ providerErrors[`designated_fallback_gemini_${model}`] = `HTTP ${res.status}: ${errText.slice(0, 120)}`;
979
+ }
980
+ } catch (e) {
981
+ providerErrors[`designated_fallback_gemini_${model}`] = e?.message || "Network error";
982
+ }
983
+ }
984
+ }
985
+ }
986
+ const isRateLimited = Object.values(providerErrors).some((msg) => msg.includes("429"));
987
+ const isAuthFailed = Object.values(providerErrors).some((msg) => msg.includes("401") || msg.includes("403"));
988
+ const errorCode = isRateLimited ? "ERR_AI_RATE_LIMIT" : isAuthFailed ? "ERR_AI_AUTH_FAILED" : "ERR_AI_ALL_PROVIDERS_FAILED";
989
+ throw createAiInferenceError({
990
+ errorCode,
991
+ message: `Kh\xF4ng th\u1EC3 k\u1EBFt n\u1ED1i t\u1EDBi c\xE1c nh\xE0 cung c\u1EA5p AI trong chu\u1ED7i Fallback. Chi ti\u1EBFt: ${JSON.stringify(providerErrors)}`,
992
+ rawError: providerErrors
993
+ });
994
+ }
995
+ async function runCurriculumAIInference(messages, projectContext, options = {}, onChunk) {
996
+ return await streamCurriculumAIInference(messages, projectContext, options, onChunk);
997
+ }
998
+
999
+ // src/media/curator.ts
1000
+ async function curateMediaLedger(options) {
1001
+ const { ledger, artifactContent, policy, context } = options;
1002
+ const pendingEntries = ledger.pending();
1003
+ if (pendingEntries.length === 0) return [];
1004
+ const genQuota = Math.max(0, (policy.maxGenImagesPerArtifact ?? 3) - ledger.doneCount());
1005
+ const entriesBrief = pendingEntries.map((e) => `- slug=${e.slug} | mode=${e.mode} | name=${e.name} | desc=${e.description.slice(0, 300)}`).join("\n");
1006
+ const prompt = `B\u1EA1n l\xE0 Media Curator cho h\u1ECDc li\u1EC7u gi\xE1o d\u1EE5c. S\u1ED5 c\xE1i c\xF3 ${pendingEntries.length} \u1EA3nh \u0111\u1EC1 xu\u1EA5t (pending), \u0111\xE3 c\xF3 ${ledger.doneCount()} \u1EA3nh ho\xE0n t\u1EA5t, quota GEN t\u1ED1i \u0111a ${policy.maxGenImagesPerArtifact} \u1EA3nh/b\xE0i (search kho \u1EA3nh KH\xD4NG t\u1ED1n quota \u2014 x\u1EED \u1EDF t\u1EA7ng th\u1EF1c thi).
1007
+ ${context.lessonTitle ? `B\xE0i h\u1ECDc: ${context.lessonTitle}` : ""}
1008
+ ${context.gradeBand ? `Grade band: ${context.gradeBand[0]}-${context.gradeBand[1]}` : ""}
1009
+
1010
+ C\xE1c \u1EA3nh \u0111\u1EC1 xu\u1EA5t (s\u1ED5 c\xE1i):
1011
+ ${entriesBrief}
1012
+
1013
+ N\u1ED9i dung b\xE0i (\u0111\xE3 duy\u1EC7t):
1014
+ ${artifactContent.slice(0, 12e3)}
1015
+
1016
+ QUY T\u1EAEC PH\xC2N B\u1ED4 THEO 2 TR\u01AF\u1EDCNG H\u1EE2P:
1017
+ A) N\u1EBFu ${pendingEntries.length} <= ${genQuota}: APPROVE T\u1EA4T C\u1EA2 \u2014 tr\u1EEB khi c\xF3 l\xFD do C\u1EE8NG:
1018
+ - duplicate: 2+ \u1EA3nh c\xF9ng ch\u1EE7 \u0111\u1EC1 (ch\u1EC9 gi\u1EEF 1, c\xE1i c\xF2n l\u1EA1i action=drop reasonCode=duplicate)
1019
+ - content_mismatch: m\xF4 t\u1EA3 kh\xF4ng li\xEAn quan g\xEC \u0111\u1EBFn n\u1ED9i dung b\xE0i
1020
+ - low_quality_description: m\xF4 t\u1EA3 m\u01A1 h\u1ED3 \u0111\u1EBFn m\u1EE9c kh\xF4ng th\u1EC3 t\u1EA1o \u1EA3nh d\xF9ng \u0111\u01B0\u1EE3c (vd "\u1EA3nh minh h\u1ECDa chung")
1021
+ KH\xD4NG \u0111\u01B0\u1EE3c drop v\xEC "c\u1EA3m th\u1EA5y kh\xF4ng c\u1EA7n" \u2014 agent t\u1EA1o \u0111\xE3 l\u1ECDc t\u1EEB \u0111\u1EA7u.
1022
+ B) V\u1EC1 quota GEN (t\u1ED1i \u0111a ${policy.maxGenImagesPerArtifact} \u1EA3nh AI/b\xE0i): KH\xD4NG drop \u1EDF b\u01B0\u1EDBc n\xE0y v\xEC quota \u2014
1023
+ t\u1EA7ng th\u1EF1c thi s\u1EBD search kho \u1EA3nh tr\u01B0\u1EDBc cho m\u1ECDi slot, ch\u1EC9 nh\u1EEFng slot search-miss m\u1EDBi \u0111\u01B0\u1EE3c x\u1EBFp h\u1EA1ng \u0111\u1EC3 gen.
1024
+ Vi\u1EC7c c\u1EE7a b\u1EA1n \u1EDF \u0111\xE2y: approve/re-describe/drop theo 3 l\xFD do C\u1EE8NG \u1EDF tr\xEAn.
1025
+
1026
+ V\u1EDBi m\u1ED7i \u1EA3nh approve/re-describe: vi\u1EBFt enrichedPrompt TI\u1EBENG ANH chi ti\u1EBFt (subject, layout, labeled parts, educational-diagram style, age-appropriate).
1027
+ KH\xD4NG BAO GI\u1EDC approve v\u01B0\u1EE3t ${genQuota}.
1028
+
1029
+ Tr\u1EA3 v\u1EC1 CH\xCDNH X\xC1C JSON (kh\xF4ng markdown fence) cho T\u1EA4T C\u1EA2 c\xE1c slug:
1030
+ [{"slug":"...","action":"approve|drop|re-describe","reasonCode":"duplicate|content_mismatch|low_quality_description|over_quota","enrichedPrompt":"...","reason":"..."}]`;
1031
+ const raw = await runCurriculumAIInference(
1032
+ [{ role: "user", content: prompt }],
1033
+ "",
1034
+ options.llm || {}
1035
+ );
1036
+ const jsonMatch = raw.match(/\[[\s\S]*\]/);
1037
+ if (!jsonMatch) throw new MediaError(`Curator LLM tr\u1EA3 v\u1EC1 kh\xF4ng parse \u0111\u01B0\u1EE3c JSON: ${raw.slice(0, 200)}`, "curator");
1038
+ let decisions;
1039
+ try {
1040
+ decisions = JSON.parse(jsonMatch[0]);
1041
+ } catch (e) {
1042
+ throw new MediaError(`Curator JSON invalid: ${e?.message}`, "curator");
1043
+ }
1044
+ const validSlugs = new Set(pendingEntries.map((e) => e.slug));
1045
+ decisions = decisions.filter((d) => validSlugs.has(d.slug));
1046
+ const approved = decisions.filter((d) => d.action === "approve" || d.action === "re-describe");
1047
+ const overflow = approved.slice(genQuota);
1048
+ for (const d of overflow) {
1049
+ d.action = "drop";
1050
+ d.reasonCode = "over_quota";
1051
+ d.reason = "V\u01B0\u1EE3t maxImagesPerArtifact sau ki\u1EC3m tra server-side";
1052
+ }
1053
+ for (const d of decisions) {
1054
+ if (d.action === "drop" && !d.reasonCode) d.reasonCode = "none_needed";
1055
+ }
1056
+ for (const d of decisions) {
1057
+ if (d.action === "drop") {
1058
+ ledger.setStatus(d.slug, "rejected", { decisionReasonCode: d.reasonCode, decisionReason: d.reason, lastError: d.reason });
1059
+ } else {
1060
+ ledger.setStatus(d.slug, "approved", { decisionReasonCode: void 0, decisionReason: d.reason, finalPrompt: d.enrichedPrompt });
1061
+ }
1062
+ }
1063
+ return decisions;
1064
+ }
1065
+ async function rankGenCandidates(candidates, quota, context, llm) {
1066
+ if (candidates.length <= quota) {
1067
+ return { selected: candidates.map((c) => c.slug), dropped: [] };
1068
+ }
1069
+ const brief = candidates.map((c, i) => `${i + 1}. slug=${c.slug} | name=${c.name} | desc=${c.description.slice(0, 250)}`).join("\n");
1070
+ const prompt = `X\u1EBFp h\u1EA1ng ${candidates.length} \u1EA3nh minh h\u1ECDa gi\xE1o d\u1EE5c, ch\u1ECDn \u0110\xDANG ${quota} \u1EA3nh \u01B0u ti\xEAn gen (\u1EA3nh AI t\u1ED1n k\xE9m).
1071
+ ${context.lessonTitle ? `B\xE0i h\u1ECDc: ${context.lessonTitle}` : ""}
1072
+ ${context.gradeBand ? `Grade band: ${context.gradeBand[0]}-${context.gradeBand[1]}` : ""}
1073
+
1074
+ Candidates:
1075
+ ${brief}
1076
+
1077
+ Th\u1EE9 t\u1EF1 \u01B0u ti\xEAn:
1078
+ 1. Diagram/s\u01A1 \u0111\u1ED3 gi\u1EA3i th\xEDch kh\xE1i ni\u1EC7m tr\u1EEBu t\u01B0\u1EE3ng ho\u1EB7c quy tr\xECnh nhi\u1EC1u b\u01B0\u1EDBc (thi\u1EBFu l\xE0 kh\xF3 hi\u1EC3u nh\u1EA5t)
1079
+ 2. Minh h\u1ECDa step-by-step th\u1EF1c h\xE0nh (wiring, thao t\xE1c, UI)
1080
+ 3. \u1EA2nh b\u1ED1i c\u1EA3nh/\u0111\u1ED9ng l\u1EF1c (\xEDt c\u1EA7n nh\u1EA5t)
1081
+
1082
+ Tr\u1EA3 CH\xCDNH X\xC1C JSON (kh\xF4ng fence): {"selected":["slug1","slug2",...]} \u2014 \u0111\xFAng ${quota} slugs, theo th\u1EE9 t\u1EF1 \u01B0u ti\xEAn.`;
1083
+ try {
1084
+ const raw = await runCurriculumAIInference([{ role: "user", content: prompt }], "", llm || {});
1085
+ const m = raw.match(/\{[\s\S]*\}/);
1086
+ if (!m) throw new Error("no JSON in response");
1087
+ const parsed = JSON.parse(m[0]);
1088
+ const valid = (parsed.selected || []).filter((s) => candidates.some((c) => c.slug === s)).slice(0, quota);
1089
+ if (valid.length === 0) throw new Error("empty selection");
1090
+ const selectedSet = new Set(valid);
1091
+ for (const c of candidates) {
1092
+ if (valid.length >= quota) break;
1093
+ if (!selectedSet.has(c.slug)) {
1094
+ valid.push(c.slug);
1095
+ selectedSet.add(c.slug);
1096
+ }
1097
+ }
1098
+ const dropped = candidates.filter((c) => !selectedSet.has(c.slug)).map((c, i) => ({ slug: c.slug, rank: i + 1 }));
1099
+ return { selected: valid, dropped };
1100
+ } catch (err) {
1101
+ console.warn("[curator] rankGenCandidates LLM failed \u2014 fallback first-come order:", err?.message || err);
1102
+ const selected = candidates.slice(0, quota).map((c) => c.slug);
1103
+ const dropped = candidates.slice(quota).map((c, i) => ({ slug: c.slug, rank: i + 1 }));
1104
+ return { selected, dropped };
1105
+ }
1106
+ }
1107
+ async function fulfillMediaLedger(options) {
1108
+ const { ledger, projectDir, policy } = options;
1109
+ const maxRetries = options.maxRetries ?? 2;
1110
+ const approved = ledger.listByStatus("approved");
1111
+ const result = { done: [], failed: [], skipped: [] };
1112
+ const mediaDir = path2__default.default.join(projectDir, "_media");
1113
+ fs__default.default.mkdirSync(mediaDir, { recursive: true });
1114
+ const mode = policy.mode ?? "auto";
1115
+ const canSearch = mode === "search_only" || mode === "auto";
1116
+ const canGen = mode === "gen_only" || mode === "auto";
1117
+ const searchMisses = [];
1118
+ if (canSearch) {
1119
+ for (const entry of approved) {
1120
+ if ((entry.retryCount ?? 0) >= maxRetries) {
1121
+ result.skipped.push(entry.slug);
1122
+ continue;
1123
+ }
1124
+ options.onProgress?.(entry.slug, "searching");
1125
+ try {
1126
+ const images = await searchEducationalImages({
1127
+ query: entry.finalPrompt || entry.description,
1128
+ perPage: 1,
1129
+ apiKey: options.apiKeyOverrides?.pexels
1130
+ });
1131
+ const target = images[0];
1132
+ if (target) {
1133
+ ledger.setStatus(entry.slug, "done", { relPath: target.url, provider: "pexels" });
1134
+ result.done.push(entry.slug);
1135
+ options.onProgress?.(entry.slug, "done", "source: pexels");
1136
+ } else {
1137
+ searchMisses.push(entry);
1138
+ }
1139
+ } catch (err) {
1140
+ if (mode === "search_only") {
1141
+ const msg = err?.message || String(err);
1142
+ ledger.setStatus(entry.slug, "failed", { lastError: msg });
1143
+ options.onProgress?.(entry.slug, "failed", msg);
1144
+ result.failed.push(entry.slug);
1145
+ } else {
1146
+ searchMisses.push(entry);
1147
+ }
1148
+ }
1149
+ }
1150
+ } else {
1151
+ for (const entry of approved) {
1152
+ if ((entry.retryCount ?? 0) >= maxRetries) {
1153
+ result.skipped.push(entry.slug);
1154
+ continue;
1155
+ }
1156
+ searchMisses.push(entry);
1157
+ }
1158
+ }
1159
+ if (canGen && searchMisses.length > 0) {
1160
+ const genQuota = Math.max(0, (policy.maxGenImagesPerArtifact ?? 3) - ledger.doneCount());
1161
+ let toGen = [];
1162
+ if (searchMisses.length > genQuota) {
1163
+ const ranking = await rankGenCandidates(
1164
+ searchMisses.map((e) => ({ slug: e.slug, name: e.name, description: e.finalPrompt || e.description })),
1165
+ genQuota,
1166
+ options.rankingContext || {},
1167
+ options.llm
1168
+ );
1169
+ const allowed = new Set(ranking.selected);
1170
+ toGen = searchMisses.filter((e) => allowed.has(e.slug));
1171
+ for (const dropped of ranking.dropped) {
1172
+ const entry = searchMisses.find((e) => e.slug === dropped.slug);
1173
+ if (!entry) continue;
1174
+ ledger.setStatus(entry.slug, "failed", {
1175
+ lastError: `V\u01B0\u1EE3t quota gen (${policy.maxGenImagesPerArtifact}/b\xE0i) \u2014 h\u1EA1ng ${dropped.rank} khi x\u1EBFp h\u1EA1ng theo m\u1EE9c c\u1EA7n thi\u1EBFt cho vi\u1EC7c hi\u1EC3u b\xE0i`
1176
+ });
1177
+ result.failed.push(entry.slug);
1178
+ options.onProgress?.(entry.slug, "failed", "over gen quota");
1179
+ }
1180
+ } else {
1181
+ toGen = searchMisses;
1182
+ }
1183
+ for (const entry of toGen) {
1184
+ options.onProgress?.(entry.slug, "generating");
1185
+ try {
1186
+ const provider = policy.preferredProvider || "openai";
1187
+ const image = await generateEducationalImage({
1188
+ prompt: entry.finalPrompt || entry.description,
1189
+ aspectRatio: options.defaultAspect || "4:3",
1190
+ provider,
1191
+ apiKey: provider === "openai" ? options.apiKeyOverrides?.openai : options.apiKeyOverrides?.gemini
1192
+ });
1193
+ const ext = image.mimeType === "image/jpeg" ? "jpg" : "png";
1194
+ const filename = `${entry.slug}-${Date.now()}.${ext}`;
1195
+ fs__default.default.writeFileSync(path2__default.default.join(mediaDir, filename), Buffer.from(image.base64, "base64"));
1196
+ ledger.setStatus(entry.slug, "done", { relPath: `_media/${filename}`, provider: image.provider });
1197
+ result.done.push(entry.slug);
1198
+ options.onProgress?.(entry.slug, "done", `source: ${image.provider}`);
1199
+ } catch (err) {
1200
+ const msg = err?.message || String(err);
1201
+ ledger.setStatus(entry.slug, "failed", { lastError: msg });
1202
+ options.onProgress?.(entry.slug, "failed", msg);
1203
+ result.failed.push(entry.slug);
1204
+ }
1205
+ }
1206
+ }
1207
+ return result;
1208
+ }
1209
+ function injectMediaIntoMarkdown(markdown, ledger) {
1210
+ let out = markdown;
1211
+ const entries = Array.isArray(ledger) ? ledger : ledger.list();
1212
+ for (const entry of entries) {
1213
+ if (entry.status !== "done" || !entry.relPath) continue;
1214
+ const placeholder = new RegExp(`\\[IMAGE\\s*:\\s*${entry.slug}\\]`, "g");
1215
+ out = out.replace(placeholder, `![${entry.name}](${entry.relPath})`);
1216
+ }
1217
+ return out;
1218
+ }
1219
+ function renderMediaPlaceholder(entry) {
1220
+ if (entry.status === "failed") {
1221
+ return `> \u26A0\uFE0F [\u1EA2nh ${entry.name} ch\u01B0a t\u1EA1o \u0111\u01B0\u1EE3c (${entry.lastError || "l\u1ED7i kh\xF4ng r\xF5"}) \u2014 b\u1EA5m "T\u1EA1o \u1EA3nh b\xF9" \u0111\u1EC3 th\u1EED l\u1EA1i]`;
1222
+ }
1223
+ return `> \u{1F5BC}\uFE0F [\u1EA2nh \u0111ang ch\u1EDD: ${entry.name} \u2014 s\u1EBD \u0111\u01B0\u1EE3c t\u1EA1o sau khi duy\u1EC7t media]`;
1224
+ }
1225
+
1226
+ exports.MediaError = MediaError;
1227
+ exports.MediaLedger = MediaLedger;
1228
+ exports.buildImagePrompt = buildImagePrompt;
1229
+ exports.curateMediaLedger = curateMediaLedger;
1230
+ exports.fulfillMediaLedger = fulfillMediaLedger;
1231
+ exports.generateEducationalImage = generateEducationalImage;
1232
+ exports.injectMediaIntoMarkdown = injectMediaIntoMarkdown;
1233
+ exports.rankGenCandidates = rankGenCandidates;
1234
+ exports.renderMediaPlaceholder = renderMediaPlaceholder;
1235
+ exports.searchEducationalImages = searchEducationalImages;
1236
+ exports.searchEducationalVideos = searchEducationalVideos;
1237
+ //# sourceMappingURL=index.cjs.map
1238
+ //# sourceMappingURL=index.cjs.map