Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@
"copy-joule-vendor": "node scripts/copy-joule-vendor.mjs",
"check-deploy-cap-target": "node scripts/check-deploy-cap-target.cjs",
"build:all": "npm run prebuild && npm run fetch-tutorials -- --regenerate && npm run fetch-advocates && npm run fetch-homepage-shelves && npm run fetch-channels && npm run fetch-channel-atlas && npm run fetch-channel-collections && npm run fetch-channels-stats && npm run fetch-verb-definitions && npm run fetch-tags && npm run fetch-shelf-definitions && npm run fetch-featured-topics && npm run fetch-topic-clusters && npm run fetch-sitemap-catalog && npm run fetch-teched && npm run generate-ktt-lessons && npm run build:icon-subset && npm run build:css && npm run build:apps && npm run build:island-manifest && npm run check:ui5-single-copy && npx tsx scripts/check-ui5-entry-coverage.ts && npm run build:analytics-explorer && npm run copy-joule-vendor && npm run build:explore && npm run build:channel-atlas && npm run build:hugo && npm run build:page-fallback && npm run build:whats-new-snapshot && npm run retain:assets && npm run build:publish-island-manifest && npm run build:highlight && npm run build:display && npm run build:sdl",
"build:deploy": "npm run check-deploy-cap-target && npm run build:all",
"build:deploy": "npm run check-deploy-cap-target && cross-env SKIP_AI_AUTHOR=1 npm run build:all",
"deploy": "node scripts/deploy-mta.cjs",
"build:admin": "npm --prefix app/admin-shell run build",
"predev": "npm run copy-joule-vendor && node -e \"var r=require('fs').existsSync('srv/data/admin-docs-index.json')?{status:0}:require('child_process').spawnSync('npx',['tsx','scripts/build-admin-docs-index.ts'],{stdio:'inherit',shell:true});if(r.status!==0)process.exit(r.status||1)\"",
Expand Down
55 changes: 40 additions & 15 deletions scripts/fetch-tutorials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import { expandAiAuthoredQuestions, populateAiAuthoredSiblingMaps, isStepSubstan
import { loadAiQuizCache, saveAiQuizCache } from './lib/ai-quiz-cache.js'
import { seedAiQuizCache, autoDetectPrimaryCacheDir } from './lib/ai-quiz-cache-seed.js'
import { callQuizModel } from '../srv/lib/ai-quiz-llm.js'
import { resolveAiAuthorGate } from './lib/ai-author-gate.js'
import { resolveChatLlmSettings } from '../srv/lib/chat-settings-resolver.js'
import { generateChallengeSpec } from '../srv/lib/ai-challenge-spec.js'
import { PROMPT_VERSION as CHALLENGE_PROMPT_VERSION } from '../srv/lib/ai-challenge-spec.js'
import { loadChallengeSpecCache, saveChallengeSpecCache, hashKey as challengeHashKey } from './lib/challenge-spec-cache.js'
Expand Down Expand Up @@ -1038,6 +1040,14 @@ async function main() {
let cacheRefreshes = 0
let cacheFetches = 0

// [#2761] Resolve ONCE whether the AI-author phase (AI-quiz expansion +
// json-render challenge specs) runs this build. SKIP_AI_AUTHOR skips it on
// deploy builds (the rendered bodies are discarded by the approuter); a
// missing AI Hub binding fails open to a single warning instead of a
// per-step throw loop that burns the AI_AUTHOR_BUILD_CAP. See ai-author-gate.ts.
const aiAuthorGate = await resolveAiAuthorGate({ resolveSettings: resolveChatLlmSettings })
console.log(`[ai-author] ${aiAuthorGate.enabled ? 'ENABLED' : 'DISABLED'} — ${aiAuthorGate.reason}`)

const tasks = allTutorials.map((t, idx) => async () => {
const tutStart = performance.now()
const label = `[${idx + 1}/${allTutorials.length}] ${t.repo}/${t.slug}`
Expand Down Expand Up @@ -1155,20 +1165,35 @@ async function main() {
steps.map(s => [s.number, s.content ?? '']),
)
const aiCache = loadAiQuizCache(t.slug)
await expandAiAuthoredQuestions(validationMap, stepBodies, {
cache: aiCache,
callModel: callQuizModel,
onCallStats: globalCallStats,
allDirective,
// [#208 precedence-fix] forward the set of hand-authored steps
// so AI never fires on top of regex-substring or other [VALIDATE_N]
// blocks where parseBlock returned [].
handAuthoredSteps,
// [#2345] video-sourced quizzes ([AUTOAUTHOR_VIDEO_*]) fetch the
// transcript from the deployed transcript endpoint (CAP_BASE_URL).
fetchTranscript: fetchTranscriptViaHttp,
})
saveAiQuizCache(t.slug, aiCache)
// [#2761] Gate the only model-calling line in this block. When disabled
// (SKIP_AI_AUTHOR, or no AI Hub binding), hand-authored [VALIDATE_N]
// questions in validationMap still flow through untouched — only the
// [AUTOAUTHOR_*] expansion is skipped, and its placeholders are dropped
// below so no unmaterialized placeholder leaks into the page.
if (aiAuthorGate.enabled) {
await expandAiAuthoredQuestions(validationMap, stepBodies, {
cache: aiCache,
callModel: callQuizModel,
onCallStats: globalCallStats,
allDirective,
// [#208 precedence-fix] forward the set of hand-authored steps
// so AI never fires on top of regex-substring or other [VALIDATE_N]
// blocks where parseBlock returned [].
handAuthoredSteps,
// [#2345] video-sourced quizzes ([AUTOAUTHOR_VIDEO_*]) fetch the
// transcript from the deployed transcript endpoint (CAP_BASE_URL).
fetchTranscript: fetchTranscriptViaHttp,
})
saveAiQuizCache(t.slug, aiCache)
} else {
// Drop any unmaterialized [AUTOAUTHOR_*] placeholders so a skipped
// AI phase never ships a placeholder as a real question.
for (const [stepNum, questions] of validationMap) {
if (questions.some(q => (q as { __autoauthor?: boolean }).__autoauthor === true)) {
validationMap.set(stepNum, questions.filter(q => (q as { __autoauthor?: boolean }).__autoauthor !== true))
}
}
}

// [#208] Populate sibling maps for AI-authored text questions so
// collectAiGradedSpecs (below) emits the validate-answer-spec
Expand Down Expand Up @@ -1277,7 +1302,7 @@ async function main() {
// step.challenge (rides in <script id="tutorial-data">) and writes the
// freeText reference answers to a server-only sidecar for the
// ValidateAnswerSpecs path — mirroring the AI-quiz anti-leak seam above.
if (process.env.CHALLENGE_WIDGET_ENABLED === 'true') {
if (process.env.CHALLENGE_WIDGET_ENABLED === 'true' && aiAuthorGate.enabled) {
const challengeAnswers: Array<{ stepNumber: number; nodeId: string; reference: string }> = []
// [#2441 gate 2] Per-slug spec cache mirroring the AI-quiz cache above.
// A HIT reconstructs step.challenge + the reference sidecar with no model
Expand Down
81 changes: 81 additions & 0 deletions scripts/lib/ai-author-gate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// scripts/lib/ai-author-gate.ts
//
// [#2761] Single decision point for whether the build's AI-author phase
// (AI-quiz expansion + json-render challenge specs) should run at all.
//
// Why this exists — two deploy-pipeline wins from the 2026-10-13 release retro:
//
// 1. A deploy build renders ~1400 tutorial bodies that the approuter then
// DISCARDS (tutorial HTML is served from HANA BLOBs, not static files —
// see CLAUDE.md). The expensive AI-author pass on that throwaway render is
// pure waste. Setting SKIP_AI_AUTHOR=1 on the deploy path skips it; the
// separate content rebuild (rebuild-content.yml) leaves the flag unset and
// still generates quizzes for the HANA-served bodies that actually keep them.
//
// 2. When there is no AI Hub binding (no deploymentId resolvable), the quiz
// generator fails fast per step but still consumes one AI_AUTHOR_BUILD_CAP
// slot per attempt — up to 200 throw-and-log cycles per build. Resolving
// the binding ONCE up front and skipping the whole phase (fail-open, one
// warning) removes that dead work.
//
// Pure module — no I/O of its own; the settings resolver is injected so the
// decision is unit-testable without a real binding.

export interface AiAuthorGate {
/** Run the AI-author phase (quiz + challenge) this build? */
enabled: boolean;
/** Human-readable reason, logged once by the caller. */
reason: string;
}

interface ResolveArgs {
/** Explicit override; when omitted, read from `env.SKIP_AI_AUTHOR`. */
skip?: boolean;
/** Environment source (defaults to process.env). */
env?: Record<string, string | undefined>;
/**
* Injected binding resolver — typically resolveChatLlmSettings from
* packages/core/chat-settings-resolver.js. Returns { deploymentId } or
* throws ("No deploymentId ...") when the AI Hub binding is absent.
*/
resolveSettings: () => Promise<{ deploymentId?: string | null }>;
}

/**
* Decide — once per build — whether the AI-author phase runs.
*
* Order of checks:
* 1. SKIP_AI_AUTHOR set → disabled, resolver NOT called (deploy builds).
* 2. binding resolves → enabled.
* 3. binding missing/throws → disabled, fail-open (one warning, no throw loop).
*/
export async function resolveAiAuthorGate(args: ResolveArgs): Promise<AiAuthorGate> {
const env = args.env ?? process.env;
const skip = args.skip ?? isTruthy(env.SKIP_AI_AUTHOR);

if (skip) {
return { enabled: false, reason: 'SKIP_AI_AUTHOR is set — skipping AI-author phase (quiz + challenge)' };
}

try {
const settings = await args.resolveSettings();
if (!settings?.deploymentId) {
return {
enabled: false,
reason: 'No deploymentId resolved for the SAP AI Hub binding — skipping AI-author phase (fail-open)',
};
}
return { enabled: true, reason: 'AI Hub binding resolved — AI-author phase enabled' };
} catch (err) {
return {
enabled: false,
reason: `AI Hub binding unavailable (${(err as Error).message}) — skipping AI-author phase (fail-open)`,
};
}
}

function isTruthy(v: string | undefined): boolean {
if (!v) return false;
const s = v.trim().toLowerCase();
return s !== '' && s !== '0' && s !== 'false' && s !== 'no';
}
59 changes: 59 additions & 0 deletions test/lib/ai-author-gate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// test/lib/ai-author-gate.test.ts
//
// [#2761] Deploy-pipeline efficiency: the AI-author (quiz + challenge) phase
// is the single most expensive, most wasted part of a deploy build (the
// rendered tutorial bodies are discarded by the approuter — only HANA-served
// bodies from the content rebuild keep their quizzes). This gate lets a deploy
// build skip the whole AI phase via SKIP_AI_AUTHOR, and fails open (one warning,
// no per-step throw loop) when the AI Hub binding is genuinely missing.
import { describe, it, expect, vi } from 'vitest';
import { resolveAiAuthorGate } from '../../scripts/lib/ai-author-gate.js';

// A resolver that succeeds (binding present).
const okResolver = async () => ({ modelName: 'm', deploymentId: 'd-123' });
// A resolver that throws the real "No deploymentId" error.
const missingResolver = async () => {
throw new Error('No deploymentId for SAP AI Hub call. Set ChatSettings.deploymentId ...');
};

describe('resolveAiAuthorGate [#2761]', () => {
it('disabled when SKIP_AI_AUTHOR is set — resolver is never called', async () => {
const resolveSettings = vi.fn(okResolver);
const gate = await resolveAiAuthorGate({ skip: true, resolveSettings });
expect(gate.enabled).toBe(false);
expect(gate.reason).toMatch(/SKIP_AI_AUTHOR/);
// Short-circuit: a skipped build must not even probe the binding.
expect(resolveSettings).not.toHaveBeenCalled();
});

it('enabled when not skipped and the AI Hub binding resolves', async () => {
const resolveSettings = vi.fn(okResolver);
const gate = await resolveAiAuthorGate({ skip: false, resolveSettings });
expect(gate.enabled).toBe(true);
// Probed exactly once — the gate is resolved per build, not per step.
expect(resolveSettings).toHaveBeenCalledTimes(1);
});

it('fails open (disabled, one probe) when the binding is missing', async () => {
const resolveSettings = vi.fn(missingResolver);
const gate = await resolveAiAuthorGate({ skip: false, resolveSettings });
expect(gate.enabled).toBe(false);
expect(gate.reason).toMatch(/No deploymentId|binding/i);
// The whole point: resolve ONCE, not once per step (no 200x throw loop).
expect(resolveSettings).toHaveBeenCalledTimes(1);
});

it('fails open when the resolver returns a null deploymentId without throwing', async () => {
const resolveSettings = vi.fn(async () => ({ modelName: 'm', deploymentId: null }));
const gate = await resolveAiAuthorGate({ skip: false, resolveSettings });
expect(gate.enabled).toBe(false);
expect(gate.reason).toMatch(/deploymentId|binding/i);
});

it('reads SKIP_AI_AUTHOR from the environment when skip is omitted', async () => {
const resolveSettings = vi.fn(okResolver);
const gate = await resolveAiAuthorGate({ env: { SKIP_AI_AUTHOR: '1' }, resolveSettings });
expect(gate.enabled).toBe(false);
expect(resolveSettings).not.toHaveBeenCalled();
});
});
Loading