@frontera-sdk/knowledge 0.0.0-stage → 1.53.17
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/LICENSE +202 -0
- package/README.md +176 -2
- package/package.json +83 -4
- package/src/errors.ts +89 -0
- package/src/hooks.ts +46 -0
- package/src/knowledge-client.ts +155 -0
- package/src/provider.tsx +20 -0
- package/src/routes.ts +55 -0
- package/src/types.ts +187 -0
- package/src/upload-files.ts +133 -0
- package/src/wait-until-ready.ts +120 -0
- package/src/wire-types.ts +588 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { FronteraClient } from '@frontera-sdk/core/client'
|
|
2
|
+
import type { RequestOptions } from '@frontera-sdk/core/transport'
|
|
3
|
+
|
|
4
|
+
import { KNOWLEDGE_ROUTES as R, routeRequest, type KnowledgeRoute } from './routes'
|
|
5
|
+
import type {
|
|
6
|
+
AgentKnowledgeBases,
|
|
7
|
+
AnyKnowledgeSource,
|
|
8
|
+
DeletedKnowledgeBase,
|
|
9
|
+
DocumentListQuery,
|
|
10
|
+
DocumentPage,
|
|
11
|
+
DocumentStatuses,
|
|
12
|
+
DocumentText,
|
|
13
|
+
DocumentTextQuery,
|
|
14
|
+
KnowledgeBase,
|
|
15
|
+
KnowledgeCapabilities,
|
|
16
|
+
KnowledgeDocument,
|
|
17
|
+
KnowledgeDocumentDetails,
|
|
18
|
+
ManagedKnowledgeSource,
|
|
19
|
+
RemovedDocumentPage,
|
|
20
|
+
RemovedSource,
|
|
21
|
+
SearchRequest,
|
|
22
|
+
SearchResponse,
|
|
23
|
+
SourceInput,
|
|
24
|
+
SourcePatch,
|
|
25
|
+
SourcesOfBase,
|
|
26
|
+
SourceTest,
|
|
27
|
+
UploadResult,
|
|
28
|
+
} from './types'
|
|
29
|
+
import { waitUntilReady, type WaitOptions, type WaitResult } from './wait-until-ready'
|
|
30
|
+
|
|
31
|
+
/** The most ids one status request takes. */
|
|
32
|
+
const STATUS_BATCH = 200
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Typed client for Knowledge.
|
|
36
|
+
*
|
|
37
|
+
* The credential on the `FronteraClient` decides who acts and from where: a
|
|
38
|
+
* workspace key created with the Knowledge purpose acts as its workspace, an
|
|
39
|
+
* App token as the person using the App in the App's workspace, and a person's
|
|
40
|
+
* own sign-in as that person. Bases, documents and search take all three
|
|
41
|
+
* (an App does not create, rename or delete bases, or choose an Agent's bases);
|
|
42
|
+
* sources take only a person signed in. A refusal throws a `FronteraError`;
|
|
43
|
+
* `refusalReason(error)` from `./errors` says why.
|
|
44
|
+
*/
|
|
45
|
+
export class KnowledgeClient {
|
|
46
|
+
constructor(private readonly client: FronteraClient) {}
|
|
47
|
+
|
|
48
|
+
private call<T>(route: KnowledgeRoute, params: Record<string, string> = {}, options: Omit<RequestOptions, 'method'> = {}): Promise<T> {
|
|
49
|
+
const { method, path } = routeRequest(route, params)
|
|
50
|
+
return this.client.request<T>(path, { ...options, method })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** What the caller may do from where it stands. Throws with reason `knowledge_off` where Knowledge does not run. */
|
|
54
|
+
me(): Promise<KnowledgeCapabilities> {
|
|
55
|
+
return this.call<KnowledgeCapabilities>(R.me)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Search the knowledge bases the caller reads; each result is a citation. */
|
|
59
|
+
search(request: SearchRequest, signal?: AbortSignal): Promise<SearchResponse> {
|
|
60
|
+
return this.call<SearchResponse>(R.search, {}, { body: request, signal })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
readonly bases = {
|
|
64
|
+
/** The knowledge bases the caller reads: its workspace's own, and organization bases shared with that workspace. */
|
|
65
|
+
list: async (): Promise<KnowledgeBase[]> => (await this.call<{ bases: KnowledgeBase[] }>(R.listBases)).bases,
|
|
66
|
+
get: async (id: string): Promise<KnowledgeBase> => (await this.call<{ base: KnowledgeBase }>(R.getBase, { id })).base,
|
|
67
|
+
/** A workspace base in the caller's workspace. */
|
|
68
|
+
create: async (input: { name: string; description?: string | null }): Promise<KnowledgeBase> =>
|
|
69
|
+
(await this.call<{ base: KnowledgeBase }>(R.createBase, {}, { body: input })).base,
|
|
70
|
+
update: async (id: string, input: { name?: string; description?: string | null }): Promise<KnowledgeBase> =>
|
|
71
|
+
(await this.call<{ base: KnowledgeBase }>(R.updateBase, { id }, { body: input })).base,
|
|
72
|
+
/** Deletes the base and every document in it. */
|
|
73
|
+
delete: async (id: string): Promise<DeletedKnowledgeBase> => (await this.call<{ base: DeletedKnowledgeBase }>(R.deleteBase, { id })).base,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
readonly documents = {
|
|
77
|
+
/** A page of a base's documents, newest first. Pass `nextCursor` back as `cursor` for the next page. */
|
|
78
|
+
list: (baseId: string, query: DocumentListQuery = {}): Promise<DocumentPage> =>
|
|
79
|
+
this.call<DocumentPage>(R.listDocuments, { id: baseId }, { query: { status: query.status, limit: query.limit, cursor: query.cursor } }),
|
|
80
|
+
/**
|
|
81
|
+
* Adds a file. The same bytes sent to the same base again answer the
|
|
82
|
+
* document they made, with `duplicate: true`, so sending again after a
|
|
83
|
+
* timeout or a crash never makes a copy. A `Blob` needs `fileName`.
|
|
84
|
+
*/
|
|
85
|
+
upload: async (baseId: string, file: Blob, options: { fileName?: string; title?: string; signal?: AbortSignal } = {}): Promise<UploadResult> => {
|
|
86
|
+
const fileName = options.fileName ?? (file instanceof File ? file.name : undefined)
|
|
87
|
+
if (!fileName) throw new TypeError('A Blob needs options.fileName: Knowledge reads the type of a file from its name')
|
|
88
|
+
const form = new FormData()
|
|
89
|
+
form.append('file', file, fileName)
|
|
90
|
+
if (options.title !== undefined) form.append('title', options.title)
|
|
91
|
+
return this.call<UploadResult>(R.uploadDocument, { id: baseId }, { body: form, signal: options.signal })
|
|
92
|
+
},
|
|
93
|
+
/** One document with its base. A document not yet read (`scanning`) answers `not_found`; `status` sees it. */
|
|
94
|
+
get: async (id: string): Promise<KnowledgeDocumentDetails> => (await this.call<{ document: KnowledgeDocumentDetails }>(R.getDocument, { id })).document,
|
|
95
|
+
/** Where each document stands, in any status, in the order asked; any id the caller cannot see is in `missing`. */
|
|
96
|
+
status: async (ids: readonly string[], signal?: AbortSignal): Promise<DocumentStatuses> => {
|
|
97
|
+
const answer: DocumentStatuses = { documents: [], missing: [] }
|
|
98
|
+
for (let at = 0; at < ids.length; at += STATUS_BATCH) {
|
|
99
|
+
const batch = await this.call<DocumentStatuses>(R.documentStatuses, {}, { body: { ids: ids.slice(at, at + STATUS_BATCH) }, signal })
|
|
100
|
+
answer.documents.push(...batch.documents)
|
|
101
|
+
answer.missing.push(...batch.missing)
|
|
102
|
+
}
|
|
103
|
+
return answer
|
|
104
|
+
},
|
|
105
|
+
remove: async (id: string): Promise<KnowledgeDocument> => (await this.call<{ document: KnowledgeDocument }>(R.removeDocument, { id })).document,
|
|
106
|
+
/** Read a failed or old document again. */
|
|
107
|
+
reprocess: async (id: string): Promise<KnowledgeDocument> => (await this.call<{ document: KnowledgeDocument }>(R.reprocessDocument, { id })).document,
|
|
108
|
+
/** A ready document's text page by page, for citing it. */
|
|
109
|
+
text: (id: string, query: DocumentTextQuery = {}): Promise<DocumentText> =>
|
|
110
|
+
this.call<DocumentText>(R.documentText, { id }, { query: { fromPage: query.fromPage, toPage: query.toPage, maxChars: query.maxChars } }),
|
|
111
|
+
/** The original file, its body unread. */
|
|
112
|
+
file: (id: string, signal?: AbortSignal): Promise<Response> => {
|
|
113
|
+
const { method, path } = routeRequest(R.documentFile, { id })
|
|
114
|
+
return this.client.requestRaw(path, { method, signal })
|
|
115
|
+
},
|
|
116
|
+
/** Polls until every document is settled; see `WaitOptions`. */
|
|
117
|
+
waitUntilReady: (ids: readonly string[], options: WaitOptions = {}): Promise<WaitResult> => waitUntilReady(this, ids, options),
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
readonly agents = {
|
|
121
|
+
/** The knowledge bases an Agent may search, and which it picks. */
|
|
122
|
+
bases: (agentId: string): Promise<AgentKnowledgeBases> => this.call<AgentKnowledgeBases>(R.agentBases, { agentId }),
|
|
123
|
+
/** Replaces the Agent's picks; they apply at once. */
|
|
124
|
+
setBases: (agentId: string, knowledgeBaseIds: readonly string[]): Promise<AgentKnowledgeBases> =>
|
|
125
|
+
this.call<AgentKnowledgeBases>(R.setAgentBases, { agentId }, { body: { knowledgeBaseIds } }),
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Sources: another system's list of documents that a base syncs from. Only a person signed in manages them. */
|
|
129
|
+
readonly sources = {
|
|
130
|
+
list: (baseId: string): Promise<SourcesOfBase> => this.call<SourcesOfBase>(R.listSources, { id: baseId }),
|
|
131
|
+
get: async (id: string): Promise<AnyKnowledgeSource> => (await this.call<{ source: AnyKnowledgeSource }>(R.getSource, { id })).source,
|
|
132
|
+
add: async (baseId: string, input: SourceInput): Promise<ManagedKnowledgeSource> =>
|
|
133
|
+
(await this.call<{ source: ManagedKnowledgeSource }>(R.addSource, { id: baseId }, { body: input })).source,
|
|
134
|
+
/** Reads the first page as the source would be added, and stores nothing. With `detect`, works out where the items and fields are first. */
|
|
135
|
+
test: async (baseId: string, input: SourceInput, options: { detect?: boolean } = {}): Promise<SourceTest> =>
|
|
136
|
+
(await this.call<{ test: SourceTest }>(R.testSource, { id: baseId }, { body: input, query: { detect: options.detect } })).test,
|
|
137
|
+
/** A change that moves where the credential would be sent needs `credential` again (reason `credential_required`). */
|
|
138
|
+
update: async (id: string, patch: SourcePatch): Promise<ManagedKnowledgeSource> =>
|
|
139
|
+
(await this.call<{ source: ManagedKnowledgeSource }>(R.updateSource, { id }, { body: patch })).source,
|
|
140
|
+
replaceCredential: async (id: string, credential: string): Promise<ManagedKnowledgeSource> =>
|
|
141
|
+
(await this.call<{ source: ManagedKnowledgeSource }>(R.replaceSourceCredential, { id }, { body: { credential } })).source,
|
|
142
|
+
/** Sync now. Refused while it syncs, is paused or is being removed. */
|
|
143
|
+
sync: async (id: string): Promise<ManagedKnowledgeSource> => (await this.call<{ source: ManagedKnowledgeSource }>(R.syncSource, { id })).source,
|
|
144
|
+
pause: async (id: string): Promise<AnyKnowledgeSource> => (await this.call<{ source: AnyKnowledgeSource }>(R.pauseSource, { id })).source,
|
|
145
|
+
resume: async (id: string): Promise<ManagedKnowledgeSource> => (await this.call<{ source: ManagedKnowledgeSource }>(R.resumeSource, { id })).source,
|
|
146
|
+
/** Removes the source; its documents are deleted in the background. */
|
|
147
|
+
remove: async (id: string): Promise<RemovedSource> => (await this.call<{ source: RemovedSource }>(R.removeSource, { id })).source,
|
|
148
|
+
/** A page of the base's documents their source no longer has, most recently hidden first. */
|
|
149
|
+
removedDocuments: (baseId: string, query: { limit?: number; cursor?: string } = {}): Promise<RemovedDocumentPage> =>
|
|
150
|
+
this.call<RemovedDocumentPage>(R.removedDocuments, { id: baseId }, { query: { limit: query.limit, cursor: query.cursor } }),
|
|
151
|
+
/** Deletes up to 100 of them now; answers the ids deleted. */
|
|
152
|
+
deleteRemovedDocuments: async (baseId: string, documentIds: readonly string[]): Promise<string[]> =>
|
|
153
|
+
(await this.call<{ deleted: string[] }>(R.deleteRemovedDocuments, { id: baseId }, { body: { documentIds } })).deleted,
|
|
154
|
+
}
|
|
155
|
+
}
|
package/src/provider.tsx
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import type { FronteraClient } from '@frontera-sdk/core/client'
|
|
3
|
+
|
|
4
|
+
import { FronteraKnowledgeContext } from './hooks'
|
|
5
|
+
import { KnowledgeClient } from './knowledge-client'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Plug Knowledge into `createFronteraApp` or `FronteraAppProvider`:
|
|
9
|
+
*
|
|
10
|
+
* ```tsx
|
|
11
|
+
* createFronteraApp(<App />, { providers: [knowledgeProvider] })
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* The App reads and adds as the person using it, in the App's workspace. The
|
|
15
|
+
* client is rebuilt whenever the host rotates the credential, because the
|
|
16
|
+
* `FronteraClient` it wraps is replaced rather than changed.
|
|
17
|
+
*/
|
|
18
|
+
export function knowledgeProvider(value: { client: FronteraClient }, children: ReactNode): ReactNode {
|
|
19
|
+
return <FronteraKnowledgeContext.Provider value={new KnowledgeClient(value.client)}>{children}</FronteraKnowledgeContext.Provider>
|
|
20
|
+
}
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every Knowledge route this package calls, by the method it backs. The
|
|
3
|
+
* service's contract test checks each one against the platform's own route
|
|
4
|
+
* table, and lists the routes left out on purpose, so a route added to the
|
|
5
|
+
* platform is a decision here rather than a gap.
|
|
6
|
+
*/
|
|
7
|
+
export const KNOWLEDGE_ROUTES = {
|
|
8
|
+
me: 'GET /me',
|
|
9
|
+
listBases: 'GET /bases',
|
|
10
|
+
createBase: 'POST /bases',
|
|
11
|
+
getBase: 'GET /bases/:id',
|
|
12
|
+
updateBase: 'PATCH /bases/:id',
|
|
13
|
+
deleteBase: 'DELETE /bases/:id',
|
|
14
|
+
listDocuments: 'GET /bases/:id/documents',
|
|
15
|
+
uploadDocument: 'POST /bases/:id/documents',
|
|
16
|
+
documentStatuses: 'POST /documents/status',
|
|
17
|
+
getDocument: 'GET /documents/:id',
|
|
18
|
+
removeDocument: 'DELETE /documents/:id',
|
|
19
|
+
reprocessDocument: 'POST /documents/:id/reprocess',
|
|
20
|
+
documentText: 'GET /documents/:id/text',
|
|
21
|
+
documentFile: 'GET /documents/:id/file',
|
|
22
|
+
search: 'POST /search',
|
|
23
|
+
agentBases: 'GET /agents/:agentId/bases',
|
|
24
|
+
setAgentBases: 'PUT /agents/:agentId/bases',
|
|
25
|
+
listSources: 'GET /bases/:id/sources',
|
|
26
|
+
addSource: 'POST /bases/:id/sources',
|
|
27
|
+
testSource: 'POST /bases/:id/sources/test',
|
|
28
|
+
getSource: 'GET /sources/:id',
|
|
29
|
+
updateSource: 'PATCH /sources/:id',
|
|
30
|
+
replaceSourceCredential: 'PUT /sources/:id/credential',
|
|
31
|
+
syncSource: 'POST /sources/:id/sync',
|
|
32
|
+
pauseSource: 'POST /sources/:id/pause',
|
|
33
|
+
resumeSource: 'POST /sources/:id/resume',
|
|
34
|
+
removeSource: 'DELETE /sources/:id',
|
|
35
|
+
removedDocuments: 'GET /bases/:id/removed-documents',
|
|
36
|
+
deleteRemovedDocuments: 'POST /bases/:id/removed-documents/batch-delete',
|
|
37
|
+
} as const
|
|
38
|
+
|
|
39
|
+
export type KnowledgeRoute = (typeof KNOWLEDGE_ROUTES)[keyof typeof KNOWLEDGE_ROUTES]
|
|
40
|
+
|
|
41
|
+
/** Where the platform serves Knowledge. */
|
|
42
|
+
export const KNOWLEDGE_PATH = '/v1/knowledge-app'
|
|
43
|
+
|
|
44
|
+
type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
|
45
|
+
|
|
46
|
+
/** The method and the path of a route, each `:name` filled from `params`. */
|
|
47
|
+
export function routeRequest(route: KnowledgeRoute, params: Record<string, string> = {}): { method: Method; path: string } {
|
|
48
|
+
const space = route.indexOf(' ')
|
|
49
|
+
const path = route.slice(space + 1).replace(/:(\w+)/g, (_, name: string) => {
|
|
50
|
+
const value = params[name]
|
|
51
|
+
if (value === undefined) throw new TypeError(`${route} needs ${name}`)
|
|
52
|
+
return encodeURIComponent(value)
|
|
53
|
+
})
|
|
54
|
+
return { method: route.slice(0, space) as Method, path: `${KNOWLEDGE_PATH}${path}` }
|
|
55
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type * as Wire from './wire-types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The public shapes of `@frontera-sdk/knowledge`. Everything a route answers
|
|
5
|
+
* through a response schema is named from `wire-types.ts`, which the service
|
|
6
|
+
* generates from those schemas; the source answers, which have no schema yet,
|
|
7
|
+
* are written here and pinned by the service's contract test. Dates travel as
|
|
8
|
+
* ISO strings. A field may be added in a release; none is removed or retyped.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Where a document stands. `ready`, `failed` and `blocked` are settled; the rest are still on their way. */
|
|
12
|
+
export const DOCUMENT_STATUSES = ['scanning', 'pending', 'processing', 'ready', 'failed', 'blocked'] as const
|
|
13
|
+
export type DocumentStatus = (typeof DOCUMENT_STATUSES)[number]
|
|
14
|
+
|
|
15
|
+
/** The statuses a document does not leave by itself. */
|
|
16
|
+
export const SETTLED_STATUSES: ReadonlySet<DocumentStatus> = new Set(['ready', 'failed', 'blocked'])
|
|
17
|
+
|
|
18
|
+
/** The kinds of file a search can be narrowed to. */
|
|
19
|
+
export const DOCUMENT_TYPES = ['pdf', 'word', 'sheet', 'slides', 'image', 'text', 'other'] as const
|
|
20
|
+
export type DocumentType = (typeof DOCUMENT_TYPES)[number]
|
|
21
|
+
|
|
22
|
+
/** How recently a document changed, for narrowing a search. */
|
|
23
|
+
export const UPDATED_RANGES = ['week', 'month', 'year'] as const
|
|
24
|
+
export type UpdatedRange = (typeof UPDATED_RANGES)[number]
|
|
25
|
+
|
|
26
|
+
/** What the caller may do from where it stands. */
|
|
27
|
+
export type KnowledgeCapabilities = Wire.CapabilitiesAnswer
|
|
28
|
+
export type KnowledgeBase = Wire.BaseAnswer['base']
|
|
29
|
+
export type DeletedKnowledgeBase = Wire.DeletedBaseAnswer['base']
|
|
30
|
+
export type KnowledgeDocument = Wire.DocumentAnswer['document']
|
|
31
|
+
/** A document with the base it is in. */
|
|
32
|
+
export type KnowledgeDocumentDetails = Wire.DocumentDetailsAnswer['document']
|
|
33
|
+
/** One page of a base's documents, newest first; `nextCursor` is null on the last page. */
|
|
34
|
+
export type DocumentPage = Wire.DocumentsAnswer
|
|
35
|
+
/** The document of the uploaded bytes; `duplicate` when the base already had those bytes. */
|
|
36
|
+
export type UploadResult = Wire.UploadedAnswer
|
|
37
|
+
/** The asked documents the caller can see, in the order asked, and every other id in `missing`. */
|
|
38
|
+
export type DocumentStatuses = Wire.StatusesAnswer
|
|
39
|
+
/** A document's text page by page, for citing it; `nextPage` is where to continue when `cut`. */
|
|
40
|
+
export type DocumentText = Wire.DocumentTextAnswer
|
|
41
|
+
export type SearchResponse = Wire.SearchAnswer
|
|
42
|
+
/** One search result: the passage's document, page and snippet, and `openUrl`, the document's page on the platform. */
|
|
43
|
+
export type Citation = Wire.SearchAnswer['results'][number]
|
|
44
|
+
/** The knowledge bases an Agent may search, which of them it picks, and whether the caller may change the picks. */
|
|
45
|
+
export type AgentKnowledgeBases = Wire.AgentBasesAnswer
|
|
46
|
+
|
|
47
|
+
export type DocumentListQuery = {
|
|
48
|
+
status?: DocumentStatus
|
|
49
|
+
/** 1 to 200. Without `limit` and `cursor` the route answers every document at once. */
|
|
50
|
+
limit?: number
|
|
51
|
+
cursor?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type SearchRequest = {
|
|
55
|
+
query: string
|
|
56
|
+
knowledgeBaseIds?: string[]
|
|
57
|
+
types?: DocumentType[]
|
|
58
|
+
/** Who added the documents. */
|
|
59
|
+
addedBy?: string[]
|
|
60
|
+
updated?: UpdatedRange
|
|
61
|
+
/** 1 to 50. */
|
|
62
|
+
limit?: number
|
|
63
|
+
/** The words as typed: no corrected spelling, even when they find nothing. */
|
|
64
|
+
exact?: boolean
|
|
65
|
+
/** Each passage's whole text in `text`, for a program that writes its own answer from the results. */
|
|
66
|
+
includeText?: boolean
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type DocumentTextQuery = {
|
|
70
|
+
fromPage?: number
|
|
71
|
+
toPage?: number
|
|
72
|
+
/** 1 to 100,000; 20,000 when not given. */
|
|
73
|
+
maxChars?: number
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type SourceSpec = Wire.SourceSpec
|
|
77
|
+
export type SourceInput = Wire.SourceInput
|
|
78
|
+
export type SourcePatch = Wire.SourcePatch
|
|
79
|
+
export type PullConfig = Wire.PullConfig
|
|
80
|
+
export type SourceAuth = Wire.SourceAuth
|
|
81
|
+
|
|
82
|
+
export type SourceSchedule = SourceSpec['schedule']
|
|
83
|
+
export type KeepRemovedDays = SourceSpec['keepRemovedDays']
|
|
84
|
+
/** `running` while a sync is in progress, `failed` after the last sync failed (it tries again by itself), `removing` while its documents are deleted. */
|
|
85
|
+
export type SourceState = 'idle' | 'running' | 'failed' | 'paused' | 'removing'
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Where a source's sync is: when the next sync is due (null for none), when the
|
|
89
|
+
* last started and the last that finished well, the failures in a row, the last
|
|
90
|
+
* failure (its own sentence only for whoever manages the base), and while a
|
|
91
|
+
* sync runs, how far it is.
|
|
92
|
+
*/
|
|
93
|
+
export type SourceSync = {
|
|
94
|
+
nextRunAt: string | null
|
|
95
|
+
lastRunAt: string | null
|
|
96
|
+
lastSuccessAt: string | null
|
|
97
|
+
failures: number
|
|
98
|
+
error: { kind: string; message: string } | null
|
|
99
|
+
pass: { pages: number; listed: number; stored: number; removed: number } | null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A source as anyone who reads its base, or an organization owner or admin, sees it. */
|
|
103
|
+
export type KnowledgeSource = {
|
|
104
|
+
id: string
|
|
105
|
+
knowledgeBaseId: string
|
|
106
|
+
kind: 'api_pull'
|
|
107
|
+
name: string
|
|
108
|
+
host: string
|
|
109
|
+
schedule: SourceSchedule
|
|
110
|
+
state: SourceState
|
|
111
|
+
documents: number
|
|
112
|
+
sync: SourceSync
|
|
113
|
+
createdAt: string
|
|
114
|
+
updatedAt: string
|
|
115
|
+
canManage: boolean
|
|
116
|
+
/** Pause or remove it: whoever manages its base, and organization owners and admins. */
|
|
117
|
+
canStop: boolean
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** A source as whoever manages its base sees it: its whole shape, what waits in Removed at source, and when and by whom its credential was set. */
|
|
121
|
+
export type ManagedKnowledgeSource = KnowledgeSource & {
|
|
122
|
+
keepRemovedDays: KeepRemovedDays
|
|
123
|
+
removedAtSource: number
|
|
124
|
+
config: PullConfig
|
|
125
|
+
auth: SourceAuth
|
|
126
|
+
credential: { setAt: string; setBy: string } | null
|
|
127
|
+
createdBy: string
|
|
128
|
+
updatedBy: string
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export type AnyKnowledgeSource = KnowledgeSource | ManagedKnowledgeSource
|
|
132
|
+
|
|
133
|
+
export function isManagedSource(source: AnyKnowledgeSource): source is ManagedKnowledgeSource {
|
|
134
|
+
return 'config' in source
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export type SourcesOfBase = {
|
|
138
|
+
sources: AnyKnowledgeSource[]
|
|
139
|
+
/** The organization's policy lets this base's layer have sources. */
|
|
140
|
+
sourcesAllowed: boolean
|
|
141
|
+
canAdd: boolean
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** A removed source and how many documents it had, which are deleted in the background. */
|
|
145
|
+
export type RemovedSource = { id: string; documents: number }
|
|
146
|
+
|
|
147
|
+
export type SourcePullFailure = 'refused' | 'notFound' | 'slowDown' | 'unreachable' | 'timedOut' | 'badAnswer' | 'tooLarge' | 'blocked'
|
|
148
|
+
export type SourceItemField = 'id' | 'title' | 'updatedAt' | 'link' | 'content'
|
|
149
|
+
|
|
150
|
+
export type SourceSampleItem =
|
|
151
|
+
| { kind: 'removed'; id: string }
|
|
152
|
+
| {
|
|
153
|
+
kind: 'document'
|
|
154
|
+
id: string
|
|
155
|
+
title: string
|
|
156
|
+
updatedAt: string
|
|
157
|
+
link: string | null
|
|
158
|
+
content:
|
|
159
|
+
| { kind: 'inline'; format: 'html' | 'markdown' | 'text'; chars: number; overLimit: boolean; excerpt: string }
|
|
160
|
+
| { kind: 'file'; host: string; credential: boolean }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Test connection's answer: the first page as a sync would read it, or the one problem that stopped it. Nothing is stored. */
|
|
164
|
+
export type SourceTest =
|
|
165
|
+
| {
|
|
166
|
+
connected: true
|
|
167
|
+
page: { items: number; bytes: number; ms: number; more: boolean; large: boolean }
|
|
168
|
+
items: SourceSampleItem[]
|
|
169
|
+
unmapped: { items: number; fields: Array<{ field: SourceItemField; at: string; missing: number }> }
|
|
170
|
+
overLimit: number
|
|
171
|
+
/** With `detect`: the config the page was read with, and the first item's fields with samples. */
|
|
172
|
+
detected?: { config: PullConfig; itemFields: Array<{ path: string; sample: string }> }
|
|
173
|
+
}
|
|
174
|
+
| { connected: false; problem: { kind: SourcePullFailure; message: string; retryAt: string | null } }
|
|
175
|
+
|
|
176
|
+
/** A document its source no longer has, hidden until its source's retention deletes it. */
|
|
177
|
+
export type RemovedDocument = {
|
|
178
|
+
id: string
|
|
179
|
+
title: string
|
|
180
|
+
fileName: string | null
|
|
181
|
+
mimeType: string | null
|
|
182
|
+
source: { id: string; name: string }
|
|
183
|
+
removedAt: string
|
|
184
|
+
deletesAt: string
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export type RemovedDocumentPage = { documents: RemovedDocument[]; nextCursor: string | null }
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { refusalReason, retryAfterSeconds, type KnowledgeRefusalReason } from './errors'
|
|
2
|
+
import type { KnowledgeClient } from './knowledge-client'
|
|
3
|
+
import type { KnowledgeDocument } from './types'
|
|
4
|
+
import { sleep, throwIfAborted, type WaitOptions } from './wait-until-ready'
|
|
5
|
+
|
|
6
|
+
/** The largest file Knowledge accepts. The service's contract test keeps it equal to the platform's limit. */
|
|
7
|
+
export const MAX_UPLOAD_BYTES = 50 * 1024 * 1024
|
|
8
|
+
|
|
9
|
+
/** One file to add: `path` names it in the outcome, and its last part is the file name Knowledge reads the type from. */
|
|
10
|
+
export type FileToUpload = { path: string; file: Blob; title?: string }
|
|
11
|
+
|
|
12
|
+
export type UploadOutcome =
|
|
13
|
+
| { path: string; outcome: 'uploaded' | 'duplicate'; document: KnowledgeDocument }
|
|
14
|
+
/** Knowledge refused the file and sending it again would be refused too. */
|
|
15
|
+
| { path: string; outcome: 'refused'; reason: KnowledgeRefusalReason | null; message: string }
|
|
16
|
+
/** No answer after every try, or, with `wait`, the document was gone. */
|
|
17
|
+
| { path: string; outcome: 'failed'; message: string }
|
|
18
|
+
|
|
19
|
+
export type UploadFilesOptions = {
|
|
20
|
+
/** Files in flight at once; 4 by default. */
|
|
21
|
+
concurrency?: number
|
|
22
|
+
/** Tries after the first for a network failure or a server error; 3 by default. A refusal is never retried. */
|
|
23
|
+
retries?: number
|
|
24
|
+
/** The first pause before a retry, doubled each time, with jitter; 1,000 ms by default. */
|
|
25
|
+
retryDelayMs?: number
|
|
26
|
+
signal?: AbortSignal
|
|
27
|
+
/** Each file's outcome as soon as its upload answers, before any wait. */
|
|
28
|
+
onOutcome?: (outcome: UploadOutcome) => void
|
|
29
|
+
/** Wait until every added document is settled, and send a file once more if its upload did not get it stored. */
|
|
30
|
+
wait?: boolean | Omit<WaitOptions, 'resend' | 'signal'>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const isAsync = <T>(files: Iterable<T> | AsyncIterable<T>): files is AsyncIterable<T> => Symbol.asyncIterator in files
|
|
34
|
+
|
|
35
|
+
const fileNameOf = (path: string) => path.split(/[\\/]/).pop() || path
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Sends one file until it gets an answer. Sending again is safe: the same bytes
|
|
39
|
+
* in the same base answer the document they made. While Knowledge is `busy` it
|
|
40
|
+
* waits as long as it is asked to, without counting a try; a network failure,
|
|
41
|
+
* a rate limit or a server error is tried again up to `retries` times; any
|
|
42
|
+
* other refusal is final.
|
|
43
|
+
*/
|
|
44
|
+
async function sendFile(knowledge: KnowledgeClient, baseId: string, entry: FileToUpload, options: UploadFilesOptions): Promise<UploadOutcome> {
|
|
45
|
+
const { retries = 3, retryDelayMs = 1000, signal } = options
|
|
46
|
+
const fileName = fileNameOf(entry.path)
|
|
47
|
+
if (entry.file.size > MAX_UPLOAD_BYTES) {
|
|
48
|
+
return { path: entry.path, outcome: 'refused', reason: 'file_too_large', message: `${fileName}: a file may be at most ${MAX_UPLOAD_BYTES / 1024 / 1024} MB` }
|
|
49
|
+
}
|
|
50
|
+
let failures = 0
|
|
51
|
+
for (;;) {
|
|
52
|
+
throwIfAborted(signal)
|
|
53
|
+
try {
|
|
54
|
+
const { document, duplicate } = await knowledge.documents.upload(baseId, entry.file, { fileName, title: entry.title, signal })
|
|
55
|
+
return { path: entry.path, outcome: duplicate ? 'duplicate' : 'uploaded', document }
|
|
56
|
+
} catch (error) {
|
|
57
|
+
throwIfAborted(signal)
|
|
58
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
59
|
+
const reason = refusalReason(error)
|
|
60
|
+
const wait = retryAfterSeconds(error)
|
|
61
|
+
if (reason === 'busy') {
|
|
62
|
+
await sleep((wait ?? 30) * 1000, signal)
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
const status = (error as { status?: unknown }).status
|
|
66
|
+
const retryable = typeof status !== 'number' || status === 429 || status >= 500
|
|
67
|
+
if (!retryable) return { path: entry.path, outcome: 'refused', reason: reason ?? null, message }
|
|
68
|
+
if (++failures > retries) return { path: entry.path, outcome: 'failed', message }
|
|
69
|
+
await sleep(wait !== undefined ? wait * 1000 : retryDelayMs * 2 ** (failures - 1) * (0.5 + Math.random()), signal)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Adds many files to one base, `concurrency` at a time, and answers one
|
|
76
|
+
* outcome per file in the order given. Files are read from `files` as workers
|
|
77
|
+
* free up, so a long folder walk can feed it lazily.
|
|
78
|
+
*/
|
|
79
|
+
export async function uploadFiles(
|
|
80
|
+
knowledge: KnowledgeClient,
|
|
81
|
+
baseId: string,
|
|
82
|
+
files: Iterable<FileToUpload> | AsyncIterable<FileToUpload>,
|
|
83
|
+
options: UploadFilesOptions = {},
|
|
84
|
+
): Promise<UploadOutcome[]> {
|
|
85
|
+
const { concurrency = 4, signal, onOutcome, wait } = options
|
|
86
|
+
const source = isAsync(files) ? files[Symbol.asyncIterator]() : files[Symbol.iterator]()
|
|
87
|
+
const entries: FileToUpload[] = []
|
|
88
|
+
const outcomes: UploadOutcome[] = []
|
|
89
|
+
const worker = async () => {
|
|
90
|
+
for (;;) {
|
|
91
|
+
const next = await source.next()
|
|
92
|
+
if (next.done) return
|
|
93
|
+
const at = entries.push(next.value) - 1
|
|
94
|
+
outcomes[at] = await sendFile(knowledge, baseId, next.value, options)
|
|
95
|
+
onOutcome?.(outcomes[at]!)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
await Promise.all(Array.from({ length: Math.max(1, concurrency) }, worker))
|
|
99
|
+
if (!wait) return outcomes
|
|
100
|
+
|
|
101
|
+
// A document id can stand for several files with the same bytes; each one's outcome follows it.
|
|
102
|
+
const filesOf = new Map<string, number[]>()
|
|
103
|
+
outcomes.forEach((outcome, at) => {
|
|
104
|
+
if (outcome.outcome === 'uploaded' || outcome.outcome === 'duplicate') filesOf.set(outcome.document.id, [...(filesOf.get(outcome.document.id) ?? []), at])
|
|
105
|
+
})
|
|
106
|
+
const resendFailures = new Map<number, string>()
|
|
107
|
+
const settled = await knowledge.documents.waitUntilReady([...filesOf.keys()], {
|
|
108
|
+
...(wait === true ? {} : wait),
|
|
109
|
+
signal,
|
|
110
|
+
resend: async (documentId) => {
|
|
111
|
+
const ats = filesOf.get(documentId)!
|
|
112
|
+
const again = await sendFile(knowledge, baseId, entries[ats[0]!]!, options)
|
|
113
|
+
if (again.outcome === 'refused' || again.outcome === 'failed') {
|
|
114
|
+
for (const at of ats) resendFailures.set(at, again.message)
|
|
115
|
+
throw new Error(again.message)
|
|
116
|
+
}
|
|
117
|
+
filesOf.set(again.document.id, ats)
|
|
118
|
+
return { document: again.document, duplicate: again.outcome === 'duplicate' }
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
for (const document of settled.documents) {
|
|
122
|
+
for (const at of filesOf.get(document.id) ?? []) {
|
|
123
|
+
const outcome = outcomes[at]!
|
|
124
|
+
if (outcome.outcome === 'uploaded' || outcome.outcome === 'duplicate') outcomes[at] = { ...outcome, document }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
for (const id of settled.missing) {
|
|
128
|
+
for (const at of filesOf.get(id) ?? []) {
|
|
129
|
+
outcomes[at] = { path: entries[at]!.path, outcome: 'failed', message: resendFailures.get(at) ?? `${fileNameOf(entries[at]!.path)}: the document was removed before it was read` }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return outcomes
|
|
133
|
+
}
|