@thanh01.pmt/curriculum-kit 1.4.8 → 1.4.10

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/dist/index.d.cts CHANGED
@@ -365,6 +365,8 @@ interface LayerPrefillOptions {
365
365
  timeoutMs?: number;
366
366
  /** Idle (no-data) budget override for this prefill run (ms). */
367
367
  idleTimeoutMs?: number;
368
+ /** Optional parent abort signal (e.g. from HTTP request) to stop immediately on client disconnect. */
369
+ signal?: AbortSignal;
368
370
  }
369
371
  interface PreliminaryResearchResult {
370
372
  groundTruthSummary: string;
@@ -388,7 +390,7 @@ interface StreamBudget {
388
390
  /** Abort when the whole stream exceeds this total wall time (ms). 0 disables. */
389
391
  totalMs?: number;
390
392
  }
391
- declare const DEFAULT_STREAM_IDLE_MS = 90000;
393
+ declare const DEFAULT_STREAM_IDLE_MS = 180000;
392
394
  declare const DEFAULT_STREAM_TOTAL_MS = 480000;
393
395
  declare const LAYER_IDLE_BUDGET_MS: Record<number, number>;
394
396
  /**
@@ -455,7 +457,7 @@ declare function createStreamChunkExtractor(): StreamChunkExtractor;
455
457
  * createStreamChunkExtractor, whose latch is the authoritative dedupe.
456
458
  */
457
459
  declare function extractStreamChunk(part: any): ExtractedStreamChunk;
458
- declare function createStreamAbortSignal(budget: Required<StreamBudget>): StreamAbortHandle;
460
+ declare function createStreamAbortSignal(budget: Required<StreamBudget>, parentSignal?: AbortSignal): StreamAbortHandle;
459
461
  /**
460
462
  * Close an unterminated JSON object/array: append the delimiters that are
461
463
  * still open, closing any open string first. Handles streams aborted
@@ -485,7 +487,7 @@ declare function safeParseJson<T = any>(rawText: string | null): T | null;
485
487
  /**
486
488
  * Multi-tiered resilient streaming inference helper using 100% Vercel AI SDK.
487
489
  */
488
- declare function streamLLMWithFallback(systemPrompt: string, userPrompt: string, apiKeys?: Record<string, string>, onChunk?: LayerChunkCallback, preferredModel?: string, preferredProvider?: AIProviderName, budget?: StreamBudget): Promise<string | null>;
490
+ declare function streamLLMWithFallback(systemPrompt: string, userPrompt: string, apiKeys?: Record<string, string>, onChunk?: LayerChunkCallback, preferredModel?: string, preferredProvider?: AIProviderName, budget?: StreamBudget, signal?: AbortSignal): Promise<string | null>;
489
491
  /**
490
492
  * Conducts preliminary ecosystem research for a course project.
491
493
  */
@@ -540,6 +542,7 @@ declare function buildSessionSliceContext(plan: CurriculumPlan, lessonCode: stri
540
542
  interface ProjectionMeta {
541
543
  courseName: string;
542
544
  shortDescription?: string;
545
+ targetLanguage?: string;
543
546
  }
544
547
  declare function renderFrameworkFromPlan(plan: CurriculumPlan, meta: ProjectionMeta): string;
545
548
  /** Extract the glossary scope (terms per session) the EXPOSITION service consumes. */
package/dist/index.d.ts CHANGED
@@ -365,6 +365,8 @@ interface LayerPrefillOptions {
365
365
  timeoutMs?: number;
366
366
  /** Idle (no-data) budget override for this prefill run (ms). */
367
367
  idleTimeoutMs?: number;
368
+ /** Optional parent abort signal (e.g. from HTTP request) to stop immediately on client disconnect. */
369
+ signal?: AbortSignal;
368
370
  }
369
371
  interface PreliminaryResearchResult {
370
372
  groundTruthSummary: string;
@@ -388,7 +390,7 @@ interface StreamBudget {
388
390
  /** Abort when the whole stream exceeds this total wall time (ms). 0 disables. */
389
391
  totalMs?: number;
390
392
  }
391
- declare const DEFAULT_STREAM_IDLE_MS = 90000;
393
+ declare const DEFAULT_STREAM_IDLE_MS = 180000;
392
394
  declare const DEFAULT_STREAM_TOTAL_MS = 480000;
393
395
  declare const LAYER_IDLE_BUDGET_MS: Record<number, number>;
394
396
  /**
@@ -455,7 +457,7 @@ declare function createStreamChunkExtractor(): StreamChunkExtractor;
455
457
  * createStreamChunkExtractor, whose latch is the authoritative dedupe.
456
458
  */
457
459
  declare function extractStreamChunk(part: any): ExtractedStreamChunk;
458
- declare function createStreamAbortSignal(budget: Required<StreamBudget>): StreamAbortHandle;
460
+ declare function createStreamAbortSignal(budget: Required<StreamBudget>, parentSignal?: AbortSignal): StreamAbortHandle;
459
461
  /**
460
462
  * Close an unterminated JSON object/array: append the delimiters that are
461
463
  * still open, closing any open string first. Handles streams aborted
@@ -485,7 +487,7 @@ declare function safeParseJson<T = any>(rawText: string | null): T | null;
485
487
  /**
486
488
  * Multi-tiered resilient streaming inference helper using 100% Vercel AI SDK.
487
489
  */
488
- declare function streamLLMWithFallback(systemPrompt: string, userPrompt: string, apiKeys?: Record<string, string>, onChunk?: LayerChunkCallback, preferredModel?: string, preferredProvider?: AIProviderName, budget?: StreamBudget): Promise<string | null>;
490
+ declare function streamLLMWithFallback(systemPrompt: string, userPrompt: string, apiKeys?: Record<string, string>, onChunk?: LayerChunkCallback, preferredModel?: string, preferredProvider?: AIProviderName, budget?: StreamBudget, signal?: AbortSignal): Promise<string | null>;
489
491
  /**
490
492
  * Conducts preliminary ecosystem research for a course project.
491
493
  */
@@ -540,6 +542,7 @@ declare function buildSessionSliceContext(plan: CurriculumPlan, lessonCode: stri
540
542
  interface ProjectionMeta {
541
543
  courseName: string;
542
544
  shortDescription?: string;
545
+ targetLanguage?: string;
543
546
  }
544
547
  declare function renderFrameworkFromPlan(plan: CurriculumPlan, meta: ProjectionMeta): string;
545
548
  /** Extract the glossary scope (terms per session) the EXPOSITION service consumes. */
package/dist/index.mjs CHANGED
@@ -8406,13 +8406,16 @@ function computeContentHash(content) {
8406
8406
  return "sha256:" + crypto.createHash("sha256").update(content.trim(), "utf-8").digest("hex");
8407
8407
  }
8408
8408
  var STANDARD_SOT_FILES = [
8409
- "PROJECT_BRIEF.md",
8410
8409
  "LEARNER_PROFILE.md",
8410
+ "PROJECT_BRIEF.md",
8411
+ "REFERENCE_PACK.md",
8412
+ "PROJECT_GRAPH.json",
8413
+ "HYBRID_GRAPH.json",
8414
+ "SECTION_LANGUAGE_CONTRACT.md",
8411
8415
  "CURRICULUM_FRAMEWORK.md",
8412
- "PROJECT_STATUS.md",
8413
8416
  "CONTENT_STYLE_GUIDE.md",
8414
8417
  "ART_DIRECTION.md",
8415
- "REFERENCE_PACK.md"
8418
+ "ALIGNMENT_MATRIX.md"
8416
8419
  ];
8417
8420
  var FileSystemCurriculumAdapter = class {
8418
8421
  baseDir;
@@ -8454,19 +8457,33 @@ var FileSystemCurriculumAdapter = class {
8454
8457
  async listSotDocuments(projectId) {
8455
8458
  const projectDir = this.getProjectDir(projectId);
8456
8459
  const sotDir = path3.join(projectDir, "_sot");
8457
- return STANDARD_SOT_FILES.map((filename) => {
8460
+ const discoveredFiles = new Set(STANDARD_SOT_FILES);
8461
+ if (fs2.existsSync(sotDir)) {
8462
+ try {
8463
+ const diskFiles = fs2.readdirSync(sotDir);
8464
+ for (const f of diskFiles) {
8465
+ if (f.startsWith(".") || f.endsWith(".review.json")) continue;
8466
+ const fullPath = path3.join(sotDir, f);
8467
+ if (fs2.statSync(fullPath).isFile()) {
8468
+ discoveredFiles.add(f);
8469
+ }
8470
+ }
8471
+ } catch {
8472
+ }
8473
+ }
8474
+ return Array.from(discoveredFiles).map((filename) => {
8458
8475
  const p = fs2.existsSync(path3.join(sotDir, filename)) ? path3.join(sotDir, filename) : fs2.existsSync(path3.join(projectDir, filename)) ? path3.join(projectDir, filename) : null;
8459
8476
  if (p && fs2.existsSync(p)) {
8460
8477
  const stats = fs2.statSync(p);
8461
8478
  return {
8462
- name: filename.replace(".md", "").replace(/_/g, " "),
8479
+ name: filename.replace(/\.(md|json)$/i, "").replace(/_/g, " "),
8463
8480
  filename,
8464
8481
  exists: true,
8465
8482
  sizeBytes: stats.size
8466
8483
  };
8467
8484
  }
8468
8485
  return {
8469
- name: filename.replace(".md", "").replace(/_/g, " "),
8486
+ name: filename.replace(/\.(md|json)$/i, "").replace(/_/g, " "),
8470
8487
  filename,
8471
8488
  exists: false,
8472
8489
  sizeBytes: 0
@@ -8712,11 +8729,23 @@ var SupabaseCurriculumAdapter = class {
8712
8729
  return null;
8713
8730
  }
8714
8731
  async listSotDocuments(projectId) {
8732
+ const discoveredFiles = new Set(STANDARD_SOT_FILES);
8733
+ try {
8734
+ const { data, error } = await this.client.storage.from(this.bucketName).list(`${projectId}/_sot`);
8735
+ if (!error && data) {
8736
+ for (const item of data) {
8737
+ if (item.name && !item.name.startsWith(".") && !item.name.endsWith(".review.json")) {
8738
+ discoveredFiles.add(item.name);
8739
+ }
8740
+ }
8741
+ }
8742
+ } catch {
8743
+ }
8715
8744
  const results = [];
8716
- for (const filename of STANDARD_SOT_FILES) {
8745
+ for (const filename of discoveredFiles) {
8717
8746
  const content = await this.readSotDocument(projectId, filename);
8718
8747
  results.push({
8719
- name: filename.replace(".md", "").replace(/_/g, " "),
8748
+ name: filename.replace(/\.(md|json)$/i, "").replace(/_/g, " "),
8720
8749
  filename,
8721
8750
  exists: !!content,
8722
8751
  content: content || void 0,
@@ -9093,10 +9122,12 @@ async function auditQualityReport(storage, projectId) {
9093
9122
  computedScore += Math.round(bloomPassCount / total * 20);
9094
9123
  computedScore += Math.round(Math.min(syntaxExecutableCount, total) / total * 20);
9095
9124
  computedScore += Math.round(scopeCompletenessCount / total * 20);
9125
+ const sotCompletedCount = status.sotReadiness.filter((s) => s.exists).length;
9126
+ const sotTotalCount = status.sotReadiness.length;
9096
9127
  if (sotReady) {
9097
- details.push("\u2705 N\u1EC1n t\u1EA3ng SOT (7/7 t\xE0i li\u1EC7u) s\u1EB5n s\xE0ng.");
9128
+ details.push(`\u2705 N\u1EC1n t\u1EA3ng SOT (${sotCompletedCount}/${sotTotalCount} t\xE0i li\u1EC7u) s\u1EB5n s\xE0ng.`);
9098
9129
  } else {
9099
- details.push("\u26A0\uFE0F C\u1EA7n ho\xE0n thi\u1EC7n \u0111\u1EA7y \u0111\u1EE7 t\xE0i li\u1EC7u SOT c\u01A1 s\u1EDF.");
9130
+ details.push(`\u26A0\uFE0F C\u1EA7n ho\xE0n thi\u1EC7n \u0111\u1EA7y \u0111\u1EE7 t\xE0i li\u1EC7u SOT c\u01A1 s\u1EDF (${sotCompletedCount}/${sotTotalCount}).`);
9100
9131
  }
9101
9132
  details.push(`\u{1F3AF} Ph\u1EA1m vi h\u1ECDc li\u1EC7u theo SOT (Artifact Scope): [${artifactScope.join(", ")}].`);
9102
9133
  if (pedagogy5EPass) {
@@ -9112,7 +9143,7 @@ async function auditQualityReport(storage, projectId) {
9112
9143
  details.push(`\u2705 \u0110\u1ED9 ph\u1EE7 \u0111\u1EA7y \u0111\u1EE7 c\xE1c h\u1ECDc li\u1EC7u trong Artifact Scope: ${scopeCompletenessCount}/${total} b\xE0i h\u1ECDc.`);
9113
9144
  const rawMarkdownReport = `## \u{1F6E1}\uFE0F B\xC1O C\xC1O KI\u1EC2M \u0110\u1ECANH CH\u1EA4T L\u01AF\u1EE2NG (QUALITY AUDIT): \`${projectId.toUpperCase()}\`
9114
9145
  - **\u0110i\u1EC3m th\u1EA9m \u0111\u1ECBnh ch\u1EA5t l\u01B0\u1EE3ng:** **${computedScore}/100**
9115
- - **Tr\u1EA1ng th\xE1i SOT:** ${sotReady ? "\u2705 7/7 File SOT ho\xE0n t\u1EA5t" : "\u26A0\uFE0F C\u1EA7n b\u1ED5 sung t\xE0i li\u1EC7u SOT thi\u1EBFu"}.
9146
+ - **Tr\u1EA1ng th\xE1i SOT:** ${sotReady ? `\u2705 ${sotCompletedCount}/${sotTotalCount} File SOT ho\xE0n t\u1EA5t` : `\u26A0\uFE0F C\u1EA7n b\u1ED5 sung t\xE0i li\u1EC7u SOT thi\u1EBFu (${sotCompletedCount}/${sotTotalCount})`}.
9116
9147
  - **Ph\u1EA1m vi h\u1ECDc li\u1EC7u (SOT Artifact Scope):** \`${artifactScope.join(", ")}\`
9117
9148
  - **C\u1EA5u tr\xFAc S\u01B0 ph\u1EA1m (5E / EDP):** ${pedagogy5ECount}/${total} b\xE0i h\u1ECDc \u0111\u1EA1t chu\u1EA9n ph\xE2n pha.
9118
9149
  - **Thang \u0111o nh\u1EADn th\u1EE9c Bloom:** ${bloomPassCount}/${total} b\xE0i h\u1ECDc \u0111\u1EA1t ma tr\u1EADn m\u1EE5c ti\xEAu.
@@ -9214,7 +9245,9 @@ function extractScopeSequenceRows(framework) {
9214
9245
  const trimmed = line.trim();
9215
9246
  if (!trimmed.startsWith("|")) continue;
9216
9247
  const lower = trimmed.toLowerCase();
9217
- if (lower.includes("lesson code") && lower.includes("learning objective")) {
9248
+ const hasCodeCol = lower.includes("lesson code") || lower.includes("m\xE3 b\xE0i") || lower.includes("m\xE3 b\xE0i h\u1ECDc");
9249
+ const hasObjCol = lower.includes("learning objective") || lower.includes("m\u1EE5c ti\xEAu") || lower.includes("m\u1EE5c ti\xEAu h\u1ECDc t\u1EADp");
9250
+ if (hasCodeCol && hasObjCol) {
9218
9251
  headerFound = true;
9219
9252
  continue;
9220
9253
  }
@@ -27258,13 +27291,13 @@ var DeterministicPipelineRunner = class {
27258
27291
 
27259
27292
  // src/services/prefillService.ts
27260
27293
  init_streamRunner();
27261
- var DEFAULT_STREAM_IDLE_MS = 9e4;
27294
+ var DEFAULT_STREAM_IDLE_MS = 18e4;
27262
27295
  var DEFAULT_STREAM_TOTAL_MS = 48e4;
27263
27296
  var LAYER_IDLE_BUDGET_MS = {
27264
- 1: 9e4,
27265
- 2: 9e4,
27297
+ 1: 18e4,
27298
+ 2: 18e4,
27266
27299
  3: 18e4
27267
- // Layer 3 synthesizes all accumulated context + pathways (3 minutes idle protection)
27300
+ // 3 minutes idle protection across all layers for free-tier reasoning models
27268
27301
  };
27269
27302
  var LAYER_TOTAL_BUDGET_MS = {
27270
27303
  1: 48e4,
@@ -27325,10 +27358,20 @@ function extractStreamChunk(part) {
27325
27358
  }
27326
27359
  return {};
27327
27360
  }
27328
- function createStreamAbortSignal(budget) {
27361
+ function createStreamAbortSignal(budget, parentSignal) {
27329
27362
  const controller = new AbortController();
27330
27363
  let idleTimer = null;
27331
27364
  let totalTimer = null;
27365
+ const onParentAbort = () => {
27366
+ controller.abort(parentSignal?.reason || new Error("Aborted by parent signal"));
27367
+ };
27368
+ if (parentSignal) {
27369
+ if (parentSignal.aborted) {
27370
+ controller.abort(parentSignal.reason || new Error("Parent signal already aborted"));
27371
+ } else {
27372
+ parentSignal.addEventListener("abort", onParentAbort, { once: true });
27373
+ }
27374
+ }
27332
27375
  const armIdle = () => {
27333
27376
  if (idleTimer) clearTimeout(idleTimer);
27334
27377
  if (budget.idleMs > 0) {
@@ -27355,6 +27398,9 @@ function createStreamAbortSignal(budget) {
27355
27398
  dispose: () => {
27356
27399
  if (idleTimer) clearTimeout(idleTimer);
27357
27400
  if (totalTimer) clearTimeout(totalTimer);
27401
+ if (parentSignal) {
27402
+ parentSignal.removeEventListener("abort", onParentAbort);
27403
+ }
27358
27404
  idleTimer = null;
27359
27405
  totalTimer = null;
27360
27406
  }
@@ -27466,7 +27512,7 @@ function safeParseJson(rawText) {
27466
27512
  }
27467
27513
  return null;
27468
27514
  }
27469
- async function streamLLMWithFallback(systemPrompt, userPrompt, apiKeys = {}, onChunk, preferredModel, preferredProvider, budget) {
27515
+ async function streamLLMWithFallback(systemPrompt, userPrompt, apiKeys = {}, onChunk, preferredModel, preferredProvider, budget, signal) {
27470
27516
  const alibabaKey = apiKeys.alibaba || apiKeys.dashscope || process.env.ALIBABA_API_KEY || process.env.DASHSCOPE_API_KEY;
27471
27517
  const nvidiaKey = apiKeys.nvidia || process.env.NVIDIA_API_KEY;
27472
27518
  const deepseekKey = apiKeys.deepseek || process.env.DEEPSEEK_API_KEY;
@@ -27498,7 +27544,6 @@ async function streamLLMWithFallback(systemPrompt, userPrompt, apiKeys = {}, onC
27498
27544
  const nemotronModels = [
27499
27545
  "nvidia/nemotron-3-ultra-550b-a55b",
27500
27546
  "nvidia/nemotron-3.5-lightning-30b-a3b",
27501
- "nvidia/nemotron-3-super-120b-a12b",
27502
27547
  "nvidia/llama-3.1-nemotron-70b-instruct"
27503
27548
  ].filter((m) => isModelAllowed(m));
27504
27549
  for (const m of nemotronModels) {
@@ -27549,8 +27594,12 @@ THINKING DIRECTIVE: Do ALL your reasoning internally BEFORE emitting output. Do
27549
27594
  totalMs: budget?.totalMs ?? DEFAULT_STREAM_TOTAL_MS
27550
27595
  };
27551
27596
  for (const candidate of candidates) {
27597
+ if (signal?.aborted) {
27598
+ console.log(`[curriculum-kit:VercelAI] AbortSignal already aborted, cancelling candidate loop.`);
27599
+ break;
27600
+ }
27552
27601
  const t0 = Date.now();
27553
- const abort = createStreamAbortSignal(resolvedBudget);
27602
+ const abort = createStreamAbortSignal(resolvedBudget, signal);
27554
27603
  try {
27555
27604
  const modelInstance = getAIModel({
27556
27605
  provider: candidate.provider,
@@ -27582,6 +27631,13 @@ THINKING DIRECTIVE: Do ALL your reasoning internally BEFORE emitting output. Do
27582
27631
  }
27583
27632
  } catch (e) {
27584
27633
  console.warn(`[curriculum-kit:VercelAI] Candidate ${candidate.provider} (${candidate.model}) failed in ${Date.now() - t0}ms:`, e?.message || e);
27634
+ const isClientAborted = Boolean(
27635
+ signal?.aborted || e?.name === "AbortError" && signal?.aborted || e?.message && (e.message.includes("Controller is already closed") || e.message.includes("The operation was aborted") || e.message.includes("Aborted by parent signal"))
27636
+ );
27637
+ if (isClientAborted) {
27638
+ console.log(`[curriculum-kit:VercelAI] Client aborted or stream closed, halting candidate fallback loop.`);
27639
+ break;
27640
+ }
27585
27641
  } finally {
27586
27642
  abort.dispose();
27587
27643
  }
@@ -27633,7 +27689,8 @@ Return concise JSON matching:
27633
27689
  totalMs: options.timeoutMs,
27634
27690
  model: options.model,
27635
27691
  provider: options.provider
27636
- })
27692
+ }),
27693
+ options.signal
27637
27694
  );
27638
27695
  let parsedResearch = {
27639
27696
  groundTruthSummary: `Verified ecosystem standards for ${projectName}`,
@@ -27843,7 +27900,8 @@ REQUIREMENT: Synthesize ALL parameters above \u2014 every single line in FULL AC
27843
27900
  totalMs: options.timeoutMs,
27844
27901
  model: options.model,
27845
27902
  provider: options.provider
27846
- })
27903
+ }),
27904
+ options.signal
27847
27905
  );
27848
27906
  const parsed = safeParseJson(rawContent);
27849
27907
  return {
@@ -27859,6 +27917,7 @@ var pad22 = (n) => String(n).padStart(2, "0");
27859
27917
  var esc = (s) => s.replace(/\|/g, "/").replace(/\n/g, " ").trim();
27860
27918
  var BLOOM_OF_DEPTH = { ulo: "Understand", cio: "Apply", sio: "Create" };
27861
27919
  function renderFrameworkFromPlan(plan, meta) {
27920
+ const isVi = meta.targetLanguage === "vi";
27862
27921
  const dateStr = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
27863
27922
  const totalSessions = plan.sessions.length;
27864
27923
  const totalMinutes = plan.sessions.reduce(
@@ -27876,10 +27935,16 @@ function renderFrameworkFromPlan(plan, meta) {
27876
27935
  const depthTops = [...new Set(s.depth_assignments.map((d) => d.depth))].map((d) => BLOOM_OF_DEPTH[d] ?? "Apply");
27877
27936
  const bloom = depthTops[depthTops.length - 1] ?? "Apply";
27878
27937
  const keyConcepts = s.new_keywords.length > 0 ? s.new_keywords.join(", ") : s.depth_assignments.map((d) => d.node_id).join(", ");
27879
- const prereq = s.prerequisite_decisions.length > 0 ? s.prerequisite_decisions.filter((p) => p.decision === "taught_in_prior_lesson").length + " prior-taught, " + s.prerequisite_decisions.filter((p) => p.decision === "recap_in_lesson").length + " recap" : "None";
27938
+ const prereq = s.prerequisite_decisions.length > 0 ? s.prerequisite_decisions.filter((p) => p.decision === "taught_in_prior_lesson").length + " prior-taught, " + s.prerequisite_decisions.filter((p) => p.decision === "recap_in_lesson").length + " recap" : isVi ? "Kh\xF4ng" : "None";
27880
27939
  const duration = s.knowledge_minutes + s.practice_minutes + s.overhead_minutes;
27881
27940
  return "| " + pad22(i + 1) + " | " + s.id + " | " + esc(s.title) + " | " + s.unit_id + " | " + s.unit_id + "_M01 | " + esc(s.prose_objective) + " | " + esc(keyConcepts) + " | " + esc(s.exit_evidence[0] ?? "") + " | " + esc(prereq) + " | " + bloom + " | " + duration + " |";
27882
27941
  }).join("\n");
27942
+ const courseOverviewHeader = isVi ? "## [REQUIRED] T\u1ED5ng quan Kh\xF3a h\u1ECDc" : "## [REQUIRED] Course Overview";
27943
+ const globalObjectivesHeader = isVi ? "## [REQUIRED] M\u1EE5c ti\xEAu H\u1ECDc t\u1EADp To\xE0n di\u1EC7n" : "## [REQUIRED] Global Learning Objectives";
27944
+ const structuralHierarchyHeader = isVi ? "## [REQUIRED] C\u1EA5u tr\xFAc Kh\xF3a h\u1ECDc (Unit & Module)" : "## [REQUIRED] Structural Hierarchy (Unit & Module)";
27945
+ const scopeSequenceHeader = isVi ? "## [REQUIRED] Ph\u1EA1m vi & Tr\xECnh t\u1EF1 Chi ti\u1EBFt (Scope & Sequence)" : "## [REQUIRED] Scope & Sequence (Detailed Roadmap)";
27946
+ const standardFormatHeader = isVi ? "## [REQUIRED] C\u1EA5u tr\xFAc B\xE0i h\u1ECDc Chu\u1EA9n" : "## [REQUIRED] Standard Lesson Format";
27947
+ const scopeTableHeader = isVi ? "| # | Lesson Code | Title | Unit | Module | Learning Objective (H\u1ECDc sinh l\xE0m \u0111\u01B0\u1EE3c g\xEC...) | Key Concepts (H\u1ECDc g\xEC) | Hands-on Deliverable (S\u1EA3n ph\u1EA9m bu\u1ED5i h\u1ECDc) | Prerequisites | Bloom | Duration (mins) |" : "| # | Lesson Code | Title | Unit | Module | Learning Objective (Students will be able to...) | Key Concepts (Hoc gi) | Hands-on Deliverable (Lam duoc gi) | Prerequisites | Bloom | Duration (mins) |";
27883
27948
  return [
27884
27949
  "---",
27885
27950
  'id: "CURRICULUM-FRAMEWORK"',
@@ -27901,7 +27966,7 @@ function renderFrameworkFromPlan(plan, meta) {
27901
27966
  "",
27902
27967
  "_Projection of CURRICULUM_PLAN (hash " + plan.plan_hash + "). Approving this framework approves the per-session SCOPE recorded in the plan._",
27903
27968
  "",
27904
- "## [REQUIRED] Course Overview",
27969
+ courseOverviewHeader,
27905
27970
  "- **Official Course Name:** " + esc(meta.courseName),
27906
27971
  meta.shortDescription ? "- **Short Description:** " + esc(meta.shortDescription) : "",
27907
27972
  "- **Total Units:** " + plan.units.length,
@@ -27909,20 +27974,20 @@ function renderFrameworkFromPlan(plan, meta) {
27909
27974
  "- **Total Duration:** ~" + totalHours + " hours (" + totalSessions + " sessions x " + plan.constraints.session_duration_minutes + " min).",
27910
27975
  "- **Entry Level:** " + plan.constraints.entry_level + " (age band " + plan.constraints.age_band[0] + "-" + plan.constraints.age_band[1] + ").",
27911
27976
  "",
27912
- "## [REQUIRED] Global Learning Objectives",
27977
+ globalObjectivesHeader,
27913
27978
  ...plan.course.objectives.map((o, i) => i + 1 + ". **[" + o.bloom + "]:** " + esc(o.statement)),
27914
27979
  "",
27915
- "## [REQUIRED] Structural Hierarchy (Unit & Module)",
27980
+ structuralHierarchyHeader,
27916
27981
  "| Unit | Module | Lessons | Module Objective | Lesson Codes |",
27917
27982
  "|---|---|:---:|---|---|",
27918
27983
  hierarchyRows || "| (none) | | | | |",
27919
27984
  "",
27920
- "## [REQUIRED] Scope & Sequence (Detailed Roadmap)",
27921
- "| # | Lesson Code | Title | Unit | Module | Learning Objective (Students will be able to...) | Key Concepts (Hoc gi) | Hands-on Deliverable (Lam duoc gi) | Prerequisites | Bloom | Duration (mins) |",
27985
+ scopeSequenceHeader,
27986
+ scopeTableHeader,
27922
27987
  "|:---:|:---:|---|---|---|---|---|---|---|:---:|:---:|",
27923
27988
  scopeRows || "| 01 | U01_M01_L01 | (empty plan) | U01 | U01_M01 | - | - | - | None | Understand | " + plan.constraints.session_duration_minutes + " |",
27924
27989
  "",
27925
- "## [REQUIRED] Standard Lesson Format",
27990
+ standardFormatHeader,
27926
27991
  "- **Session duration:** " + plan.constraints.session_duration_minutes + " minutes (overhead " + String(plan.sessions[0]?.overhead_minutes ?? 0) + " min).",
27927
27992
  "- **Knowledge/practice split per session:** see plan JSON (knowledge_minutes / practice_minutes).",
27928
27993
  "- **Pedagogical flow:** 5E (Engage / Explore / Explain / Elaborate / Evaluate) unless policy overrides.",