-
Notifications
You must be signed in to change notification settings - Fork 8
DOC-2450: scope Ask AI to the reader's docs version #431
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 4 commits
fbe6be5
5d6b889
acd058d
c4d3e2a
e9b6eed
35b25ab
0e5d3f2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,155 @@ | ||
| 'use strict' | ||
|
|
||
| /** | ||
| * Resolves the Kapa source group that scopes Ask AI retrieval to the docs | ||
| * version the reader is actually on. | ||
| * | ||
| * WHY THIS EXISTS | ||
| * --------------- | ||
| * Kapa indexes one separately crawled source per published docs version. With no | ||
| * scoping, a question is answered from any of them. Measured against the live | ||
| * retrieval API for "hardware requirements for enterprise redpanda self-hosted", | ||
| * the exact question in DOC-2450: 11 results spread across 24.2, 24.3, 25.1, | ||
| * 25.2, 25.3 and current, with only ONE from current. Scoped to the current | ||
| * group, 14 of 14 came from current. | ||
| * | ||
| * HOW THE SEGMENT IS DERIVED | ||
| * -------------------------- | ||
| * From `page.url`, not from page.version. The two disagree for the latest | ||
| * release: `latest_version_segment: 'current'` publishes 26.2 at | ||
| * /streaming/current/ while page.version reads 26.2. The Kapa mapping is keyed on | ||
| * the URL segment because that is what Kapa's own source_url values use, so | ||
| * reading the URL avoids having to know which version is currently latest. | ||
| * | ||
| * WHAT IT RETURNS | ||
| * --------------- | ||
| * An array, because that is the shape both Kapa providers want | ||
| * (sourceGroupIdsInclude on AgentProvider, sourceGroupIDsInclude on | ||
| * KapaProvider). An EMPTY array means "send no filter", which is the pre-DOC-2450 | ||
| * behaviour: Kapa searches everything. That is the deliberate degradation for any | ||
| * case where scoping cannot be resolved, because a wrong group is worse than no | ||
| * group -- scoping to a group that does not hold the reader's version returns | ||
| * only Kapa's global sources, so the reader gets no version-specific content at | ||
| * all and no error either. | ||
| * | ||
| * Unversioned pages (Cloud, Connect, Agentic Data Plane, labs, home, search, the | ||
| * 404 page) resolve to the mapping's default_segment rather than to nothing. | ||
| * Sending no filter there is what produced DOC-2450 in the first place: the | ||
| * reporter was on a page with no version of its own. | ||
| * | ||
| * Note that scoping to a version group does NOT hide Cloud, Connect or Agentic | ||
| * Data Plane content. Those sources are deliberately left unassigned in Kapa, so | ||
| * they are "global" and come through alongside whichever group is selected. | ||
| * Verified live: scoped to the 25.2 group, an Agentic Data Plane question | ||
| * returned 10 of 10 results from /agentic-data-plane/. | ||
| * | ||
| * Usage in templates: | ||
| * window.KAPA_SOURCE_GROUP_IDS = [ | ||
| * {{#each (get-kapa-source-groups)}}"{{{this}}}"{{#unless @last}},{{/unless}}{{/each}} | ||
| * ]; | ||
| * | ||
| * @param {object} options - Handlebars options with data.root.page and data.root.site | ||
| * @returns {string[]} Zero or one Kapa source group id | ||
| */ | ||
| module.exports = function (options) { | ||
| const root = (options && options.data && options.data.root) || {} | ||
| const { page, site } = root | ||
|
|
||
| const mapping = readMapping(page, site) | ||
| if (!mapping || !mapping.segments) return [] | ||
|
|
||
| const segment = versionSegmentFromUrl(page && page.url, mapping.segments) | ||
|
|
||
| // A versioned page whose segment has no group is the case the drift check | ||
| // exists to catch: a version was published and nobody created the Kapa source | ||
| // and group. Fall back to the default rather than sending nothing, so the | ||
| // reader gets current-version answers instead of every version at once. | ||
| const entry = (segment && mapping.segments[segment]) || mapping.segments[mapping.default_segment] | ||
| if (!entry || !entry.group_id) return [] | ||
|
|
||
| return [entry.group_id] | ||
| } | ||
|
|
||
| /** | ||
| * The mapping is generated in docs-extensions-and-macros | ||
| * (docs-data/kapa-source-groups.json) and surfaced to the UI as an AsciiDoc | ||
| * attribute, because docs-ui does not depend on that package and must not carry | ||
| * a second copy that can drift. | ||
| * | ||
| * Read from the component version first and the site second, matching how | ||
| * add-global-attributes.js merges shared attributes onto every component | ||
| * version. Absent in a bare docs-ui preview, which is why every failure path | ||
| * degrades to "no filter" rather than throwing. | ||
| */ | ||
| function readMapping (page, site) { | ||
| const candidates = [ | ||
| page && page.componentVersion && page.componentVersion.asciidoc && page.componentVersion.asciidoc.attributes, | ||
| page && page.component && page.component.asciidoc && page.component.asciidoc.attributes, | ||
| page && page.attributes, | ||
| site && site.asciidoc && site.asciidoc.attributes, | ||
| // site.keys last but never redundant: it is the ONLY channel that reaches a | ||
| // page with no component. The 404 page renders the Ask AI panel yet has no | ||
| // page.component or page.componentVersion, so without this it would search | ||
| // every docs version -- and a 404 is a plausible place to ask the AI where | ||
| // something went. | ||
| site && site.keys, | ||
| ] | ||
|
|
||
| for (const attrs of candidates) { | ||
| const raw = attrs && (attrs['kapa-source-groups'] || attrs.kapa_source_groups) | ||
| if (!raw) continue | ||
| if (typeof raw === 'object') return raw | ||
| try { | ||
| return JSON.parse(raw) | ||
| } catch (err) { | ||
| // A malformed attribute must not break the page. Losing version scoping is | ||
| // a degraded answer; a thrown helper is a broken build. | ||
| return null | ||
| } | ||
| } | ||
| return null | ||
| } | ||
|
|
||
| /** | ||
| * Pull the version segment out of a page URL. | ||
| * | ||
| * Recognises a segment by looking it up in the mapping, rather than by matching | ||
| * a hardcoded /streaming/ prefix, and checks the first TWO path positions: | ||
| * | ||
| * /streaming/25.2/manage/monitoring/ -> 25.2 (today's layout) | ||
| * /24.3/manage/monitoring/ -> 24.3 (the pre-rename layout) | ||
| * | ||
| * Both are checked because the layout has already changed once: the docs | ||
| * component was renamed from ROOT to streaming, which moved every versioned | ||
| * page from /<version>/ to /streaming/<version>/. A prefix-matching version of | ||
| * this function silently returned null for the old layout, so an all-components | ||
| * build over pre-rename branches produced 451 pages of 24.3 content advertising | ||
| * the current group. Nothing failed; the answers were just wrong. | ||
| * | ||
| * Driven off the mapping's own keys, so this stays correct if a second | ||
| * component is ever versioned, and cannot mistake an ordinary path word for a | ||
| * version: /connect/current/ only resolves if 'current' is a real segment, and | ||
| * /cloud-data-platform/manage/ never resolves because 'manage' is not. | ||
| * | ||
| * @param {string} url - e.g. /streaming/25.2/get-started/intro-to-events/ | ||
| * @param {object} segments - The mapping's segments, keyed by URL segment | ||
| * @returns {string|null} e.g. '25.2', 'current', or null when not versioned | ||
| */ | ||
| function versionSegmentFromUrl (url, segments) { | ||
| if (typeof url !== 'string' || !segments) return null | ||
| // Leading empty string from the leading slash, so [1] and [2] are the first | ||
| // two path positions. | ||
| const parts = url.split('/') | ||
| for (const candidate of [parts[1], parts[2]]) { | ||
| // Requires a trailing slash after the candidate, so a FILE named after a | ||
| // version (/25.2.html, or /streaming/25.2.json) is not read as a segment. | ||
| if (candidate && Object.prototype.hasOwnProperty.call(segments, candidate) && | ||
| url.includes(`/${candidate}/`)) { | ||
| return candidate | ||
| } | ||
| } | ||
| return null | ||
| } | ||
|
|
||
| module.exports.versionSegmentFromUrl = versionSegmentFromUrl | ||
| module.exports.readMapping = readMapping | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -207,8 +207,11 @@ const CUSTOM_INSTRUCTIONS = `## Domain context | |
| - Ask a follow-up ONLY when the answer actually depends on it: | ||
| - Cloud: assume the general case unless it differs by cluster type, then ask | ||
| which (BYOC, Dedicated, or Serverless). | ||
| - Self-Managed Streaming: assume the latest version unless it differs by | ||
| version, then ask which (e.g. 25.2). | ||
| - Self-Managed Streaming: the version is in "Current page" below when the | ||
| user is on a versioned page, and your search results are already restricted | ||
| to it. Use it; do not ask. Ask which version ONLY when the page context has | ||
| no version (for example the home page or a Cloud page) AND the answer | ||
| actually differs by version. | ||
| - Redpanda Connect (including any Bloblang question): if you do not know | ||
| where they run Connect, ask whether it is on Redpanda Cloud or | ||
| Self-Managed BEFORE answering. This applies even when the mapping or | ||
|
|
@@ -287,13 +290,52 @@ const CUSTOM_INSTRUCTIONS = `## Domain context | |
| // The docs page the widget is open on, appended to the agent instructions so it | ||
| // can infer the user's product (Cloud / Self-Managed / ADP) from context before | ||
| // asking. Antora sets <body data-component> to the docs component. | ||
| // Kapa source group scoping retrieval to the docs version of THIS page | ||
| // (DOC-1807, DOC-2450). The array is emitted per page by chat-panel.hbs via the | ||
| // get-kapa-source-groups helper, so it varies by URL without rebuilding the bundle. | ||
| // | ||
| // The two SDKs spell the same option differently, and Kapa documents the | ||
| // inconsistency deliberately (dev/agent/migrating-from-chat-sdk): | ||
| // | ||
| // Agent SDK (signed in) sourceGroupIdsInclude lowercase d | ||
| // Chat SDK (anonymous) sourceGroupIDsInclude capital ID | ||
| // | ||
| // A typo in either fails silently -- an unknown prop is ignored, no filter is | ||
| // sent, and answers quietly come from every docs version. So the name is derived | ||
| // from one place rather than written out at each call site. | ||
| // | ||
| // Spread rather than passed directly so that an empty array omits the prop | ||
| // entirely instead of sending []. Kapa treats an explicit empty list as "clear | ||
| // filtering", which is the same outcome, but omitting keeps the provider props | ||
| // identical to their pre-DOC-2450 shape when scoping cannot be resolved. | ||
| const SOURCE_GROUP_PROP = { agent: 'sourceGroupIdsInclude', chat: 'sourceGroupIDsInclude' } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Critical — the guarding test is self-referential, so a wrong prop name ships silently. The whole feature hinges on these two hand-written SDK prop names ( The test Recommend a check that asserts against the SDKs' actual prop types (or exercises the real provider), so a bad name fails CI instead of the test merely confirming the string is present. |
||
|
|
||
| function sourceGroupProps (tier) { | ||
| const ids = Array.isArray(window.KAPA_SOURCE_GROUP_IDS) ? window.KAPA_SOURCE_GROUP_IDS.filter(Boolean) : [] | ||
| if (!ids.length) return {} | ||
| return { [SOURCE_GROUP_PROP[tier]]: ids } | ||
| } | ||
|
|
||
| function currentPageContext () { | ||
| try { | ||
| const path = window.location.pathname | ||
| const component = (document.body && document.body.getAttribute('data-component')) || null | ||
| // Read from the URL for the same reason the source-group helper does: with | ||
| // latest_version_segment: 'current', the newest release publishes at | ||
| // /streaming/current/ while its page.version reads 26.2. 'current' is what | ||
| // the reader sees in the address bar and what Kapa's own source_url values | ||
| // use, so it is the honest thing to tell the agent. | ||
| const version = (path.match(/^\/(?:streaming\/)?(\d+\.\d+|current)\//) || [])[1] || null | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Critical — this hardcoded version regex reintroduces the friction DOC-2450 removes.
Suggest deriving the version for the prompt from the same mapping/ |
||
| return '\n\n## Current page\n' + | ||
| `- The user has the docs open at: ${path}` + | ||
| (component ? ` (docs component: ${component})` : '') + '\n' + | ||
| // Without this the agent asks which version while the reader is standing | ||
| // on the answer, and retrieval is ALREADY pinned to that version, so a | ||
| // guess of "latest" contradicts the sections it just received. | ||
| (version | ||
| ? `- Docs version: ${version}${version === 'current' ? ' (the latest release)' : ''}. ` + | ||
| 'Your search results are restricted to this version, so do not ask which version they are on.\n' | ||
| : '- This page has no version of its own, and searches cover the latest release.\n') + | ||
| '- Use this together with the conversation so far to infer their product before asking.' | ||
| } catch (e) { | ||
| return '' | ||
|
|
@@ -419,6 +461,7 @@ function App () { | |
| tools={agentTools} | ||
| customInstructions={CUSTOM_INSTRUCTIONS + currentPageContext()} | ||
| user={user?.email ? { email: user.email } : undefined} | ||
| {...sourceGroupProps('agent')} | ||
| enableHistory | ||
| onEvent={handleAgentEvent} | ||
| theme={{ accentColor: '#444ce7', colorScheme }} | ||
|
|
@@ -441,6 +484,7 @@ function App () { | |
| <KapaProvider | ||
| integrationId={window.KAPA_CHAT_INTEGRATION_ID} | ||
| apiService={persistentApiService} | ||
| {...sourceGroupProps('chat')} | ||
| callbacks={{ | ||
| askAI: { | ||
| onQuerySubmit: (data) => { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -56,6 +56,15 @@ | |
| "{{{this}}}"{{#unless @last}},{{/unless}} | ||
| {{/each}} | ||
| ]; | ||
| {{!-- Kapa source group scoping retrieval to THIS page's docs version (DOC-1807, | ||
| DOC-2450). Empty array means send no filter, which is the pre-DOC-2450 | ||
| behaviour of searching every version at once. Emitted per page rather than | ||
| bundled, so it varies by URL for free. --}} | ||
| window.KAPA_SOURCE_GROUP_IDS = window.KAPA_SOURCE_GROUP_IDS || [ | ||
| {{#each (get-kapa-source-groups)}} | ||
| "{{{this}}}"{{#unless @last}},{{/unless}} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🌐 Web query:
💡 Result: In Handlebars 4.7.0, the triple-stash expression {{{value}}} emits the raw value without HTML escaping, regardless of where it is placed in the template, including inside a <script> element [1][2][3]. Handlebars does not distinguish between different HTML contexts (such as scripts, attributes, or standard element content) when processing templates; it simply computes the output string based on the expression [4][5]. Because the triple-stash intentionally suppresses the library's default HTML-escaping mechanism, it will render the provided content exactly as it is without any modifications [1][2]. While the content will appear in the DOM, it is important to note that inserting <script> tags into the DOM via innerHTML (the method typically used to insert Handlebars output) does not inherently cause those scripts to execute [6][5]. Furthermore, placing raw, unescaped content into a <script> block is generally discouraged due to security risks, such as Cross-Site Scripting (XSS), and potential syntax errors if the injected content is not properly formatted for the JavaScript context [4][7]. Citations:
🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- changed hunk ---'
git diff -- src/partials/chat-panel.hbs
printf '%s\n' '--- template context ---'
cat -n src/partials/chat-panel.hbs | sed -n '1,120p'
printf '%s\n' '--- readMapping definitions and callers ---'
rg -n -C 5 'readMapping|group_id|chat-panel' src test tests 2>/dev/null | head -n 240
printf '%s\n' '--- package and relevant scripts ---'
rg -n -C 3 '"handlebars"|"test"|"build"' package.json yarn.lock package-lock.json pnpm-lock.yaml 2>/dev/null | head -n 160Repository: redpanda-data/docs-ui Length of output: 27846 🤖 get_repo_knowledge executed:
Length of output: 2819 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- helper files ---'
fd -t f . src/helpers | sort
printf '%s\n' '--- helper bindings ---'
rg -n -C 8 'get-kapa-source-groups|get-ai-suggestions|readMapping|kapa-source-groups' src/helpers src tests | head -n 320
printf '%s\n' '--- relevant tests ---'
cat -n tests/kapa-source-groups/kapa-source-groups.test.js | sed -n '1,230p'
printf '%s\n' '--- lockfile handlebars version ---'
node -e "const p=require('./package-lock.json'); console.log(p.packages?.['node_modules/handlebars']?.version || 'not found')" 2>/dev/null || trueRepository: redpanda-data/docs-ui Length of output: 46402 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- source-group helper implementation ---'
cat -n src/helpers/get-kapa-source-groups.js | sed -n '1,125p'
printf '%s\n' '--- existing JSON helpers ---'
cat -n src/helpers/json-safe.js src/helpers/json-stringify.js src/helpers/json.js 2>/dev/null
printf '%s\n' '--- helper registration and usage ---'
rg -n -C 5 'json-safe|json-stringify|get-kapa-source-groups' . -g '!package-lock.json' -g '!node_modules' | head -n 240Repository: redpanda-data/docs-ui Length of output: 24415 Use script-safe JSON encoding for 🤖 Prompt for AI Agents |
||
| {{/each}} | ||
| ]; | ||
| {{!-- Signed-in agent tier: per-component prompts that showcase the agent tools --}} | ||
| window.AGENT_SUGGESTIONS = window.AGENT_SUGGESTIONS || [ | ||
| {{#each (get-agent-suggestions)}} | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
Reject non-string source group IDs.
group_idis checked only for truthiness. A mapping withgroup_id: {}returns an object.src/partials/chat-panel.hbsthen renders it as"[object Object]", andsourceGroupPropssends an invalid filter instead of preserving the unfiltered fallback.Require a non-empty string before returning the ID. Add this malformed-shape case to the helper tests.
🤖 Prompt for AI Agents