@botpress/vai 0.0.1-beta.1

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.
@@ -0,0 +1,72 @@
1
+ import { z } from '@botpress/sdk'
2
+ import { TestFunction } from 'vitest'
3
+ import { createTaskCollector, getCurrentSuite } from 'vitest/suite'
4
+ import { TestMetadata } from '../context'
5
+ import { Deferred } from '../utils/deferred'
6
+
7
+ const scenarioId = z
8
+ .string()
9
+ .trim()
10
+ .min(1, 'Scenario ID/name must not be empty')
11
+ .max(50, 'Scenario ID/name is too long')
12
+
13
+ export type ScenarioLike = z.infer<typeof ScenarioLike>
14
+ const ScenarioLike = z.union([
15
+ scenarioId,
16
+ z.object({ name: scenarioId }).passthrough(),
17
+ z.object({ id: scenarioId }).passthrough()
18
+ ])
19
+
20
+ const getScenarioName = (scenario: ScenarioLike) =>
21
+ (typeof scenario === 'string' ? scenario : 'name' in scenario ? scenario?.name : scenario?.id) as string
22
+
23
+ const scenarioArgs = z
24
+ .array(ScenarioLike)
25
+ .min(2, 'You need at least two scenarios to compare')
26
+ .max(10, 'You can only compare up to 10 scenarios')
27
+ .refine((scenarios) => {
28
+ const set = new Set<string>()
29
+ scenarios.forEach((scenario) => set.add(getScenarioName(scenario)))
30
+ return set.size === scenarios.length
31
+ }, 'Scenarios names must be unique')
32
+
33
+ export function compare<T extends ReadonlyArray<ScenarioLike>>(
34
+ name: string | Function,
35
+ scenarios: T,
36
+ fn?: TestFunction<{
37
+ scenario: T[number]
38
+ }>
39
+ ) {
40
+ scenarios = scenarioArgs.parse(scenarios) as unknown as T
41
+
42
+ return createTaskCollector((_name, fn, timeout) => {
43
+ const currentSuite = getCurrentSuite()
44
+
45
+ let completedCount = 0
46
+ const finished = new Deferred<void>()
47
+
48
+ for (const scenario of scenarios) {
49
+ const key = getScenarioName(scenario)
50
+
51
+ currentSuite.task(key, {
52
+ meta: {
53
+ scenario: key,
54
+ isVaiTest: true
55
+ } satisfies TestMetadata,
56
+ handler: async (context) => {
57
+ const extendedContext = Object.freeze({
58
+ scenario
59
+ })
60
+ context.onTestFinished(() => {
61
+ if (++completedCount === scenarios.length) {
62
+ finished.resolve()
63
+ }
64
+ })
65
+
66
+ await fn({ ...context, ...extendedContext })
67
+ },
68
+ timeout: timeout ?? 10_000
69
+ })
70
+ }
71
+ })(name, fn)
72
+ }
@@ -0,0 +1,40 @@
1
+ import { Assertion, expect } from 'vitest'
2
+ import { getCurrentTest } from 'vitest/suite'
3
+ import { Context } from '../context'
4
+ import { Output } from './predictJson'
5
+
6
+ export class AsyncExpectError<T> extends Error {
7
+ constructor(message: string, public readonly output: Output<T>) {
8
+ super(message)
9
+ this.name = 'AsyncExpectError'
10
+ }
11
+ }
12
+
13
+ const getErrorMessages = (e: unknown): string => {
14
+ if (e instanceof Error) {
15
+ return e.message
16
+ } else if (typeof e === 'string') {
17
+ return e
18
+ } else if (typeof e === 'object' && e !== null) {
19
+ return JSON.stringify(e)
20
+ }
21
+
22
+ return `Unknown error: ${e}`
23
+ }
24
+
25
+ export const asyncExpect = <T>(output: Promise<Output<T>>, assertion: (assert: Assertion<T>) => void) => {
26
+ const promise = output.then((x) => {
27
+ try {
28
+ assertion(expect(x.result, x.reason))
29
+ } catch (e: unknown) {
30
+ if (Context.wrapError) {
31
+ return new AsyncExpectError<T>(getErrorMessages(e), x)
32
+ }
33
+ throw e
34
+ }
35
+ return x
36
+ })
37
+ getCurrentTest()!.promises ??= []
38
+ getCurrentTest()!.promises!.push(promise)
39
+ return promise
40
+ }
@@ -0,0 +1,20 @@
1
+ export class Deferred<T> {
2
+ promise: Promise<T>
3
+ private _resolve!: (value: T | PromiseLike<T>) => void
4
+ private _reject!: (reason?: unknown) => void
5
+
6
+ constructor() {
7
+ this.promise = new Promise<T>((resolve, reject) => {
8
+ this._resolve = resolve
9
+ this._reject = reject
10
+ })
11
+ }
12
+
13
+ resolve(value: T | PromiseLike<T>): void {
14
+ this._resolve(value)
15
+ }
16
+
17
+ reject(reason?: unknown): void {
18
+ this._reject(reason)
19
+ }
20
+ }
@@ -0,0 +1,114 @@
1
+ import { z, ZodSchema } from '@botpress/sdk'
2
+ import JSON5 from 'json5'
3
+ import { Context } from '../context'
4
+ import { llm } from '../sdk-interfaces/llm/generateContent'
5
+
6
+ const nonEmptyString = z.string().trim().min(1)
7
+ const nonEmptyObject = z
8
+ .object({})
9
+ .passthrough()
10
+ .refine((value) => Object.keys(value).length > 0, {
11
+ message: 'Expected a non-empty object'
12
+ })
13
+
14
+ export type Input = z.infer<typeof Input>
15
+ const Input = nonEmptyString.or(nonEmptyObject).or(z.array(z.any()))
16
+
17
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
18
+ export type Output<T = any> = z.infer<typeof Output> & { result: T }
19
+ const Output = z.object({
20
+ reason: nonEmptyString.describe('A human-readable explanation of the result'),
21
+ result: z
22
+ .any()
23
+ .describe(
24
+ 'Your best guess at the output according to the instructions provided, rooted in the context of the input and the reason above'
25
+ )
26
+ })
27
+
28
+ type Example = z.infer<typeof Example>
29
+ const Example = z.object({
30
+ input: Input,
31
+ output: Output
32
+ })
33
+
34
+ type InputOptions<T extends ZodSchema = ZodSchema> = z.input<typeof Options> & { outputSchema: T }
35
+ type Options = z.infer<typeof Options>
36
+ const Options = z.object({
37
+ systemMessage: z.string(),
38
+ examples: z.array(Example).default([]),
39
+ input: Input,
40
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
41
+ outputSchema: z.custom<ZodSchema<any>>((value) => value instanceof ZodSchema),
42
+ model: z.string()
43
+ })
44
+
45
+ type Message = {
46
+ role: 'user' | 'assistant' | 'system'
47
+ content: string
48
+ }
49
+
50
+ const isValidExample =
51
+ (outputSchema: ZodSchema) =>
52
+ (example: Example): example is Example =>
53
+ Input.safeParse(example.input).success &&
54
+ Output.safeParse(example.output).success &&
55
+ outputSchema.safeParse(example.output.result).success
56
+
57
+ export async function predictJson<T extends ZodSchema>(_options: InputOptions<T>): Promise<Output<z.infer<T>>> {
58
+ const options = Options.parse(_options)
59
+ const [integration, model] = options.model.split('__')
60
+
61
+ if (!model?.length) {
62
+ throw new Error('Invalid model')
63
+ }
64
+
65
+ const exampleMessages = options.examples
66
+ .filter(isValidExample(options.outputSchema))
67
+ .flatMap(({ input, output }) => [
68
+ { role: 'user', content: JSON.stringify(input, null, 2) } satisfies Message,
69
+ { role: 'assistant', content: JSON.stringify(output, null, 2) } satisfies Message
70
+ ])
71
+
72
+ const outputSchema = Output.extend({
73
+ result: options.outputSchema.describe(Output.shape.result.description!)
74
+ })
75
+
76
+ const result = await Context.client.callAction({
77
+ type: `${integration}:generateContent`,
78
+ input: {
79
+ systemPrompt: `
80
+ ${options.systemMessage}
81
+
82
+ ---
83
+ Please generate a JSON response with the following format:
84
+ \`\`\`typescript
85
+ ${await outputSchema.toTypescriptAsync()}
86
+ \`\`\`
87
+ `.trim(),
88
+ messages: [
89
+ ...exampleMessages,
90
+ {
91
+ role: 'user',
92
+ content: JSON.stringify(options.input, null, 2)
93
+ }
94
+ ],
95
+ temperature: 0,
96
+ responseFormat: 'json_object',
97
+ model: { id: model! }
98
+ } satisfies llm.generateContent.Input
99
+ })
100
+
101
+ const output = result.output as llm.generateContent.Output
102
+
103
+ if (!output.choices.length || typeof output.choices?.[0]?.content !== 'string') {
104
+ throw new Error('Invalid response from the model')
105
+ }
106
+
107
+ const json = output.choices[0].content.trim()
108
+
109
+ if (!json.length) {
110
+ throw new Error('No response from the model')
111
+ }
112
+
113
+ return outputSchema.parse(JSON5.parse(json)) as Output<z.infer<T>>
114
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "compilerOptions": {
3
+ "esModuleInterop": true,
4
+ "module": "ESNext",
5
+ "moduleResolution": "Node",
6
+ "target": "ES2017",
7
+ "strict": true,
8
+ "jsx": "preserve",
9
+ "noUnusedLocals": true,
10
+ "noUnusedParameters": true,
11
+ "noUncheckedIndexedAccess": true,
12
+ "allowSyntheticDefaultImports": true,
13
+ "lib": ["dom", "ESNext", "dom.iterable"],
14
+ "allowJs": false,
15
+ "declaration": false,
16
+ "skipLibCheck": true,
17
+ "forceConsistentCasingInFileNames": true,
18
+ "noEmit": false,
19
+ "resolveJsonModule": true,
20
+ "isolatedModules": true
21
+ },
22
+ "exclude": ["node_modules", "dist"],
23
+ "include": ["src/**/*", "vitest.d.ts"],
24
+ "ts-node": {
25
+ "esm": true,
26
+ "require": ["dotenv/config", "./ensure-env.cjs"]
27
+ }
28
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { defineConfig } from 'tsup'
2
+
3
+ export default defineConfig({
4
+ entry: ['src/index.ts'],
5
+ splitting: false,
6
+ format: ['esm', 'cjs'],
7
+ sourcemap: true,
8
+ keepNames: true,
9
+ dts: true,
10
+ platform: 'browser',
11
+ clean: true,
12
+ shims: true,
13
+ external: ['react', 'react-dom'],
14
+ bundle: true,
15
+ plugins: [],
16
+ })
@@ -0,0 +1,9 @@
1
+ import 'dotenv/config'
2
+ import { defineConfig } from 'vitest/config'
3
+
4
+ export default defineConfig({
5
+ test: {
6
+ include: ['./src/**/*.test.ts'],
7
+ setupFiles: './vitest.setup.ts'
8
+ }
9
+ })
@@ -0,0 +1,13 @@
1
+ import { beforeAll } from 'vitest'
2
+ import { setupClient } from './src/hooks/setupClient'
3
+ import { Client } from '@botpress/client'
4
+
5
+ beforeAll(async () => {
6
+ setupClient(
7
+ new Client({
8
+ apiUrl: process.env.CLOUD_API_ENDPOINT ?? 'https://api.botpress.dev',
9
+ botId: process.env.CLOUD_BOT_ID,
10
+ token: process.env.CLOUD_PAT
11
+ })
12
+ )
13
+ })