Skip to content
Open
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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@
"test:negative-cache": "node tests/negative-cache/test-runner.js",
"test:head-meta": "node --test tests/head-meta/*.test.js",
"test:property-tooltips": "node --test tests/property-tooltips/*.test.js",
"test:all": "npm run test:playground && npm run test:interactive && npm run test:negative-cache && npm run test:head-meta && npm run test:property-tooltips",
"test:kapa-source-groups": "node --test tests/kapa-source-groups/*.test.js",
"test:all": "npm run test:playground && npm run test:interactive && npm run test:negative-cache && npm run test:head-meta && npm run test:property-tooltips && npm run test:kapa-source-groups",
"build:wasm": "cd blobl-editor/wasm && GOOS=js GOARCH=wasm go build -o ../../src/static/blobl.wasm .",
"copy:wasm-exec": "cp \"$(go env GOROOT)/lib/wasm/wasm_exec.js\" src/js/vendor/",
"serve:playground": "npx serve ."
Expand Down
229 changes: 229 additions & 0 deletions src/helpers/get-kapa-source-groups.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
'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
*/
/**
* Two outputs from one resolution, selected by an optional mode argument:
*
* {{#each (get-kapa-source-groups)}} -> array of zero or one group id
* {{get-kapa-source-groups 'segment'}} -> the segment that group represents
*
* One helper rather than two, because Antora compiles every UI helper in
* isolation and a helper CANNOT require a sibling helper: doing so fails at
* page-composition time with a fatal "Cannot find module" and takes the whole
* build down. Copying the resolution into a second file instead would
* reintroduce exactly the disagreement this is here to prevent.
*
* Handlebars passes params before the options object, so with no argument the
* first parameter IS the options object.
*/
module.exports = function (mode, options) {
const opts = options === undefined ? mode : options
const { segment, groupId } = resolve(opts)
// Only name a segment when a group is genuinely being sent, so the agent
// prompt cannot claim a restriction that is not in force.
const segmentOut = groupId && segment ? segment : ''
const idsOut = groupId ? [groupId] : []
switch (mode) {
case 'segment': return segmentOut
// The two JSON modes are what the template uses. They exist so that no raw
// value is ever interpolated into an executable <script>: the ids come from
// page or site attributes, and a quote or a </script> in one would end the
// string or the script element. Handlebars' triple-stash does no escaping
// at all inside <script>, so the encoding has to happen here.
case 'json': return scriptSafeJson(idsOut)
case 'segment-json': return scriptSafeJson(segmentOut)
default: return idsOut
}
}

/**
* JSON.stringify plus `<` as \u003c. JSON.stringify alone leaves `<` intact, so
* a literal `</script>` inside a value would close the script element early and
* dump the rest into the document as markup. Same treatment the docs-site edge
* function gives the same value for the /api/ pages.
*
* @param {*} value
* @returns {string} A JavaScript expression safe to place inside <script>
*/
function scriptSafeJson (value) {
return JSON.stringify(value).replace(/</g, '\\u003c')
}

/**
* Resolve a page to the group that will actually scope its retrieval, AND the
* segment that group represents.
*
* Both are returned from one place on purpose. The agent's prompt needs to tell
* the reader's model which version the answers came from, and any second
* derivation of that (say, a regex over window.location) can disagree with the
* group actually sent. It did: for /streaming/26.2/... a URL regex yields
* "26.2" while this resolves to the `current` group, because the latest release
* publishes at /streaming/current/ and 26.2 is not a segment. The prompt would
* then promise 26.2-only results over `current` retrieval.
*
* `segment` is the EFFECTIVE segment, after the fallback to default_segment, so
* it always names the group in `groupId` rather than what the URL asked for.
*
* @param {object} options - Handlebars options
* @returns {{segment: string|null, groupId: string|null}}
*/
function resolve (options) {
const root = (options && options.data && options.data.root) || {}
const { page, site } = root
const none = { segment: null, groupId: null }

const mapping = readMapping(page, site)
if (!mapping || !mapping.segments) return none

const asked = 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 effective = (asked && mapping.segments[asked]) ? asked : mapping.default_segment
const entry = mapping.segments[effective]
// A non-empty string, not merely truthy. A malformed mapping with
// group_id: {} would otherwise be emitted as "[object Object]" and sent to
// Kapa as a filter, which returns only global sources with no error. Nothing
// is the safer failure.
if (!entry || typeof entry.group_id !== 'string' || !entry.group_id) return none

return { segment: effective, groupId: 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.resolve = resolve
module.exports.scriptSafeJson = scriptSafeJson
module.exports.versionSegmentFromUrl = versionSegmentFromUrl
module.exports.readMapping = readMapping
82 changes: 80 additions & 2 deletions src/js/react/AskAI.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { agentTools } from './agentTools.js'
import { safeHeap } from './heap.js'
import { saveConversation } from './chatPersistence.js'
import { createPersistentApiService } from './persistentApiService.js'
import { readScopeIds, dropScope, isScopeRejection, SCOPE_DROPPED_EVENT } from './kapaScope.js'

// Singleton Chat SDK api service for the anonymous tier: injects the saved
// threadId so a conversation survives page navigation. The signed-in tier gets
Expand Down Expand Up @@ -207,8 +208,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: when "Current page" below names a docs version, your
search results are already restricted to it, so use it and do not ask. When
it says searches are not restricted to a version, the results may mix
versions: read each result's url, and ask which version only if 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
Expand Down Expand Up @@ -287,13 +291,80 @@ 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' }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 (sourceGroupIdsInclude / sourceGroupIDsInclude). A wrong name is silently ignored by React as an unknown prop → no filter is sent → answers come from every docs version again, which is exactly the silent failure the comment above warns about.

The test AskAI.jsx uses the correct, different prop name for each SDK tier reads this file and regex-matches the same literal strings it's guarding, so it passes even if both names are wrong for the installed @kapaai SDKs — nothing exercises the real provider, so the casing is unverified by CI.

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.


//
// `ids` is the scope App holds in state (see useKapaScopeIds), so a scope that
// Kapa rejected mid-session can be dropped by re-rendering the provider without
// the prop. With no argument it reads the page globals directly.
function sourceGroupProps (tier, ids) {
const fromPage = Array.isArray(window.KAPA_SOURCE_GROUP_IDS) ? window.KAPA_SOURCE_GROUP_IDS : []
const list = Array.isArray(ids) ? ids : fromPage
const clean = list.filter(Boolean)
if (!clean.length) return {}
return { [SOURCE_GROUP_PROP[tier]]: clean }
}

// The scope as React state, so the providers can be re-rendered without it.
// Starts from the page globals and empties when kapaScope.dropScope() fires
// SCOPE_DROPPED_EVENT: Kapa answers a stale group id with a 400 (measured live,
// not the silent global-only fallback the design first assumed), so a group the
// dashboard no longer knows would otherwise fail every question on the page
// until the regenerated mapping ships through three repos.
function useKapaScopeIds () {
const [ids, setIds] = useState(readScopeIds)
useEffect(() => {
const onDropped = () => setIds([])
window.addEventListener(SCOPE_DROPPED_EVENT, onDropped)
return () => window.removeEventListener(SCOPE_DROPPED_EVENT, onDropped)
}, [])
return ids
}

function currentPageContext () {
try {
const path = window.location.pathname
const component = (document.body && document.body.getAttribute('data-component')) || null
// Taken from the SAME resolution that chose the source group, never
// re-derived. A URL regex here reads "26.2" out of /streaming/26.2/... while
// the group actually sent is `current`, because the latest release publishes
// at /streaming/current/ and its own number is not a segment. The prompt
// below asserts a restriction and forbids asking, so a disagreement makes
// the agent attribute an answer to a version it never searched.
//
// Empty or absent means no group was sent, so nothing is restricted.
const version = (typeof window.KAPA_SOURCE_GROUP_SEGMENT === 'string' && window.KAPA_SOURCE_GROUP_SEGMENT) || null
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'
// No group was sent, so retrieval spans every indexed version. Saying
// so is what stops the model asserting a version it cannot support.
: '- Searches are NOT restricted to a version, so results may mix versions. ' +
'Check each result url before stating that something applies to a particular version.\n') +
'- Use this together with the conversation so far to infer their product before asking.'
} catch (e) {
return ''
Expand All @@ -313,6 +384,10 @@ function handleAgentEvent (event) {
thread_id: event.data.threadId,
error: event.data.error,
})
// The Agent SDK puts Kapa's response body in the message, so a rejected
// source group is named outright. Drop the scope so the reader's next
// question (and the retry the SDK offers) goes out unscoped.
if (isScopeRejection(event.data.error)) dropScope(event.data.error)
break
case 'thread_resumed':
safeHeap('thread_resumed_docs_home', { thread_id: event.data.threadId })
Expand Down Expand Up @@ -400,6 +475,7 @@ class ErrorBoundary extends Component {
}

function App () {
const scopeIds = useKapaScopeIds()
const colorScheme = useSiteColorScheme()
const { authenticated, user, loginUrl } = useSession()

Expand All @@ -419,6 +495,7 @@ function App () {
tools={agentTools}
customInstructions={CUSTOM_INSTRUCTIONS + currentPageContext()}
user={user?.email ? { email: user.email } : undefined}
{...sourceGroupProps('agent', scopeIds)}
enableHistory
onEvent={handleAgentEvent}
theme={{ accentColor: '#444ce7', colorScheme }}
Expand All @@ -441,6 +518,7 @@ function App () {
<KapaProvider
integrationId={window.KAPA_CHAT_INTEGRATION_ID}
apiService={persistentApiService}
{...sourceGroupProps('chat', scopeIds)}
callbacks={{
askAI: {
onQuerySubmit: (data) => {
Expand Down
Loading
Loading