diff --git a/.github/scripts/generate_sample_catalog.mjs b/.github/scripts/generate_sample_catalog.mjs index 300ceca..68f2b19 100644 --- a/.github/scripts/generate_sample_catalog.mjs +++ b/.github/scripts/generate_sample_catalog.mjs @@ -71,6 +71,9 @@ const DIMENSION_DEFAULTS = { protocol: { title: 'Select a Protocol', placeholder: 'Choose the protocol for your agent', + // Only special-cased ids need an entry here (ordering + custom label); + // any other id is auto-formatted by titleCaseId (e.g. `activity` -> + // `Activity`), so a newly declared protocol needs no code change. options: { responses: 'Responses', invocations: 'Invocations', @@ -759,22 +762,32 @@ Examples: "Agent that discovers and invokes tools from a remote MCP server."`; /** - * Generate a one-sentence description from README content (used when only - * BLANK descriptions are being filled — the default, non-refine path). - * displayName is intentionally NOT requested here; when not refining it is - * derived deterministically from the folder name. + * Generate a displayName AND a one-sentence description from README content + * (used when only BLANK fields are being filled — the default, non-refine + * path). Both fields are produced by the LLM so newly added samples get an + * AI-authored displayName instead of a raw folder-name derivation. The caller + * only writes back whichever field was blank, so existing values are never + * touched, and falls back to `displayNameFromPath` when the LLM yields no + * usable displayName. * * @param {string} readmeContent * @param {string} samplePath - * @returns {Promise<{ description: string } | null>} + * @returns {Promise<{ displayName: string, description: string } | null>} */ async function generateWithLLM(readmeContent, samplePath) { - const systemPrompt = `You generate one-sentence descriptions for a VS Code template picker. -The user has already selected language, framework, and protocol before seeing these items, so the description must NOT repeat those choices. + const systemPrompt = `You generate the displayName and description of a hosted-agent sample shown in a VS Code template picker. +The user has already selected language, framework, and protocol before seeing these items, so neither field should repeat those choices. +displayName rules: +- A short, human-friendly Title Case name for the scenario (2-4 words) +- Name what the sample demonstrates, not the folder (e.g. "Basic Agent", "Foundry Toolbox", "Azure Search RAG", "Human-in-the-Loop") +- No leading numbers, no raw folder tokens, no words like "Sample" / "Demo" +- Keep known acronyms uppercased (MCP, RAG, SDK, API, UI) + +description rules: ${DESCRIPTION_GUIDANCE} -Respond ONLY with a JSON object: {"description": "..."}`; +Respond ONLY with a JSON object: {"displayName": "...", "description": "..."}`; const userPrompt = `Path: ${samplePath} @@ -785,12 +798,13 @@ ${readmeContent.substring(0, 2000)}`; if (!parsed) { return null; } + const displayName = typeof parsed.displayName === 'string' ? parsed.displayName.trim() : ''; const description = typeof parsed.description === 'string' ? parsed.description.trim() : ''; - if (!description) { - warn(`LLM response for ${samplePath} had no usable "description" field; description left empty.`); + if (!displayName && !description) { + warn(`LLM response for ${samplePath} had no usable "displayName"/"description" fields; values left empty.`); return null; } - return { description }; + return { displayName, description }; } /** @@ -871,11 +885,28 @@ const DISPLAY_NAME_TOKEN_CASING = { ui: 'UI', }; +/** + * Title-case a dash/underscore-delimited id into a user-facing label: split on + * `-`/`_`, capitalize each word, and apply `DISPLAY_NAME_TOKEN_CASING` for + * known acronyms/brands. `azure-search-rag` -> `Azure Search RAG`, + * `activity` -> `Activity`. Used both as the dimension-option label fallback + * (buildDimensions) and by displayNameFromPath. + * + * @param {string} id + * @returns {string} + */ +function titleCaseId(id) { + return id + .split(/[-_]/) + .filter((w) => w.length > 0) + .map((w) => DISPLAY_NAME_TOKEN_CASING[w.toLowerCase()] ?? (w.charAt(0).toUpperCase() + w.slice(1))) + .join(' '); +} + /** * Derive a displayName from the template's directory name. Strips a leading * numeric ordering prefix (`09-`, `12_`) so upstream reorderings don't bleed - * into the picker, then converts dash/underscore tokens into Title Case - * words and applies `DISPLAY_NAME_TOKEN_CASING` for known acronyms/brands: + * into the picker, then title-cases the remaining tokens (see titleCaseId): * `09-declarative-customer-support` -> `Declarative Customer Support`, * `azure-search-rag` -> `Azure Search RAG`. * @@ -884,12 +915,7 @@ const DISPLAY_NAME_TOKEN_CASING = { */ function displayNameFromPath(samplePath) { const dirName = samplePath.split('/').pop() || ''; - return dirName - .replace(/^\d+[-_]/, '') - .split(/[-_]/) - .filter((w) => w.length > 0) - .map((w) => DISPLAY_NAME_TOKEN_CASING[w.toLowerCase()] ?? (w.charAt(0).toUpperCase() + w.slice(1))) - .join(' '); + return titleCaseId(dirName.replace(/^\d+[-_]/, '')); } /** @@ -980,7 +1006,7 @@ function buildDimensions(templates) { ]; const options = orderedIds.map((id) => ({ id, - displayName: defaults.options[id] || id, + displayName: defaults.options[id] || titleCaseId(id), })); dimensions[dimKey] = { title: defaults.title, @@ -1099,10 +1125,11 @@ function applyOverrides(templates, overrides) { /** * Fill and (optionally) refine displayName and description fields. * - * Default path (AI_REFINE off): empty displayName is derived deterministically - * from the folder name; empty description is filled by the LLM when configured. - * Values that already exist (PM-curated or preserved from the prior catalog) - * are left untouched. + * Default path (AI_REFINE off): only BLANK displayName/description fields are + * filled — the LLM generates both from the sample's README when configured + * (empty displayName falls back to folder-name derivation otherwise). Values + * that already exist (PM-curated or preserved from the prior catalog) are left + * untouched, so a normal run is strictly incremental. * * Refine path (AI_REFINE on, opt-in via the `refine_with_ai` workflow input): * the LLM reviews BOTH fields of every template against its README and rewrites @@ -1133,40 +1160,52 @@ async function autoFillDisplayFields(templates, commitSha) { } /** - * Default path: derive empty displayName from the folder name and fill empty - * descriptions via the LLM. Never touches values that already exist. + * Default path: fill only BLANK displayName/description via the LLM, using each + * sample's README as context. Values that already exist (PM-curated or carried + * over from the previous catalog) are never touched, so a normal run is purely + * incremental — only newly added samples get generated. Empty displayName falls + * back to folder-name derivation when the LLM is unavailable or yields nothing. * * @param {Array<{displayName: string, description: string, path: string}>} templates * @param {string} commitSha * @param {boolean} hasLLM */ async function fillBlankDisplayFields(templates, commitSha, hasLLM) { - // Fill displayName first — deterministic, no API calls. - for (const template of templates) { - if (!template.displayName) { - template.displayName = displayNameFromPath(template.path); - } - } - - const needsDescription = templates.filter((t) => !t.description); - if (needsDescription.length === 0) { - console.log('All templates already have a description.'); - return; - } - if (!hasLLM) { - console.log(`${needsDescription.length} templates need a description but no LLM is configured; leaving empty (will be flagged as anomalies).`); + const needsFill = templates.filter((t) => !t.displayName || !t.description); + if (needsFill.length === 0) { + console.log('All templates already have a displayName and description.'); return; } - console.log(`Generating descriptions for ${needsDescription.length} templates using LLM...`); - for (const template of needsDescription) { - const readme = await fetchReadme(template.path, commitSha); - if (!readme) { - continue; + if (hasLLM) { + console.log(`Generating displayName/description for ${needsFill.length} templates using LLM...`); + for (const template of needsFill) { + const readme = await fetchReadme(template.path, commitSha); + if (!readme) { + continue; + } + const result = await generateWithLLM(readme, template.path); + if (!result) { + continue; + } + // Only write back the field that was blank — never overwrite an + // existing value, keeping the default run strictly incremental. + if (!template.displayName && result.displayName) { + template.displayName = result.displayName; + } + if (!template.description && result.description) { + template.description = result.description; + } } - const result = await generateWithLLM(readme, template.path); - if (result?.description) { - template.description = result.description; + } else { + console.log(`${needsFill.length} templates need a displayName/description but no LLM is configured; deriving displayName from the folder name and leaving description empty.`); + } + + // Fallback for any displayName still blank (no LLM, missing README, or the + // LLM returned nothing) so the picker never shows an empty label. + for (const template of templates) { + if (!template.displayName) { + template.displayName = displayNameFromPath(template.path); } } } diff --git a/.github/workflows/sync-sample-catalog.yml b/.github/workflows/sync-sample-catalog.yml index 687a53a..5ac15be 100644 --- a/.github/workflows/sync-sample-catalog.yml +++ b/.github/workflows/sync-sample-catalog.yml @@ -4,17 +4,19 @@ on: workflow_dispatch: inputs: commit_sha: - description: "Commit SHA from microsoft-foundry/foundry-samples to pin to (leave blank to use the latest main commit)" + description: "foundry-samples commit to pin to. Blank = latest main." required: false type: string default: "" + # Default (both off): incremental — only fill blank displayName/description + # with AI; existing values are kept untouched. refine_with_ai: - description: "Let AI review and refine existing displayName/description (not just fill empty ones). Leave off to only fill blanks." + description: "Full refresh, keep existing values. AI reviews all entries but only rewrites ones that no longer fit." required: false type: boolean default: false ignore_existing_catalog: - description: "Ignore the previous catalog's displayName/description (regenerate from scratch). Use for the first AI refine so existing values don't anchor the result." + description: "Full refresh, overwrite everything. AI regenerates ALL displayName/description from scratch. Use only when necessary." required: false type: boolean default: false