From 312e8ce1bab6c2c63548f74c183d4dc2f7a78638 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Wed, 14 Oct 2026 17:43:35 -0400 Subject: [PATCH] perf(build): skip AI-author phase on deploy builds (#2761) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deploy build renders ~1400 tutorial bodies that the approuter discards (tutorial HTML is served from HANA BLOBs, not static files), so the expensive AI-author pass on that render is pure waste. Two quick wins from the 2026-10-13 release retro: 1. SKIP_AI_AUTHOR flag — build:deploy sets it via cross-env, skipping the AI-quiz expansion and json-render challenge-spec loop. The content rebuild (rebuild-content.yml) leaves it unset, so HANA-served bodies still get quizzes. 2. Fail-open on missing AI Hub binding — the gate resolves the binding once per build; a missing deploymentId skips the whole phase with one warning instead of up to 200 per-step throw-and-log cycles that burn the AI_AUTHOR_BUILD_CAP. Both share a single decision point (scripts/lib/ai-author-gate.ts). When disabled, unmaterialized [AUTOAUTHOR_*] placeholders are dropped so no placeholder leaks into the page; hand-authored [VALIDATE_N] questions are untouched. --- package.json | 2 +- scripts/fetch-tutorials.ts | 55 ++++++++++++++++------ scripts/lib/ai-author-gate.ts | 81 +++++++++++++++++++++++++++++++++ test/lib/ai-author-gate.test.ts | 59 ++++++++++++++++++++++++ 4 files changed, 181 insertions(+), 16 deletions(-) create mode 100644 scripts/lib/ai-author-gate.ts create mode 100644 test/lib/ai-author-gate.test.ts diff --git a/package.json b/package.json index 5e9a030c8..337cc9427 100644 --- a/package.json +++ b/package.json @@ -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)\"", diff --git a/scripts/fetch-tutorials.ts b/scripts/fetch-tutorials.ts index f7a753524..b9b627d3a 100644 --- a/scripts/fetch-tutorials.ts +++ b/scripts/fetch-tutorials.ts @@ -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' @@ -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}` @@ -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 @@ -1277,7 +1302,7 @@ async function main() { // step.challenge (rides in