@drewswiredin/backstage-plugin-assistants-backend-module-semantic-search 0.1.3 → 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 +59 -20
- package/config.schema.json +1 -157
- package/dist/action.cjs.js +10 -8
- package/dist/action.cjs.js.map +1 -1
- package/dist/chunk.cjs.js +2 -3
- package/dist/chunk.cjs.js.map +1 -1
- package/dist/config.cjs.js +1 -10
- package/dist/config.cjs.js.map +1 -1
- package/dist/documents.cjs.js +84 -0
- package/dist/documents.cjs.js.map +1 -0
- package/dist/feed.cjs.js +57 -0
- package/dist/feed.cjs.js.map +1 -0
- package/dist/index.cjs.js +11 -2
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +25 -4
- package/dist/indexer.cjs.js +18 -8
- package/dist/indexer.cjs.js.map +1 -1
- package/dist/module.cjs.js +26 -16
- package/dist/module.cjs.js.map +1 -1
- package/dist/searchModule.cjs.js +27 -0
- package/dist/searchModule.cjs.js.map +1 -0
- package/dist/store.cjs.js +42 -12
- package/dist/store.cjs.js.map +1 -1
- package/package.json +11 -8
- package/dist/sources.cjs.js +0 -220
- package/dist/sources.cjs.js.map +0 -1
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
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
|
86
|
-
|
|
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
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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,
|
|
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
|
|
147
|
-
| ------------------------------------------ |
|
|
148
|
-
| `assistants.semanticSearch.embeddingModel` | (required)
|
|
149
|
-
|
|
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
|
|
package/config.schema.json
CHANGED
|
@@ -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:
|
|
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
|
}
|
package/dist/action.cjs.js
CHANGED
|
@@ -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
|
|
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,
|
|
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(
|
|
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) {
|
package/dist/action.cjs.js.map
CHANGED
|
@@ -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":"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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 { 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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"}
|
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.
|
|
10
|
-
|
|
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,
|
package/dist/chunk.cjs.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"chunk.cjs.js","sources":["../src/chunk.ts"],"sourcesContent":["import { createHash } from 'node:crypto';\nimport type { SourceDocument } from './
|
|
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;;;;;;"}
|
package/dist/config.cjs.js
CHANGED
|
@@ -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) {
|
package/dist/config.cjs.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.cjs.js","sources":["../src/config.ts"],"sourcesContent":["import {
|
|
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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"}
|