@drewswiredin/backstage-plugin-assistants-backend-module-semantic-search 0.1.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +168 -28
- package/config.schema.json +30 -153
- package/dist/action.cjs.js +83 -29
- package/dist/action.cjs.js.map +1 -1
- package/dist/alpha.cjs.js +8 -0
- package/dist/alpha.cjs.js.map +1 -0
- package/dist/alpha.d.ts +78 -0
- package/dist/chunk.cjs.js +8 -4
- package/dist/chunk.cjs.js.map +1 -1
- package/dist/config.cjs.js +43 -8
- package/dist/config.cjs.js.map +1 -1
- package/dist/documents.cjs.js +120 -0
- package/dist/documents.cjs.js.map +1 -0
- package/dist/feed.cjs.js +104 -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 +28 -4
- package/dist/indexer.cjs.js +31 -9
- package/dist/indexer.cjs.js.map +1 -1
- package/dist/module.cjs.js +55 -17
- package/dist/module.cjs.js.map +1 -1
- package/dist/rank.cjs.js +14 -6
- package/dist/rank.cjs.js.map +1 -1
- package/dist/ranking.cjs.js +61 -0
- package/dist/ranking.cjs.js.map +1 -0
- package/dist/searchModule.cjs.js +34 -0
- package/dist/searchModule.cjs.js.map +1 -0
- package/dist/store.cjs.js +274 -44
- package/dist/store.cjs.js.map +1 -1
- package/package.json +13 -2
- 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,59 @@ assistants:
|
|
|
82
100
|
On Azure OpenAI, deploy an embedding model and name the deployment:
|
|
83
101
|
`embeddingModel: azure:<embedding-deployment>`.
|
|
84
102
|
|
|
85
|
-
|
|
86
|
-
|
|
103
|
+
### Rank by source
|
|
104
|
+
|
|
105
|
+
Every source shares one index, so a large one (thousands of tickets that all
|
|
106
|
+
mention the same system) can crowd out the one runbook section that answers
|
|
107
|
+
the question. Name the search document types to weigh them:
|
|
108
|
+
|
|
109
|
+
```yaml
|
|
110
|
+
assistants:
|
|
111
|
+
semanticSearch:
|
|
112
|
+
embeddingModel: openai:text-embedding-3-small
|
|
113
|
+
maxResults: 8
|
|
114
|
+
types:
|
|
115
|
+
software-catalog: { weight: 1.5 }
|
|
116
|
+
techdocs: { weight: 1.2 }
|
|
117
|
+
confluence: { weight: 1.0 }
|
|
118
|
+
jira:
|
|
119
|
+
weight: 0.5
|
|
120
|
+
maxResults: 2
|
|
121
|
+
description: ticket history, decisions and status
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The keys are the type names Backstage search uses: `software-catalog`,
|
|
125
|
+
`techdocs`, or a collator's own type. Types left out are copied and ranked
|
|
126
|
+
with weight 1. Set `default: false` to make a source opt-in: the model then
|
|
127
|
+
searches it only when it passes `types: ['jira']`, and the tool description
|
|
128
|
+
says so. Set `enabled: false` to stop copying a source; its chunks are
|
|
129
|
+
deleted on its next search run.
|
|
130
|
+
|
|
131
|
+
The index fills as search runs: each document type arrives when its collator
|
|
132
|
+
next runs, on the schedule you gave that collator. A source's first run embeds
|
|
133
|
+
all of it; later runs embed only new and changed sections and delete removed
|
|
134
|
+
ones. Changing `embeddingModel` empties the index, and each type refills on its
|
|
135
|
+
next search run.
|
|
136
|
+
|
|
137
|
+
A type added to `types` takes its name, weight, and metadata on its next run,
|
|
138
|
+
keeping the embeddings it already has; 0.2.0 indexes upgrade the same way. To
|
|
139
|
+
run a collator now rather than wait for its schedule, trigger its search task
|
|
140
|
+
with a user or service token:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
curl -X POST -H "Authorization: Bearer $TOKEN" \
|
|
144
|
+
https://backstage.example.com/api/search/.backstage/scheduler/v1/tasks/search_index_jira/trigger
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The task id is `search_index_<type>`; the backend logs `Knowledge index
|
|
148
|
+
refreshed` with the type and counts when the copy finishes.
|
|
87
149
|
|
|
88
150
|
## Give an assistant the tool
|
|
89
151
|
|
|
90
152
|
Add `search-knowledge` to the assistant's tools in the assistant editor. It
|
|
91
153
|
pairs with `read-techdocs` (from `builtinActions: true`), which opens the full
|
|
92
|
-
page behind a TechDocs result
|
|
154
|
+
page behind a TechDocs result; other results carry a link. Until the first
|
|
155
|
+
search run is copied, the tool
|
|
93
156
|
answers with an error that points the model at keyword search: Backstage's
|
|
94
157
|
`query` action when the assistant has it, otherwise `search-techdocs` and
|
|
95
158
|
`search-catalog`.
|
|
@@ -113,17 +176,38 @@ backend:
|
|
|
113
176
|
|
|
114
177
|
## How it works
|
|
115
178
|
|
|
116
|
-
**Indexing.** A
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
179
|
+
**Indexing.** A search decorator sits on every document type's pipeline. It
|
|
180
|
+
passes each document on to the search engine unchanged and keeps it; when the
|
|
181
|
+
collator finishes, it hands the run's documents to the index. A failed run
|
|
182
|
+
hands over nothing, and a run with no documents changes nothing. Runs are
|
|
183
|
+
copied one at a time, in the backend that ran the collator.
|
|
184
|
+
|
|
185
|
+
Each document becomes one or more chunks of up to ~1600 characters, titled
|
|
186
|
+
with its heading path (`Site › Page › Section` for TechDocs) and embedded with
|
|
187
|
+
that title. The facts collators attach (entity ref, type, owner, lifecycle)
|
|
188
|
+
join the text, so a catalog entity with no description is still found. A
|
|
189
|
+
document gated by a catalog entity (its `authorization.resourceRef`) keeps that
|
|
190
|
+
entity for the permission check; a document gated by any other kind of
|
|
191
|
+
resource is skipped, since the index can only check entity read permission.
|
|
192
|
+
|
|
193
|
+
Search never tells a decorator which document type it is decorating, so the
|
|
194
|
+
module adds a pass-through decorator to each type named in `types`. Search
|
|
195
|
+
builds a run's pipeline in one pass, so that decorator names the run's type.
|
|
196
|
+
A named type's run is stored under its name; a run of any other type joins
|
|
197
|
+
the stored collection that already holds most of its locations. Chunks the
|
|
198
|
+
run no longer has are deleted. A collection no search run has refreshed in
|
|
199
|
+
30 days (a source removed from search, or an unnamed one whose locations all
|
|
200
|
+
changed at once) is dropped. Each row stores the embedding, a generated
|
|
201
|
+
full-text vector, a trigram index over its text, its type, and the short
|
|
202
|
+
fields its collator attached. The table is a rebuildable copy, not migrated
|
|
203
|
+
data.
|
|
204
|
+
|
|
205
|
+
**Retrieval.** `search-knowledge` takes the question, up to three alternative
|
|
206
|
+
phrasings, and optionally the `types` to search (otherwise every type that is
|
|
207
|
+
on by default, plus unnamed sources). It builds one semantic list per
|
|
208
|
+
phrasing, a keyword list, a rare-word list, and a fuzzy list, each keeping
|
|
209
|
+
its best 30 chunks of every type, so a large source cannot fill a list on its
|
|
210
|
+
own:
|
|
127
211
|
|
|
128
212
|
| List | Matches | Postgres |
|
|
129
213
|
| --------- | --------------------------------------------- | ----------------------------------------------------------- |
|
|
@@ -135,18 +219,74 @@ list, and a fuzzy list:
|
|
|
135
219
|
Query words include adjacent pairs written as one word, so "tally up" also
|
|
136
220
|
matches "TallyUp". Reciprocal rank fusion merges the lists with one vote per
|
|
137
221
|
method: the semantic lists share one vote, so extra phrasings broaden recall
|
|
138
|
-
without outvoting an exact match.
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
222
|
+
without outvoting an exact match. Each chunk's fused score is multiplied by
|
|
223
|
+
its type's weight. The rare-word list keeps a name or acronym mentioned once
|
|
224
|
+
in a paragraph about something else from being outranked by sections that
|
|
225
|
+
are only near the question in meaning. The top candidates' entities are
|
|
226
|
+
fetched from the catalog as the user; results for entities the user cannot
|
|
227
|
+
read are dropped, results with no entity are kept, and repeated text is shown
|
|
228
|
+
once. The ranker then picks the results: by default best first, with at most
|
|
229
|
+
`maxResults` of a capped type while other types can fill the slots.
|
|
230
|
+
|
|
231
|
+
Each result carries its title, location, type, excerpt, the entity ref for
|
|
232
|
+
catalog and TechDocs results, and `metadata`: the short fields its collator
|
|
233
|
+
attached (strings up to 200 characters, numbers, booleans), such as a
|
|
234
|
+
ticket's status or a page's last update.
|
|
235
|
+
|
|
236
|
+
## Custom ranking
|
|
237
|
+
|
|
238
|
+
A backend module can replace the ranker, for example to favour recently
|
|
239
|
+
updated tickets:
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
import { createBackendModule } from '@backstage/backend-plugin-api';
|
|
243
|
+
import { knowledgeRankingExtensionPoint } from '@drewswiredin/backstage-plugin-assistants-backend-module-semantic-search/alpha';
|
|
244
|
+
|
|
245
|
+
export default createBackendModule({
|
|
246
|
+
pluginId: 'assistants',
|
|
247
|
+
moduleId: 'knowledge-recency',
|
|
248
|
+
register(env) {
|
|
249
|
+
env.registerInit({
|
|
250
|
+
deps: { ranking: knowledgeRankingExtensionPoint },
|
|
251
|
+
async init({ ranking }) {
|
|
252
|
+
ranking.setRanker(({ candidates, maxResults }) =>
|
|
253
|
+
candidates
|
|
254
|
+
.map(candidate => {
|
|
255
|
+
const updated = Date.parse(String(candidate.metadata?.updated));
|
|
256
|
+
const ageDays = (Date.now() - updated) / 86_400_000;
|
|
257
|
+
const boost = ageDays < 90 ? 1.2 : 1;
|
|
258
|
+
return { candidate, score: candidate.score * boost };
|
|
259
|
+
})
|
|
260
|
+
.sort((a, b) => b.score - a.score)
|
|
261
|
+
.slice(0, maxResults)
|
|
262
|
+
.map(({ candidate }) => candidate),
|
|
263
|
+
);
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
The ranker receives the query, its phrasings, the types searched, and up to
|
|
271
|
+
60 candidates the caller may read. Each candidate has its fused score after
|
|
272
|
+
weights, its rank in each retrieval list, its type, entity ref, and metadata.
|
|
273
|
+
It runs after the permission check, so it cannot surface what the caller
|
|
274
|
+
cannot read; results it returns that were not candidates are dropped.
|
|
143
275
|
|
|
144
276
|
## Reference
|
|
145
277
|
|
|
146
|
-
| Key
|
|
147
|
-
|
|
|
148
|
-
| `assistants.semanticSearch.embeddingModel`
|
|
149
|
-
| `assistants.semanticSearch.
|
|
278
|
+
| Key | Default | Meaning |
|
|
279
|
+
| ---------------------------------------------------- | ---------- | ----------------------------------------------------------------------------- |
|
|
280
|
+
| `assistants.semanticSearch.embeddingModel` | (required) | `<providerId>:<model>` from `assistants.providers`. |
|
|
281
|
+
| `assistants.semanticSearch.maxResults` | `8` | Results per answer unless the model asks for a number (it may ask for 20). |
|
|
282
|
+
| `assistants.semanticSearch.types.<type>.weight` | `1` | Multiplies the type's fused score; `0` never returns it. |
|
|
283
|
+
| `assistants.semanticSearch.types.<type>.default` | `true` | Searched when the model names no types; `false` makes the type opt-in. |
|
|
284
|
+
| `assistants.semanticSearch.types.<type>.maxResults` | | At most this many of the type's results while other types can fill the slots. |
|
|
285
|
+
| `assistants.semanticSearch.types.<type>.enabled` | `true` | `false` stops copying the type and deletes its chunks on its next run. |
|
|
286
|
+
| `assistants.semanticSearch.types.<type>.description` | | What the source holds, shown to the model in the tool description. |
|
|
287
|
+
|
|
288
|
+
The index refreshes when search does; set each collator's schedule in its own
|
|
289
|
+
config.
|
|
150
290
|
|
|
151
291
|
## License
|
|
152
292
|
|
package/config.schema.json
CHANGED
|
@@ -12,167 +12,44 @@
|
|
|
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
14
|
},
|
|
15
|
-
"
|
|
15
|
+
"maxResults": {
|
|
16
|
+
"type": "number",
|
|
17
|
+
"description": "Results per `search-knowledge` answer unless the caller asks for a number. Defaults to 8."
|
|
18
|
+
},
|
|
19
|
+
"types": {
|
|
16
20
|
"type": "object",
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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."
|
|
21
|
+
"additionalProperties": {
|
|
22
|
+
"type": "object",
|
|
23
|
+
"properties": {
|
|
24
|
+
"weight": {
|
|
25
|
+
"type": "number",
|
|
26
|
+
"description": "Multiplies the type's fused score. Defaults to 1; 0 means its chunks are never returned."
|
|
27
|
+
},
|
|
28
|
+
"default": {
|
|
29
|
+
"type": "boolean",
|
|
30
|
+
"description": "Searched when the caller names no types. Defaults to true; false makes the type opt-in (the model passes it in `types`)."
|
|
31
|
+
},
|
|
32
|
+
"maxResults": {
|
|
33
|
+
"type": "number",
|
|
34
|
+
"description": "At most this many of the type's results in one answer."
|
|
35
|
+
},
|
|
36
|
+
"enabled": {
|
|
37
|
+
"type": "boolean",
|
|
38
|
+
"description": "Copied into the index. Defaults to true; false stops copying it and deletes its chunks on its next search run."
|
|
39
|
+
},
|
|
40
|
+
"description": {
|
|
41
|
+
"type": "string",
|
|
42
|
+
"description": "What the source holds, shown to the model in the tool description, e.g. \"ticket history, decisions and status\"."
|
|
43
|
+
}
|
|
163
44
|
}
|
|
164
45
|
},
|
|
165
|
-
"
|
|
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."
|
|
46
|
+
"description": "Settings per Backstage search document type, keyed by the type name search uses (`software-catalog`, `techdocs`, or a collator's own type). Types left out are copied and ranked with the defaults."
|
|
170
47
|
}
|
|
171
48
|
},
|
|
172
49
|
"required": [
|
|
173
50
|
"embeddingModel"
|
|
174
51
|
],
|
|
175
|
-
"description": "The knowledge index:
|
|
52
|
+
"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
53
|
}
|
|
177
54
|
}
|
|
178
55
|
}
|
package/dist/action.cjs.js
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
var ai = require('ai');
|
|
4
4
|
var rank = require('./rank.cjs.js');
|
|
5
|
+
var ranking = require('./ranking.cjs.js');
|
|
5
6
|
|
|
6
7
|
const CANDIDATES = 30;
|
|
7
|
-
const FUSED =
|
|
8
|
+
const FUSED = 60;
|
|
9
|
+
const MAX_LIMIT = 20;
|
|
8
10
|
const EXCERPT_CHARS = 1500;
|
|
9
11
|
const RARE_MAX_CHUNKS = 3;
|
|
10
12
|
function registerSearchKnowledgeAction({
|
|
@@ -12,13 +14,17 @@ function registerSearchKnowledgeAction({
|
|
|
12
14
|
store,
|
|
13
15
|
catalog,
|
|
14
16
|
auth,
|
|
15
|
-
embeddingModel
|
|
17
|
+
embeddingModel,
|
|
18
|
+
types,
|
|
19
|
+
maxResults,
|
|
20
|
+
ranker
|
|
16
21
|
}) {
|
|
17
22
|
actionsRegistry.register({
|
|
18
23
|
name: "search-knowledge",
|
|
19
24
|
title: "Search Knowledge",
|
|
20
25
|
attributes: { destructive: false, readOnly: true, idempotent: true },
|
|
21
|
-
description: `Search the organization's TechDocs
|
|
26
|
+
description: `Search everything the organization's Backstage search indexes (TechDocs, the software catalog, and any other source it collects, such as Confluence or wiki pages) by meaning, not only by exact words. Best for questions in the user's own words: it finds a page even when the question is worded differently from it ("fix errored messages" finds "Retry dead-letter messages") and tolerates misspelled names ("Pilon" finds "Pylon"). It returns the most relevant sections, not every match, and cannot filter by entity kind.
|
|
27
|
+
${sourcesDescription(types)}
|
|
22
28
|
|
|
23
29
|
How to search the organization's docs and catalog:
|
|
24
30
|
1. Start here, with the user's question.
|
|
@@ -27,20 +33,27 @@ How to search the organization's docs and catalog:
|
|
|
27
33
|
|
|
28
34
|
Pass the user's question as the "query" parameter. Pass up to three "alternatives": other phrasings, synonyms, or the internal terms the docs might use.
|
|
29
35
|
|
|
30
|
-
Each result has a title (its heading path), an excerpt,
|
|
36
|
+
Each result has a title (its heading path), an excerpt, a location, its source type, the fields the source attached (such as status, space, or last update: use them to judge how current it is), and, for catalog and TechDocs results, the entity ref. A location starting with /docs/ is a TechDocs page: pass it to read-techdocs to read the full page. Any other location is a link to the page or entity; cite it.`,
|
|
31
37
|
schema: {
|
|
32
38
|
input: (z) => z.object({
|
|
33
39
|
query: z.string().min(1).describe("The question or topic to find"),
|
|
34
40
|
alternatives: z.array(z.string()).max(3).optional().describe("Other phrasings or terms for the same thing"),
|
|
35
|
-
|
|
41
|
+
types: z.array(z.string()).optional().describe(
|
|
42
|
+
"Search only these sources (search document types); omit to search the default ones"
|
|
43
|
+
),
|
|
44
|
+
limit: z.number().int().min(1).max(Math.max(MAX_LIMIT, maxResults)).optional().default(maxResults).describe("Number of results to return")
|
|
36
45
|
}),
|
|
37
46
|
output: (z) => z.object({
|
|
38
47
|
results: z.array(
|
|
39
48
|
z.object({
|
|
40
|
-
source: z.enum(["techdocs", "catalog"]),
|
|
41
49
|
title: z.string(),
|
|
42
50
|
location: z.string(),
|
|
43
|
-
|
|
51
|
+
type: z.string().optional(),
|
|
52
|
+
entityRef: z.string().optional(),
|
|
53
|
+
metadata: z.record(
|
|
54
|
+
z.string(),
|
|
55
|
+
z.union([z.string(), z.number(), z.boolean()])
|
|
56
|
+
).optional(),
|
|
44
57
|
excerpt: z.string()
|
|
45
58
|
})
|
|
46
59
|
)
|
|
@@ -52,12 +65,17 @@ Each result has a title (its heading path), an excerpt, and a location. For Tech
|
|
|
52
65
|
"The knowledge index is still being built; meanwhile use keyword search: the query tool when it is available, otherwise search-techdocs and search-catalog."
|
|
53
66
|
);
|
|
54
67
|
}
|
|
68
|
+
const filter = ranking.selectTypes(types, await store.types(), input.types);
|
|
55
69
|
const phrasings = [input.query, ...input.alternatives ?? []];
|
|
56
70
|
const words = rank.queryWords(phrasings);
|
|
57
71
|
const joined = rank.joinedWords(phrasings).filter((w) => !words.includes(w));
|
|
58
72
|
const [{ embeddings }, frequencies] = await Promise.all([
|
|
59
73
|
ai.embedMany({ model: embeddingModel, values: phrasings }),
|
|
60
|
-
store.documentFrequencies(
|
|
74
|
+
store.documentFrequencies(
|
|
75
|
+
[...words, ...joined],
|
|
76
|
+
RARE_MAX_CHUNKS + 1,
|
|
77
|
+
filter
|
|
78
|
+
)
|
|
61
79
|
]);
|
|
62
80
|
const known = [...words, ...joined].filter(
|
|
63
81
|
(w) => (frequencies.get(w) ?? 0) > 0
|
|
@@ -66,45 +84,80 @@ Each result has a title (its heading path), an excerpt, and a location. For Tech
|
|
|
66
84
|
const unknown = words.filter((w) => frequencies.get(w) === 0);
|
|
67
85
|
const lists = await Promise.all([
|
|
68
86
|
...embeddings.map(async (embedding) => ({
|
|
69
|
-
|
|
87
|
+
name: "semantic",
|
|
88
|
+
hits: await store.nearest(embedding, CANDIDATES, filter),
|
|
70
89
|
weight: 1 / embeddings.length
|
|
71
90
|
})),
|
|
72
|
-
store.keyword(known, CANDIDATES).then((
|
|
73
|
-
store.keyword(rare, CANDIDATES).then((
|
|
74
|
-
store.fuzzy(unknown, CANDIDATES).then((
|
|
91
|
+
store.keyword(known, CANDIDATES, filter).then((hits) => ({ name: "keyword", hits, weight: 1 })),
|
|
92
|
+
store.keyword(rare, CANDIDATES, filter).then((hits) => ({ name: "rareWord", hits, weight: 1 })),
|
|
93
|
+
store.fuzzy(unknown, CANDIDATES, filter).then((hits) => ({ name: "fuzzy", hits, weight: 1 }))
|
|
75
94
|
]);
|
|
76
|
-
const
|
|
95
|
+
const settingsOf = ranking.typeSettingsOf(types);
|
|
96
|
+
const fused = rank.fuseRankings(lists, (type) => settingsOf(type).weight).slice(
|
|
97
|
+
0,
|
|
98
|
+
FUSED
|
|
99
|
+
);
|
|
77
100
|
const byId = new Map(
|
|
78
|
-
(await store.chunks(
|
|
101
|
+
(await store.chunks(fused.map((hit) => hit.id))).map((chunk) => [
|
|
102
|
+
chunk.id,
|
|
103
|
+
chunk
|
|
104
|
+
])
|
|
79
105
|
);
|
|
80
|
-
const
|
|
106
|
+
const found = fused.flatMap((hit) => {
|
|
107
|
+
const chunk = byId.get(hit.id);
|
|
108
|
+
return chunk ? [{ hit, chunk }] : [];
|
|
109
|
+
});
|
|
81
110
|
const readable = await readableEntityRefs(catalog, auth, credentials, [
|
|
82
|
-
...new Set(
|
|
111
|
+
...new Set(
|
|
112
|
+
found.flatMap(
|
|
113
|
+
({ chunk }) => chunk.entityRef ? [chunk.entityRef] : []
|
|
114
|
+
)
|
|
115
|
+
)
|
|
83
116
|
]);
|
|
84
117
|
const seen = /* @__PURE__ */ new Set();
|
|
85
|
-
const
|
|
86
|
-
for (const chunk of
|
|
118
|
+
const candidates = [];
|
|
119
|
+
for (const { hit, chunk } of found) {
|
|
87
120
|
const key = `${chunk.location}|${chunk.title}`;
|
|
88
|
-
if (!readable.has(chunk.entityRef) || seen.has(key) || seen.has(chunk.content)) {
|
|
121
|
+
if (chunk.entityRef && !readable.has(chunk.entityRef) || seen.has(key) || seen.has(chunk.content)) {
|
|
89
122
|
continue;
|
|
90
123
|
}
|
|
91
124
|
seen.add(key);
|
|
92
125
|
seen.add(chunk.content);
|
|
93
|
-
|
|
94
|
-
source: chunk.source,
|
|
95
|
-
title: chunk.title,
|
|
96
|
-
location: chunk.location,
|
|
97
|
-
entityRef: chunk.entityRef,
|
|
98
|
-
excerpt: chunk.content.slice(0, EXCERPT_CHARS)
|
|
99
|
-
});
|
|
100
|
-
if (results.length === input.limit) {
|
|
101
|
-
break;
|
|
102
|
-
}
|
|
126
|
+
candidates.push({ ...chunk, score: hit.score, ranks: hit.ranks });
|
|
103
127
|
}
|
|
128
|
+
const ids = new Set(candidates.map((candidate) => candidate.id));
|
|
129
|
+
const ranked = await ranker()({
|
|
130
|
+
query: input.query,
|
|
131
|
+
phrasings,
|
|
132
|
+
types: filter.types,
|
|
133
|
+
candidates,
|
|
134
|
+
maxResults: input.limit
|
|
135
|
+
});
|
|
136
|
+
const results = ranked.filter((candidate) => ids.delete(candidate.id)).slice(0, input.limit).map((candidate) => ({
|
|
137
|
+
title: candidate.title,
|
|
138
|
+
location: candidate.location,
|
|
139
|
+
...candidate.type ? { type: candidate.type } : {},
|
|
140
|
+
...candidate.entityRef ? { entityRef: candidate.entityRef } : {},
|
|
141
|
+
...candidate.metadata ? { metadata: candidate.metadata } : {},
|
|
142
|
+
excerpt: candidate.content.slice(0, EXCERPT_CHARS)
|
|
143
|
+
}));
|
|
104
144
|
return { output: { results } };
|
|
105
145
|
}
|
|
106
146
|
});
|
|
107
147
|
}
|
|
148
|
+
function sourcesDescription(types) {
|
|
149
|
+
const listed = [...types].filter(
|
|
150
|
+
([, settings]) => settings.enabled && settings.weight > 0
|
|
151
|
+
);
|
|
152
|
+
const lines = listed.map(
|
|
153
|
+
([type, settings]) => `- ${type}${settings.default ? "" : ' (only when named in "types")'}${settings.description ? `: ${settings.description}` : ""}`
|
|
154
|
+
);
|
|
155
|
+
return [
|
|
156
|
+
"",
|
|
157
|
+
`Sources: pass "types" to search only the named search document types; without it the search covers every source except those marked as searched only when named. An unknown name returns the list of types.`,
|
|
158
|
+
...lines
|
|
159
|
+
].join("\n");
|
|
160
|
+
}
|
|
108
161
|
async function readableEntityRefs(catalog, auth, credentials, entityRefs) {
|
|
109
162
|
if (entityRefs.length === 0) {
|
|
110
163
|
return /* @__PURE__ */ new Set();
|
|
@@ -121,4 +174,5 @@ async function readableEntityRefs(catalog, auth, credentials, entityRefs) {
|
|
|
121
174
|
}
|
|
122
175
|
|
|
123
176
|
exports.registerSearchKnowledgeAction = registerSearchKnowledgeAction;
|
|
177
|
+
exports.sourcesDescription = sourcesDescription;
|
|
124
178
|
//# sourceMappingURL=action.cjs.js.map
|