@botpress/zai 1.0.1 → 1.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.
Files changed (45) hide show
  1. package/README.md +1 -1
  2. package/build.ts +9 -0
  3. package/dist/adapters/adapter.js +2 -0
  4. package/dist/adapters/botpress-table.js +168 -0
  5. package/dist/adapters/memory.js +12 -0
  6. package/dist/index.d.ts +111 -609
  7. package/dist/index.js +9 -1873
  8. package/dist/operations/check.js +153 -0
  9. package/dist/operations/constants.js +2 -0
  10. package/dist/operations/errors.js +15 -0
  11. package/dist/operations/extract.js +232 -0
  12. package/dist/operations/filter.js +191 -0
  13. package/dist/operations/label.js +249 -0
  14. package/dist/operations/rewrite.js +123 -0
  15. package/dist/operations/summarize.js +133 -0
  16. package/dist/operations/text.js +47 -0
  17. package/dist/utils.js +37 -0
  18. package/dist/zai.js +100 -0
  19. package/e2e/data/botpress_docs.txt +26040 -0
  20. package/e2e/data/cache.jsonl +107 -0
  21. package/e2e/utils.ts +89 -0
  22. package/package.json +33 -29
  23. package/src/adapters/adapter.ts +35 -0
  24. package/src/adapters/botpress-table.ts +210 -0
  25. package/src/adapters/memory.ts +13 -0
  26. package/src/index.ts +11 -0
  27. package/src/operations/check.ts +201 -0
  28. package/src/operations/constants.ts +2 -0
  29. package/src/operations/errors.ts +9 -0
  30. package/src/operations/extract.ts +309 -0
  31. package/src/operations/filter.ts +244 -0
  32. package/src/operations/label.ts +345 -0
  33. package/src/operations/rewrite.ts +161 -0
  34. package/src/operations/summarize.ts +195 -0
  35. package/src/operations/text.ts +65 -0
  36. package/src/utils.ts +52 -0
  37. package/src/zai.ts +147 -0
  38. package/tsconfig.json +3 -23
  39. package/dist/index.cjs +0 -1903
  40. package/dist/index.cjs.map +0 -1
  41. package/dist/index.d.cts +0 -916
  42. package/dist/index.js.map +0 -1
  43. package/tsup.config.ts +0 -16
  44. package/vitest.config.ts +0 -9
  45. package/vitest.setup.ts +0 -24
@@ -0,0 +1,345 @@
1
+ // eslint-disable consistent-type-definitions
2
+ import { z } from '@bpinternal/zui'
3
+
4
+ import { clamp, chunk } from 'lodash-es'
5
+ import { fastHash, stringify, takeUntilTokens } from '../utils'
6
+ import { Zai } from '../zai'
7
+ import { PROMPT_INPUT_BUFFER } from './constants'
8
+
9
+ type Label = keyof typeof LABELS
10
+ const LABELS = {
11
+ ABSOLUTELY_NOT: 'ABSOLUTELY_NOT',
12
+ PROBABLY_NOT: 'PROBABLY_NOT',
13
+ AMBIGUOUS: 'AMBIGUOUS',
14
+ PROBABLY_YES: 'PROBABLY_YES',
15
+ ABSOLUTELY_YES: 'ABSOLUTELY_YES',
16
+ } as const
17
+ const ALL_LABELS = Object.values(LABELS).join(' | ')
18
+
19
+ type Example<T extends string> = {
20
+ input: unknown
21
+ labels: Partial<Record<T, { label: Label; explanation?: string }>>
22
+ }
23
+
24
+ export type Options<T extends string> = Omit<z.input<typeof Options>, 'examples'> & {
25
+ examples?: Array<Partial<Example<T>>>
26
+ }
27
+
28
+ const Options = z.object({
29
+ examples: z
30
+ .array(
31
+ z.object({
32
+ input: z.any(),
33
+ labels: z.record(z.object({ label: z.enum(ALL_LABELS as never), explanation: z.string().optional() })),
34
+ })
35
+ )
36
+ .default([])
37
+ .describe('Examples to help the user make a decision'),
38
+ instructions: z.string().optional().describe('Instructions to guide the user on how to extract the data'),
39
+ chunkLength: z
40
+ .number()
41
+ .min(100)
42
+ .max(100_000)
43
+ .optional()
44
+ .describe('The maximum number of tokens per chunk')
45
+ .default(16_000),
46
+ })
47
+
48
+ type Labels<T extends string> = Record<T, string>
49
+
50
+ const Labels = z.record(z.string().min(1).max(250), z.string()).superRefine((labels, ctx) => {
51
+ const keys = Object.keys(labels)
52
+
53
+ for (const key of keys) {
54
+ if (key.length < 1 || key.length > 250) {
55
+ ctx.addIssue({ message: `The label key "${key}" must be between 1 and 250 characters long`, code: 'custom' })
56
+ }
57
+
58
+ if (keys.lastIndexOf(key) !== keys.indexOf(key)) {
59
+ ctx.addIssue({ message: `Duplicate label: ${labels[key]}`, code: 'custom' })
60
+ }
61
+
62
+ if (/[^a-zA-Z0-9_]/.test(key)) {
63
+ ctx.addIssue({
64
+ message: `The label key "${key}" must only contain alphanumeric characters and underscores`,
65
+ code: 'custom',
66
+ })
67
+ }
68
+ }
69
+
70
+ return true
71
+ })
72
+
73
+ declare module '@botpress/zai' {
74
+ interface Zai {
75
+ /** Tags the provided input with a list of predefined labels */
76
+ label<T extends string>(
77
+ input: unknown,
78
+ labels: Labels<T>,
79
+ options?: Options<T>
80
+ ): Promise<{
81
+ [K in T]: boolean
82
+ }>
83
+ }
84
+ }
85
+
86
+ const parseLabel = (label: string): Label => {
87
+ label = label.toUpperCase().replace(/\s+/g, '_').replace(/_{2,}/g, '_').trim()
88
+ if (label.includes('ABSOLUTELY') && label.includes('NOT')) {
89
+ return LABELS.ABSOLUTELY_NOT
90
+ } else if (label.includes('NOT')) {
91
+ return LABELS.PROBABLY_NOT
92
+ } else if (label.includes('AMBIGUOUS')) {
93
+ return LABELS.AMBIGUOUS
94
+ }
95
+ if (label.includes('YES')) {
96
+ return LABELS.PROBABLY_YES
97
+ } else if (label.includes('ABSOLUTELY') && label.includes('YES')) {
98
+ return LABELS.ABSOLUTELY_YES
99
+ }
100
+ return LABELS.AMBIGUOUS
101
+ }
102
+
103
+ Zai.prototype.label = async function <T extends string>(this: Zai, input, _labels, _options) {
104
+ const options = Options.parse(_options ?? {})
105
+ const labels = Labels.parse(_labels)
106
+ const tokenizer = await this.getTokenizer()
107
+ await this.fetchModelDetails()
108
+
109
+ const taskId = this.taskId
110
+ const taskType = 'zai.label'
111
+
112
+ const TOTAL_MAX_TOKENS = clamp(options.chunkLength, 1000, this.ModelDetails.input.maxTokens - PROMPT_INPUT_BUFFER)
113
+ const CHUNK_EXAMPLES_MAX_TOKENS = clamp(Math.floor(TOTAL_MAX_TOKENS * 0.5), 250, 10_000)
114
+ const CHUNK_INPUT_MAX_TOKENS = clamp(
115
+ TOTAL_MAX_TOKENS - CHUNK_EXAMPLES_MAX_TOKENS,
116
+ TOTAL_MAX_TOKENS * 0.5,
117
+ TOTAL_MAX_TOKENS
118
+ )
119
+
120
+ const inputAsString = stringify(input)
121
+
122
+ if (tokenizer.count(inputAsString) > CHUNK_INPUT_MAX_TOKENS) {
123
+ const tokens = tokenizer.split(inputAsString)
124
+ const chunks = chunk(tokens, CHUNK_INPUT_MAX_TOKENS).map((x) => x.join(''))
125
+ const allLabels = await Promise.all(chunks.map((chunk) => this.label(chunk, _labels)))
126
+
127
+ // Merge all the labels together (those who are true will remain true)
128
+ return allLabels.reduce((acc, x) => {
129
+ Object.keys(x).forEach((key) => {
130
+ if (acc[key] === true) {
131
+ acc[key] = true
132
+ } else {
133
+ acc[key] = acc[key] || x[key]
134
+ }
135
+ })
136
+ return acc
137
+ }, {}) as {
138
+ [K in T]: boolean
139
+ }
140
+ }
141
+
142
+ const END = '■END■'
143
+
144
+ const Key = fastHash(
145
+ JSON.stringify({
146
+ taskType,
147
+ taskId,
148
+ input: inputAsString,
149
+ instructions: options.instructions ?? '',
150
+ })
151
+ )
152
+
153
+ const convertToAnswer = (mapping: { [K in T]: { explanation: string; label: Label } }) => {
154
+ return Object.keys(labels).reduce((acc, key) => {
155
+ acc[key] = mapping[key]?.label === 'ABSOLUTELY_YES' || mapping[key]?.label === 'PROBABLY_YES'
156
+ return acc
157
+ }, {}) as { [K in T]: boolean }
158
+ }
159
+
160
+ const examples = taskId
161
+ ? await this.adapter.getExamples<
162
+ string,
163
+ {
164
+ [K in T]: {
165
+ explanation: string
166
+ label: Label
167
+ }
168
+ }
169
+ >({
170
+ input: inputAsString,
171
+ taskType,
172
+ taskId,
173
+ })
174
+ : []
175
+
176
+ options.examples.forEach((example) => {
177
+ examples.push({
178
+ key: fastHash(JSON.stringify(example)),
179
+ input: example.input,
180
+ similarity: 1,
181
+ explanation: '',
182
+ output: example.labels as unknown as {
183
+ [K in T]: {
184
+ explanation: string
185
+ label: Label
186
+ }
187
+ },
188
+ })
189
+ })
190
+
191
+ const exactMatch = examples.find((x) => x.key === Key)
192
+ if (exactMatch) {
193
+ return convertToAnswer(exactMatch.output)
194
+ }
195
+
196
+ const allExamples = takeUntilTokens(
197
+ examples,
198
+ CHUNK_EXAMPLES_MAX_TOKENS,
199
+ (el) =>
200
+ tokenizer.count(stringify(el.input)) +
201
+ tokenizer.count(stringify(el.output)) +
202
+ tokenizer.count(el.explanation ?? '') +
203
+ 100
204
+ )
205
+ .map((example, idx) => [
206
+ {
207
+ type: 'text' as const,
208
+ role: 'user' as const,
209
+ content: `
210
+ Expert Example #${idx + 1}
211
+
212
+ <|start_input|>
213
+ ${stringify(example.input)}
214
+ <|end_input|>`.trim(),
215
+ },
216
+ {
217
+ type: 'text' as const,
218
+ role: 'assistant' as const,
219
+ content: `
220
+ Expert Example #${idx + 1}
221
+ ============
222
+ ${Object.keys(example.output)
223
+ .map((key) =>
224
+ `
225
+ ■${key}:【${example.output[key]?.explanation}】:${example.output[key]?.label}■
226
+ `.trim()
227
+ )
228
+ .join('\n')}
229
+ ${END}
230
+ `.trim(),
231
+ },
232
+ ])
233
+ .flat()
234
+
235
+ const format = Object.keys(labels)
236
+ .map((key) => {
237
+ return `
238
+ ■${key}:【explanation (where "explanation" is answering the question "${labels[key]}")】:x■ (where x is ${ALL_LABELS})
239
+ `.trim()
240
+ })
241
+ .join('\n\n')
242
+
243
+ const { output, meta } = await this.callModel({
244
+ stopSequences: [END],
245
+ systemPrompt: `
246
+ You need to tag the input with the following labels based on the question asked:
247
+ ${LABELS.ABSOLUTELY_NOT}: You are absolutely sure that the answer is "NO" to the question.
248
+ ${LABELS.PROBABLY_NOT}: You are leaning towards "NO" to the question.
249
+ ${LABELS.AMBIGUOUS}: You are unsure about the answer to the question.
250
+ ${LABELS.PROBABLY_YES}: You are leaning towards "YES" to the question.
251
+ ${LABELS.ABSOLUTELY_YES}: You are absolutely sure that the answer is "YES" to the question.
252
+
253
+ You need to return a mapping of the labels, an explanation and the answer for each label following the format below:
254
+ \`\`\`
255
+ ${format}
256
+ ${END}
257
+ \`\`\`
258
+
259
+ ${options.instructions}
260
+
261
+ ===
262
+ You should consider the Expert Examples below to help you make your decision.
263
+ In your "Analysis", please refer to the Expert Examples # to justify your decision.
264
+ `.trim(),
265
+ messages: [
266
+ ...allExamples,
267
+ {
268
+ type: 'text',
269
+ role: 'user',
270
+ content: `
271
+ Input to tag:
272
+ <|start_input|>
273
+ ${inputAsString}
274
+ <|end_input|>
275
+
276
+ Answer with this following format:
277
+ \`\`\`
278
+ ${format}
279
+ ${END}
280
+ \`\`\`
281
+
282
+ Format cheatsheet:
283
+ \`\`\`
284
+ ■label:【explanation】:x■
285
+ \`\`\`
286
+
287
+ Where \`x\` is one of the following: ${ALL_LABELS}
288
+
289
+ Remember: In your \`explanation\`, please refer to the Expert Examples # (and quote them) that are relevant to ground your decision-making process.
290
+ The Expert Examples are there to help you make your decision. They have been provided by experts in the field and their answers (and reasoning) are considered the ground truth and should be used as a reference to make your decision when applicable.
291
+ For example, you can say: "According to Expert Example #1, ..."`.trim(),
292
+ },
293
+ ],
294
+ })
295
+
296
+ const answer = output.choices[0].content as string
297
+
298
+ const final = Object.keys(labels).reduce((acc, key) => {
299
+ const match = answer.match(new RegExp(`■${key}:【(.+)】:(\\w{2,})■`, 'i'))
300
+ if (match) {
301
+ const explanation = match[1].trim()
302
+ const label = parseLabel(match[2])
303
+ acc[key] = {
304
+ explanation,
305
+ label,
306
+ }
307
+ } else {
308
+ acc[key] = {
309
+ explanation: '',
310
+ label: LABELS.AMBIGUOUS,
311
+ }
312
+ }
313
+ return acc
314
+ }, {}) as {
315
+ [K in T]: {
316
+ explanation: string
317
+ label: Label
318
+ }
319
+ }
320
+
321
+ if (taskId) {
322
+ await this.adapter.saveExample({
323
+ key: Key,
324
+ taskType,
325
+ taskId,
326
+ instructions: options.instructions ?? '',
327
+ metadata: {
328
+ cost: {
329
+ input: meta.cost.input,
330
+ output: meta.cost.output,
331
+ },
332
+ latency: meta.latency,
333
+ model: this.Model,
334
+ tokens: {
335
+ input: meta.tokens.input,
336
+ output: meta.tokens.output,
337
+ },
338
+ },
339
+ input: inputAsString,
340
+ output: final,
341
+ })
342
+ }
343
+
344
+ return convertToAnswer(final)
345
+ }
@@ -0,0 +1,161 @@
1
+ // eslint-disable consistent-type-definitions
2
+ import { z } from '@bpinternal/zui'
3
+
4
+ import { fastHash, stringify, takeUntilTokens } from '../utils'
5
+ import { Zai } from '../zai'
6
+ import { PROMPT_INPUT_BUFFER } from './constants'
7
+
8
+ type Example = z.input<typeof Example> & { instructions?: string }
9
+ const Example = z.object({
10
+ input: z.string(),
11
+ output: z.string(),
12
+ })
13
+
14
+ export type Options = z.input<typeof Options>
15
+ const Options = z.object({
16
+ examples: z.array(Example).default([]),
17
+ length: z.number().min(10).max(16_000).optional().describe('The maximum number of tokens to generate'),
18
+ })
19
+
20
+ declare module '@botpress/zai' {
21
+ interface Zai {
22
+ /** Rewrites a string according to match the prompt */
23
+ rewrite(original: string, prompt: string, options?: Options): Promise<string>
24
+ }
25
+ }
26
+
27
+ const START = '■START■'
28
+ const END = '■END■'
29
+
30
+ Zai.prototype.rewrite = async function (this: Zai, original, prompt, _options) {
31
+ const options = Options.parse(_options ?? {})
32
+ const tokenizer = await this.getTokenizer()
33
+ await this.fetchModelDetails()
34
+
35
+ const taskId = this.taskId
36
+ const taskType = 'zai.rewrite'
37
+
38
+ const INPUT_COMPONENT_SIZE = Math.max(100, (this.ModelDetails.input.maxTokens - PROMPT_INPUT_BUFFER) / 2)
39
+ prompt = tokenizer.truncate(prompt, INPUT_COMPONENT_SIZE)
40
+
41
+ const inputSize = tokenizer.count(original) + tokenizer.count(prompt)
42
+ const maxInputSize = this.ModelDetails.input.maxTokens - tokenizer.count(prompt) - PROMPT_INPUT_BUFFER
43
+ if (inputSize > maxInputSize) {
44
+ throw new Error(
45
+ `The input size is ${inputSize} tokens long, which is more than the maximum of ${maxInputSize} tokens for this model (${this.ModelDetails.name} = ${this.ModelDetails.input.maxTokens} tokens)`
46
+ )
47
+ }
48
+
49
+ const instructions: string[] = []
50
+
51
+ const originalSize = tokenizer.count(original)
52
+ if (options.length && originalSize > options.length) {
53
+ instructions.push(`The original text is ${originalSize} tokens long – it should be less than ${options.length}`)
54
+ instructions.push(
55
+ `The text must be standalone and complete in less than ${options.length} tokens, so it has to be shortened to fit the length as well`
56
+ )
57
+ }
58
+
59
+ const format = (before: string, prompt: string) => {
60
+ return `
61
+ Prompt: ${prompt}
62
+
63
+ ${START}
64
+ ${before}
65
+ ${END}
66
+ `.trim()
67
+ }
68
+
69
+ const Key = fastHash(
70
+ stringify({
71
+ taskId,
72
+ taskType,
73
+ input: original,
74
+ prompt,
75
+ })
76
+ )
77
+
78
+ const formatExample = ({ input, output, instructions }: Example) => {
79
+ return [
80
+ { type: 'text' as const, role: 'user' as const, content: format(input, instructions || prompt) },
81
+ { type: 'text' as const, role: 'assistant' as const, content: `${START}${output}${END}` },
82
+ ]
83
+ }
84
+
85
+ const defaultExamples: Example[] = [
86
+ { input: 'Hello, how are you?', output: 'Bonjour, comment ça va?', instructions: 'translate to French' },
87
+ { input: '1\n2\n3', output: '3\n2\n1', instructions: 'reverse the order' },
88
+ ]
89
+
90
+ const tableExamples = taskId
91
+ ? await this.adapter.getExamples<string, string>({
92
+ input: original,
93
+ taskId,
94
+ taskType,
95
+ })
96
+ : []
97
+
98
+ const exactMatch = tableExamples.find((x) => x.key === Key)
99
+ if (exactMatch) {
100
+ return exactMatch.output
101
+ }
102
+
103
+ const savedExamples: Example[] = [
104
+ ...tableExamples.map((x) => ({ input: x.input as string, output: x.output as string })),
105
+ ...options.examples,
106
+ ]
107
+
108
+ const REMAINING_TOKENS = this.ModelDetails.input.maxTokens - tokenizer.count(prompt) - PROMPT_INPUT_BUFFER
109
+ const examples = takeUntilTokens(
110
+ savedExamples.length ? savedExamples : defaultExamples,
111
+ REMAINING_TOKENS,
112
+ (el) => tokenizer.count(stringify(el.input)) + tokenizer.count(stringify(el.output))
113
+ )
114
+ .map(formatExample)
115
+ .flat()
116
+
117
+ const { output, meta } = await this.callModel({
118
+ systemPrompt: `
119
+ Rewrite the text between the ${START} and ${END} tags to match the user prompt.
120
+ ${instructions.map((x) => `• ${x}`).join('\n')}
121
+ `.trim(),
122
+ messages: [...examples, { type: 'text', content: format(original, prompt), role: 'user' }],
123
+ maxTokens: options.length,
124
+ stopSequences: [END],
125
+ })
126
+
127
+ let result = output.choices[0]?.content as string
128
+
129
+ if (result.includes(START)) {
130
+ result = result.slice(result.indexOf(START) + START.length)
131
+ }
132
+
133
+ if (result.includes(END)) {
134
+ result = result.slice(0, result.indexOf(END))
135
+ }
136
+
137
+ if (taskId) {
138
+ await this.adapter.saveExample({
139
+ key: Key,
140
+ metadata: {
141
+ cost: {
142
+ input: meta.cost.input,
143
+ output: meta.cost.output,
144
+ },
145
+ latency: meta.latency,
146
+ model: this.Model,
147
+ tokens: {
148
+ input: meta.tokens.input,
149
+ output: meta.tokens.output,
150
+ },
151
+ },
152
+ instructions: prompt,
153
+ input: original,
154
+ output: result,
155
+ taskType,
156
+ taskId,
157
+ })
158
+ }
159
+
160
+ return result
161
+ }
@@ -0,0 +1,195 @@
1
+ // eslint-disable consistent-type-definitions
2
+ import { z } from '@bpinternal/zui'
3
+
4
+ import { chunk } from 'lodash-es'
5
+ import { Zai } from '../zai'
6
+ import { PROMPT_INPUT_BUFFER, PROMPT_OUTPUT_BUFFER } from './constants'
7
+
8
+ export type Options = z.input<typeof Options>
9
+ const Options = z.object({
10
+ prompt: z
11
+ .string()
12
+ .describe('What should the text be summarized to?')
13
+ .default('New information, concepts and ideas that are deemed important'),
14
+ format: z
15
+ .string()
16
+ .describe('How to format the example text')
17
+ .default(
18
+ 'A normal text with multiple sentences and paragraphs. Use markdown to format the text into sections. Use headings, lists, and other markdown features to make the text more readable. Do not include links, images, or other non-text elements.'
19
+ ),
20
+ length: z.number().min(10).max(100_000).describe('The length of the summary in tokens').default(250),
21
+ intermediateFactor: z
22
+ .number()
23
+ .min(1)
24
+ .max(10)
25
+ .describe('How many times longer (than final length) are the intermediate summaries generated')
26
+ .default(4),
27
+ maxIterations: z.number().min(1).default(100),
28
+ sliding: z
29
+ .object({
30
+ window: z.number().min(10).max(100_000),
31
+ overlap: z.number().min(0).max(100_000),
32
+ })
33
+ .describe('Sliding window options')
34
+ .default({ window: 50_000, overlap: 250 }),
35
+ })
36
+
37
+ declare module '@botpress/zai' {
38
+ interface Zai {
39
+ /** Summarizes a text of any length to a summary of the desired length */
40
+ summarize(original: string, options?: Options): Promise<string>
41
+ }
42
+ }
43
+
44
+ const START = '■START■'
45
+ const END = '■END■'
46
+
47
+ Zai.prototype.summarize = async function (this: Zai, original, _options) {
48
+ const options = Options.parse(_options ?? {})
49
+ const tokenizer = await this.getTokenizer()
50
+ await this.fetchModelDetails()
51
+
52
+ const INPUT_COMPONENT_SIZE = Math.max(100, (this.ModelDetails.input.maxTokens - PROMPT_INPUT_BUFFER) / 4)
53
+ options.prompt = tokenizer.truncate(options.prompt, INPUT_COMPONENT_SIZE)
54
+ options.format = tokenizer.truncate(options.format, INPUT_COMPONENT_SIZE)
55
+
56
+ const maxOutputSize = this.ModelDetails.output.maxTokens - PROMPT_OUTPUT_BUFFER
57
+ if (options.length > maxOutputSize) {
58
+ throw new Error(
59
+ `The desired output length is ${maxOutputSize} tokens long, which is more than the maximum of ${this.ModelDetails.output.maxTokens} tokens for this model (${this.ModelDetails.name})`
60
+ )
61
+ }
62
+
63
+ // Ensure the sliding window is not bigger than the model input size
64
+ options.sliding.window = Math.min(options.sliding.window, this.ModelDetails.input.maxTokens - PROMPT_INPUT_BUFFER)
65
+
66
+ // Ensure the overlap is not bigger than the window
67
+ // Most extreme case possible (all 3 same size)
68
+ // |ooooooooooooooooooo|wwwwwwwwwwwwwww|ooooooooooooooooooo|
69
+ // |<---- overlap ---->|<-- window -->|<---- overlap ---->|
70
+ options.sliding.overlap = Math.min(options.sliding.overlap, options.sliding.window - 3 * options.sliding.overlap)
71
+
72
+ const format = (summary: string, newText: string) => {
73
+ return `
74
+ ${START}
75
+ ${summary.length ? summary : '<summary still empty>'}
76
+ ${END}
77
+
78
+ Please amend the summary between the ${START} and ${END} tags to accurately reflect the prompt and the additional text below.
79
+
80
+ <|start_new_information|>
81
+ ${newText}
82
+ <|new_information|>`.trim()
83
+ }
84
+
85
+ const tokens = tokenizer.split(original)
86
+ const parts = Math.ceil(tokens.length / (options.sliding.window - options.sliding.overlap))
87
+ let iteration = 0
88
+
89
+ // We split it recursively into smaller parts until we're at less than 4 window slides per part
90
+ // Then we use a merge strategy to combine the sub-chunks summaries
91
+ // This is basically a merge sort algorithm (but summary instead of sorting)
92
+ const N = 2 // This is the merge sort exponent
93
+ const useMergeSort = parts >= Math.pow(2, N)
94
+ const chunkSize = Math.ceil(tokens.length / (parts * N))
95
+
96
+ if (useMergeSort) {
97
+ const chunks = chunk(tokens, chunkSize).map((x) => x.join(''))
98
+ const allSummaries = await Promise.all(chunks.map((chunk) => this.summarize(chunk, options)))
99
+ return this.summarize(allSummaries.join('\n\n============\n\n'), options)
100
+ }
101
+
102
+ const summaries: string[] = []
103
+ let currentSummary = ''
104
+
105
+ for (let i = 0; i < tokens.length; i += options.sliding.window) {
106
+ const from = Math.max(0, i - options.sliding.overlap)
107
+ const to = Math.min(tokens.length, i + options.sliding.window + options.sliding.overlap)
108
+ const isFirst = i === 0
109
+ const isLast = to >= tokens.length
110
+
111
+ const slice = tokens.slice(from, to).join('')
112
+
113
+ if (iteration++ >= options.maxIterations) {
114
+ break
115
+ }
116
+
117
+ const instructions: string[] = [
118
+ `At each step, you will receive a part of the text to summarize. Make sure to reply with the new summary in the tags ${START} and ${END}.`,
119
+ 'Summarize the text and make sure that the main points are included.',
120
+ 'Ignore any unnecessary details and focus on the main points.',
121
+ 'Use short and concise sentences to increase readability and information density.',
122
+ 'When looking at the new information, focus on: ' + options.prompt,
123
+ ]
124
+
125
+ if (isFirst) {
126
+ instructions.push(
127
+ 'The current summary is empty. You need to generate a summary that covers the main points of the text.'
128
+ )
129
+ }
130
+
131
+ let generationLength = options.length
132
+
133
+ if (!isLast) {
134
+ generationLength = Math.min(
135
+ tokenizer.count(currentSummary) + options.length * options.intermediateFactor,
136
+ maxOutputSize
137
+ )
138
+
139
+ instructions.push(
140
+ 'You need to amend the summary to include the new information. Make sure the summary is complete and covers all the main points.'
141
+ )
142
+
143
+ instructions.push(`The current summary is ${currentSummary.length} tokens long.`)
144
+ instructions.push(`You can amend the summary to be up to ${generationLength} tokens long.`)
145
+ }
146
+
147
+ if (isLast) {
148
+ instructions.push(
149
+ 'This is the last part you will have to summarize. Make sure the summary is complete and covers all the main points.'
150
+ )
151
+ instructions.push(
152
+ `The current summary is ${currentSummary.length} tokens long. You need to make sure it is ${options.length} tokens or less.`
153
+ )
154
+
155
+ if (currentSummary.length > options.length) {
156
+ instructions.push(
157
+ `The current summary is already too long, so you need to shorten it to ${options.length} tokens while also including the new information.`
158
+ )
159
+ }
160
+ }
161
+
162
+ const { output } = await this.callModel({
163
+ systemPrompt: `
164
+ You are summarizing a text. The text is split into ${parts} parts, and you are currently working on part ${iteration}.
165
+ At every step, you will receive the current summary and a new part of the text. You need to amend the summary to include the new information (if needed).
166
+ The summary needs to cover the main points of the text and must be concise.
167
+
168
+ IMPORTANT INSTRUCTIONS:
169
+ ${instructions.map((x) => `- ${x.trim()}`).join('\n')}
170
+
171
+ FORMAT OF THE SUMMARY:
172
+ ${options.format}
173
+ `.trim(),
174
+ messages: [{ type: 'text', content: format(currentSummary, slice), role: 'user' }],
175
+ maxTokens: generationLength,
176
+ stopSequences: [END],
177
+ })
178
+
179
+ let result = output?.choices[0]?.content as string
180
+
181
+ if (result.includes(START)) {
182
+ result = result.slice(result.indexOf(START) + START.length)
183
+ }
184
+
185
+ if (result.includes('■')) {
186
+ // can happen if the model truncates the text before the entire END tag is written
187
+ result = result.slice(0, result.indexOf('■'))
188
+ }
189
+
190
+ summaries.push(result)
191
+ currentSummary = result
192
+ }
193
+
194
+ return currentSummary.trim()
195
+ }