@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,51 @@
1
+ import json5 from 'json5'
2
+ import { expect } from 'vitest'
3
+ import { getCurrentTest } from 'vitest/suite'
4
+
5
+ import { asyncExpect } from '../utils/asyncAssertion'
6
+ import { Output } from '../utils/predictJson'
7
+
8
+ export type ExtendedPromise<T> = PromiseLike<Output<T>> & {
9
+ value: PromiseLike<T>
10
+ }
11
+
12
+ export const toAssertion = <T>(promise: Promise<Output<T>>): ExtendedPromise<T> => {
13
+ return {
14
+ then: promise.then.bind(promise),
15
+ value: promise.then((value) => value.result)
16
+ }
17
+ }
18
+
19
+ export const makeToMatchInlineSnapshot =
20
+ <T>(promise: Promise<Output<T>>) =>
21
+ async (expected?: string) => {
22
+ const stack = new Error().stack!.split('\n')[2]
23
+ const newStack = `
24
+ at __INLINE_SNAPSHOT__ (node:internal/process/task_queues:1:1)
25
+ at randomLine (node:internal/process/task_queues:1:1)
26
+ ${stack}
27
+ `.trim()
28
+
29
+ const obj = json5.parse(expected ?? '""')
30
+ const expectation = asyncExpect(promise, (expect) => expect.toMatchObject(obj)).catch(() => {
31
+ // we swallow the error here, as we're going to throw a new one with the correct stack
32
+ // this is just to make vitest happy and show a nice error message
33
+ })
34
+
35
+ try {
36
+ expect((await promise).result).toMatchObject(obj)
37
+ } catch (err) {
38
+ const newError = new Error()
39
+ newError.stack = newStack
40
+
41
+ expect.getState().snapshotState.match({
42
+ isInline: true,
43
+ received: (await promise).result,
44
+ testName: getCurrentTest()!.name,
45
+ error: newError,
46
+ inlineSnapshot: expected
47
+ })
48
+ }
49
+
50
+ return expectation
51
+ }
@@ -0,0 +1,39 @@
1
+ import { z } from '@botpress/sdk'
2
+
3
+ import { Context } from '../context'
4
+ import { asyncExpect } from '../utils/asyncAssertion'
5
+ import { Input, predictJson } from '../utils/predictJson'
6
+ import { makeToMatchInlineSnapshot, toAssertion } from './extension'
7
+
8
+ export type ExtractOptions<T, S> = {
9
+ description?: string
10
+ examples?: { value: T; extracted: S; reason: string }[]
11
+ }
12
+
13
+ export function extract<T extends Input, S extends z.AnyZodObject>(
14
+ value: T,
15
+ shape: S,
16
+ options?: ExtractOptions<T, z.infer<S>>
17
+ ) {
18
+ const additionalMessage = options?.description
19
+ ? `\nIn order to extract the right information, follow these instructions:\n${options.description}\n`
20
+ : ''
21
+ const promise = predictJson({
22
+ systemMessage:
23
+ 'From the given input, extract the required information into the requested format.' + additionalMessage.trim(),
24
+ examples: options?.examples?.map(({ value, reason, extracted }) => ({
25
+ input: value,
26
+ output: { reason, result: extracted }
27
+ })),
28
+ outputSchema: shape,
29
+ model: Context.evaluatorModel,
30
+ input: value
31
+ })
32
+
33
+ return {
34
+ ...toAssertion(promise),
35
+ toBe: (expected: z.infer<S>) => asyncExpect(promise, (expect) => expect.toEqual(expected)),
36
+ toMatchObject: (expected: Partial<z.infer<S>>) => asyncExpect(promise, (expect) => expect.toMatchObject(expected)),
37
+ toMatchInlineSnapshot: makeToMatchInlineSnapshot(promise)
38
+ }
39
+ }
@@ -0,0 +1,86 @@
1
+ import { literal, z } from '@botpress/sdk'
2
+
3
+ import { Context } from '../context'
4
+ import { asyncExpect } from '../utils/asyncAssertion'
5
+ import { predictJson } from '../utils/predictJson'
6
+ import { makeToMatchInlineSnapshot, toAssertion } from './extension'
7
+
8
+ export type FilterOptions<T> = {
9
+ examples?: { value: T; reason: string; keep: boolean }[]
10
+ }
11
+
12
+ export function filter<U>(values: U[], condition: string, options?: FilterOptions<U>) {
13
+ const mappedValues = values.map((_, idx) =>
14
+ z.object({
15
+ index: literal(idx),
16
+ reason: z.string(),
17
+ keep: z.boolean()
18
+ })
19
+ )
20
+
21
+ const input = values.map((value, idx) => ({
22
+ index: idx,
23
+ value
24
+ }))
25
+
26
+ const schema = z
27
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
28
+ .tuple(mappedValues as any)
29
+ .describe(
30
+ 'An array of the objects with the index and a boolean value indicating if the object should be kept or not'
31
+ )
32
+
33
+ const promise = predictJson({
34
+ systemMessage: `
35
+ Based on the following qualification criteria, you need to filter the given list of objects.
36
+ Citeria: ${condition}
37
+
38
+ ---
39
+ You need to return an array of objects with the index and a boolean value indicating if the object should be kept or not.
40
+ `.trim(),
41
+ examples: options?.examples
42
+ ? [
43
+ {
44
+ input: options?.examples?.map((v, index) => ({
45
+ index,
46
+ value: v.value
47
+ })),
48
+ output: {
49
+ reason: 'Here are some examples',
50
+ result: options?.examples?.map((v, idx) => ({
51
+ index: idx,
52
+ reason: v.reason,
53
+ keep: v.keep
54
+ }))
55
+ }
56
+ }
57
+ ]
58
+ : undefined,
59
+ outputSchema: schema,
60
+ model: Context.evaluatorModel,
61
+ input
62
+ }).then((x) => {
63
+ const results = schema.parse(x.result) as { index: number; keep: boolean }[]
64
+ return {
65
+ result: values.filter((_, idx) => results.find((r) => r.index === idx)?.keep),
66
+ reason: x.reason
67
+ }
68
+ })
69
+
70
+ return {
71
+ ...toAssertion(promise),
72
+ toBe: (expected: U[]) => asyncExpect(promise, (expect) => expect.toEqual(expected)),
73
+ toMatchInlineSnapshot: makeToMatchInlineSnapshot(promise),
74
+ toHaveNoneFiltered: () => asyncExpect(promise, (expect) => expect.toEqual(values)),
75
+ toHaveSomeFiltered: () => asyncExpect(promise, (expect) => expect.not.toEqual(values)),
76
+ toBeEmpty: () => asyncExpect(promise, (expect) => expect.toHaveLength(0)),
77
+ length: {
78
+ toBe: (expected: number) => asyncExpect(promise, (expect) => expect.toHaveLength(expected)),
79
+ toBeGreaterThanOrEqual: (expected: number) =>
80
+ asyncExpect(promise, (expect) => expect.length.greaterThanOrEqual(expected)),
81
+ toBeLessThanOrEqual: (expected: number) =>
82
+ asyncExpect(promise, (expect) => expect.length.lessThanOrEqual(expected)),
83
+ toBeBetween: (min: number, max: number) => asyncExpect(promise, (expect) => expect.length.within(min, max))
84
+ }
85
+ }
86
+ }
@@ -0,0 +1,40 @@
1
+ import { z } from '@botpress/sdk'
2
+
3
+ import { Context } from '../context'
4
+ import { asyncExpect } from '../utils/asyncAssertion'
5
+ import { Input, predictJson } from '../utils/predictJson'
6
+ import { makeToMatchInlineSnapshot, toAssertion } from './extension'
7
+
8
+ export type RatingScore = 1 | 2 | 3 | 4 | 5
9
+ export type RateOptions<T> = {
10
+ examples?: { value: T; rating: number; reason: string }[]
11
+ }
12
+
13
+ export function rate<T extends Input>(value: T, condition: string, options?: RateOptions<T>) {
14
+ const schema = z.number().min(1).max(5).describe('Rating score, higher is better (1 is the worst, 5 is the best)')
15
+ const promise = predictJson({
16
+ systemMessage: `Based on the following qualification criteria, you need to rate the given situation from a score of 1 to 5.\nScoring: 1 is the worst score, 5 is the best score possible.\nCriteria: ${condition}`,
17
+ examples: options?.examples?.map(({ value, reason, rating }) => ({
18
+ input: value,
19
+ output: { reason, result: rating }
20
+ })),
21
+ outputSchema: schema,
22
+ model: Context.evaluatorModel,
23
+ input: value
24
+ }).then((x) => {
25
+ return {
26
+ result: typeof x.result === 'number' ? x.result : parseInt(x.result, 10),
27
+ reason: x.reason
28
+ }
29
+ })
30
+
31
+ return {
32
+ ...toAssertion(promise),
33
+ toBe: (expected: number) => asyncExpect(promise, (expect) => expect.toEqual(expected)),
34
+ toMatchInlineSnapshot: makeToMatchInlineSnapshot(promise),
35
+ toBeGreaterThanOrEqual: (expected: RatingScore) =>
36
+ asyncExpect(promise, (expect) => expect.toBeGreaterThanOrEqual(expected)),
37
+ toBeLessThanOrEqual: (expected: RatingScore) =>
38
+ asyncExpect(promise, (expect) => expect.toBeLessThanOrEqual(expected))
39
+ }
40
+ }
package/src/context.ts ADDED
@@ -0,0 +1,65 @@
1
+ import type { Client } from '@botpress/client'
2
+ import { onTestFinished } from 'vitest'
3
+ import { getCurrentTest } from 'vitest/suite'
4
+ import { Models } from './models'
5
+
6
+ export type EvaluatorModel = (typeof Models)[number]['id']
7
+
8
+ export type TestMetadata = {
9
+ isVaiTest: boolean
10
+ scenario?: string
11
+ evaluatorModel?: EvaluatorModel
12
+ }
13
+
14
+ const getTestMetadata = (): TestMetadata => {
15
+ const test = getCurrentTest()
16
+ return (test?.meta ?? {
17
+ isVaiTest: false
18
+ }) as TestMetadata
19
+ }
20
+
21
+ class VaiContext {
22
+ #client: Client | null = null
23
+ #wrapError = false
24
+
25
+ get wrapError() {
26
+ return this.#wrapError
27
+ }
28
+
29
+ get client() {
30
+ if (!this.#client) {
31
+ throw new Error('Botpress client is not set')
32
+ }
33
+
34
+ return this.#client
35
+ }
36
+
37
+ get evaluatorModel(): EvaluatorModel {
38
+ return getTestMetadata().evaluatorModel ?? 'openai__gpt-4o-mini-2024-07-18'
39
+ }
40
+
41
+ get scenario() {
42
+ return getTestMetadata().scenario
43
+ }
44
+
45
+ get isVaiTest() {
46
+ return getTestMetadata().isVaiTest
47
+ }
48
+
49
+ setClient(cognitive: Client) {
50
+ this.#client = cognitive
51
+ }
52
+
53
+ swallowErrors() {
54
+ if (!getCurrentTest()) {
55
+ throw new Error('cancelBail is a Vitest hook and must be called within a test')
56
+ }
57
+
58
+ this.#wrapError = true
59
+ onTestFinished(() => {
60
+ this.#wrapError = false
61
+ })
62
+ }
63
+ }
64
+
65
+ export const Context = new VaiContext()
@@ -0,0 +1,13 @@
1
+ import { getCurrentTest } from 'vitest/suite'
2
+ import { EvaluatorModel, TestMetadata } from '../context'
3
+
4
+ export const setEvaluator = (model: EvaluatorModel) => {
5
+ const test = getCurrentTest()
6
+
7
+ if (!test) {
8
+ throw new Error('setEvaluator is a Vitest hook and must be called within a test')
9
+ }
10
+
11
+ const meta = test.meta as TestMetadata
12
+ meta.evaluatorModel = model
13
+ }
@@ -0,0 +1,6 @@
1
+ import { Client } from '@botpress/client'
2
+ import { Context } from '../context'
3
+
4
+ export const setupClient = (client: Client) => {
5
+ Context.setClient(client)
6
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ export { compare } from './task/compare'
2
+
3
+ export { check } from './assertions/check'
4
+ export { extract } from './assertions/extract'
5
+ export { filter } from './assertions/filter'
6
+ export { rate } from './assertions/rate'
7
+
8
+ export { setEvaluator } from './hooks/setEvaluator'
9
+ export { setupClient } from './hooks/setupClient'