@drewswiredin/backstage-plugin-assistants-backend-module-semantic-search 0.1.4 → 0.2.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
@@ -1,16 +1,24 @@
1
1
  # @drewswiredin/backstage-plugin-assistants-backend-module-semantic-search
2
2
 
3
- Knowledge index for AI Assistants for Backstage. It embeds every published
4
- TechDocs section and every catalog entity except `Location` into the assistants plugin's Postgres database and
5
- gives assistants a `search-knowledge` tool that finds them by meaning, by near
6
- spelling, and by exact words:
3
+ Knowledge index for AI Assistants for Backstage. It copies everything your
4
+ Backstage search indexes (TechDocs, the catalog, and any other source a
5
+ collator brings in, such as Confluence or a wiki), embeds it into the
6
+ assistants plugin's Postgres database, and gives assistants a
7
+ `search-knowledge` tool that finds it by meaning, by near spelling, and by
8
+ exact words:
7
9
 
8
10
  - "how do I fix errored-out messages" finds _Dead-letter handling › Replaying
9
11
  dead-lettered messages_.
10
12
  - "Pilon deployment" finds the _Pylon Gateway › Deployment runbook_.
11
13
  - "dlq-replay" finds the page that names the job.
12
14
 
13
- Results are filtered to entities the user can read in the catalog.
15
+ It reads no source itself. Each time a search collator finishes a run, the
16
+ module copies that run's documents, so the index follows your search schedule
17
+ and a rate-limited source such as Confluence is read once, by search.
18
+
19
+ Catalog and TechDocs results are filtered to entities the user can read in the
20
+ catalog. Results from other sources are visible to every assistant user, as
21
+ they are in Backstage search.
14
22
 
15
23
  Adds to the backend plugin
16
24
  [`@drewswiredin/backstage-plugin-assistants-backend`](https://www.npmjs.com/package/@drewswiredin/backstage-plugin-assistants-backend).
@@ -18,6 +26,8 @@ Adds to the backend plugin
18
26
  ## Requirements
19
27
 
20
28
  - The assistants backend plugin, with its database on Postgres 13 or later.
29
+ - Backstage search (`@backstage/plugin-search-backend`) in the same backend,
30
+ with the collators for the sources you want: any search engine works.
21
31
  - The `vector` ([pgvector](https://github.com/pgvector/pgvector)) and
22
32
  `pg_trgm` extensions in the assistants plugin's database.
23
33
  - An embedding model from an `openai`, `azure`, or `openai-compatible`
@@ -34,6 +44,7 @@ yarn --cwd packages/backend add @drewswiredin/backstage-plugin-assistants-backen
34
44
  ```ts
35
45
  // packages/backend/src/index.ts
36
46
  backend.add(import('@drewswiredin/backstage-plugin-assistants-backend'));
47
+ backend.add(import('@backstage/plugin-search-backend'));
37
48
  backend.add(
38
49
  import(
39
50
  '@drewswiredin/backstage-plugin-assistants-backend-module-semantic-search'
@@ -41,6 +52,13 @@ backend.add(
41
52
  );
42
53
  ```
43
54
 
55
+ The one `backend.add` installs two modules: the index and `search-knowledge`
56
+ under the `assistants` plugin, and a document decorator under the `search`
57
+ plugin that copies what each search run collected. To add them separately,
58
+ import `assistantsModuleSemanticSearch` and `searchModuleAssistantsKnowledge`
59
+ by name. Without the search plugin the module logs a warning at startup and
60
+ the index stays empty.
61
+
44
62
  ## Enable the extensions
45
63
 
46
64
  The module enables both extensions itself when its database role may. `pg_trgm`
@@ -82,14 +100,18 @@ assistants:
82
100
  On Azure OpenAI, deploy an embedding model and name the deployment:
83
101
  `embeddingModel: azure:<embedding-deployment>`.
84
102
 
85
- The first run embeds everything; later runs embed only new and changed
86
- sections and delete removed ones. Changing `embeddingModel` rebuilds the index.
103
+ The index fills as search runs: each document type arrives when its collator
104
+ next runs, on the schedule you gave that collator. A source's first run embeds
105
+ all of it; later runs embed only new and changed sections and delete removed
106
+ ones. Changing `embeddingModel` empties the index, and each type refills on its
107
+ next search run.
87
108
 
88
109
  ## Give an assistant the tool
89
110
 
90
111
  Add `search-knowledge` to the assistant's tools in the assistant editor. It
91
112
  pairs with `read-techdocs` (from `builtinActions: true`), which opens the full
92
- page behind a TechDocs result. Until the first index run finishes, the tool
113
+ page behind a TechDocs result; other results carry a link. Until the first
114
+ search run is copied, the tool
93
115
  answers with an error that points the model at keyword search: Backstage's
94
116
  `query` action when the assistant has it, otherwise `search-techdocs` and
95
117
  `search-catalog`.
@@ -113,13 +135,27 @@ backend:
113
135
 
114
136
  ## How it works
115
137
 
116
- **Indexing.** A scheduled task (one replica at a time) reads each published
117
- TechDocs site's MkDocs search index (the same source Backstage's TechDocs
118
- search uses) and every catalog entity except `Location`. Each TechDocs section
119
- becomes one or more chunks of up to ~1600 characters, titled with its heading
120
- path (`Site › Page › Section`) and embedded with that title. Each row stores
121
- the embedding, a generated full-text vector, and a trigram index over its text.
122
- The table is a rebuildable cache, not migrated data.
138
+ **Indexing.** A search decorator sits on every document type's pipeline. It
139
+ passes each document on to the search engine unchanged and keeps it; when the
140
+ collator finishes, it hands the run's documents to the index. A failed run
141
+ hands over nothing, and a run with no documents changes nothing. Runs are
142
+ copied one at a time, in the backend that ran the collator.
143
+
144
+ Each document becomes one or more chunks of up to ~1600 characters, titled
145
+ with its heading path (`Site › Page › Section` for TechDocs) and embedded with
146
+ that title. The facts collators attach (entity ref, type, owner, lifecycle)
147
+ join the text, so a catalog entity with no description is still found. A
148
+ document gated by a catalog entity (its `authorization.resourceRef`) keeps that
149
+ entity for the permission check; a document gated by any other kind of
150
+ resource is skipped, since the index can only check entity read permission.
151
+
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
158
+ data.
123
159
 
124
160
  **Retrieval.** `search-knowledge` takes the question and up to three alternative
125
161
  phrasings and builds one semantic list per phrasing, a keyword list, a rare-word
@@ -139,14 +175,17 @@ without outvoting an exact match. The rare-word list keeps a name or acronym
139
175
  mentioned once in a paragraph about something else from being outranked by
140
176
  sections that are only near the question in meaning. The top candidates'
141
177
  entities are fetched from the catalog as the user; results for entities the
142
- user cannot read are dropped, and repeated text is shown once.
178
+ user cannot read are dropped, results with no entity are kept, and repeated
179
+ text is shown once.
143
180
 
144
181
  ## Reference
145
182
 
146
- | Key | Default | Meaning |
147
- | ------------------------------------------ | ---------------------------------------------------- | --------------------------------------------------------------------------------------------- |
148
- | `assistants.semanticSearch.embeddingModel` | (required) | `<providerId>:<model>` from `assistants.providers`. |
149
- | `assistants.semanticSearch.schedule` | every 30 minutes, 1 hour timeout, 15 s initial delay | Backstage task schedule for refreshing the index; a configured schedule replaces the default. |
183
+ | Key | Default | Meaning |
184
+ | ------------------------------------------ | ---------- | --------------------------------------------------- |
185
+ | `assistants.semanticSearch.embeddingModel` | (required) | `<providerId>:<model>` from `assistants.providers`. |
186
+
187
+ The index refreshes when search does; set each collator's schedule in its own
188
+ config.
150
189
 
151
190
  ## License
152
191
 
@@ -11,168 +11,12 @@
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
- "schedule": {
16
- "type": "object",
17
- "properties": {
18
- "frequency": {
19
- "anyOf": [
20
- {
21
- "type": "object",
22
- "properties": {
23
- "cron": {
24
- "type": "string",
25
- "description": "A crontab style string."
26
- }
27
- },
28
- "required": [
29
- "cron"
30
- ]
31
- },
32
- {
33
- "type": "string"
34
- },
35
- {
36
- "type": "object",
37
- "properties": {
38
- "years": {
39
- "type": "number"
40
- },
41
- "months": {
42
- "type": "number"
43
- },
44
- "weeks": {
45
- "type": "number"
46
- },
47
- "days": {
48
- "type": "number"
49
- },
50
- "hours": {
51
- "type": "number"
52
- },
53
- "minutes": {
54
- "type": "number"
55
- },
56
- "seconds": {
57
- "type": "number"
58
- },
59
- "milliseconds": {
60
- "type": "number"
61
- }
62
- },
63
- "description": "Human friendly durations object."
64
- },
65
- {
66
- "type": "object",
67
- "properties": {
68
- "trigger": {
69
- "type": "string",
70
- "const": "manual"
71
- }
72
- },
73
- "required": [
74
- "trigger"
75
- ]
76
- }
77
- ],
78
- "description": "How often you want the task to run. The system does its best to avoid overlapping invocations."
79
- },
80
- "timeout": {
81
- "anyOf": [
82
- {
83
- "type": "string"
84
- },
85
- {
86
- "type": "object",
87
- "properties": {
88
- "years": {
89
- "type": "number"
90
- },
91
- "months": {
92
- "type": "number"
93
- },
94
- "weeks": {
95
- "type": "number"
96
- },
97
- "days": {
98
- "type": "number"
99
- },
100
- "hours": {
101
- "type": "number"
102
- },
103
- "minutes": {
104
- "type": "number"
105
- },
106
- "seconds": {
107
- "type": "number"
108
- },
109
- "milliseconds": {
110
- "type": "number"
111
- }
112
- },
113
- "description": "Human friendly durations object."
114
- }
115
- ],
116
- "description": "The maximum amount of time that a single task invocation can take, before it's considered timed out and gets \"released\" such that a new invocation is permitted to take place (possibly, then, on a different worker)."
117
- },
118
- "initialDelay": {
119
- "anyOf": [
120
- {
121
- "type": "string"
122
- },
123
- {
124
- "type": "object",
125
- "properties": {
126
- "years": {
127
- "type": "number"
128
- },
129
- "months": {
130
- "type": "number"
131
- },
132
- "weeks": {
133
- "type": "number"
134
- },
135
- "days": {
136
- "type": "number"
137
- },
138
- "hours": {
139
- "type": "number"
140
- },
141
- "minutes": {
142
- "type": "number"
143
- },
144
- "seconds": {
145
- "type": "number"
146
- },
147
- "milliseconds": {
148
- "type": "number"
149
- }
150
- },
151
- "description": "Human friendly durations object."
152
- }
153
- ],
154
- "description": "The amount of time that should pass before the first invocation happens."
155
- },
156
- "scope": {
157
- "type": "string",
158
- "enum": [
159
- "global",
160
- "local"
161
- ],
162
- "description": "Sets the scope of concurrency control / locking to apply for invocations of this task."
163
- }
164
- },
165
- "required": [
166
- "frequency",
167
- "timeout"
168
- ],
169
- "description": "How often the index refreshes. Each run embeds only new and changed content. Default: every 30 minutes, 1 hour timeout, 15 s initial delay; a configured schedule replaces it."
170
14
  }
171
15
  },
172
16
  "required": [
173
17
  "embeddingModel"
174
18
  ],
175
- "description": "The knowledge index: published TechDocs sections and catalog entities (except `Location`) embedded into the assistants plugin's Postgres database and served to assistants as the `search-knowledge` action. Requires the `vector` and `pg_trgm` extensions."
19
+ "description": "The knowledge index: a copy of everything Backstage search indexes (TechDocs, the catalog, and any other collator's documents), embedded into the assistants plugin's Postgres database as each search run finishes and served to assistants as the `search-knowledge` action. Requires the search plugin in the same backend, and the `vector` and `pg_trgm` extensions."
176
20
  }
177
21
  }
178
22
  }
@@ -18,7 +18,7 @@ function registerSearchKnowledgeAction({
18
18
  name: "search-knowledge",
19
19
  title: "Search Knowledge",
20
20
  attributes: { destructive: false, readOnly: true, idempotent: true },
21
- description: `Search the organization's TechDocs and software catalog 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.
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.
22
22
 
23
23
  How to search the organization's docs and catalog:
24
24
  1. Start here, with the user's question.
@@ -27,7 +27,7 @@ How to search the organization's docs and catalog:
27
27
 
28
28
  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
29
 
30
- Each result has a title (its heading path), an excerpt, and a location. For TechDocs results, pass the location to read-techdocs to read the full page. For catalog results, the location is the entity ref.`,
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.`,
31
31
  schema: {
32
32
  input: (z) => z.object({
33
33
  query: z.string().min(1).describe("The question or topic to find"),
@@ -37,10 +37,9 @@ Each result has a title (its heading path), an excerpt, and a location. For Tech
37
37
  output: (z) => z.object({
38
38
  results: z.array(
39
39
  z.object({
40
- source: z.enum(["techdocs", "catalog"]),
41
40
  title: z.string(),
42
41
  location: z.string(),
43
- entityRef: z.string(),
42
+ entityRef: z.string().optional(),
44
43
  excerpt: z.string()
45
44
  })
46
45
  )
@@ -79,22 +78,25 @@ Each result has a title (its heading path), an excerpt, and a location. For Tech
79
78
  );
80
79
  const candidates = ranked.map((id) => byId.get(id)).filter((chunk) => Boolean(chunk));
81
80
  const readable = await readableEntityRefs(catalog, auth, credentials, [
82
- ...new Set(candidates.map((chunk) => chunk.entityRef))
81
+ ...new Set(
82
+ candidates.flatMap(
83
+ (chunk) => chunk.entityRef ? [chunk.entityRef] : []
84
+ )
85
+ )
83
86
  ]);
84
87
  const seen = /* @__PURE__ */ new Set();
85
88
  const results = [];
86
89
  for (const chunk of candidates) {
87
90
  const key = `${chunk.location}|${chunk.title}`;
88
- if (!readable.has(chunk.entityRef) || seen.has(key) || seen.has(chunk.content)) {
91
+ if (chunk.entityRef && !readable.has(chunk.entityRef) || seen.has(key) || seen.has(chunk.content)) {
89
92
  continue;
90
93
  }
91
94
  seen.add(key);
92
95
  seen.add(chunk.content);
93
96
  results.push({
94
- source: chunk.source,
95
97
  title: chunk.title,
96
98
  location: chunk.location,
97
- entityRef: chunk.entityRef,
99
+ ...chunk.entityRef ? { entityRef: chunk.entityRef } : {},
98
100
  excerpt: chunk.content.slice(0, EXCERPT_CHARS)
99
101
  });
100
102
  if (results.length === input.limit) {
@@ -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 are filtered to entities the\n * caller can read in the catalog, and repeated text is shown 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 the organization's TechDocs and software catalog 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\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, and a location. For TechDocs results, pass the location to read-techdocs to read the full page. For catalog results, the location is the entity ref.`,\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 source: z.enum(['techdocs', 'catalog']),\n title: z.string(),\n location: z.string(),\n entityRef: z.string(),\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(candidates.map(chunk => chunk.entityRef)),\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 !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 source: chunk.source,\n title: chunk.title,\n location: chunk.location,\n 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":";;;;;AAWA,MAAM,UAAA,GAAa,EAAA;AAEnB,MAAM,KAAA,GAAQ,EAAA;AACd,MAAM,aAAA,GAAgB,IAAA;AAEtB,MAAM,eAAA,GAAkB,CAAA;AAuBjB,SAAS,6BAAA,CAA8B;AAAA,EAC5C,eAAA;AAAA,EACA,KAAA;AAAA,EACA,OAAA;AAAA,EACA,IAAA;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;AAAA;AAAA;AAAA;;AAAA;;AAAA,4MAAA,CAAA;AAAA,IAUb,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,OAAO,CAAA,CACJ,MAAA,GACA,GAAA,EAAI,CACJ,IAAI,CAAC,CAAA,CACL,GAAA,CAAI,EAAE,EACN,QAAA,EAAS,CACT,QAAQ,CAAC,CAAA,CACT,SAAS,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,QAAQ,CAAA,CAAE,IAAA,CAAK,CAAC,UAAA,EAAY,SAAS,CAAC,CAAA;AAAA,YACtC,KAAA,EAAO,EAAE,MAAA,EAAO;AAAA,YAChB,QAAA,EAAU,EAAE,MAAA,EAAO;AAAA,YACnB,SAAA,EAAW,EAAE,MAAA,EAAO;AAAA,YACpB,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,SAAA,GAAY,CAAC,KAAA,CAAM,KAAA,EAAO,GAAI,KAAA,CAAM,YAAA,IAAgB,EAAG,CAAA;AAC7D,MAAA,MAAM,KAAA,GAAQA,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,oBAAoB,CAAC,GAAG,OAAO,GAAG,MAAM,CAAA,EAAG,eAAA,GAAkB,CAAC;AAAA,OACrE,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,GAAA,EAAK,MAAM,KAAA,CAAM,OAAA,CAAQ,WAAW,UAAU,CAAA;AAAA,UAC9C,MAAA,EAAQ,IAAI,UAAA,CAAW;AAAA,SACzB,CAAE,CAAA;AAAA,QACF,KAAA,CAAM,OAAA,CAAQ,KAAA,EAAO,UAAU,CAAA,CAAE,IAAA,CAAK,CAAA,GAAA,MAAQ,EAAE,GAAA,EAAK,MAAA,EAAQ,CAAA,EAAE,CAAE,CAAA;AAAA,QACjE,KAAA,CAAM,OAAA,CAAQ,IAAA,EAAM,UAAU,CAAA,CAAE,IAAA,CAAK,CAAA,GAAA,MAAQ,EAAE,GAAA,EAAK,MAAA,EAAQ,CAAA,EAAE,CAAE,CAAA;AAAA,QAChE,KAAA,CAAM,KAAA,CAAM,OAAA,EAAS,UAAU,CAAA,CAAE,IAAA,CAAK,CAAA,GAAA,MAAQ,EAAE,GAAA,EAAK,MAAA,EAAQ,CAAA,EAAE,CAAE;AAAA,OAClE,CAAA;AAED,MAAA,MAAM,SAASC,iBAAA,CAAa,KAAK,CAAA,CAAE,KAAA,CAAM,GAAG,KAAK,CAAA;AACjD,MAAA,MAAM,OAAO,IAAI,GAAA;AAAA,QAAA,CACd,MAAM,KAAA,CAAM,MAAA,CAAO,MAAM,CAAA,EAAG,GAAA,CAAI,CAAA,KAAA,KAAS,CAAC,KAAA,CAAM,EAAA,EAAI,KAAK,CAAC;AAAA,OAC7D;AACA,MAAA,MAAM,UAAA,GAAa,MAAA,CAChB,GAAA,CAAI,CAAA,EAAA,KAAM,KAAK,GAAA,CAAI,EAAE,CAAC,CAAA,CACtB,MAAA,CAAO,CAAC,KAAA,KAAgC,OAAA,CAAQ,KAAK,CAAC,CAAA;AAEzD,MAAA,MAAM,QAAA,GAAW,MAAM,kBAAA,CAAmB,OAAA,EAAS,MAAM,WAAA,EAAa;AAAA,QACpE,GAAG,IAAI,GAAA,CAAI,UAAA,CAAW,IAAI,CAAA,KAAA,KAAS,KAAA,CAAM,SAAS,CAAC;AAAA,OACpD,CAAA;AAED,MAAA,MAAM,IAAA,uBAAW,GAAA,EAAY;AAC7B,MAAA,MAAM,UAAU,EAAC;AACjB,MAAA,KAAA,MAAW,SAAS,UAAA,EAAY;AAC9B,QAAA,MAAM,MAAM,CAAA,EAAG,KAAA,CAAM,QAAQ,CAAA,CAAA,EAAI,MAAM,KAAK,CAAA,CAAA;AAC5C,QAAA,IACE,CAAC,QAAA,CAAS,GAAA,CAAI,KAAA,CAAM,SAAS,CAAA,IAC7B,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,OAAA,CAAQ,IAAA,CAAK;AAAA,UACX,QAAQ,KAAA,CAAM,MAAA;AAAA,UACd,OAAO,KAAA,CAAM,KAAA;AAAA,UACb,UAAU,KAAA,CAAM,QAAA;AAAA,UAChB,WAAW,KAAA,CAAM,SAAA;AAAA,UACjB,OAAA,EAAS,KAAA,CAAM,OAAA,CAAQ,KAAA,CAAM,GAAG,aAAa;AAAA,SAC9C,CAAA;AACD,QAAA,IAAI,OAAA,CAAQ,MAAA,KAAW,KAAA,CAAM,KAAA,EAAO;AAClC,UAAA;AAAA,QACF;AAAA,MACF;AACA,MAAA,OAAO,EAAE,MAAA,EAAQ,EAAE,OAAA,EAAQ,EAAE;AAAA,IAC/B;AAAA,GACD,CAAA;AACH;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;;;;"}
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":";;;;;AAWA,MAAM,UAAA,GAAa,EAAA;AAEnB,MAAM,KAAA,GAAQ,EAAA;AACd,MAAM,aAAA,GAAgB,IAAA;AAEtB,MAAM,eAAA,GAAkB,CAAA;AAyBjB,SAAS,6BAAA,CAA8B;AAAA,EAC5C,eAAA;AAAA,EACA,KAAA;AAAA,EACA,OAAA;AAAA,EACA,IAAA;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;AAAA;AAAA;AAAA;;AAAA;;AAAA,2RAAA,CAAA;AAAA,IAUb,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,OAAO,CAAA,CACJ,MAAA,GACA,GAAA,EAAI,CACJ,IAAI,CAAC,CAAA,CACL,GAAA,CAAI,EAAE,EACN,QAAA,EAAS,CACT,QAAQ,CAAC,CAAA,CACT,SAAS,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,SAAA,EAAW,CAAA,CAAE,MAAA,EAAO,CAAE,QAAA,EAAS;AAAA,YAC/B,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,SAAA,GAAY,CAAC,KAAA,CAAM,KAAA,EAAO,GAAI,KAAA,CAAM,YAAA,IAAgB,EAAG,CAAA;AAC7D,MAAA,MAAM,KAAA,GAAQA,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,oBAAoB,CAAC,GAAG,OAAO,GAAG,MAAM,CAAA,EAAG,eAAA,GAAkB,CAAC;AAAA,OACrE,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,GAAA,EAAK,MAAM,KAAA,CAAM,OAAA,CAAQ,WAAW,UAAU,CAAA;AAAA,UAC9C,MAAA,EAAQ,IAAI,UAAA,CAAW;AAAA,SACzB,CAAE,CAAA;AAAA,QACF,KAAA,CAAM,OAAA,CAAQ,KAAA,EAAO,UAAU,CAAA,CAAE,IAAA,CAAK,CAAA,GAAA,MAAQ,EAAE,GAAA,EAAK,MAAA,EAAQ,CAAA,EAAE,CAAE,CAAA;AAAA,QACjE,KAAA,CAAM,OAAA,CAAQ,IAAA,EAAM,UAAU,CAAA,CAAE,IAAA,CAAK,CAAA,GAAA,MAAQ,EAAE,GAAA,EAAK,MAAA,EAAQ,CAAA,EAAE,CAAE,CAAA;AAAA,QAChE,KAAA,CAAM,KAAA,CAAM,OAAA,EAAS,UAAU,CAAA,CAAE,IAAA,CAAK,CAAA,GAAA,MAAQ,EAAE,GAAA,EAAK,MAAA,EAAQ,CAAA,EAAE,CAAE;AAAA,OAClE,CAAA;AAED,MAAA,MAAM,SAASC,iBAAA,CAAa,KAAK,CAAA,CAAE,KAAA,CAAM,GAAG,KAAK,CAAA;AACjD,MAAA,MAAM,OAAO,IAAI,GAAA;AAAA,QAAA,CACd,MAAM,KAAA,CAAM,MAAA,CAAO,MAAM,CAAA,EAAG,GAAA,CAAI,CAAA,KAAA,KAAS,CAAC,KAAA,CAAM,EAAA,EAAI,KAAK,CAAC;AAAA,OAC7D;AACA,MAAA,MAAM,UAAA,GAAa,MAAA,CAChB,GAAA,CAAI,CAAA,EAAA,KAAM,KAAK,GAAA,CAAI,EAAE,CAAC,CAAA,CACtB,MAAA,CAAO,CAAC,KAAA,KAAgC,OAAA,CAAQ,KAAK,CAAC,CAAA;AAEzD,MAAA,MAAM,QAAA,GAAW,MAAM,kBAAA,CAAmB,OAAA,EAAS,MAAM,WAAA,EAAa;AAAA,QACpE,GAAG,IAAI,GAAA;AAAA,UACL,UAAA,CAAW,OAAA;AAAA,YAAQ,WACjB,KAAA,CAAM,SAAA,GAAY,CAAC,KAAA,CAAM,SAAS,IAAI;AAAC;AACzC;AACF,OACD,CAAA;AAED,MAAA,MAAM,IAAA,uBAAW,GAAA,EAAY;AAC7B,MAAA,MAAM,UAAU,EAAC;AACjB,MAAA,KAAA,MAAW,SAAS,UAAA,EAAY;AAC9B,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,OAAA,CAAQ,IAAA,CAAK;AAAA,UACX,OAAO,KAAA,CAAM,KAAA;AAAA,UACb,UAAU,KAAA,CAAM,QAAA;AAAA,UAChB,GAAI,MAAM,SAAA,GAAY,EAAE,WAAW,KAAA,CAAM,SAAA,KAAc,EAAC;AAAA,UACxD,OAAA,EAAS,KAAA,CAAM,OAAA,CAAQ,KAAA,CAAM,GAAG,aAAa;AAAA,SAC9C,CAAA;AACD,QAAA,IAAI,OAAA,CAAQ,MAAA,KAAW,KAAA,CAAM,KAAA,EAAO;AAClC,UAAA;AAAA,QACF;AAAA,MACF;AACA,MAAA,OAAO,EAAE,MAAA,EAAQ,EAAE,OAAA,EAAQ,EAAE;AAAA,IAC/B;AAAA,GACD,CAAA;AACH;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;;;;"}
package/dist/chunk.cjs.js CHANGED
@@ -6,9 +6,8 @@ const MAX_CHARS = 1600;
6
6
  const OVERLAP_CHARS = 200;
7
7
  function chunkDocument(doc) {
8
8
  return splitText(doc.text).map((content, index) => ({
9
- id: sha256(`${doc.source}|${doc.key}|${index}`),
10
- source: doc.source,
11
- entityRef: doc.entityRef,
9
+ id: sha256(`${doc.location}|${index}`),
10
+ ...doc.entityRef ? { entityRef: doc.entityRef } : {},
12
11
  location: doc.location,
13
12
  title: doc.title,
14
13
  content,
@@ -1 +1 @@
1
- {"version":3,"file":"chunk.cjs.js","sources":["../src/chunk.ts"],"sourcesContent":["import { createHash } from 'node:crypto';\nimport type { SourceDocument } from './sources';\n\n/** A stored, embedded unit of a {@link SourceDocument}. */\nexport interface Chunk {\n id: string;\n source: SourceDocument['source'];\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. MkDocs 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.source}|${doc.key}|${index}`),\n source: doc.source,\n 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":";;;;AAeA,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,EAAA,EAAI,MAAA,CAAO,CAAA,EAAG,GAAA,CAAI,MAAM,IAAI,GAAA,CAAI,GAAG,CAAA,CAAA,EAAI,KAAK,CAAA,CAAE,CAAA;AAAA,IAC9C,QAAQ,GAAA,CAAI,MAAA;AAAA,IACZ,WAAW,GAAA,CAAI,SAAA;AAAA,IACf,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 { 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,15 +1,9 @@
1
1
  'use strict';
2
2
 
3
- var backendPluginApi = require('@backstage/backend-plugin-api');
4
3
  var errors = require('@backstage/errors');
5
4
  var azure = require('@ai-sdk/azure');
6
5
  var openai = require('@ai-sdk/openai');
7
6
 
8
- const DEFAULT_SCHEDULE = {
9
- frequency: { minutes: 30 },
10
- timeout: { hours: 1 },
11
- initialDelay: { seconds: 15 }
12
- };
13
7
  function readSemanticSearchConfig(root) {
14
8
  const settings = root.getOptionalConfig("assistants.semanticSearch");
15
9
  if (!settings) {
@@ -32,10 +26,7 @@ function readSemanticSearchConfig(root) {
32
26
  }
33
27
  return {
34
28
  embeddingModelId,
35
- embeddingModel: buildEmbeddingModel(providerId, provider, modelName),
36
- schedule: settings.has("schedule") ? backendPluginApi.readSchedulerServiceTaskScheduleDefinitionFromConfig(
37
- settings.getConfig("schedule")
38
- ) : DEFAULT_SCHEDULE
29
+ embeddingModel: buildEmbeddingModel(providerId, provider, modelName)
39
30
  };
40
31
  }
41
32
  function buildEmbeddingModel(providerId, provider, modelName) {
@@ -1 +1 @@
1
- {"version":3,"file":"config.cjs.js","sources":["../src/config.ts"],"sourcesContent":["import {\n readSchedulerServiceTaskScheduleDefinitionFromConfig,\n type SchedulerServiceTaskScheduleDefinition,\n} from '@backstage/backend-plugin-api';\nimport { Config } from '@backstage/config';\nimport { InputError } from '@backstage/errors';\nimport { createAzure } from '@ai-sdk/azure';\nimport { createOpenAI } from '@ai-sdk/openai';\nimport type { EmbeddingModel } from 'ai';\n\n/** The `assistants.semanticSearch` block, resolved. */\nexport interface SemanticSearchConfig {\n /** `<providerId>:<model>` as configured; part of the index signature. */\n embeddingModelId: string;\n embeddingModel: EmbeddingModel;\n schedule: SchedulerServiceTaskScheduleDefinition;\n}\n\nconst DEFAULT_SCHEDULE: SchedulerServiceTaskScheduleDefinition = {\n frequency: { minutes: 30 },\n timeout: { hours: 1 },\n initialDelay: { seconds: 15 },\n};\n\n/**\n * Reads `assistants.semanticSearch`, or `undefined` when it is absent (the module is\n * installed but off). The embedding model resolves against the same\n * `assistants.providers` block the chat models use, so keys live in one place.\n */\nexport function readSemanticSearchConfig(\n root: Config,\n): SemanticSearchConfig | undefined {\n const settings = root.getOptionalConfig('assistants.semanticSearch');\n if (!settings) {\n return undefined;\n }\n\n const embeddingModelId = settings.getString('embeddingModel');\n const separator = embeddingModelId.indexOf(':');\n if (separator <= 0 || separator === embeddingModelId.length - 1) {\n throw new InputError(\n `assistants.semanticSearch.embeddingModel must be '<providerId>:<model>'; got '${embeddingModelId}'`,\n );\n }\n const providerId = embeddingModelId.slice(0, separator);\n const modelName = embeddingModelId.slice(separator + 1);\n\n const provider = root\n .getOptionalConfig('assistants.providers')\n ?.getOptionalConfig(providerId);\n if (!provider) {\n throw new InputError(\n `assistants.semanticSearch.embeddingModel references provider '${providerId}', which is not in assistants.providers`,\n );\n }\n\n return {\n embeddingModelId,\n embeddingModel: buildEmbeddingModel(providerId, provider, modelName),\n schedule: settings.has('schedule')\n ? readSchedulerServiceTaskScheduleDefinitionFromConfig(\n settings.getConfig('schedule'),\n )\n : DEFAULT_SCHEDULE,\n };\n}\n\n/**\n * The AI SDK embedding model for a configured provider. Anthropic has no\n * embeddings API, so only the OpenAI-shaped types qualify.\n */\nfunction buildEmbeddingModel(\n providerId: string,\n provider: Config,\n modelName: string,\n): EmbeddingModel {\n const type = provider.getString('type');\n const apiKey = provider.getString('apiKey');\n const baseURL = provider.getOptionalString('baseUrl');\n const options =\n (provider.getOptional('options') as Record<string, unknown>) ?? {};\n\n switch (type) {\n case 'openai':\n case 'openai-compatible':\n return createOpenAI({ apiKey, baseURL, ...options }).embedding(modelName);\n case 'azure':\n return createAzure({ apiKey, baseURL, ...options }).embedding(modelName);\n default:\n throw new InputError(\n `assistants.semanticSearch.embeddingModel: provider '${providerId}' is type '${type}', which has no embeddings API; use an openai, azure, or openai-compatible provider`,\n );\n }\n}\n"],"names":["InputError","readSchedulerServiceTaskScheduleDefinitionFromConfig","createOpenAI","createAzure"],"mappings":";;;;;;;AAkBA,MAAM,gBAAA,GAA2D;AAAA,EAC/D,SAAA,EAAW,EAAE,OAAA,EAAS,EAAA,EAAG;AAAA,EACzB,OAAA,EAAS,EAAE,KAAA,EAAO,CAAA,EAAE;AAAA,EACpB,YAAA,EAAc,EAAE,OAAA,EAAS,EAAA;AAC3B,CAAA;AAOO,SAAS,yBACd,IAAA,EACkC;AAClC,EAAA,MAAM,QAAA,GAAW,IAAA,CAAK,iBAAA,CAAkB,2BAA2B,CAAA;AACnE,EAAA,IAAI,CAAC,QAAA,EAAU;AACb,IAAA,OAAO,MAAA;AAAA,EACT;AAEA,EAAA,MAAM,gBAAA,GAAmB,QAAA,CAAS,SAAA,CAAU,gBAAgB,CAAA;AAC5D,EAAA,MAAM,SAAA,GAAY,gBAAA,CAAiB,OAAA,CAAQ,GAAG,CAAA;AAC9C,EAAA,IAAI,SAAA,IAAa,CAAA,IAAK,SAAA,KAAc,gBAAA,CAAiB,SAAS,CAAA,EAAG;AAC/D,IAAA,MAAM,IAAIA,iBAAA;AAAA,MACR,iFAAiF,gBAAgB,CAAA,CAAA;AAAA,KACnG;AAAA,EACF;AACA,EAAA,MAAM,UAAA,GAAa,gBAAA,CAAiB,KAAA,CAAM,CAAA,EAAG,SAAS,CAAA;AACtD,EAAA,MAAM,SAAA,GAAY,gBAAA,CAAiB,KAAA,CAAM,SAAA,GAAY,CAAC,CAAA;AAEtD,EAAA,MAAM,WAAW,IAAA,CACd,iBAAA,CAAkB,sBAAsB,CAAA,EACvC,kBAAkB,UAAU,CAAA;AAChC,EAAA,IAAI,CAAC,QAAA,EAAU;AACb,IAAA,MAAM,IAAIA,iBAAA;AAAA,MACR,iEAAiE,UAAU,CAAA,uCAAA;AAAA,KAC7E;AAAA,EACF;AAEA,EAAA,OAAO;AAAA,IACL,gBAAA;AAAA,IACA,cAAA,EAAgB,mBAAA,CAAoB,UAAA,EAAY,QAAA,EAAU,SAAS,CAAA;AAAA,IACnE,QAAA,EAAU,QAAA,CAAS,GAAA,CAAI,UAAU,CAAA,GAC7BC,qEAAA;AAAA,MACE,QAAA,CAAS,UAAU,UAAU;AAAA,KAC/B,GACA;AAAA,GACN;AACF;AAMA,SAAS,mBAAA,CACP,UAAA,EACA,QAAA,EACA,SAAA,EACgB;AAChB,EAAA,MAAM,IAAA,GAAO,QAAA,CAAS,SAAA,CAAU,MAAM,CAAA;AACtC,EAAA,MAAM,MAAA,GAAS,QAAA,CAAS,SAAA,CAAU,QAAQ,CAAA;AAC1C,EAAA,MAAM,OAAA,GAAU,QAAA,CAAS,iBAAA,CAAkB,SAAS,CAAA;AACpD,EAAA,MAAM,OAAA,GACH,QAAA,CAAS,WAAA,CAAY,SAAS,KAAiC,EAAC;AAEnE,EAAA,QAAQ,IAAA;AAAM,IACZ,KAAK,QAAA;AAAA,IACL,KAAK,mBAAA;AACH,MAAA,OAAOC,mBAAA,CAAa,EAAE,MAAA,EAAQ,OAAA,EAAS,GAAG,OAAA,EAAS,CAAA,CAAE,SAAA,CAAU,SAAS,CAAA;AAAA,IAC1E,KAAK,OAAA;AACH,MAAA,OAAOC,iBAAA,CAAY,EAAE,MAAA,EAAQ,OAAA,EAAS,GAAG,OAAA,EAAS,CAAA,CAAE,SAAA,CAAU,SAAS,CAAA;AAAA,IACzE;AACE,MAAA,MAAM,IAAIH,iBAAA;AAAA,QACR,CAAA,oDAAA,EAAuD,UAAU,CAAA,WAAA,EAAc,IAAI,CAAA,mFAAA;AAAA,OACrF;AAAA;AAEN;;;;"}
1
+ {"version":3,"file":"config.cjs.js","sources":["../src/config.ts"],"sourcesContent":["import { Config } from '@backstage/config';\nimport { InputError } from '@backstage/errors';\nimport { createAzure } from '@ai-sdk/azure';\nimport { createOpenAI } from '@ai-sdk/openai';\nimport type { EmbeddingModel } from 'ai';\n\n/** The `assistants.semanticSearch` block, resolved. */\nexport interface SemanticSearchConfig {\n /** `<providerId>:<model>` as configured; part of the index signature. */\n embeddingModelId: string;\n embeddingModel: EmbeddingModel;\n}\n\n/**\n * Reads `assistants.semanticSearch`, or `undefined` when it is absent (the module is\n * installed but off). The embedding model resolves against the same\n * `assistants.providers` block the chat models use, so keys live in one place.\n */\nexport function readSemanticSearchConfig(\n root: Config,\n): SemanticSearchConfig | undefined {\n const settings = root.getOptionalConfig('assistants.semanticSearch');\n if (!settings) {\n return undefined;\n }\n\n const embeddingModelId = settings.getString('embeddingModel');\n const separator = embeddingModelId.indexOf(':');\n if (separator <= 0 || separator === embeddingModelId.length - 1) {\n throw new InputError(\n `assistants.semanticSearch.embeddingModel must be '<providerId>:<model>'; got '${embeddingModelId}'`,\n );\n }\n const providerId = embeddingModelId.slice(0, separator);\n const modelName = embeddingModelId.slice(separator + 1);\n\n const provider = root\n .getOptionalConfig('assistants.providers')\n ?.getOptionalConfig(providerId);\n if (!provider) {\n throw new InputError(\n `assistants.semanticSearch.embeddingModel references provider '${providerId}', which is not in assistants.providers`,\n );\n }\n\n return {\n embeddingModelId,\n embeddingModel: buildEmbeddingModel(providerId, provider, modelName),\n };\n}\n\n/**\n * The AI SDK embedding model for a configured provider. Anthropic has no\n * embeddings API, so only the OpenAI-shaped types qualify.\n */\nfunction buildEmbeddingModel(\n providerId: string,\n provider: Config,\n modelName: string,\n): EmbeddingModel {\n const type = provider.getString('type');\n const apiKey = provider.getString('apiKey');\n const baseURL = provider.getOptionalString('baseUrl');\n const options =\n (provider.getOptional('options') as Record<string, unknown>) ?? {};\n\n switch (type) {\n case 'openai':\n case 'openai-compatible':\n return createOpenAI({ apiKey, baseURL, ...options }).embedding(modelName);\n case 'azure':\n return createAzure({ apiKey, baseURL, ...options }).embedding(modelName);\n default:\n throw new InputError(\n `assistants.semanticSearch.embeddingModel: provider '${providerId}' is type '${type}', which has no embeddings API; use an openai, azure, or openai-compatible provider`,\n );\n }\n}\n"],"names":["InputError","createOpenAI","createAzure"],"mappings":";;;;;;AAkBO,SAAS,yBACd,IAAA,EACkC;AAClC,EAAA,MAAM,QAAA,GAAW,IAAA,CAAK,iBAAA,CAAkB,2BAA2B,CAAA;AACnE,EAAA,IAAI,CAAC,QAAA,EAAU;AACb,IAAA,OAAO,MAAA;AAAA,EACT;AAEA,EAAA,MAAM,gBAAA,GAAmB,QAAA,CAAS,SAAA,CAAU,gBAAgB,CAAA;AAC5D,EAAA,MAAM,SAAA,GAAY,gBAAA,CAAiB,OAAA,CAAQ,GAAG,CAAA;AAC9C,EAAA,IAAI,SAAA,IAAa,CAAA,IAAK,SAAA,KAAc,gBAAA,CAAiB,SAAS,CAAA,EAAG;AAC/D,IAAA,MAAM,IAAIA,iBAAA;AAAA,MACR,iFAAiF,gBAAgB,CAAA,CAAA;AAAA,KACnG;AAAA,EACF;AACA,EAAA,MAAM,UAAA,GAAa,gBAAA,CAAiB,KAAA,CAAM,CAAA,EAAG,SAAS,CAAA;AACtD,EAAA,MAAM,SAAA,GAAY,gBAAA,CAAiB,KAAA,CAAM,SAAA,GAAY,CAAC,CAAA;AAEtD,EAAA,MAAM,WAAW,IAAA,CACd,iBAAA,CAAkB,sBAAsB,CAAA,EACvC,kBAAkB,UAAU,CAAA;AAChC,EAAA,IAAI,CAAC,QAAA,EAAU;AACb,IAAA,MAAM,IAAIA,iBAAA;AAAA,MACR,iEAAiE,UAAU,CAAA,uCAAA;AAAA,KAC7E;AAAA,EACF;AAEA,EAAA,OAAO;AAAA,IACL,gBAAA;AAAA,IACA,cAAA,EAAgB,mBAAA,CAAoB,UAAA,EAAY,QAAA,EAAU,SAAS;AAAA,GACrE;AACF;AAMA,SAAS,mBAAA,CACP,UAAA,EACA,QAAA,EACA,SAAA,EACgB;AAChB,EAAA,MAAM,IAAA,GAAO,QAAA,CAAS,SAAA,CAAU,MAAM,CAAA;AACtC,EAAA,MAAM,MAAA,GAAS,QAAA,CAAS,SAAA,CAAU,QAAQ,CAAA;AAC1C,EAAA,MAAM,OAAA,GAAU,QAAA,CAAS,iBAAA,CAAkB,SAAS,CAAA;AACpD,EAAA,MAAM,OAAA,GACH,QAAA,CAAS,WAAA,CAAY,SAAS,KAAiC,EAAC;AAEnE,EAAA,QAAQ,IAAA;AAAM,IACZ,KAAK,QAAA;AAAA,IACL,KAAK,mBAAA;AACH,MAAA,OAAOC,mBAAA,CAAa,EAAE,MAAA,EAAQ,OAAA,EAAS,GAAG,OAAA,EAAS,CAAA,CAAE,SAAA,CAAU,SAAS,CAAA;AAAA,IAC1E,KAAK,OAAA;AACH,MAAA,OAAOC,iBAAA,CAAY,EAAE,MAAA,EAAQ,OAAA,EAAS,GAAG,OAAA,EAAS,CAAA,CAAE,SAAA,CAAU,SAAS,CAAA;AAAA,IACzE;AACE,MAAA,MAAM,IAAIF,iBAAA;AAAA,QACR,CAAA,oDAAA,EAAuD,UAAU,CAAA,WAAA,EAAc,IAAI,CAAA,mFAAA;AAAA,OACrF;AAAA;AAEN;;;;"}
@@ -0,0 +1,84 @@
1
+ 'use strict';
2
+
3
+ var catalogModel = require('@backstage/catalog-model');
4
+ var nodeHtmlParser = require('node-html-parser');
5
+
6
+ function toSourceDocuments(docs) {
7
+ const pageTitles = /* @__PURE__ */ new Map();
8
+ for (const doc of docs) {
9
+ if (!doc.location.includes("#") && !pageTitles.has(doc.location)) {
10
+ pageTitles.set(doc.location, htmlToText(doc.title));
11
+ }
12
+ }
13
+ const documents = [];
14
+ const seen = /* @__PURE__ */ new Set();
15
+ let skipped = 0;
16
+ for (const doc of docs) {
17
+ const resourceRef = doc.authorization?.resourceRef;
18
+ const entityRef = resourceRef ? asEntityRef(resourceRef) : void 0;
19
+ if (resourceRef && !entityRef) {
20
+ skipped++;
21
+ continue;
22
+ }
23
+ if (seen.has(doc.location)) {
24
+ continue;
25
+ }
26
+ const text = [htmlToText(doc.text), facts(doc, entityRef)].filter(Boolean).join("\n");
27
+ if (!text) {
28
+ continue;
29
+ }
30
+ seen.add(doc.location);
31
+ documents.push({
32
+ location: doc.location,
33
+ ...entityRef ? { entityRef } : {},
34
+ title: headingPath([
35
+ field(doc, "entityTitle"),
36
+ pageTitles.get(doc.location.split("#")[0]),
37
+ htmlToText(doc.title)
38
+ ]),
39
+ text
40
+ });
41
+ }
42
+ return { documents, skipped };
43
+ }
44
+ function asEntityRef(ref) {
45
+ try {
46
+ const { kind, namespace, name } = catalogModel.parseEntityRef(ref);
47
+ return `${kind}:${namespace}/${name}`.toLocaleLowerCase("en-US");
48
+ } catch {
49
+ return void 0;
50
+ }
51
+ }
52
+ function field(doc, key) {
53
+ const value = doc[key];
54
+ return typeof value === "string" && value ? value : void 0;
55
+ }
56
+ function facts(doc, entityRef) {
57
+ const type = field(doc, "type") ?? field(doc, "componentType");
58
+ const owner = field(doc, "owner");
59
+ const lifecycle = field(doc, "lifecycle");
60
+ return [
61
+ entityRef && (type ? `${entityRef} (${type})` : entityRef),
62
+ owner && `Owner: ${owner}`,
63
+ lifecycle && `Lifecycle: ${lifecycle}`
64
+ ].filter(Boolean).join(" \xB7 ");
65
+ }
66
+ function htmlToText(html) {
67
+ if (!html) {
68
+ return "";
69
+ }
70
+ return nodeHtmlParser.parse(html).text.replace(/\s+/g, " ").trim();
71
+ }
72
+ function headingPath(parts) {
73
+ const path = [];
74
+ for (const part of parts) {
75
+ if (part && part !== path[path.length - 1]) {
76
+ path.push(part);
77
+ }
78
+ }
79
+ return path.join(" \u203A ");
80
+ }
81
+
82
+ exports.htmlToText = htmlToText;
83
+ exports.toSourceDocuments = toSourceDocuments;
84
+ //# sourceMappingURL=documents.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"documents.cjs.js","sources":["../src/documents.ts"],"sourcesContent":["import { parseEntityRef } from '@backstage/catalog-model';\nimport type { IndexableDocument } from '@backstage/plugin-search-common';\nimport { parse } from 'node-html-parser';\n\n/**\n * One searchable unit before chunking: a document from a Backstage search\n * run (a TechDocs section, a catalog entity, a Confluence page, …) as text.\n */\nexport interface SourceDocument {\n /** Its search result location: a link, and its identity across runs. */\n location: string;\n /**\n * The catalog entity whose read permission gates it. Without one, every\n * assistant user may see it, as everyone may in Backstage search.\n */\n entityRef?: string;\n /** Heading path, e.g. `Pylon › Messaging › Dead-letter handling`. */\n title: string;\n text: string;\n}\n\nexport interface MappedDocuments {\n documents: SourceDocument[];\n /** Documents whose `authorization.resourceRef` is not an entity ref, so no permission check applies. */\n skipped: number;\n}\n\n/**\n * Maps the documents of one search run. TechDocs sections take their page's\n * title from the page entry in the same run; HTML (MkDocs section text) is\n * flattened; the facts collators attach (entity ref, type, owner, lifecycle)\n * join the text, so a catalog entity with no description is still findable.\n * A document gated by anything other than a catalog entity is skipped: the\n * index can only check entity read permission. Pure; unit-tested.\n */\nexport function toSourceDocuments(docs: IndexableDocument[]): MappedDocuments {\n const pageTitles = new Map<string, string>();\n for (const doc of docs) {\n if (!doc.location.includes('#') && !pageTitles.has(doc.location)) {\n pageTitles.set(doc.location, htmlToText(doc.title));\n }\n }\n\n const documents: SourceDocument[] = [];\n const seen = new Set<string>();\n let skipped = 0;\n for (const doc of docs) {\n const resourceRef = doc.authorization?.resourceRef;\n const entityRef = resourceRef ? asEntityRef(resourceRef) : undefined;\n if (resourceRef && !entityRef) {\n skipped++;\n continue;\n }\n if (seen.has(doc.location)) {\n continue;\n }\n const text = [htmlToText(doc.text), facts(doc, entityRef)]\n .filter(Boolean)\n .join('\\n');\n if (!text) {\n continue;\n }\n seen.add(doc.location);\n documents.push({\n location: doc.location,\n ...(entityRef ? { entityRef } : {}),\n title: headingPath([\n field(doc, 'entityTitle'),\n pageTitles.get(doc.location.split('#')[0]),\n htmlToText(doc.title),\n ]),\n text,\n });\n }\n return { documents, skipped };\n}\n\n/** The ref in full form when it names a catalog entity. */\nfunction asEntityRef(ref: string): string | undefined {\n try {\n const { kind, namespace, name } = parseEntityRef(ref);\n return `${kind}:${namespace}/${name}`.toLocaleLowerCase('en-US');\n } catch {\n return undefined;\n }\n}\n\nfunction field(doc: IndexableDocument, key: string): string | undefined {\n const value = (doc as unknown as Record<string, unknown>)[key];\n return typeof value === 'string' && value ? value : undefined;\n}\n\nfunction facts(doc: IndexableDocument, entityRef: string | undefined): string {\n const type = field(doc, 'type') ?? field(doc, 'componentType');\n const owner = field(doc, 'owner');\n const lifecycle = field(doc, 'lifecycle');\n return [\n entityRef && (type ? `${entityRef} (${type})` : entityRef),\n owner && `Owner: ${owner}`,\n lifecycle && `Lifecycle: ${lifecycle}`,\n ]\n .filter(Boolean)\n .join(' · ');\n}\n\nexport function htmlToText(html: string | undefined): string {\n if (!html) {\n return '';\n }\n return parse(html).text.replace(/\\s+/g, ' ').trim();\n}\n\nfunction headingPath(parts: Array<string | undefined>): string {\n const path: string[] = [];\n for (const part of parts) {\n if (part && part !== path[path.length - 1]) {\n path.push(part);\n }\n }\n return path.join(' › ');\n}\n"],"names":["parseEntityRef","parse"],"mappings":"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"}