@drewswiredin/backstage-plugin-assistants-backend-module-semantic-search 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -100,12 +100,53 @@ assistants:
100
100
  On Azure OpenAI, deploy an embedding model and name the deployment:
101
101
  `embeddingModel: azure:<embedding-deployment>`.
102
102
 
103
+ ### Rank by source
104
+
105
+ Every source shares one index, so a large one (thousands of tickets that all
106
+ mention the same system) can crowd out the one runbook section that answers
107
+ the question. Name the search document types to weigh them:
108
+
109
+ ```yaml
110
+ assistants:
111
+ semanticSearch:
112
+ embeddingModel: openai:text-embedding-3-small
113
+ maxResults: 8
114
+ types:
115
+ software-catalog: { weight: 1.5 }
116
+ techdocs: { weight: 1.2 }
117
+ confluence: { weight: 1.0 }
118
+ jira:
119
+ weight: 0.5
120
+ maxResults: 2
121
+ description: ticket history, decisions and status
122
+ ```
123
+
124
+ The keys are the type names Backstage search uses: `software-catalog`,
125
+ `techdocs`, or a collator's own type. Types left out are copied and ranked
126
+ with weight 1. Set `default: false` to make a source opt-in: the model then
127
+ searches it only when it passes `types: ['jira']`, and the tool description
128
+ says so. Set `enabled: false` to stop copying a source; its chunks are
129
+ deleted on its next search run.
130
+
103
131
  The index fills as search runs: each document type arrives when its collator
104
132
  next runs, on the schedule you gave that collator. A source's first run embeds
105
133
  all of it; later runs embed only new and changed sections and delete removed
106
134
  ones. Changing `embeddingModel` empties the index, and each type refills on its
107
135
  next search run.
108
136
 
137
+ A type added to `types` takes its name, weight, and metadata on its next run,
138
+ keeping the embeddings it already has; 0.2.0 indexes upgrade the same way. To
139
+ run a collator now rather than wait for its schedule, trigger its search task
140
+ with a user or service token:
141
+
142
+ ```bash
143
+ curl -X POST -H "Authorization: Bearer $TOKEN" \
144
+ https://backstage.example.com/api/search/.backstage/scheduler/v1/tasks/search_index_jira/trigger
145
+ ```
146
+
147
+ The task id is `search_index_<type>`; the backend logs `Knowledge index
148
+ refreshed` with the type and counts when the copy finishes.
149
+
109
150
  ## Give an assistant the tool
110
151
 
111
152
  Add `search-knowledge` to the assistant's tools in the assistant editor. It
@@ -149,17 +190,24 @@ document gated by a catalog entity (its `authorization.resourceRef`) keeps that
149
190
  entity for the permission check; a document gated by any other kind of
150
191
  resource is skipped, since the index can only check entity read permission.
151
192
 
152
- A run does not say which document type it is, so the index recognizes a source
153
- by its locations: the run joins the stored collection that already holds most
154
- of its locations, and that collection's chunks the run no longer has are
155
- deleted. A collection no search run has refreshed in 30 days (a source removed
156
- from search, or one whose locations all changed at once) is dropped. Each row stores the embedding, a generated full-text vector, and a
157
- trigram index over its text. The table is a rebuildable copy, not migrated
193
+ Search never tells a decorator which document type it is decorating, so the
194
+ module adds a pass-through decorator to each type named in `types`. Search
195
+ builds a run's pipeline in one pass, so that decorator names the run's type.
196
+ A named type's run is stored under its name; a run of any other type joins
197
+ the stored collection that already holds most of its locations. Chunks the
198
+ run no longer has are deleted. A collection no search run has refreshed in
199
+ 30 days (a source removed from search, or an unnamed one whose locations all
200
+ changed at once) is dropped. Each row stores the embedding, a generated
201
+ full-text vector, a trigram index over its text, its type, and the short
202
+ fields its collator attached. The table is a rebuildable copy, not migrated
158
203
  data.
159
204
 
160
- **Retrieval.** `search-knowledge` takes the question and up to three alternative
161
- phrasings and builds one semantic list per phrasing, a keyword list, a rare-word
162
- list, and a fuzzy list:
205
+ **Retrieval.** `search-knowledge` takes the question, up to three alternative
206
+ phrasings, and optionally the `types` to search (otherwise every type that is
207
+ on by default, plus unnamed sources). It builds one semantic list per
208
+ phrasing, a keyword list, a rare-word list, and a fuzzy list, each keeping
209
+ its best 30 chunks of every type, so a large source cannot fill a list on its
210
+ own:
163
211
 
164
212
  | List | Matches | Postgres |
165
213
  | --------- | --------------------------------------------- | ----------------------------------------------------------- |
@@ -171,18 +219,71 @@ list, and a fuzzy list:
171
219
  Query words include adjacent pairs written as one word, so "tally up" also
172
220
  matches "TallyUp". Reciprocal rank fusion merges the lists with one vote per
173
221
  method: the semantic lists share one vote, so extra phrasings broaden recall
174
- without outvoting an exact match. The rare-word list keeps a name or acronym
175
- mentioned once in a paragraph about something else from being outranked by
176
- sections that are only near the question in meaning. The top candidates'
177
- entities are fetched from the catalog as the user; results for entities the
178
- user cannot read are dropped, results with no entity are kept, and repeated
179
- text is shown once.
222
+ without outvoting an exact match. Each chunk's fused score is multiplied by
223
+ its type's weight. The rare-word list keeps a name or acronym mentioned once
224
+ in a paragraph about something else from being outranked by sections that
225
+ are only near the question in meaning. The top candidates' entities are
226
+ fetched from the catalog as the user; results for entities the user cannot
227
+ read are dropped, results with no entity are kept, and repeated text is shown
228
+ once. The ranker then picks the results: by default best first, with at most
229
+ `maxResults` of a capped type while other types can fill the slots.
230
+
231
+ Each result carries its title, location, type, excerpt, the entity ref for
232
+ catalog and TechDocs results, and `metadata`: the short fields its collator
233
+ attached (strings up to 200 characters, numbers, booleans), such as a
234
+ ticket's status or a page's last update.
235
+
236
+ ## Custom ranking
237
+
238
+ A backend module can replace the ranker, for example to favour recently
239
+ updated tickets:
240
+
241
+ ```ts
242
+ import { createBackendModule } from '@backstage/backend-plugin-api';
243
+ import { knowledgeRankingExtensionPoint } from '@drewswiredin/backstage-plugin-assistants-backend-module-semantic-search/alpha';
244
+
245
+ export default createBackendModule({
246
+ pluginId: 'assistants',
247
+ moduleId: 'knowledge-recency',
248
+ register(env) {
249
+ env.registerInit({
250
+ deps: { ranking: knowledgeRankingExtensionPoint },
251
+ async init({ ranking }) {
252
+ ranking.setRanker(({ candidates, maxResults }) =>
253
+ candidates
254
+ .map(candidate => {
255
+ const updated = Date.parse(String(candidate.metadata?.updated));
256
+ const ageDays = (Date.now() - updated) / 86_400_000;
257
+ const boost = ageDays < 90 ? 1.2 : 1;
258
+ return { candidate, score: candidate.score * boost };
259
+ })
260
+ .sort((a, b) => b.score - a.score)
261
+ .slice(0, maxResults)
262
+ .map(({ candidate }) => candidate),
263
+ );
264
+ },
265
+ });
266
+ },
267
+ });
268
+ ```
269
+
270
+ The ranker receives the query, its phrasings, the types searched, and up to
271
+ 60 candidates the caller may read. Each candidate has its fused score after
272
+ weights, its rank in each retrieval list, its type, entity ref, and metadata.
273
+ It runs after the permission check, so it cannot surface what the caller
274
+ cannot read; results it returns that were not candidates are dropped.
180
275
 
181
276
  ## Reference
182
277
 
183
- | Key | Default | Meaning |
184
- | ------------------------------------------ | ---------- | --------------------------------------------------- |
185
- | `assistants.semanticSearch.embeddingModel` | (required) | `<providerId>:<model>` from `assistants.providers`. |
278
+ | Key | Default | Meaning |
279
+ | ---------------------------------------------------- | ---------- | ----------------------------------------------------------------------------- |
280
+ | `assistants.semanticSearch.embeddingModel` | (required) | `<providerId>:<model>` from `assistants.providers`. |
281
+ | `assistants.semanticSearch.maxResults` | `8` | Results per answer unless the model asks for a number (it may ask for 20). |
282
+ | `assistants.semanticSearch.types.<type>.weight` | `1` | Multiplies the type's fused score; `0` never returns it. |
283
+ | `assistants.semanticSearch.types.<type>.default` | `true` | Searched when the model names no types; `false` makes the type opt-in. |
284
+ | `assistants.semanticSearch.types.<type>.maxResults` | | At most this many of the type's results while other types can fill the slots. |
285
+ | `assistants.semanticSearch.types.<type>.enabled` | `true` | `false` stops copying the type and deletes its chunks on its next run. |
286
+ | `assistants.semanticSearch.types.<type>.description` | | What the source holds, shown to the model in the tool description. |
186
287
 
187
288
  The index refreshes when search does; set each collator's schedule in its own
188
289
  config.
@@ -11,6 +11,39 @@
11
11
  "embeddingModel": {
12
12
  "type": "string",
13
13
  "description": "Embedding model in `<providerId>:<model>` format, resolved against `assistants.providers`, e.g. `openai:text-embedding-3-small`. The provider must be `openai`, `azure`, or `openai-compatible`. Changing it rebuilds the index."
14
+ },
15
+ "maxResults": {
16
+ "type": "number",
17
+ "description": "Results per `search-knowledge` answer unless the caller asks for a number. Defaults to 8."
18
+ },
19
+ "types": {
20
+ "type": "object",
21
+ "additionalProperties": {
22
+ "type": "object",
23
+ "properties": {
24
+ "weight": {
25
+ "type": "number",
26
+ "description": "Multiplies the type's fused score. Defaults to 1; 0 means its chunks are never returned."
27
+ },
28
+ "default": {
29
+ "type": "boolean",
30
+ "description": "Searched when the caller names no types. Defaults to true; false makes the type opt-in (the model passes it in `types`)."
31
+ },
32
+ "maxResults": {
33
+ "type": "number",
34
+ "description": "At most this many of the type's results in one answer."
35
+ },
36
+ "enabled": {
37
+ "type": "boolean",
38
+ "description": "Copied into the index. Defaults to true; false stops copying it and deletes its chunks on its next search run."
39
+ },
40
+ "description": {
41
+ "type": "string",
42
+ "description": "What the source holds, shown to the model in the tool description, e.g. \"ticket history, decisions and status\"."
43
+ }
44
+ }
45
+ },
46
+ "description": "Settings per Backstage search document type, keyed by the type name search uses (`software-catalog`, `techdocs`, or a collator's own type). Types left out are copied and ranked with the defaults."
14
47
  }
15
48
  },
16
49
  "required": [
@@ -2,9 +2,11 @@
2
2
 
3
3
  var ai = require('ai');
4
4
  var rank = require('./rank.cjs.js');
5
+ var ranking = require('./ranking.cjs.js');
5
6
 
6
7
  const CANDIDATES = 30;
7
- const FUSED = 40;
8
+ const FUSED = 60;
9
+ const MAX_LIMIT = 20;
8
10
  const EXCERPT_CHARS = 1500;
9
11
  const RARE_MAX_CHUNKS = 3;
10
12
  function registerSearchKnowledgeAction({
@@ -12,13 +14,17 @@ function registerSearchKnowledgeAction({
12
14
  store,
13
15
  catalog,
14
16
  auth,
15
- embeddingModel
17
+ embeddingModel,
18
+ types,
19
+ maxResults,
20
+ ranker
16
21
  }) {
17
22
  actionsRegistry.register({
18
23
  name: "search-knowledge",
19
24
  title: "Search Knowledge",
20
25
  attributes: { destructive: false, readOnly: true, idempotent: true },
21
- description: `Search everything the organization's Backstage search indexes (TechDocs, the software catalog, and any other source it collects, such as Confluence or wiki pages) by meaning, not only by exact words. Best for questions in the user's own words: it finds a page even when the question is worded differently from it ("fix errored messages" finds "Retry dead-letter messages") and tolerates misspelled names ("Pilon" finds "Pylon"). It returns the most relevant sections, not every match, and cannot filter by entity kind or source.
26
+ description: `Search everything the organization's Backstage search indexes (TechDocs, the software catalog, and any other source it collects, such as Confluence or wiki pages) by meaning, not only by exact words. Best for questions in the user's own words: it finds a page even when the question is worded differently from it ("fix errored messages" finds "Retry dead-letter messages") and tolerates misspelled names ("Pilon" finds "Pylon"). It returns the most relevant sections, not every match, and cannot filter by entity kind.
27
+ ${sourcesDescription(types)}
22
28
 
23
29
  How to search the organization's docs and catalog:
24
30
  1. Start here, with the user's question.
@@ -27,19 +33,27 @@ How to search the organization's docs and catalog:
27
33
 
28
34
  Pass the user's question as the "query" parameter. Pass up to three "alternatives": other phrasings, synonyms, or the internal terms the docs might use.
29
35
 
30
- Each result has a title (its heading path), an excerpt, a location, and, for catalog and TechDocs results, the entity ref. A location starting with /docs/ is a TechDocs page: pass it to read-techdocs to read the full page. Any other location is a link to the page or entity; cite it.`,
36
+ Each result has a title (its heading path), an excerpt, a location, its source type, the fields the source attached (such as status, space, or last update: use them to judge how current it is), and, for catalog and TechDocs results, the entity ref. A location starting with /docs/ is a TechDocs page: pass it to read-techdocs to read the full page. Any other location is a link to the page or entity; cite it.`,
31
37
  schema: {
32
38
  input: (z) => z.object({
33
39
  query: z.string().min(1).describe("The question or topic to find"),
34
40
  alternatives: z.array(z.string()).max(3).optional().describe("Other phrasings or terms for the same thing"),
35
- limit: z.number().int().min(1).max(20).optional().default(8).describe("Number of results to return")
41
+ types: z.array(z.string()).optional().describe(
42
+ "Search only these sources (search document types); omit to search the default ones"
43
+ ),
44
+ limit: z.number().int().min(1).max(Math.max(MAX_LIMIT, maxResults)).optional().default(maxResults).describe("Number of results to return")
36
45
  }),
37
46
  output: (z) => z.object({
38
47
  results: z.array(
39
48
  z.object({
40
49
  title: z.string(),
41
50
  location: z.string(),
51
+ type: z.string().optional(),
42
52
  entityRef: z.string().optional(),
53
+ metadata: z.record(
54
+ z.string(),
55
+ z.union([z.string(), z.number(), z.boolean()])
56
+ ).optional(),
43
57
  excerpt: z.string()
44
58
  })
45
59
  )
@@ -51,12 +65,17 @@ Each result has a title (its heading path), an excerpt, a location, and, for cat
51
65
  "The knowledge index is still being built; meanwhile use keyword search: the query tool when it is available, otherwise search-techdocs and search-catalog."
52
66
  );
53
67
  }
68
+ const filter = ranking.selectTypes(types, await store.types(), input.types);
54
69
  const phrasings = [input.query, ...input.alternatives ?? []];
55
70
  const words = rank.queryWords(phrasings);
56
71
  const joined = rank.joinedWords(phrasings).filter((w) => !words.includes(w));
57
72
  const [{ embeddings }, frequencies] = await Promise.all([
58
73
  ai.embedMany({ model: embeddingModel, values: phrasings }),
59
- store.documentFrequencies([...words, ...joined], RARE_MAX_CHUNKS + 1)
74
+ store.documentFrequencies(
75
+ [...words, ...joined],
76
+ RARE_MAX_CHUNKS + 1,
77
+ filter
78
+ )
60
79
  ]);
61
80
  const known = [...words, ...joined].filter(
62
81
  (w) => (frequencies.get(w) ?? 0) > 0
@@ -65,48 +84,80 @@ Each result has a title (its heading path), an excerpt, a location, and, for cat
65
84
  const unknown = words.filter((w) => frequencies.get(w) === 0);
66
85
  const lists = await Promise.all([
67
86
  ...embeddings.map(async (embedding) => ({
68
- ids: await store.nearest(embedding, CANDIDATES),
87
+ name: "semantic",
88
+ hits: await store.nearest(embedding, CANDIDATES, filter),
69
89
  weight: 1 / embeddings.length
70
90
  })),
71
- store.keyword(known, CANDIDATES).then((ids) => ({ ids, weight: 1 })),
72
- store.keyword(rare, CANDIDATES).then((ids) => ({ ids, weight: 1 })),
73
- store.fuzzy(unknown, CANDIDATES).then((ids) => ({ ids, weight: 1 }))
91
+ store.keyword(known, CANDIDATES, filter).then((hits) => ({ name: "keyword", hits, weight: 1 })),
92
+ store.keyword(rare, CANDIDATES, filter).then((hits) => ({ name: "rareWord", hits, weight: 1 })),
93
+ store.fuzzy(unknown, CANDIDATES, filter).then((hits) => ({ name: "fuzzy", hits, weight: 1 }))
74
94
  ]);
75
- const ranked = rank.fuseRankings(lists).slice(0, FUSED);
95
+ const settingsOf = ranking.typeSettingsOf(types);
96
+ const fused = rank.fuseRankings(lists, (type) => settingsOf(type).weight).slice(
97
+ 0,
98
+ FUSED
99
+ );
76
100
  const byId = new Map(
77
- (await store.chunks(ranked)).map((chunk) => [chunk.id, chunk])
101
+ (await store.chunks(fused.map((hit) => hit.id))).map((chunk) => [
102
+ chunk.id,
103
+ chunk
104
+ ])
78
105
  );
79
- const candidates = ranked.map((id) => byId.get(id)).filter((chunk) => Boolean(chunk));
106
+ const found = fused.flatMap((hit) => {
107
+ const chunk = byId.get(hit.id);
108
+ return chunk ? [{ hit, chunk }] : [];
109
+ });
80
110
  const readable = await readableEntityRefs(catalog, auth, credentials, [
81
111
  ...new Set(
82
- candidates.flatMap(
83
- (chunk) => chunk.entityRef ? [chunk.entityRef] : []
112
+ found.flatMap(
113
+ ({ chunk }) => chunk.entityRef ? [chunk.entityRef] : []
84
114
  )
85
115
  )
86
116
  ]);
87
117
  const seen = /* @__PURE__ */ new Set();
88
- const results = [];
89
- for (const chunk of candidates) {
118
+ const candidates = [];
119
+ for (const { hit, chunk } of found) {
90
120
  const key = `${chunk.location}|${chunk.title}`;
91
121
  if (chunk.entityRef && !readable.has(chunk.entityRef) || seen.has(key) || seen.has(chunk.content)) {
92
122
  continue;
93
123
  }
94
124
  seen.add(key);
95
125
  seen.add(chunk.content);
96
- results.push({
97
- title: chunk.title,
98
- location: chunk.location,
99
- ...chunk.entityRef ? { entityRef: chunk.entityRef } : {},
100
- excerpt: chunk.content.slice(0, EXCERPT_CHARS)
101
- });
102
- if (results.length === input.limit) {
103
- break;
104
- }
126
+ candidates.push({ ...chunk, score: hit.score, ranks: hit.ranks });
105
127
  }
128
+ const ids = new Set(candidates.map((candidate) => candidate.id));
129
+ const ranked = await ranker()({
130
+ query: input.query,
131
+ phrasings,
132
+ types: filter.types,
133
+ candidates,
134
+ maxResults: input.limit
135
+ });
136
+ const results = ranked.filter((candidate) => ids.delete(candidate.id)).slice(0, input.limit).map((candidate) => ({
137
+ title: candidate.title,
138
+ location: candidate.location,
139
+ ...candidate.type ? { type: candidate.type } : {},
140
+ ...candidate.entityRef ? { entityRef: candidate.entityRef } : {},
141
+ ...candidate.metadata ? { metadata: candidate.metadata } : {},
142
+ excerpt: candidate.content.slice(0, EXCERPT_CHARS)
143
+ }));
106
144
  return { output: { results } };
107
145
  }
108
146
  });
109
147
  }
148
+ function sourcesDescription(types) {
149
+ const listed = [...types].filter(
150
+ ([, settings]) => settings.enabled && settings.weight > 0
151
+ );
152
+ const lines = listed.map(
153
+ ([type, settings]) => `- ${type}${settings.default ? "" : ' (only when named in "types")'}${settings.description ? `: ${settings.description}` : ""}`
154
+ );
155
+ return [
156
+ "",
157
+ `Sources: pass "types" to search only the named search document types; without it the search covers every source except those marked as searched only when named. An unknown name returns the list of types.`,
158
+ ...lines
159
+ ].join("\n");
160
+ }
110
161
  async function readableEntityRefs(catalog, auth, credentials, entityRefs) {
111
162
  if (entityRefs.length === 0) {
112
163
  return /* @__PURE__ */ new Set();
@@ -123,4 +174,5 @@ async function readableEntityRefs(catalog, auth, credentials, entityRefs) {
123
174
  }
124
175
 
125
176
  exports.registerSearchKnowledgeAction = registerSearchKnowledgeAction;
177
+ exports.sourcesDescription = sourcesDescription;
126
178
  //# sourceMappingURL=action.cjs.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"action.cjs.js","sources":["../src/action.ts"],"sourcesContent":["import type {\n AuthService,\n BackstageCredentials,\n} from '@backstage/backend-plugin-api';\nimport type { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';\nimport type { CatalogApi } from '@backstage/catalog-client';\nimport { embedMany, type EmbeddingModel } from 'ai';\nimport { fuseRankings, joinedWords, queryWords, type RankedList } from './rank';\nimport type { KnowledgeStore, StoredChunk } from './store';\n\n/** Candidates per retrieval list, before fusion and permission filtering. */\nconst CANDIDATES = 30;\n/** Fused candidates checked for permission; over-fetched so filtering rarely empties the page. */\nconst FUSED = 40;\nconst EXCERPT_CHARS = 1500;\n/** A word in this many chunks or fewer is rare: strong evidence of what the user means. */\nconst RARE_MAX_CHUNKS = 3;\n\nexport interface SearchKnowledgeDeps {\n actionsRegistry: ActionsRegistryService;\n store: KnowledgeStore;\n catalog: CatalogApi;\n auth: AuthService;\n embeddingModel: EmbeddingModel;\n}\n\n/**\n * Registers `search-knowledge`: hybrid retrieval over the knowledge index. Each\n * phrasing is embedded and matched by meaning; the query words are matched\n * exactly (full-text, any word) and, for words absent from the index, by\n * near spelling (trigram). Words found in only a few chunks get a list of\n * their own, so a rare name or acronym buried in a paragraph about something\n * else is not outvoted by sections that are merely near the question.\n *\n * The lists are fused by reciprocal rank with one vote per method: the\n * phrasings share the meaning vote, so extra alternatives broaden recall\n * without drowning out exact matches. Results gated by a catalog entity are\n * filtered to entities the caller can read; the rest are visible to every\n * assistant user, as they are in Backstage search. Repeated text is shown\n * once.\n */\nexport function registerSearchKnowledgeAction({\n actionsRegistry,\n store,\n catalog,\n auth,\n embeddingModel,\n}: SearchKnowledgeDeps): void {\n actionsRegistry.register({\n name: 'search-knowledge',\n title: 'Search Knowledge',\n attributes: { destructive: false, readOnly: true, idempotent: true },\n description: `Search everything the organization's Backstage search indexes (TechDocs, the software catalog, and any other source it collects, such as Confluence or wiki pages) by meaning, not only by exact words. Best for questions in the user's own words: it finds a page even when the question is worded differently from it (\"fix errored messages\" finds \"Retry dead-letter messages\") and tolerates misspelled names (\"Pilon\" finds \"Pylon\"). It returns the most relevant sections, not every match, and cannot filter by entity kind or source.\n\nHow to search the organization's docs and catalog:\n1. Start here, with the user's question.\n2. If the results don't clearly answer, also run a keyword search: the query tool (catalog and TechDocs at once) when it is available, otherwise search-techdocs and search-catalog. Keyword search finds rare names, acronyms and jargon that ranking by meaning can miss. For a bare name, term or acronym, run both searches from the start.\n3. Report something as missing only after both searches come back empty.\n\nPass the user's question as the \"query\" parameter. Pass up to three \"alternatives\": other phrasings, synonyms, or the internal terms the docs might use.\n\nEach result has a title (its heading path), an excerpt, a location, and, for catalog and TechDocs results, the entity ref. A location starting with /docs/ is a TechDocs page: pass it to read-techdocs to read the full page. Any other location is a link to the page or entity; cite it.`,\n schema: {\n input: z =>\n z.object({\n query: z.string().min(1).describe('The question or topic to find'),\n alternatives: z\n .array(z.string())\n .max(3)\n .optional()\n .describe('Other phrasings or terms for the same thing'),\n limit: z\n .number()\n .int()\n .min(1)\n .max(20)\n .optional()\n .default(8)\n .describe('Number of results to return'),\n }),\n output: z =>\n z.object({\n results: z.array(\n z.object({\n title: z.string(),\n location: z.string(),\n entityRef: z.string().optional(),\n excerpt: z.string(),\n }),\n ),\n }),\n },\n action: async ({ input, credentials }) => {\n if (!(await store.isReady())) {\n throw new Error(\n 'The knowledge index is still being built; meanwhile use keyword search: the query tool when it is available, otherwise search-techdocs and search-catalog.',\n );\n }\n\n const phrasings = [input.query, ...(input.alternatives ?? [])];\n const words = queryWords(phrasings);\n const joined = joinedWords(phrasings).filter(w => !words.includes(w));\n const [{ embeddings }, frequencies] = await Promise.all([\n embedMany({ model: embeddingModel, values: phrasings }),\n store.documentFrequencies([...words, ...joined], RARE_MAX_CHUNKS + 1),\n ]);\n const known = [...words, ...joined].filter(\n w => (frequencies.get(w) ?? 0) > 0,\n );\n const rare = known.filter(w => frequencies.get(w)! <= RARE_MAX_CHUNKS);\n const unknown = words.filter(w => frequencies.get(w) === 0);\n\n const lists: RankedList[] = await Promise.all([\n ...embeddings.map(async embedding => ({\n ids: await store.nearest(embedding, CANDIDATES),\n weight: 1 / embeddings.length,\n })),\n store.keyword(known, CANDIDATES).then(ids => ({ ids, weight: 1 })),\n store.keyword(rare, CANDIDATES).then(ids => ({ ids, weight: 1 })),\n store.fuzzy(unknown, CANDIDATES).then(ids => ({ ids, weight: 1 })),\n ]);\n\n const ranked = fuseRankings(lists).slice(0, FUSED);\n const byId = new Map(\n (await store.chunks(ranked)).map(chunk => [chunk.id, chunk]),\n );\n const candidates = ranked\n .map(id => byId.get(id))\n .filter((chunk): chunk is StoredChunk => Boolean(chunk));\n\n const readable = await readableEntityRefs(catalog, auth, credentials, [\n ...new Set(\n candidates.flatMap(chunk =>\n chunk.entityRef ? [chunk.entityRef] : [],\n ),\n ),\n ]);\n\n const seen = new Set<string>();\n const results = [];\n for (const chunk of candidates) {\n const key = `${chunk.location}|${chunk.title}`;\n if (\n (chunk.entityRef && !readable.has(chunk.entityRef)) ||\n seen.has(key) ||\n seen.has(chunk.content)\n ) {\n continue;\n }\n seen.add(key);\n seen.add(chunk.content);\n results.push({\n title: chunk.title,\n location: chunk.location,\n ...(chunk.entityRef ? { entityRef: chunk.entityRef } : {}),\n excerpt: chunk.content.slice(0, EXCERPT_CHARS),\n });\n if (results.length === input.limit) {\n break;\n }\n }\n return { output: { results } };\n },\n });\n}\n\n/**\n * The subset of entity refs the caller can read, asked of the catalog as the\n * caller — so the catalog's own permission policy decides, and a deleted\n * entity's leftover chunks never surface.\n */\nasync function readableEntityRefs(\n catalog: CatalogApi,\n auth: AuthService,\n credentials: BackstageCredentials,\n entityRefs: string[],\n): Promise<Set<string>> {\n if (entityRefs.length === 0) {\n return new Set();\n }\n const { token } = await auth.getPluginRequestToken({\n onBehalfOf: credentials,\n targetPluginId: 'catalog',\n });\n const { items } = await catalog.getEntitiesByRefs(\n { entityRefs, fields: ['kind', 'metadata.namespace', 'metadata.name'] },\n { token },\n );\n return new Set(entityRefs.filter((_, i) => items[i]));\n}\n"],"names":["queryWords","joinedWords","embedMany","fuseRankings"],"mappings":"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"}
1
+ {"version":3,"file":"action.cjs.js","sources":["../src/action.ts"],"sourcesContent":["import type {\n AuthService,\n BackstageCredentials,\n} from '@backstage/backend-plugin-api';\nimport type { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';\nimport type { CatalogApi } from '@backstage/catalog-client';\nimport { embedMany, type EmbeddingModel } from 'ai';\nimport type { KnowledgeTypeSettings } from './config';\nimport { fuseRankings, joinedWords, queryWords, type RankedList } from './rank';\nimport {\n selectTypes,\n typeSettingsOf,\n type KnowledgeCandidate,\n type KnowledgeRanker,\n} from './ranking';\nimport type { KnowledgeStore } from './store';\n\n/** Candidates per type in each retrieval list, before fusion and permission filtering. */\nconst CANDIDATES = 30;\n/** Fused candidates checked for permission and ranked; over-fetched so filtering and type caps rarely empty the page. */\nconst FUSED = 60;\n/** The most results a caller may ask for, unless `maxResults` is configured higher. */\nconst MAX_LIMIT = 20;\nconst EXCERPT_CHARS = 1500;\n/** A word in this many chunks or fewer is rare: strong evidence of what the user means. */\nconst RARE_MAX_CHUNKS = 3;\n\nexport interface SearchKnowledgeDeps {\n actionsRegistry: ActionsRegistryService;\n store: KnowledgeStore;\n catalog: CatalogApi;\n auth: AuthService;\n embeddingModel: EmbeddingModel;\n types: Map<string, KnowledgeTypeSettings>;\n maxResults: number;\n /** The ranker in effect: the default, or one a module set. */\n ranker: () => KnowledgeRanker;\n}\n\n/**\n * Registers `search-knowledge`: hybrid retrieval over the knowledge index. Each\n * phrasing is embedded and matched by meaning; the query words are matched\n * exactly (full-text, any word) and, for words absent from the index, by\n * near spelling (trigram). Words found in only a few chunks get a list of\n * their own, so a rare name or acronym buried in a paragraph about something\n * else is not outvoted by sections that are merely near the question.\n *\n * Each list keeps its best chunks of every document type, so a large source\n * cannot fill a list on its own. The lists are fused by reciprocal rank with\n * one vote per method, each chunk's score multiplied by its type's weight:\n * the phrasings share the meaning vote, so extra alternatives broaden recall\n * without drowning out exact matches. Results gated by a catalog entity are\n * filtered to entities the caller can read; the rest are visible to every\n * assistant user, as they are in Backstage search. Repeated text is shown\n * once, and the ranker picks the results from what remains.\n */\nexport function registerSearchKnowledgeAction({\n actionsRegistry,\n store,\n catalog,\n auth,\n embeddingModel,\n types,\n maxResults,\n ranker,\n}: SearchKnowledgeDeps): void {\n actionsRegistry.register({\n name: 'search-knowledge',\n title: 'Search Knowledge',\n attributes: { destructive: false, readOnly: true, idempotent: true },\n description: `Search everything the organization's Backstage search indexes (TechDocs, the software catalog, and any other source it collects, such as Confluence or wiki pages) by meaning, not only by exact words. Best for questions in the user's own words: it finds a page even when the question is worded differently from it (\"fix errored messages\" finds \"Retry dead-letter messages\") and tolerates misspelled names (\"Pilon\" finds \"Pylon\"). It returns the most relevant sections, not every match, and cannot filter by entity kind.\n${sourcesDescription(types)}\n\nHow to search the organization's docs and catalog:\n1. Start here, with the user's question.\n2. If the results don't clearly answer, also run a keyword search: the query tool (catalog and TechDocs at once) when it is available, otherwise search-techdocs and search-catalog. Keyword search finds rare names, acronyms and jargon that ranking by meaning can miss. For a bare name, term or acronym, run both searches from the start.\n3. Report something as missing only after both searches come back empty.\n\nPass the user's question as the \"query\" parameter. Pass up to three \"alternatives\": other phrasings, synonyms, or the internal terms the docs might use.\n\nEach result has a title (its heading path), an excerpt, a location, its source type, the fields the source attached (such as status, space, or last update: use them to judge how current it is), and, for catalog and TechDocs results, the entity ref. A location starting with /docs/ is a TechDocs page: pass it to read-techdocs to read the full page. Any other location is a link to the page or entity; cite it.`,\n schema: {\n input: z =>\n z.object({\n query: z.string().min(1).describe('The question or topic to find'),\n alternatives: z\n .array(z.string())\n .max(3)\n .optional()\n .describe('Other phrasings or terms for the same thing'),\n types: z\n .array(z.string())\n .optional()\n .describe(\n 'Search only these sources (search document types); omit to search the default ones',\n ),\n limit: z\n .number()\n .int()\n .min(1)\n .max(Math.max(MAX_LIMIT, maxResults))\n .optional()\n .default(maxResults)\n .describe('Number of results to return'),\n }),\n output: z =>\n z.object({\n results: z.array(\n z.object({\n title: z.string(),\n location: z.string(),\n type: z.string().optional(),\n entityRef: z.string().optional(),\n metadata: z\n .record(\n z.string(),\n z.union([z.string(), z.number(), z.boolean()]),\n )\n .optional(),\n excerpt: z.string(),\n }),\n ),\n }),\n },\n action: async ({ input, credentials }) => {\n if (!(await store.isReady())) {\n throw new Error(\n 'The knowledge index is still being built; meanwhile use keyword search: the query tool when it is available, otherwise search-techdocs and search-catalog.',\n );\n }\n\n const filter = selectTypes(types, await store.types(), input.types);\n const phrasings = [input.query, ...(input.alternatives ?? [])];\n const words = queryWords(phrasings);\n const joined = joinedWords(phrasings).filter(w => !words.includes(w));\n const [{ embeddings }, frequencies] = await Promise.all([\n embedMany({ model: embeddingModel, values: phrasings }),\n store.documentFrequencies(\n [...words, ...joined],\n RARE_MAX_CHUNKS + 1,\n filter,\n ),\n ]);\n const known = [...words, ...joined].filter(\n w => (frequencies.get(w) ?? 0) > 0,\n );\n const rare = known.filter(w => frequencies.get(w)! <= RARE_MAX_CHUNKS);\n const unknown = words.filter(w => frequencies.get(w) === 0);\n\n const lists: RankedList[] = await Promise.all([\n ...embeddings.map(async embedding => ({\n name: 'semantic' as const,\n hits: await store.nearest(embedding, CANDIDATES, filter),\n weight: 1 / embeddings.length,\n })),\n store\n .keyword(known, CANDIDATES, filter)\n .then(hits => ({ name: 'keyword' as const, hits, weight: 1 })),\n store\n .keyword(rare, CANDIDATES, filter)\n .then(hits => ({ name: 'rareWord' as const, hits, weight: 1 })),\n store\n .fuzzy(unknown, CANDIDATES, filter)\n .then(hits => ({ name: 'fuzzy' as const, hits, weight: 1 })),\n ]);\n\n const settingsOf = typeSettingsOf(types);\n const fused = fuseRankings(lists, type => settingsOf(type).weight).slice(\n 0,\n FUSED,\n );\n const byId = new Map(\n (await store.chunks(fused.map(hit => hit.id))).map(chunk => [\n chunk.id,\n chunk,\n ]),\n );\n const found = fused.flatMap(hit => {\n const chunk = byId.get(hit.id);\n return chunk ? [{ hit, chunk }] : [];\n });\n\n const readable = await readableEntityRefs(catalog, auth, credentials, [\n ...new Set(\n found.flatMap(({ chunk }) =>\n chunk.entityRef ? [chunk.entityRef] : [],\n ),\n ),\n ]);\n\n const seen = new Set<string>();\n const candidates: KnowledgeCandidate[] = [];\n for (const { hit, chunk } of found) {\n const key = `${chunk.location}|${chunk.title}`;\n if (\n (chunk.entityRef && !readable.has(chunk.entityRef)) ||\n seen.has(key) ||\n seen.has(chunk.content)\n ) {\n continue;\n }\n seen.add(key);\n seen.add(chunk.content);\n candidates.push({ ...chunk, score: hit.score, ranks: hit.ranks });\n }\n\n const ids = new Set(candidates.map(candidate => candidate.id));\n const ranked = await ranker()({\n query: input.query,\n phrasings,\n types: filter.types,\n candidates,\n maxResults: input.limit,\n });\n const results = ranked\n .filter(candidate => ids.delete(candidate.id))\n .slice(0, input.limit)\n .map(candidate => ({\n title: candidate.title,\n location: candidate.location,\n ...(candidate.type ? { type: candidate.type } : {}),\n ...(candidate.entityRef ? { entityRef: candidate.entityRef } : {}),\n ...(candidate.metadata ? { metadata: candidate.metadata } : {}),\n excerpt: candidate.content.slice(0, EXCERPT_CHARS),\n }));\n return { output: { results } };\n },\n });\n}\n\n/**\n * The tool description's paragraph on sources: the configured types, which\n * ones are searched only when named, and what each holds.\n */\nexport function sourcesDescription(\n types: Map<string, KnowledgeTypeSettings>,\n): string {\n const listed = [...types].filter(\n ([, settings]) => settings.enabled && settings.weight > 0,\n );\n const lines = listed.map(\n ([type, settings]) =>\n `- ${type}${settings.default ? '' : ' (only when named in \"types\")'}${\n settings.description ? `: ${settings.description}` : ''\n }`,\n );\n return [\n '',\n `Sources: pass \"types\" to search only the named search document types; without it the search covers every source except those marked as searched only when named. An unknown name returns the list of types.`,\n ...lines,\n ].join('\\n');\n}\n\n/**\n * The subset of entity refs the caller can read, asked of the catalog as the\n * caller — so the catalog's own permission policy decides, and a deleted\n * entity's leftover chunks never surface.\n */\nasync function readableEntityRefs(\n catalog: CatalogApi,\n auth: AuthService,\n credentials: BackstageCredentials,\n entityRefs: string[],\n): Promise<Set<string>> {\n if (entityRefs.length === 0) {\n return new Set();\n }\n const { token } = await auth.getPluginRequestToken({\n onBehalfOf: credentials,\n targetPluginId: 'catalog',\n });\n const { items } = await catalog.getEntitiesByRefs(\n { entityRefs, fields: ['kind', 'metadata.namespace', 'metadata.name'] },\n { token },\n );\n return new Set(entityRefs.filter((_, i) => items[i]));\n}\n"],"names":["selectTypes","queryWords","joinedWords","embedMany","typeSettingsOf","fuseRankings"],"mappings":";;;;;;AAkBA,MAAM,UAAA,GAAa,EAAA;AAEnB,MAAM,KAAA,GAAQ,EAAA;AAEd,MAAM,SAAA,GAAY,EAAA;AAClB,MAAM,aAAA,GAAgB,IAAA;AAEtB,MAAM,eAAA,GAAkB,CAAA;AA+BjB,SAAS,6BAAA,CAA8B;AAAA,EAC5C,eAAA;AAAA,EACA,KAAA;AAAA,EACA,OAAA;AAAA,EACA,IAAA;AAAA,EACA,cAAA;AAAA,EACA,KAAA;AAAA,EACA,UAAA;AAAA,EACA;AACF,CAAA,EAA8B;AAC5B,EAAA,eAAA,CAAgB,QAAA,CAAS;AAAA,IACvB,IAAA,EAAM,kBAAA;AAAA,IACN,KAAA,EAAO,kBAAA;AAAA,IACP,YAAY,EAAE,WAAA,EAAa,OAAO,QAAA,EAAU,IAAA,EAAM,YAAY,IAAA,EAAK;AAAA,IACnE,WAAA,EAAa,CAAA;AAAA,EACf,kBAAA,CAAmB,KAAK,CAAC;;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA,yZAAA,CAAA;AAAA,IAUvB,MAAA,EAAQ;AAAA,MACN,KAAA,EAAO,CAAA,CAAA,KACL,CAAA,CAAE,MAAA,CAAO;AAAA,QACP,KAAA,EAAO,EAAE,MAAA,EAAO,CAAE,IAAI,CAAC,CAAA,CAAE,SAAS,+BAA+B,CAAA;AAAA,QACjE,YAAA,EAAc,CAAA,CACX,KAAA,CAAM,CAAA,CAAE,MAAA,EAAQ,CAAA,CAChB,GAAA,CAAI,CAAC,CAAA,CACL,QAAA,EAAS,CACT,SAAS,6CAA6C,CAAA;AAAA,QACzD,KAAA,EAAO,EACJ,KAAA,CAAM,CAAA,CAAE,QAAQ,CAAA,CAChB,UAAS,CACT,QAAA;AAAA,UACC;AAAA,SACF;AAAA,QACF,KAAA,EAAO,EACJ,MAAA,EAAO,CACP,KAAI,CACJ,GAAA,CAAI,CAAC,CAAA,CACL,GAAA,CAAI,IAAA,CAAK,IAAI,SAAA,EAAW,UAAU,CAAC,CAAA,CACnC,QAAA,GACA,OAAA,CAAQ,UAAU,CAAA,CAClB,QAAA,CAAS,6BAA6B;AAAA,OAC1C,CAAA;AAAA,MACH,MAAA,EAAQ,CAAA,CAAA,KACN,CAAA,CAAE,MAAA,CAAO;AAAA,QACP,SAAS,CAAA,CAAE,KAAA;AAAA,UACT,EAAE,MAAA,CAAO;AAAA,YACP,KAAA,EAAO,EAAE,MAAA,EAAO;AAAA,YAChB,QAAA,EAAU,EAAE,MAAA,EAAO;AAAA,YACnB,IAAA,EAAM,CAAA,CAAE,MAAA,EAAO,CAAE,QAAA,EAAS;AAAA,YAC1B,SAAA,EAAW,CAAA,CAAE,MAAA,EAAO,CAAE,QAAA,EAAS;AAAA,YAC/B,UAAU,CAAA,CACP,MAAA;AAAA,cACC,EAAE,MAAA,EAAO;AAAA,cACT,CAAA,CAAE,KAAA,CAAM,CAAC,CAAA,CAAE,MAAA,EAAO,EAAG,CAAA,CAAE,MAAA,EAAO,EAAG,CAAA,CAAE,OAAA,EAAS,CAAC;AAAA,cAE9C,QAAA,EAAS;AAAA,YACZ,OAAA,EAAS,EAAE,MAAA;AAAO,WACnB;AAAA;AACH,OACD;AAAA,KACL;AAAA,IACA,MAAA,EAAQ,OAAO,EAAE,KAAA,EAAO,aAAY,KAAM;AACxC,MAAA,IAAI,CAAE,MAAM,KAAA,CAAM,OAAA,EAAQ,EAAI;AAC5B,QAAA,MAAM,IAAI,KAAA;AAAA,UACR;AAAA,SACF;AAAA,MACF;AAEA,MAAA,MAAM,MAAA,GAASA,oBAAY,KAAA,EAAO,MAAM,MAAM,KAAA,EAAM,EAAG,MAAM,KAAK,CAAA;AAClE,MAAA,MAAM,SAAA,GAAY,CAAC,KAAA,CAAM,KAAA,EAAO,GAAI,KAAA,CAAM,YAAA,IAAgB,EAAG,CAAA;AAC7D,MAAA,MAAM,KAAA,GAAQC,gBAAW,SAAS,CAAA;AAClC,MAAA,MAAM,MAAA,GAASC,gBAAA,CAAY,SAAS,CAAA,CAAE,MAAA,CAAO,OAAK,CAAC,KAAA,CAAM,QAAA,CAAS,CAAC,CAAC,CAAA;AACpE,MAAA,MAAM,CAAC,EAAE,UAAA,EAAW,EAAG,WAAW,CAAA,GAAI,MAAM,QAAQ,GAAA,CAAI;AAAA,QACtDC,aAAU,EAAE,KAAA,EAAO,cAAA,EAAgB,MAAA,EAAQ,WAAW,CAAA;AAAA,QACtD,KAAA,CAAM,mBAAA;AAAA,UACJ,CAAC,GAAG,KAAA,EAAO,GAAG,MAAM,CAAA;AAAA,UACpB,eAAA,GAAkB,CAAA;AAAA,UAClB;AAAA;AACF,OACD,CAAA;AACD,MAAA,MAAM,QAAQ,CAAC,GAAG,KAAA,EAAO,GAAG,MAAM,CAAA,CAAE,MAAA;AAAA,QAClC,CAAA,CAAA,KAAA,CAAM,WAAA,CAAY,GAAA,CAAI,CAAC,KAAK,CAAA,IAAK;AAAA,OACnC;AACA,MAAA,MAAM,IAAA,GAAO,MAAM,MAAA,CAAO,CAAA,CAAA,KAAK,YAAY,GAAA,CAAI,CAAC,KAAM,eAAe,CAAA;AACrE,MAAA,MAAM,OAAA,GAAU,MAAM,MAAA,CAAO,CAAA,CAAA,KAAK,YAAY,GAAA,CAAI,CAAC,MAAM,CAAC,CAAA;AAE1D,MAAA,MAAM,KAAA,GAAsB,MAAM,OAAA,CAAQ,GAAA,CAAI;AAAA,QAC5C,GAAG,UAAA,CAAW,GAAA,CAAI,OAAM,SAAA,MAAc;AAAA,UACpC,IAAA,EAAM,UAAA;AAAA,UACN,MAAM,MAAM,KAAA,CAAM,OAAA,CAAQ,SAAA,EAAW,YAAY,MAAM,CAAA;AAAA,UACvD,MAAA,EAAQ,IAAI,UAAA,CAAW;AAAA,SACzB,CAAE,CAAA;AAAA,QACF,KAAA,CACG,OAAA,CAAQ,KAAA,EAAO,UAAA,EAAY,MAAM,CAAA,CACjC,IAAA,CAAK,CAAA,IAAA,MAAS,EAAE,IAAA,EAAM,SAAA,EAAoB,IAAA,EAAM,MAAA,EAAQ,GAAE,CAAE,CAAA;AAAA,QAC/D,KAAA,CACG,OAAA,CAAQ,IAAA,EAAM,UAAA,EAAY,MAAM,CAAA,CAChC,IAAA,CAAK,CAAA,IAAA,MAAS,EAAE,IAAA,EAAM,UAAA,EAAqB,IAAA,EAAM,MAAA,EAAQ,GAAE,CAAE,CAAA;AAAA,QAChE,KAAA,CACG,KAAA,CAAM,OAAA,EAAS,UAAA,EAAY,MAAM,CAAA,CACjC,IAAA,CAAK,CAAA,IAAA,MAAS,EAAE,IAAA,EAAM,OAAA,EAAkB,IAAA,EAAM,MAAA,EAAQ,GAAE,CAAE;AAAA,OAC9D,CAAA;AAED,MAAA,MAAM,UAAA,GAAaC,uBAAe,KAAK,CAAA;AACvC,MAAA,MAAM,KAAA,GAAQC,kBAAa,KAAA,EAAO,CAAA,IAAA,KAAQ,WAAW,IAAI,CAAA,CAAE,MAAM,CAAA,CAAE,KAAA;AAAA,QACjE,CAAA;AAAA,QACA;AAAA,OACF;AACA,MAAA,MAAM,OAAO,IAAI,GAAA;AAAA,QAAA,CACd,MAAM,KAAA,CAAM,MAAA,CAAO,KAAA,CAAM,GAAA,CAAI,CAAA,GAAA,KAAO,GAAA,CAAI,EAAE,CAAC,CAAA,EAAG,GAAA,CAAI,CAAA,KAAA,KAAS;AAAA,UAC1D,KAAA,CAAM,EAAA;AAAA,UACN;AAAA,SACD;AAAA,OACH;AACA,MAAA,MAAM,KAAA,GAAQ,KAAA,CAAM,OAAA,CAAQ,CAAA,GAAA,KAAO;AACjC,QAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,GAAA,CAAI,GAAA,CAAI,EAAE,CAAA;AAC7B,QAAA,OAAO,QAAQ,CAAC,EAAE,KAAK,KAAA,EAAO,IAAI,EAAC;AAAA,MACrC,CAAC,CAAA;AAED,MAAA,MAAM,QAAA,GAAW,MAAM,kBAAA,CAAmB,OAAA,EAAS,MAAM,WAAA,EAAa;AAAA,QACpE,GAAG,IAAI,GAAA;AAAA,UACL,KAAA,CAAM,OAAA;AAAA,YAAQ,CAAC,EAAE,KAAA,EAAM,KACrB,KAAA,CAAM,YAAY,CAAC,KAAA,CAAM,SAAS,CAAA,GAAI;AAAC;AACzC;AACF,OACD,CAAA;AAED,MAAA,MAAM,IAAA,uBAAW,GAAA,EAAY;AAC7B,MAAA,MAAM,aAAmC,EAAC;AAC1C,MAAA,KAAA,MAAW,EAAE,GAAA,EAAK,KAAA,EAAM,IAAK,KAAA,EAAO;AAClC,QAAA,MAAM,MAAM,CAAA,EAAG,KAAA,CAAM,QAAQ,CAAA,CAAA,EAAI,MAAM,KAAK,CAAA,CAAA;AAC5C,QAAA,IACG,MAAM,SAAA,IAAa,CAAC,QAAA,CAAS,GAAA,CAAI,MAAM,SAAS,CAAA,IACjD,IAAA,CAAK,GAAA,CAAI,GAAG,CAAA,IACZ,IAAA,CAAK,GAAA,CAAI,KAAA,CAAM,OAAO,CAAA,EACtB;AACA,UAAA;AAAA,QACF;AACA,QAAA,IAAA,CAAK,IAAI,GAAG,CAAA;AACZ,QAAA,IAAA,CAAK,GAAA,CAAI,MAAM,OAAO,CAAA;AACtB,QAAA,UAAA,CAAW,IAAA,CAAK,EAAE,GAAG,KAAA,EAAO,KAAA,EAAO,IAAI,KAAA,EAAO,KAAA,EAAO,GAAA,CAAI,KAAA,EAAO,CAAA;AAAA,MAClE;AAEA,MAAA,MAAM,GAAA,GAAM,IAAI,GAAA,CAAI,UAAA,CAAW,IAAI,CAAA,SAAA,KAAa,SAAA,CAAU,EAAE,CAAC,CAAA;AAC7D,MAAA,MAAM,MAAA,GAAS,MAAM,MAAA,EAAO,CAAE;AAAA,QAC5B,OAAO,KAAA,CAAM,KAAA;AAAA,QACb,SAAA;AAAA,QACA,OAAO,MAAA,CAAO,KAAA;AAAA,QACd,UAAA;AAAA,QACA,YAAY,KAAA,CAAM;AAAA,OACnB,CAAA;AACD,MAAA,MAAM,UAAU,MAAA,CACb,MAAA,CAAO,CAAA,SAAA,KAAa,GAAA,CAAI,OAAO,SAAA,CAAU,EAAE,CAAC,CAAA,CAC5C,MAAM,CAAA,EAAG,KAAA,CAAM,KAAK,CAAA,CACpB,IAAI,CAAA,SAAA,MAAc;AAAA,QACjB,OAAO,SAAA,CAAU,KAAA;AAAA,QACjB,UAAU,SAAA,CAAU,QAAA;AAAA,QACpB,GAAI,UAAU,IAAA,GAAO,EAAE,MAAM,SAAA,CAAU,IAAA,KAAS,EAAC;AAAA,QACjD,GAAI,UAAU,SAAA,GAAY,EAAE,WAAW,SAAA,CAAU,SAAA,KAAc,EAAC;AAAA,QAChE,GAAI,UAAU,QAAA,GAAW,EAAE,UAAU,SAAA,CAAU,QAAA,KAAa,EAAC;AAAA,QAC7D,OAAA,EAAS,SAAA,CAAU,OAAA,CAAQ,KAAA,CAAM,GAAG,aAAa;AAAA,OACnD,CAAE,CAAA;AACJ,MAAA,OAAO,EAAE,MAAA,EAAQ,EAAE,OAAA,EAAQ,EAAE;AAAA,IAC/B;AAAA,GACD,CAAA;AACH;AAMO,SAAS,mBACd,KAAA,EACQ;AACR,EAAA,MAAM,MAAA,GAAS,CAAC,GAAG,KAAK,CAAA,CAAE,MAAA;AAAA,IACxB,CAAC,GAAG,QAAQ,MAAM,QAAA,CAAS,OAAA,IAAW,SAAS,MAAA,GAAS;AAAA,GAC1D;AACA,EAAA,MAAM,QAAQ,MAAA,CAAO,GAAA;AAAA,IACnB,CAAC,CAAC,IAAA,EAAM,QAAQ,CAAA,KACd,CAAA,EAAA,EAAK,IAAI,CAAA,EAAG,QAAA,CAAS,UAAU,EAAA,GAAK,+BAA+B,GACjE,QAAA,CAAS,WAAA,GAAc,KAAK,QAAA,CAAS,WAAW,KAAK,EACvD,CAAA;AAAA,GACJ;AACA,EAAA,OAAO;AAAA,IACL,EAAA;AAAA,IACA,CAAA,2MAAA,CAAA;AAAA,IACA,GAAG;AAAA,GACL,CAAE,KAAK,IAAI,CAAA;AACb;AAOA,eAAe,kBAAA,CACb,OAAA,EACA,IAAA,EACA,WAAA,EACA,UAAA,EACsB;AACtB,EAAA,IAAI,UAAA,CAAW,WAAW,CAAA,EAAG;AAC3B,IAAA,2BAAW,GAAA,EAAI;AAAA,EACjB;AACA,EAAA,MAAM,EAAE,KAAA,EAAM,GAAI,MAAM,KAAK,qBAAA,CAAsB;AAAA,IACjD,UAAA,EAAY,WAAA;AAAA,IACZ,cAAA,EAAgB;AAAA,GACjB,CAAA;AACD,EAAA,MAAM,EAAE,KAAA,EAAM,GAAI,MAAM,OAAA,CAAQ,iBAAA;AAAA,IAC9B,EAAE,UAAA,EAAY,MAAA,EAAQ,CAAC,MAAA,EAAQ,oBAAA,EAAsB,eAAe,CAAA,EAAE;AAAA,IACtE,EAAE,KAAA;AAAM,GACV;AACA,EAAA,OAAO,IAAI,GAAA,CAAI,UAAA,CAAW,MAAA,CAAO,CAAC,GAAG,CAAA,KAAM,KAAA,CAAM,CAAC,CAAC,CAAC,CAAA;AACtD;;;;;"}
@@ -0,0 +1,8 @@
1
+ 'use strict';
2
+
3
+ var ranking = require('./ranking.cjs.js');
4
+
5
+
6
+
7
+ exports.knowledgeRankingExtensionPoint = ranking.knowledgeRankingExtensionPoint;
8
+ //# sourceMappingURL=alpha.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"alpha.cjs.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;"}
@@ -0,0 +1,78 @@
1
+ import * as _backstage_backend_plugin_api from '@backstage/backend-plugin-api';
2
+
3
+ /**
4
+ * One `search-knowledge` result candidate, after fusion and the permission
5
+ * check.
6
+ *
7
+ * @alpha
8
+ */
9
+ interface KnowledgeCandidate {
10
+ /** The chunk's stable id. */
11
+ id: string;
12
+ /** The Backstage search document type; absent for types not named in `assistants.semanticSearch.types`. */
13
+ type?: string;
14
+ /** The catalog entity gating it, for catalog and TechDocs results. */
15
+ entityRef?: string;
16
+ /** The search result location: a TechDocs path or a link. */
17
+ location: string;
18
+ /** Heading path, e.g. `Pylon › Messaging › Dead-letter handling`. */
19
+ title: string;
20
+ /** The chunk's text, up to ~1600 characters. */
21
+ content: string;
22
+ /** The chunk's 1-based rank in each retrieval list it appears in. */
23
+ ranks: {
24
+ semantic?: number;
25
+ keyword?: number;
26
+ rareWord?: number;
27
+ fuzzy?: number;
28
+ };
29
+ /** Reciprocal rank fusion score, after the type's configured weight. */
30
+ score: number;
31
+ /** The short fields the collator attached (status, space, updated, …). */
32
+ metadata?: Record<string, string | number | boolean>;
33
+ }
34
+ /**
35
+ * What a {@link KnowledgeRanker} picks from.
36
+ *
37
+ * @alpha
38
+ */
39
+ interface KnowledgeRankerInput {
40
+ /** The caller's question. */
41
+ query: string;
42
+ /** The query and the caller's alternatives. */
43
+ phrasings: string[];
44
+ /** The types searched: those the caller named, or the default ones. */
45
+ types: string[];
46
+ /** Candidates the caller may read, best fused score first, with repeated text removed. */
47
+ candidates: KnowledgeCandidate[];
48
+ /** How many results the caller gets. */
49
+ maxResults: number;
50
+ }
51
+ /**
52
+ * Picks and orders the results from the candidates. Results it returns that
53
+ * are not among the candidates are dropped, and the list is cut to
54
+ * `maxResults`.
55
+ *
56
+ * @alpha
57
+ */
58
+ type KnowledgeRanker = (input: KnowledgeRankerInput) => Promise<KnowledgeCandidate[]> | KnowledgeCandidate[];
59
+ /**
60
+ * Replaces how `search-knowledge` picks its results.
61
+ *
62
+ * @alpha
63
+ */
64
+ interface KnowledgeRankingExtensionPoint {
65
+ /** Replaces the default ranker (type caps, then `maxResults`). */
66
+ setRanker(ranker: KnowledgeRanker): void;
67
+ }
68
+ /**
69
+ * Lets a backend module replace how `search-knowledge` picks its results,
70
+ * for example to favour recent tickets or the caller's team. The ranker sees
71
+ * only candidates the caller may read.
72
+ *
73
+ * @alpha
74
+ */
75
+ declare const knowledgeRankingExtensionPoint: _backstage_backend_plugin_api.ExtensionPoint<KnowledgeRankingExtensionPoint>;
76
+
77
+ export { knowledgeRankingExtensionPoint };
78
+ export type { KnowledgeCandidate, KnowledgeRanker, KnowledgeRankerInput, KnowledgeRankingExtensionPoint };
package/dist/chunk.cjs.js CHANGED
@@ -5,13 +5,18 @@ var node_crypto = require('node:crypto');
5
5
  const MAX_CHARS = 1600;
6
6
  const OVERLAP_CHARS = 200;
7
7
  function chunkDocument(doc) {
8
+ const metadataHash = sha256(
9
+ JSON.stringify(Object.entries(doc.metadata ?? {}).sort())
10
+ );
8
11
  return splitText(doc.text).map((content, index) => ({
9
12
  id: sha256(`${doc.location}|${index}`),
10
13
  ...doc.entityRef ? { entityRef: doc.entityRef } : {},
11
14
  location: doc.location,
12
15
  title: doc.title,
13
16
  content,
14
- hash: sha256(embeddingInput(doc.title, content))
17
+ hash: sha256(embeddingInput(doc.title, content)),
18
+ ...doc.metadata ? { metadata: doc.metadata } : {},
19
+ metadataHash
15
20
  }));
16
21
  }
17
22
  function embeddingInput(title, content) {
@@ -1 +1 @@
1
- {"version":3,"file":"chunk.cjs.js","sources":["../src/chunk.ts"],"sourcesContent":["import { createHash } from 'node:crypto';\nimport type { SourceDocument } from './documents';\n\n/** A stored, embedded unit of a {@link SourceDocument}. */\nexport interface Chunk {\n id: string;\n entityRef?: string;\n location: string;\n title: string;\n content: string;\n /** Hash of what gets embedded; unchanged hash = no re-embedding. */\n hash: string;\n}\n\nconst MAX_CHARS = 1600;\nconst OVERLAP_CHARS = 200;\n\n/**\n * Splits a document into chunks of at most ~1600 characters on word\n * boundaries, overlapping by ~200 so a sentence cut at a boundary survives in\n * one piece. TechDocs sections are usually one chunk already.\n */\nexport function chunkDocument(doc: SourceDocument): Chunk[] {\n return splitText(doc.text).map((content, index) => ({\n id: sha256(`${doc.location}|${index}`),\n ...(doc.entityRef ? { entityRef: doc.entityRef } : {}),\n location: doc.location,\n title: doc.title,\n content,\n hash: sha256(embeddingInput(doc.title, content)),\n }));\n}\n\n/** The text a chunk is embedded as: heading path first, so a terse section keeps its context. */\nexport function embeddingInput(title: string, content: string): string {\n return `${title}\\n\\n${content}`;\n}\n\nexport function splitText(text: string): string[] {\n if (text.length <= MAX_CHARS) {\n return [text];\n }\n const pieces: string[] = [];\n let start = 0;\n while (start < text.length) {\n let end = Math.min(start + MAX_CHARS, text.length);\n if (end < text.length) {\n const space = text.lastIndexOf(' ', end);\n if (space > start + MAX_CHARS / 2) {\n end = space;\n }\n }\n pieces.push(text.slice(start, end).trim());\n if (end >= text.length) {\n break;\n }\n const restart = text.indexOf(' ', end - OVERLAP_CHARS);\n start = restart > start && restart < end ? restart + 1 : end;\n }\n return pieces.filter(Boolean);\n}\n\nfunction sha256(value: string): string {\n return createHash('sha256').update(value).digest('hex');\n}\n"],"names":["createHash"],"mappings":";;;;AAcA,MAAM,SAAA,GAAY,IAAA;AAClB,MAAM,aAAA,GAAgB,GAAA;AAOf,SAAS,cAAc,GAAA,EAA8B;AAC1D,EAAA,OAAO,UAAU,GAAA,CAAI,IAAI,EAAE,GAAA,CAAI,CAAC,SAAS,KAAA,MAAW;AAAA,IAClD,IAAI,MAAA,CAAO,CAAA,EAAG,IAAI,QAAQ,CAAA,CAAA,EAAI,KAAK,CAAA,CAAE,CAAA;AAAA,IACrC,GAAI,IAAI,SAAA,GAAY,EAAE,WAAW,GAAA,CAAI,SAAA,KAAc,EAAC;AAAA,IACpD,UAAU,GAAA,CAAI,QAAA;AAAA,IACd,OAAO,GAAA,CAAI,KAAA;AAAA,IACX,OAAA;AAAA,IACA,MAAM,MAAA,CAAO,cAAA,CAAe,GAAA,CAAI,KAAA,EAAO,OAAO,CAAC;AAAA,GACjD,CAAE,CAAA;AACJ;AAGO,SAAS,cAAA,CAAe,OAAe,OAAA,EAAyB;AACrE,EAAA,OAAO,GAAG,KAAK;;AAAA,EAAO,OAAO,CAAA,CAAA;AAC/B;AAEO,SAAS,UAAU,IAAA,EAAwB;AAChD,EAAA,IAAI,IAAA,CAAK,UAAU,SAAA,EAAW;AAC5B,IAAA,OAAO,CAAC,IAAI,CAAA;AAAA,EACd;AACA,EAAA,MAAM,SAAmB,EAAC;AAC1B,EAAA,IAAI,KAAA,GAAQ,CAAA;AACZ,EAAA,OAAO,KAAA,GAAQ,KAAK,MAAA,EAAQ;AAC1B,IAAA,IAAI,MAAM,IAAA,CAAK,GAAA,CAAI,KAAA,GAAQ,SAAA,EAAW,KAAK,MAAM,CAAA;AACjD,IAAA,IAAI,GAAA,GAAM,KAAK,MAAA,EAAQ;AACrB,MAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,WAAA,CAAY,GAAA,EAAK,GAAG,CAAA;AACvC,MAAA,IAAI,KAAA,GAAQ,KAAA,GAAQ,SAAA,GAAY,CAAA,EAAG;AACjC,QAAA,GAAA,GAAM,KAAA;AAAA,MACR;AAAA,IACF;AACA,IAAA,MAAA,CAAO,KAAK,IAAA,CAAK,KAAA,CAAM,OAAO,GAAG,CAAA,CAAE,MAAM,CAAA;AACzC,IAAA,IAAI,GAAA,IAAO,KAAK,MAAA,EAAQ;AACtB,MAAA;AAAA,IACF;AACA,IAAA,MAAM,OAAA,GAAU,IAAA,CAAK,OAAA,CAAQ,GAAA,EAAK,MAAM,aAAa,CAAA;AACrD,IAAA,KAAA,GAAQ,OAAA,GAAU,KAAA,IAAS,OAAA,GAAU,GAAA,GAAM,UAAU,CAAA,GAAI,GAAA;AAAA,EAC3D;AACA,EAAA,OAAO,MAAA,CAAO,OAAO,OAAO,CAAA;AAC9B;AAEA,SAAS,OAAO,KAAA,EAAuB;AACrC,EAAA,OAAOA,uBAAW,QAAQ,CAAA,CAAE,OAAO,KAAK,CAAA,CAAE,OAAO,KAAK,CAAA;AACxD;;;;;;"}
1
+ {"version":3,"file":"chunk.cjs.js","sources":["../src/chunk.ts"],"sourcesContent":["import { createHash } from 'node:crypto';\nimport type { KnowledgeMetadata, SourceDocument } from './documents';\n\n/** A stored, embedded unit of a {@link SourceDocument}. */\nexport interface Chunk {\n id: string;\n entityRef?: string;\n location: string;\n title: string;\n content: string;\n /** Hash of what gets embedded; unchanged hash = no re-embedding. */\n hash: string;\n metadata?: KnowledgeMetadata;\n /** Hash of the metadata; a change alone rewrites the row without re-embedding. */\n metadataHash: string;\n}\n\nconst MAX_CHARS = 1600;\nconst OVERLAP_CHARS = 200;\n\n/**\n * Splits a document into chunks of at most ~1600 characters on word\n * boundaries, overlapping by ~200 so a sentence cut at a boundary survives in\n * one piece. TechDocs sections are usually one chunk already.\n */\nexport function chunkDocument(doc: SourceDocument): Chunk[] {\n const metadataHash = sha256(\n JSON.stringify(Object.entries(doc.metadata ?? {}).sort()),\n );\n return splitText(doc.text).map((content, index) => ({\n id: sha256(`${doc.location}|${index}`),\n ...(doc.entityRef ? { entityRef: doc.entityRef } : {}),\n location: doc.location,\n title: doc.title,\n content,\n hash: sha256(embeddingInput(doc.title, content)),\n ...(doc.metadata ? { metadata: doc.metadata } : {}),\n metadataHash,\n }));\n}\n\n/** The text a chunk is embedded as: heading path first, so a terse section keeps its context. */\nexport function embeddingInput(title: string, content: string): string {\n return `${title}\\n\\n${content}`;\n}\n\nexport function splitText(text: string): string[] {\n if (text.length <= MAX_CHARS) {\n return [text];\n }\n const pieces: string[] = [];\n let start = 0;\n while (start < text.length) {\n let end = Math.min(start + MAX_CHARS, text.length);\n if (end < text.length) {\n const space = text.lastIndexOf(' ', end);\n if (space > start + MAX_CHARS / 2) {\n end = space;\n }\n }\n pieces.push(text.slice(start, end).trim());\n if (end >= text.length) {\n break;\n }\n const restart = text.indexOf(' ', end - OVERLAP_CHARS);\n start = restart > start && restart < end ? restart + 1 : end;\n }\n return pieces.filter(Boolean);\n}\n\nfunction sha256(value: string): string {\n return createHash('sha256').update(value).digest('hex');\n}\n"],"names":["createHash"],"mappings":";;;;AAiBA,MAAM,SAAA,GAAY,IAAA;AAClB,MAAM,aAAA,GAAgB,GAAA;AAOf,SAAS,cAAc,GAAA,EAA8B;AAC1D,EAAA,MAAM,YAAA,GAAe,MAAA;AAAA,IACnB,IAAA,CAAK,SAAA,CAAU,MAAA,CAAO,OAAA,CAAQ,GAAA,CAAI,YAAY,EAAE,CAAA,CAAE,IAAA,EAAM;AAAA,GAC1D;AACA,EAAA,OAAO,UAAU,GAAA,CAAI,IAAI,EAAE,GAAA,CAAI,CAAC,SAAS,KAAA,MAAW;AAAA,IAClD,IAAI,MAAA,CAAO,CAAA,EAAG,IAAI,QAAQ,CAAA,CAAA,EAAI,KAAK,CAAA,CAAE,CAAA;AAAA,IACrC,GAAI,IAAI,SAAA,GAAY,EAAE,WAAW,GAAA,CAAI,SAAA,KAAc,EAAC;AAAA,IACpD,UAAU,GAAA,CAAI,QAAA;AAAA,IACd,OAAO,GAAA,CAAI,KAAA;AAAA,IACX,OAAA;AAAA,IACA,MAAM,MAAA,CAAO,cAAA,CAAe,GAAA,CAAI,KAAA,EAAO,OAAO,CAAC,CAAA;AAAA,IAC/C,GAAI,IAAI,QAAA,GAAW,EAAE,UAAU,GAAA,CAAI,QAAA,KAAa,EAAC;AAAA,IACjD;AAAA,GACF,CAAE,CAAA;AACJ;AAGO,SAAS,cAAA,CAAe,OAAe,OAAA,EAAyB;AACrE,EAAA,OAAO,GAAG,KAAK;;AAAA,EAAO,OAAO,CAAA,CAAA;AAC/B;AAEO,SAAS,UAAU,IAAA,EAAwB;AAChD,EAAA,IAAI,IAAA,CAAK,UAAU,SAAA,EAAW;AAC5B,IAAA,OAAO,CAAC,IAAI,CAAA;AAAA,EACd;AACA,EAAA,MAAM,SAAmB,EAAC;AAC1B,EAAA,IAAI,KAAA,GAAQ,CAAA;AACZ,EAAA,OAAO,KAAA,GAAQ,KAAK,MAAA,EAAQ;AAC1B,IAAA,IAAI,MAAM,IAAA,CAAK,GAAA,CAAI,KAAA,GAAQ,SAAA,EAAW,KAAK,MAAM,CAAA;AACjD,IAAA,IAAI,GAAA,GAAM,KAAK,MAAA,EAAQ;AACrB,MAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,WAAA,CAAY,GAAA,EAAK,GAAG,CAAA;AACvC,MAAA,IAAI,KAAA,GAAQ,KAAA,GAAQ,SAAA,GAAY,CAAA,EAAG;AACjC,QAAA,GAAA,GAAM,KAAA;AAAA,MACR;AAAA,IACF;AACA,IAAA,MAAA,CAAO,KAAK,IAAA,CAAK,KAAA,CAAM,OAAO,GAAG,CAAA,CAAE,MAAM,CAAA;AACzC,IAAA,IAAI,GAAA,IAAO,KAAK,MAAA,EAAQ;AACtB,MAAA;AAAA,IACF;AACA,IAAA,MAAM,OAAA,GAAU,IAAA,CAAK,OAAA,CAAQ,GAAA,EAAK,MAAM,aAAa,CAAA;AACrD,IAAA,KAAA,GAAQ,OAAA,GAAU,KAAA,IAAS,OAAA,GAAU,GAAA,GAAM,UAAU,CAAA,GAAI,GAAA;AAAA,EAC3D;AACA,EAAA,OAAO,MAAA,CAAO,OAAO,OAAO,CAAA;AAC9B;AAEA,SAAS,OAAO,KAAA,EAAuB;AACrC,EAAA,OAAOA,uBAAW,QAAQ,CAAA,CAAE,OAAO,KAAK,CAAA,CAAE,OAAO,KAAK,CAAA;AACxD;;;;;;"}