Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ ENCRYPTION_KEY="ZGV2LW9ubHktZW5jcnlwdGlvbi1rZXktMzItYnl0ZXM="
# PLAID_SECRET=""
# PLAID_ENV="sandbox"

# An OpenRouter key, for the model the home page picks and fills in its widgets with. Without it the
# home page says it is not set up.
# The home page's widgets: a TypeSafe AI key for Jev, which picks the widget, and an OpenRouter key
# for the model that fills it in. Without both the home page says it is not set up.
# TYPESAFE_API_KEY=""
# OPENROUTER_KEY=""

# Who can create an account: addresses and domains, comma-separated. Unset, anyone can.
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ services:
PLAID_ENV: ${PLAID_ENV:-}
RESEND_KEY: ${RESEND_KEY:-}
EMAIL_FROM: ${EMAIL_FROM:-}
TYPESAFE_API_KEY: ${TYPESAFE_API_KEY:-}
OPENROUTER_KEY: ${OPENROUTER_KEY:-}
TELEMETRY_ENABLED: ${TELEMETRY_ENABLED:-}
logging: *logging
Expand Down
293 changes: 148 additions & 145 deletions packages/api/src/intent/classify.test.ts

Large diffs are not rendered by default.

173 changes: 109 additions & 64 deletions packages/api/src/intent/classify.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,31 @@
import {Err, Ok, Result} from 'ts-results-es';
import {z} from 'zod';

import {ApiErr, type ApiError, type Experiment, httpRequest, parseSchema} from '@proxy/utils';
import {ApiErr, type ApiError, Do, type Experiment, httpRequest, parseSchema} from '@proxy/utils';

import {env} from '../utils/env';
import {INCOMPLETE, NO_WIDGET, offeredWidgets, type Widget, type WidgetField} from './widgets';

/**
* What a person typed into the home page's input, in one request to gpt-oss-120b over OpenRouter,
* on Cerebras, the fastest host, else Groq: the widget that fits, and its fields filled in from the
* text, in about 150 ms. Picked over Jev and GLiNER, one after the other, by trying them on typed
* requests: as fast, and every field right on nine in ten where GLiNER managed half, since it
* found no title it wasn't told of, no subject and no file's name. The answer is held to a JSON
* schema: the widget's id, and every widget's fields, the others left empty. The widgets offered
* are the organization's: a widget behind an experiment it doesn't have is in neither the
* instructions nor the schema.
* What a person typed into the home page's input, in two steps, each a request of its own so the
* home page shows the widget as soon as it is picked and fills it in when its fields come. First
* Jev, TypeSafe AI's classifier, picks the widget that fits: one choice among the widgets offered,
* `incomplete` and `none`. Then gpt-oss-120b, over OpenRouter on Cerebras, the fastest host, else
* Groq, fills in that widget's fields from the text, held to a JSON schema of them alone. It fills
* fields better than GLiNER did, every one right on nine in ten typed requests where GLiNER managed
* half, since GLiNER found no title it wasn't told of, no subject and no file's name. The widgets
* offered are the organization's: a widget behind an experiment it doesn't have is not among Jev's
* choices, nor filled in.
*/

const TYPESAFE_URL = 'https://api.typesafe.ai/v1/systemone';

// Pinned, so a new release of Jev doesn't change which widget is picked until it is moved to.
const JEV_MODEL = 'jev-1.13.0';

const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';

const MODEL = 'openai/gpt-oss-120b';
const FILL_MODEL = 'openai/gpt-oss-120b';

// While typing goes on, a newer text is asked about every 300 ms, so a slower answer is never shown.
const TIMEOUT_MS = 5000;
Expand All @@ -29,22 +35,42 @@ const MAX_TOKENS = 2000;
/** A field of the picked widget and what fills it: one value, or each of a list's. */
export type FilledField = {id: string; name: string; values: string[]};

export type Intent = {
/** The widget Jev picked. */
export type Picked = {
/** The widget's id, `none` when what was asked is whole and none does it, or `incomplete` when too little was typed to tell yet. */
widget: string;
/** The widget's fields, each with what fills it; none for `none` and `incomplete`. */
/** How long Jev took. */
ms: number;
};

/** The picked widget's fields, filled in. */
export type Filled = {
fields: FilledField[];
/** How long the model took. */
ms: number;
};

function instructions(widgets: Widget[]): string {
// Jev's question: which of the widgets, `incomplete` or `none`, each described for it to tell apart.
function widgetQuestion(widgets: Widget[]) {
return {
type: 'choice',
instructions: 'Which quick action is the person asking for in what they typed into a command box?',
criteria: {
...Object.fromEntries(widgets.map((widget) => [widget.id, widget.description])),
[INCOMPLETE]:
'The text is cut off or says too little to tell what is wanted yet, as it is while still being typed ("find the", "email", "what\'s"); never a whole sentence or question, which ends in a question mark or a full stop',
[NO_WIDGET]: 'A whole request that none of the other actions does ("summarize my week", "what\'s the weather tomorrow")',
},
};
}

const pickSchema = z.object({answers: z.object({widget: z.object({choice: z.string()})})});

function instructions(widget: Widget): string {
return [
'Pick the widget for what the person typed into a command box, and fill in its fields from the text. Copy values from the text, but write the body of an email; never invent a person, place or address. Leave the fields of other widgets empty. Answer with JSON only.',
'Widgets:',
...widgets.map((widget) => `- ${widget.id}: ${widget.description}. Fields: ${widget.fields.map((field) => `${field.id}${field.many ? ' (list)' : ''}: ${field.hint}`).join('; ')}`),
`- ${INCOMPLETE}: the text is cut off or says too little to tell what is wanted yet, as it is while still being typed ("find the", "email", "what's"); never a whole sentence or question, which ends in a question mark or a full stop`,
`- ${NO_WIDGET}: a whole request that nothing above does ("summarize my week", "what's the weather tomorrow")`,
`Fill in the fields of the ${widget.name} widget (${widget.description}) from what the person typed into a command box. Copy values from the text, but write the body of an email; never invent a person, place or address. Answer with JSON only.`,
'Fields:',
...widget.fields.map((field) => `- ${field.id}${field.many ? ' (list)' : ''}: ${field.hint}`),
].join('\n');
}

Expand All @@ -61,40 +87,19 @@ function fieldSchema(field: WidgetField) {
return {type: ['string', 'null']};
}

function jsonSchema(widgets: Widget[]) {
const fields = widgets.flatMap((widget) => widget.fields);
function jsonSchema(widget: Widget) {
return {
type: 'object',
additionalProperties: false,
required: ['widget', ...fields.map((field) => field.id)],
properties: {
widget: {type: 'string', enum: [...widgets.map((widget) => widget.id), INCOMPLETE, NO_WIDGET]},
...Object.fromEntries(fields.map((field) => [field.id, fieldSchema(field)])),
},
required: widget.fields.map((field) => field.id),
properties: Object.fromEntries(widget.fields.map((field) => [field.id, fieldSchema(field)])),
};
}

type Form = {widgets: Widget[]; instructions: string; schema: ReturnType<typeof jsonSchema>};

// The instructions and schema for each set of widgets offered, built once: there are few sets.
const forms = new Map<string, Form>();

function formFor(holder: {experiments: Experiment[]}): Form {
const widgets = offeredWidgets(holder);
const key = widgets.map((widget) => widget.id).join(' ');
const kept = forms.get(key);
if (kept) {
return kept;
}
const form = {widgets, instructions: instructions(widgets), schema: jsonSchema(widgets)};
forms.set(key, form);
return form;
}

const completionSchema = z.object({choices: z.array(z.object({message: z.object({content: z.string()})})).min(1)});

// The widget's id, and each field's value by its id.
const answerSchema = z.object({widget: z.string()}).catchall(z.union([z.string(), z.array(z.string()), z.array(z.array(z.string())), z.null()]));
// Each field's value by its id.
const answerSchema = z.record(z.string(), z.union([z.string(), z.array(z.string()), z.array(z.array(z.string())), z.null()]));

type AnswerValue = z.infer<typeof answerSchema>[string];

Expand All @@ -114,32 +119,48 @@ function cleaned(value: AnswerValue | undefined): string[] {
return unique(value.map((one) => (Array.isArray(one) ? unique(one.map((word) => word.replace(/\|/g, ' '))).join(' | ') : one)));
}

/** Which of the widgets offered to `holder` fits `text`, and its fields as the model fills them in. */
export async function classifyIntent(text: string, holder: {experiments: Experiment[]}): Promise<Result<Intent, ApiError>> {
if (!env.OPENROUTER_KEY) {
return Err(ApiErr.unavailable('The home page is not set up: set `OPENROUTER_KEY`.'));
// Which of `widgets` Jev picks for `text`: one of them, `incomplete` or `none`.
async function pickWidget(text: string, widgets: Widget[], key: string): Promise<Result<Widget | typeof INCOMPLETE | typeof NO_WIDGET, ApiError>> {
const picked = await httpRequest(
TYPESAFE_URL,
{
method: 'POST',
headers: {'content-type': 'application/json', authorization: `Bearer ${key}`},
body: JSON.stringify({model: JEV_MODEL, state: text, questions: {widget: widgetQuestion(widgets)}}),
},
{schema: pickSchema, timeoutMs: TIMEOUT_MS},
);
if (picked.isErr()) {
return Err(ApiErr.providerUnreachable(picked.error));
}

const form = formFor(holder);
const start = performance.now();
const choice = picked.value.answers.widget.choice;
if (choice === INCOMPLETE) {
return Ok(INCOMPLETE);
}
return Ok(widgets.find((widget) => widget.id === choice) ?? NO_WIDGET);
}

// `widget`'s fields, as gpt-oss-120b fills them in from `text`.
async function fillWidget(text: string, widget: Widget, key: string): Promise<Result<FilledField[], ApiError>> {
const answered = await httpRequest(
OPENROUTER_URL,
{
method: 'POST',
headers: {'content-type': 'application/json', authorization: `Bearer ${env.OPENROUTER_KEY}`},
headers: {'content-type': 'application/json', authorization: `Bearer ${key}`},
body: JSON.stringify({
model: MODEL,
model: FILL_MODEL,
messages: [
{role: 'system', content: form.instructions},
{role: 'system', content: instructions(widget)},
{role: 'user', content: text},
],
temperature: 0,
// Its thinking and every widget's fields count toward it, an email's body among them, so
// it is well past what an answer takes: cut off, the answer would be no JSON at all.
// Its thinking and the fields count toward it, an email's body among them, so it is
// well past what an answer takes: cut off, the answer would be no JSON at all.
max_tokens: MAX_TOKENS,
// It thinks before answering; a little is enough here, and keeps it fast.
reasoning: {effort: 'low'},
response_format: {type: 'json_schema', json_schema: {name: 'intent', strict: true, schema: form.schema}},
response_format: {type: 'json_schema', json_schema: {name: 'fields', strict: true, schema: jsonSchema(widget)}},
// Cerebras first, Groq next, and any other host of the model after them, but only a
// host OpenRouter records as neither keeping prompts nor training on them: the
// text is what the person typed, their bank's name or a merchant's among it.
Expand All @@ -148,7 +169,6 @@ export async function classifyIntent(text: string, holder: {experiments: Experim
},
{schema: completionSchema, timeoutMs: TIMEOUT_MS},
);
const ms = Math.round(performance.now() - start);
if (answered.isErr()) {
return Err(ApiErr.providerUnreachable(answered.error));
}
Expand All @@ -158,13 +178,38 @@ export async function classifyIntent(text: string, holder: {experiments: Experim
if (answer.isErr()) {
return Err(ApiErr.providerUnreachable(new Error(`The model answered outside the form: ${content.slice(0, 200)}`)));
}
return Ok(widget.fields.map((field) => ({id: field.id, name: field.name, values: cleaned(answer.value[field.id])})));
}

if (answer.value.widget === INCOMPLETE) {
return Ok({widget: INCOMPLETE, fields: [], ms});
// Both keys, as the home page needs Jev and the model that fills in alike.
function keys(): Result<{typesafe: string; openRouter: string}, ApiError> {
if (!env.TYPESAFE_API_KEY || !env.OPENROUTER_KEY) {
return Err(ApiErr.unavailable('The home page is not set up: set `TYPESAFE_API_KEY` and `OPENROUTER_KEY`.'));
}
const widget = form.widgets.find((candidate) => candidate.id === answer.value.widget);
if (!widget) {
return Ok({widget: NO_WIDGET, fields: [], ms});
}
return Ok({widget: widget.id, fields: widget.fields.map((field) => ({id: field.id, name: field.name, values: cleaned(answer.value[field.id])})), ms});
return Ok({typesafe: env.TYPESAFE_API_KEY, openRouter: env.OPENROUTER_KEY});
}

/** Which of the widgets offered to `holder` fits `text`, as Jev picks it. */
export function pickIntent(text: string, holder: {experiments: Experiment[]}): Promise<Result<Picked, ApiError>> {
return Do(async ($) => {
const {typesafe} = $(keys());
const start = performance.now();
const picked = $(await pickWidget(text, offeredWidgets(holder), typesafe));
return {widget: typeof picked === 'string' ? picked : picked.id, ms: Math.round(performance.now() - start)};
});
}

/** The fields of `widgetId`, one of the widgets offered to `holder`, as gpt-oss-120b fills them in from `text`. */
export function fillIntent(text: string, widgetId: string, holder: {experiments: Experiment[]}): Promise<Result<Filled, ApiError>> {
return Do(async ($) => {
const {openRouter} = $(keys());
const widget = offeredWidgets(holder).find((candidate) => candidate.id === widgetId);
if (!widget) {
return $(Err(ApiErr.notFound('widget', widgetId)));
}

const start = performance.now();
const fields = $(await fillWidget(text, widget, openRouter));
return {fields, ms: Math.round(performance.now() - start)};
});
}
43 changes: 32 additions & 11 deletions packages/api/src/intent/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,55 @@
import {beforeEach, describe, expect, mock, test} from 'bun:test';
import {Ok} from 'ts-results-es';

const classifyIntent = mock();
const pickIntent = mock();
const fillIntent = mock();

mock.module('./classify', () => ({classifyIntent}));
mock.module('./classify', () => ({pickIntent, fillIntent}));

function makeRequest(body: unknown) {
return {user: {userId: 'user-1', experiments: ['plaid']}, body} as never;
}

beforeEach(() => {
mock.clearAllMocks();
classifyIntent.mockResolvedValue(Ok({widget: 'none', fields: [], ms: 1}));
pickIntent.mockResolvedValue(Ok({widget: 'none', ms: 1}));
fillIntent.mockResolvedValue(Ok({fields: [], ms: 1}));
});

describe('handleClassifyIntentRoute', () => {
test("classifies the text, trimmed, among the widgets the person's organization is offered", async () => {
const {handleClassifyIntentRoute} = await import('./route');
const result = await handleClassifyIntentRoute(makeRequest({text: ' who is hugo '}));
describe('handlePickIntentRoute', () => {
test("picks among the widgets the person's organization is offered, for the text trimmed", async () => {
const {handlePickIntentRoute} = await import('./route');
const result = await handlePickIntentRoute(makeRequest({text: ' who is hugo '}));

expect(result.isOk()).toBe(true);
expect(classifyIntent).toHaveBeenCalledWith('who is hugo', {userId: 'user-1', experiments: ['plaid']});
expect(pickIntent).toHaveBeenCalledWith('who is hugo', {userId: 'user-1', experiments: ['plaid']});
});

test('refuses empty text, and text past 500 characters, without asking the models', async () => {
const {handleClassifyIntentRoute} = await import('./route');
const {handlePickIntentRoute} = await import('./route');

for (const text of [' ', 'a'.repeat(501)]) {
expect((await handleClassifyIntentRoute(makeRequest({text}))).unwrapErr().kind).toBe('parse_error');
expect((await handlePickIntentRoute(makeRequest({text}))).unwrapErr().kind).toBe('parse_error');
}
expect(classifyIntent).not.toHaveBeenCalled();
expect(pickIntent).not.toHaveBeenCalled();
});
});

describe('handleFillIntentRoute', () => {
test("fills in the widget's fields for the person's organization, from the text trimmed", async () => {
const {handleFillIntentRoute} = await import('./route');
const result = await handleFillIntentRoute(makeRequest({text: ' who is hugo ', widget: 'find_person'}));

expect(result.isOk()).toBe(true);
expect(fillIntent).toHaveBeenCalledWith('who is hugo', 'find_person', {userId: 'user-1', experiments: ['plaid']});
});

test('refuses a request without a widget, or with text that is empty or past 500 characters', async () => {
const {handleFillIntentRoute} = await import('./route');

for (const body of [{text: 'who is hugo'}, {text: 'who is hugo', widget: ''}, {text: ' ', widget: 'find_person'}, {text: 'a'.repeat(501), widget: 'find_person'}]) {
expect((await handleFillIntentRoute(makeRequest(body))).unwrapErr().kind).toBe('parse_error');
}
expect(fillIntent).not.toHaveBeenCalled();
});
});
24 changes: 18 additions & 6 deletions packages/api/src/intent/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,26 @@ import {z} from 'zod';
import {type ApiError, Do, parseSchema} from '@proxy/utils';

import type {AuthenticatedRequest} from '../server/middleware/require-auth';
import {classifyIntent, type Intent} from './classify';
import {type Filled, fillIntent, type Picked, pickIntent} from './classify';

const intentBodySchema = z.object({text: z.string().trim().min(1).max(500)});
const text = z.string().trim().min(1).max(500);

/** `POST /api/intent` with `{text}` — what the home page's input asks for, as it is typed. */
export function handleClassifyIntentRoute(request: AuthenticatedRequest): Promise<Result<Intent, ApiError>> {
const pickBodySchema = z.object({text});

const fillBodySchema = z.object({text, widget: z.string().min(1)});

/** `POST /api/intent` with `{text}` — which widget fits what the home page's input asks for, as it is typed. */
export function handlePickIntentRoute(request: AuthenticatedRequest): Promise<Result<Picked, ApiError>> {
return Do(async ($) => {
const body = $(parseSchema(pickBodySchema, request.body));
return $(await pickIntent(body.text, request.user));
});
}

/** `POST /api/intent/fields` with `{text, widget}` — the fields of the widget picked for `text`, filled in. */
export function handleFillIntentRoute(request: AuthenticatedRequest): Promise<Result<Filled, ApiError>> {
return Do(async ($) => {
const {text} = $(parseSchema(intentBodySchema, request.body));
return $(await classifyIntent(text, request.user));
const body = $(parseSchema(fillBodySchema, request.body));
return $(await fillIntent(body.text, body.widget, request.user));
});
}
9 changes: 4 additions & 5 deletions packages/api/src/intent/widgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,10 @@ import {TRANSACTION_CATEGORIES} from '@proxy/utils/transaction-categories';
import {UNITS} from '@proxy/utils/units';

/**
* The widgets the home page shows for what a person types, Sana's way: a small, fast LLM picks the
* one that fits and fills in its fields from the text. Most are meant for one kind of data:
* The widgets the home page shows for what a person types, Sana's way: Jev picks the one that fits,
* and a small, fast LLM fills in its fields from the text. Most are meant for one kind of data:
* Calendar, Contacts, Gmail or Drive, or a bank; Math and Convert units are worked out in the web
* app. Field ids are unique across widgets, as the LLM answers every widget's fields in one object.
* A widget behind an experiment is offered only to an organization with it on.
* app. A widget behind an experiment is offered only to an organization with it on.
*/

export type WidgetField = {
Expand All @@ -33,7 +32,7 @@ export type WidgetField = {
export type Widget = {
id: string;
name: string;
/** What the LLM reads to tell the widgets apart. */
/** What Jev reads to tell the widgets apart, and the LLM reads to know what it fills in. */
description: string;
fields: WidgetField[];
/** The experiment an organization needs on to be offered it, as banks are behind `plaid`. */
Expand Down
Loading
Loading