@swissspidy/belay-web 0.0.0-stage → 0.1.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.
- package/LICENSE +201 -0
- package/README.md +46 -2
- package/dist/classifier-types.d.ts +63 -0
- package/dist/classifier-types.d.ts.map +1 -0
- package/dist/classifier-types.js +9 -0
- package/dist/classifier-types.js.map +1 -0
- package/dist/classifier.d.ts +39 -0
- package/dist/classifier.d.ts.map +1 -0
- package/dist/classifier.js +160 -0
- package/dist/classifier.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/judge.d.ts +23 -0
- package/dist/judge.d.ts.map +1 -0
- package/dist/judge.js +39 -0
- package/dist/judge.js.map +1 -0
- package/dist/prompt-types.d.ts +37 -0
- package/dist/prompt-types.d.ts.map +1 -0
- package/dist/prompt-types.js +6 -0
- package/dist/prompt-types.js.map +1 -0
- package/dist/prompt.d.ts +25 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +117 -0
- package/dist/prompt.js.map +1 -0
- package/package.json +46 -4
- package/src/classifier-types.ts +75 -0
- package/src/classifier.ts +229 -0
- package/src/index.ts +8 -0
- package/src/judge.ts +60 -0
- package/src/prompt-types.ts +41 -0
- package/src/prompt.ts +137 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"judge.js","sourceRoot":"","sources":["../src/judge.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for the Prompt API (`LanguageModel`), after the explainer at
|
|
3
|
+
* https://github.com/webmachinelearning/prompt-api (Sep 2026). Only the members Belay uses.
|
|
4
|
+
*/
|
|
5
|
+
import type { Availability } from '@swissspidy/belay-core';
|
|
6
|
+
export interface LanguageModelMessage {
|
|
7
|
+
role: 'system' | 'user' | 'assistant';
|
|
8
|
+
content: string;
|
|
9
|
+
}
|
|
10
|
+
export interface LanguageModelExpected {
|
|
11
|
+
type: 'text' | 'image' | 'audio';
|
|
12
|
+
languages?: string[];
|
|
13
|
+
}
|
|
14
|
+
export interface LanguageModelCreateOptions {
|
|
15
|
+
initialPrompts?: LanguageModelMessage[];
|
|
16
|
+
expectedInputs?: LanguageModelExpected[];
|
|
17
|
+
expectedOutputs?: LanguageModelExpected[];
|
|
18
|
+
monitor?: (monitor: EventTarget) => void;
|
|
19
|
+
signal?: AbortSignal;
|
|
20
|
+
}
|
|
21
|
+
export interface LanguageModelPromptOptions {
|
|
22
|
+
responseConstraint?: Record<string, unknown> | RegExp;
|
|
23
|
+
omitResponseConstraintInput?: boolean;
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
}
|
|
26
|
+
export interface LanguageModelSession {
|
|
27
|
+
prompt(input: string, options?: LanguageModelPromptOptions): Promise<string>;
|
|
28
|
+
clone?(options?: {
|
|
29
|
+
signal?: AbortSignal;
|
|
30
|
+
}): Promise<LanguageModelSession>;
|
|
31
|
+
destroy(): void;
|
|
32
|
+
}
|
|
33
|
+
export interface LanguageModelStatic {
|
|
34
|
+
availability(options?: Omit<LanguageModelCreateOptions, 'monitor' | 'signal' | 'initialPrompts'>): Promise<Availability>;
|
|
35
|
+
create(options?: LanguageModelCreateOptions): Promise<LanguageModelSession>;
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=prompt-types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"prompt-types.d.ts","sourceRoot":"","sources":["../src/prompt-types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAE3D,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,QAAQ,GAAG,MAAM,GAAG,WAAW,CAAC;IACtC,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,MAAM,GAAG,OAAO,GAAG,OAAO,CAAC;IACjC,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;CACtB;AAED,MAAM,WAAW,0BAA0B;IACzC,cAAc,CAAC,EAAE,oBAAoB,EAAE,CAAC;IACxC,cAAc,CAAC,EAAE,qBAAqB,EAAE,CAAC;IACzC,eAAe,CAAC,EAAE,qBAAqB,EAAE,CAAC;IAC1C,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,WAAW,KAAK,IAAI,CAAC;IACzC,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,MAAM,WAAW,0BAA0B;IACzC,kBAAkB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC;IACtD,2BAA2B,CAAC,EAAE,OAAO,CAAC;IACtC,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,MAAM,WAAW,oBAAoB;IACnC,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,0BAA0B,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC7E,KAAK,CAAC,CAAC,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,WAAW,CAAA;KAAE,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAAC;IAC1E,OAAO,IAAI,IAAI,CAAC;CACjB;AAED,MAAM,WAAW,mBAAmB;IAClC,YAAY,CAAC,OAAO,CAAC,EAAE,IAAI,CAAC,0BAA0B,EAAE,SAAS,GAAG,QAAQ,GAAG,gBAAgB,CAAC,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;IACzH,MAAM,CAAC,OAAO,CAAC,EAAE,0BAA0B,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAAC;CAC7E"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"prompt-types.js","sourceRoot":"","sources":["../src/prompt-types.ts"],"names":[],"mappings":"AAAA;;;GAGG"}
|
package/dist/prompt.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { LocalRunner, RunnerContext, TaskSchema } from '@swissspidy/belay-core';
|
|
2
|
+
import type { LanguageModelExpected, LanguageModelStatic } from './prompt-types.js';
|
|
3
|
+
export interface PromptApiOptions {
|
|
4
|
+
/** System prompt. Defaults to the task instruction from `buildInstruction(schema, context)`. */
|
|
5
|
+
systemPrompt?: string | ((ctx: RunnerContext<TaskSchema>) => string);
|
|
6
|
+
/** Defaults to English text in and out. */
|
|
7
|
+
expectedInputs?: LanguageModelExpected[];
|
|
8
|
+
expectedOutputs?: LanguageModelExpected[];
|
|
9
|
+
/** Keep the JSON Schema out of the model's context window (the system prompt already describes the format). */
|
|
10
|
+
omitResponseConstraintInput?: boolean;
|
|
11
|
+
/** The `LanguageModel` implementation. Defaults to `globalThis.LanguageModel`. */
|
|
12
|
+
languageModel?: LanguageModelStatic;
|
|
13
|
+
/** Max number of cached base sessions (one per distinct system prompt). Defaults to 4. */
|
|
14
|
+
maxSessions?: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Local runner backed by the Prompt API with structured output (`responseConstraint`). It reports
|
|
18
|
+
* no confidence of its own: pair it with a task `judge` (e.g. `classifierJudge()`), and the output
|
|
19
|
+
* is validated against the schema before the judge sees it.
|
|
20
|
+
*
|
|
21
|
+
* Every run prompts a clone of a base session, so runs never see each other's history. Like
|
|
22
|
+
* `classifierApi()`, `run()` never triggers a model download; call `task.prepare()` from a click.
|
|
23
|
+
*/
|
|
24
|
+
export declare function promptApi(options?: PromptApiOptions): LocalRunner<any>;
|
|
25
|
+
//# sourceMappingURL=prompt.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"prompt.d.ts","sourceRoot":"","sources":["../src/prompt.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAA6B,WAAW,EAAkB,aAAa,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AAChI,OAAO,KAAK,EAA8B,qBAAqB,EAAwB,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AAEtI,MAAM,WAAW,gBAAgB;IAC/B,gGAAgG;IAChG,YAAY,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,GAAG,EAAE,aAAa,CAAC,UAAU,CAAC,KAAK,MAAM,CAAC,CAAC;IACrE,2CAA2C;IAC3C,cAAc,CAAC,EAAE,qBAAqB,EAAE,CAAC;IACzC,eAAe,CAAC,EAAE,qBAAqB,EAAE,CAAC;IAC1C,+GAA+G;IAC/G,2BAA2B,CAAC,EAAE,OAAO,CAAC;IACtC,kFAAkF;IAClF,aAAa,CAAC,EAAE,mBAAmB,CAAC;IACpC,0FAA0F;IAC1F,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAID;;;;;;;GAOG;AACH,wBAAgB,SAAS,CAAC,OAAO,GAAE,gBAAqB,GAAG,WAAW,CAAC,GAAG,CAAC,CA2G1E"}
|
package/dist/prompt.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { LocalUnavailableError, buildInstruction, toJsonSchema } from '@swissspidy/belay-core';
|
|
2
|
+
/**
|
|
3
|
+
* Local runner backed by the Prompt API with structured output (`responseConstraint`). It reports
|
|
4
|
+
* no confidence of its own: pair it with a task `judge` (e.g. `classifierJudge()`), and the output
|
|
5
|
+
* is validated against the schema before the judge sees it.
|
|
6
|
+
*
|
|
7
|
+
* Every run prompts a clone of a base session, so runs never see each other's history. Like
|
|
8
|
+
* `classifierApi()`, `run()` never triggers a model download; call `task.prepare()` from a click.
|
|
9
|
+
*/
|
|
10
|
+
export function promptApi(options = {}) {
|
|
11
|
+
const maxSessions = Math.max(1, options.maxSessions ?? 4);
|
|
12
|
+
const sessions = new Map();
|
|
13
|
+
const expected = {
|
|
14
|
+
expectedInputs: options.expectedInputs ?? [{ type: 'text', languages: ['en'] }],
|
|
15
|
+
expectedOutputs: options.expectedOutputs ?? [{ type: 'text', languages: ['en'] }],
|
|
16
|
+
};
|
|
17
|
+
const api = () => options.languageModel ?? globalThis.LanguageModel;
|
|
18
|
+
const systemPromptFor = (ctx) => typeof options.systemPrompt === 'function'
|
|
19
|
+
? options.systemPrompt(ctx)
|
|
20
|
+
: (options.systemPrompt ?? buildInstruction(ctx.schema, ctx.context));
|
|
21
|
+
function session(ctx, prepare) {
|
|
22
|
+
const LanguageModel = api();
|
|
23
|
+
if (!LanguageModel)
|
|
24
|
+
return Promise.reject(new LocalUnavailableError('Prompt API is not available'));
|
|
25
|
+
const key = systemPromptFor(ctx);
|
|
26
|
+
const cached = sessions.get(key);
|
|
27
|
+
if (cached) {
|
|
28
|
+
sessions.delete(key);
|
|
29
|
+
sessions.set(key, cached);
|
|
30
|
+
return cached;
|
|
31
|
+
}
|
|
32
|
+
// Only prepare()'s signal: the session is cached and shared, so a per-run abort must not destroy it.
|
|
33
|
+
const signal = prepare?.signal;
|
|
34
|
+
const createOptions = {
|
|
35
|
+
...expected,
|
|
36
|
+
initialPrompts: [{ role: 'system', content: key }],
|
|
37
|
+
...(signal ? { signal } : {}),
|
|
38
|
+
...(prepare?.onProgress
|
|
39
|
+
? {
|
|
40
|
+
monitor: (m) => m.addEventListener('downloadprogress', (e) => prepare.onProgress(e.loaded)),
|
|
41
|
+
}
|
|
42
|
+
: {}),
|
|
43
|
+
};
|
|
44
|
+
const created = LanguageModel.create(createOptions);
|
|
45
|
+
sessions.set(key, created);
|
|
46
|
+
created.catch(() => {
|
|
47
|
+
if (sessions.get(key) === created)
|
|
48
|
+
sessions.delete(key);
|
|
49
|
+
});
|
|
50
|
+
while (sessions.size > maxSessions) {
|
|
51
|
+
const [oldestKey, oldest] = sessions.entries().next().value;
|
|
52
|
+
sessions.delete(oldestKey);
|
|
53
|
+
oldest.then((s) => s.destroy(), () => { });
|
|
54
|
+
}
|
|
55
|
+
return created;
|
|
56
|
+
}
|
|
57
|
+
async function availability(ctx) {
|
|
58
|
+
const LanguageModel = api();
|
|
59
|
+
if (!LanguageModel)
|
|
60
|
+
return 'unavailable';
|
|
61
|
+
if (sessions.has(systemPromptFor(ctx)))
|
|
62
|
+
return 'available';
|
|
63
|
+
try {
|
|
64
|
+
return await LanguageModel.availability(expected);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return 'unavailable';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
id: 'prompt-api',
|
|
72
|
+
availability,
|
|
73
|
+
async run(input, ctx) {
|
|
74
|
+
if (!sessions.has(systemPromptFor(ctx))) {
|
|
75
|
+
const state = await availability(ctx);
|
|
76
|
+
if (state !== 'available')
|
|
77
|
+
throw new LocalUnavailableError(`Prompt API model is ${state}`);
|
|
78
|
+
}
|
|
79
|
+
const base = await session(ctx);
|
|
80
|
+
// A fresh clone per run keeps runs independent. Without clone(), fall back to a new session.
|
|
81
|
+
const LanguageModel = api();
|
|
82
|
+
const turn = base.clone
|
|
83
|
+
? await base.clone(ctx.signal ? { signal: ctx.signal } : {})
|
|
84
|
+
: await LanguageModel.create({ ...expected, initialPrompts: [{ role: 'system', content: systemPromptFor(ctx) }], ...(ctx.signal ? { signal: ctx.signal } : {}) });
|
|
85
|
+
try {
|
|
86
|
+
const text = await turn.prompt(input, {
|
|
87
|
+
responseConstraint: toJsonSchema(ctx.schema),
|
|
88
|
+
...(options.omitResponseConstraintInput ? { omitResponseConstraintInput: true } : {}),
|
|
89
|
+
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
90
|
+
});
|
|
91
|
+
let value = text;
|
|
92
|
+
try {
|
|
93
|
+
value = JSON.parse(text);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Leave the raw text: the task's schema validation reports it as invalid output.
|
|
97
|
+
}
|
|
98
|
+
return { value, raw: text };
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
turn.destroy();
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
async prepare(ctx, prepareOptions) {
|
|
105
|
+
const state = await availability(ctx);
|
|
106
|
+
if (state === 'unavailable')
|
|
107
|
+
throw new LocalUnavailableError('Prompt API model is unavailable on this device');
|
|
108
|
+
await session(ctx, prepareOptions);
|
|
109
|
+
},
|
|
110
|
+
destroy() {
|
|
111
|
+
for (const s of sessions.values())
|
|
112
|
+
s.then((c) => c.destroy(), () => { });
|
|
113
|
+
sessions.clear();
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
//# sourceMappingURL=prompt.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"prompt.js","sourceRoot":"","sources":["../src/prompt.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swissspidy/belay-web",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Belay local runners for browser built-in AI (Classifier API, Prompt API).",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "Pascal Birchler",
|
|
7
|
+
"homepage": "https://github.com/swissspidy/belay/tree/main/packages/web#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/swissspidy/belay.git",
|
|
11
|
+
"directory": "packages/web"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/swissspidy/belay/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"ai",
|
|
18
|
+
"on-device",
|
|
19
|
+
"built-in-ai",
|
|
20
|
+
"classifier-api",
|
|
21
|
+
"prompt-api",
|
|
22
|
+
"chrome",
|
|
23
|
+
"gemini-nano",
|
|
24
|
+
"cascade"
|
|
25
|
+
],
|
|
26
|
+
"type": "module",
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"import": "./dist/index.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"src"
|
|
37
|
+
],
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@swissspidy/belay-core": "^0.1.1"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "tsc -b",
|
|
43
|
+
"prepack": "tsc -b --force"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for the proposed Classifier API (`window.Classifier`), as described in the explainer
|
|
3
|
+
* (https://github.com/michaelwasserman/classifier-api, the current revision of
|
|
4
|
+
* explainers-by-googlers/classifier-api) and implemented by Chrome behind `#classifier-api` and by
|
|
5
|
+
* the WebAI Studio extension polyfill (https://web-ai.studio/docs/classifier). The proposal is in
|
|
6
|
+
* flux: only the members Belay relies on are typed, and result fields are checked at runtime.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Availability } from '@swissspidy/belay-core';
|
|
10
|
+
|
|
11
|
+
export type ClassifierQuestionType = 'binary' | 'categorical' | 'ordinal';
|
|
12
|
+
|
|
13
|
+
export interface ClassifierOption {
|
|
14
|
+
label: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ClassifierQuestion {
|
|
19
|
+
id: string;
|
|
20
|
+
type: ClassifierQuestionType;
|
|
21
|
+
prompt: string;
|
|
22
|
+
options?: ClassifierOption[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ClassifierExpectedInput {
|
|
26
|
+
type: 'text';
|
|
27
|
+
languages?: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ClassifierSchema {
|
|
31
|
+
context?: string;
|
|
32
|
+
questions: ClassifierQuestion[];
|
|
33
|
+
expectedInputs?: ClassifierExpectedInput[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ClassifierCreateOptions extends ClassifierSchema {
|
|
37
|
+
/** Same pattern as the other built-in AI APIs: `downloadprogress` events carry `loaded` in [0, 1]. */
|
|
38
|
+
monitor?: (monitor: EventTarget) => void;
|
|
39
|
+
signal?: AbortSignal;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ClassifierClassifyOptions {
|
|
43
|
+
signal?: AbortSignal;
|
|
44
|
+
context?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ClassifierProbability {
|
|
48
|
+
label: string;
|
|
49
|
+
probability: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ClassifierDecision {
|
|
53
|
+
id: string;
|
|
54
|
+
label: string;
|
|
55
|
+
/** Calibrated confidence in [0, 1]. */
|
|
56
|
+
confidence: number;
|
|
57
|
+
/** Binary questions only: calibrated P(true). */
|
|
58
|
+
probability?: number;
|
|
59
|
+
/** Ordinal questions only: E[S]. */
|
|
60
|
+
expectedScore?: number;
|
|
61
|
+
probabilities?: ClassifierProbability[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Keyed by question `id`. */
|
|
65
|
+
export type ClassifierResult = Record<string, ClassifierDecision>;
|
|
66
|
+
|
|
67
|
+
export interface ClassifierInstance {
|
|
68
|
+
classify(input: string, options?: ClassifierClassifyOptions): Promise<ClassifierResult>;
|
|
69
|
+
destroy(): void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ClassifierStatic {
|
|
73
|
+
availability(options?: ClassifierSchema): Promise<Availability>;
|
|
74
|
+
create(options: ClassifierCreateOptions): Promise<ClassifierInstance>;
|
|
75
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BelayError,
|
|
3
|
+
LocalUnavailableError,
|
|
4
|
+
clamp01,
|
|
5
|
+
normalizeOptions,
|
|
6
|
+
topProbability,
|
|
7
|
+
} from '@swissspidy/belay-core';
|
|
8
|
+
import type {
|
|
9
|
+
Availability,
|
|
10
|
+
BinarySchema,
|
|
11
|
+
CategoricalSchema,
|
|
12
|
+
LocalOutput,
|
|
13
|
+
LocalRunner,
|
|
14
|
+
OrdinalSchema,
|
|
15
|
+
PrepareOptions,
|
|
16
|
+
ProbabilityEntry,
|
|
17
|
+
RunnerContext,
|
|
18
|
+
TaskSchema,
|
|
19
|
+
} from '@swissspidy/belay-core';
|
|
20
|
+
import type {
|
|
21
|
+
ClassifierDecision,
|
|
22
|
+
ClassifierExpectedInput,
|
|
23
|
+
ClassifierInstance,
|
|
24
|
+
ClassifierSchema,
|
|
25
|
+
ClassifierStatic,
|
|
26
|
+
} from './classifier-types.js';
|
|
27
|
+
|
|
28
|
+
export type ClassifierTaskSchema = BinarySchema | CategoricalSchema | OrdinalSchema;
|
|
29
|
+
|
|
30
|
+
export interface ClassifierApiOptions {
|
|
31
|
+
/**
|
|
32
|
+
* Which number becomes the task confidence:
|
|
33
|
+
* - `label-probability` (default): the calibrated probability of the returned label
|
|
34
|
+
* (the top option). For binary questions this is max(P(true), P(false)).
|
|
35
|
+
* - `model`: the decision's own `confidence` field.
|
|
36
|
+
* See ADR 0001 for why the default is the probability.
|
|
37
|
+
*/
|
|
38
|
+
confidence?: 'label-probability' | 'model';
|
|
39
|
+
/** Defaults to `[{ type: 'text', languages: ['en'] }]`. */
|
|
40
|
+
expectedInputs?: ClassifierExpectedInput[];
|
|
41
|
+
/** Question id used in the Classifier schema. Defaults to "answer". */
|
|
42
|
+
questionId?: string;
|
|
43
|
+
/**
|
|
44
|
+
* The `Classifier` implementation. Defaults to `globalThis.Classifier` (Chrome's built-in API or
|
|
45
|
+
* the WebAI Studio extension polyfill). Injectable for tests and custom polyfills.
|
|
46
|
+
*/
|
|
47
|
+
classifier?: ClassifierStatic;
|
|
48
|
+
/** Max number of cached sessions (one per distinct context). Defaults to 4. */
|
|
49
|
+
maxSessions?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type Ctx = RunnerContext<TaskSchema>;
|
|
53
|
+
|
|
54
|
+
const DEFAULT_PROMPTS = {
|
|
55
|
+
categorical: 'Which option best describes the input?',
|
|
56
|
+
ordinal: 'Which level best describes the input?',
|
|
57
|
+
} as const;
|
|
58
|
+
|
|
59
|
+
/** Maps a Belay schema to a Classifier API schema with a single question. */
|
|
60
|
+
export function toClassifierSchema(
|
|
61
|
+
schema: TaskSchema,
|
|
62
|
+
options: { questionId?: string; context?: string; expectedInputs?: ClassifierExpectedInput[] } = {},
|
|
63
|
+
): ClassifierSchema {
|
|
64
|
+
const id = options.questionId ?? 'answer';
|
|
65
|
+
const base = {
|
|
66
|
+
...(options.context ? { context: options.context } : {}),
|
|
67
|
+
expectedInputs: options.expectedInputs ?? [{ type: 'text' as const, languages: ['en'] }],
|
|
68
|
+
};
|
|
69
|
+
switch (schema.type) {
|
|
70
|
+
case 'binary':
|
|
71
|
+
return { ...base, questions: [{ id, type: 'binary', prompt: schema.prompt }] };
|
|
72
|
+
case 'categorical':
|
|
73
|
+
case 'ordinal':
|
|
74
|
+
return {
|
|
75
|
+
...base,
|
|
76
|
+
questions: [
|
|
77
|
+
{
|
|
78
|
+
id,
|
|
79
|
+
type: schema.type,
|
|
80
|
+
prompt: schema.prompt ?? DEFAULT_PROMPTS[schema.type],
|
|
81
|
+
options: normalizeOptions(schema.options),
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
default:
|
|
86
|
+
throw new BelayError(
|
|
87
|
+
'invalid-task',
|
|
88
|
+
`the Classifier API runner supports binary, categorical and ordinal tasks, not "${schema.type}"`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Extracts value, confidence and distribution from a Classifier decision. */
|
|
94
|
+
export function readDecision(
|
|
95
|
+
schema: ClassifierTaskSchema,
|
|
96
|
+
decision: ClassifierDecision | undefined,
|
|
97
|
+
mode: 'label-probability' | 'model' = 'label-probability',
|
|
98
|
+
): LocalOutput<string | boolean> {
|
|
99
|
+
if (!decision || typeof decision !== 'object') {
|
|
100
|
+
throw new Error('Classifier result is missing the decision');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let probabilities: ProbabilityEntry[] | undefined = Array.isArray(decision.probabilities)
|
|
104
|
+
? decision.probabilities.filter((p) => typeof p?.label === 'string' && typeof p.probability === 'number')
|
|
105
|
+
: undefined;
|
|
106
|
+
if (schema.type === 'binary' && typeof decision.probability === 'number') {
|
|
107
|
+
const pTrue = clamp01(decision.probability);
|
|
108
|
+
probabilities = [
|
|
109
|
+
{ label: 'true', probability: pTrue },
|
|
110
|
+
{ label: 'false', probability: 1 - pTrue },
|
|
111
|
+
];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let label = typeof decision.label === 'string' ? decision.label : undefined;
|
|
115
|
+
if (label === undefined && probabilities?.length) {
|
|
116
|
+
label = probabilities.reduce((a, b) => (b.probability > a.probability ? b : a)).label;
|
|
117
|
+
}
|
|
118
|
+
if (label === undefined) throw new Error('Classifier decision has no label');
|
|
119
|
+
|
|
120
|
+
let confidence: number | undefined;
|
|
121
|
+
if (mode === 'label-probability' && probabilities?.length) {
|
|
122
|
+
const own = probabilities.find((p) => p.label === label);
|
|
123
|
+
confidence = own ? clamp01(own.probability) : topProbability(probabilities);
|
|
124
|
+
}
|
|
125
|
+
if (confidence === undefined && typeof decision.confidence === 'number') confidence = clamp01(decision.confidence);
|
|
126
|
+
if (confidence === undefined) throw new Error('Classifier decision has neither probabilities nor confidence');
|
|
127
|
+
|
|
128
|
+
const value = schema.type === 'binary' ? label.trim().toLowerCase() === 'true' : label;
|
|
129
|
+
return { value, confidence, ...(probabilities ? { probabilities } : {}), raw: decision };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Local runner backed by the Classifier API. It never triggers a model download from `run()`:
|
|
134
|
+
* while the model is "downloadable" or "downloading" the task escalates with reason
|
|
135
|
+
* `local-unavailable`. Call `task.prepare()` from a user gesture to download the model.
|
|
136
|
+
*/
|
|
137
|
+
export function classifierApi(options: ClassifierApiOptions = {}): LocalRunner<any> {
|
|
138
|
+
const mode = options.confidence ?? 'label-probability';
|
|
139
|
+
const maxSessions = Math.max(1, options.maxSessions ?? 4);
|
|
140
|
+
const sessions = new Map<string, Promise<ClassifierInstance>>();
|
|
141
|
+
|
|
142
|
+
const api = (): ClassifierStatic | undefined =>
|
|
143
|
+
options.classifier ?? (globalThis as { Classifier?: ClassifierStatic }).Classifier;
|
|
144
|
+
|
|
145
|
+
const schemaFor = (ctx: Ctx): ClassifierSchema =>
|
|
146
|
+
toClassifierSchema(ctx.schema, {
|
|
147
|
+
...(options.questionId ? { questionId: options.questionId } : {}),
|
|
148
|
+
...(ctx.context ? { context: ctx.context } : {}),
|
|
149
|
+
...(options.expectedInputs ? { expectedInputs: options.expectedInputs } : {}),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const keyFor = (schema: ClassifierSchema): string => JSON.stringify(schema);
|
|
153
|
+
|
|
154
|
+
function session(ctx: Ctx, prepare?: PrepareOptions): Promise<ClassifierInstance> {
|
|
155
|
+
const Classifier = api();
|
|
156
|
+
if (!Classifier) return Promise.reject(new LocalUnavailableError('Classifier API is not available'));
|
|
157
|
+
const schema = schemaFor(ctx);
|
|
158
|
+
const key = keyFor(schema);
|
|
159
|
+
const cached = sessions.get(key);
|
|
160
|
+
if (cached) {
|
|
161
|
+
sessions.delete(key); // refresh LRU position
|
|
162
|
+
sessions.set(key, cached);
|
|
163
|
+
return cached;
|
|
164
|
+
}
|
|
165
|
+
// Only prepare()'s signal: the session is cached and shared, so a per-run abort must not destroy it.
|
|
166
|
+
const signal = prepare?.signal;
|
|
167
|
+
const created = Classifier.create({
|
|
168
|
+
...schema,
|
|
169
|
+
...(signal ? { signal } : {}),
|
|
170
|
+
...(prepare?.onProgress
|
|
171
|
+
? {
|
|
172
|
+
monitor: (m: EventTarget) =>
|
|
173
|
+
m.addEventListener('downloadprogress', (e) => prepare.onProgress!((e as Event & { loaded: number }).loaded)),
|
|
174
|
+
}
|
|
175
|
+
: {}),
|
|
176
|
+
});
|
|
177
|
+
sessions.set(key, created);
|
|
178
|
+
created.catch(() => {
|
|
179
|
+
if (sessions.get(key) === created) sessions.delete(key);
|
|
180
|
+
});
|
|
181
|
+
while (sessions.size > maxSessions) {
|
|
182
|
+
const [oldestKey, oldest] = sessions.entries().next().value as [string, Promise<ClassifierInstance>];
|
|
183
|
+
sessions.delete(oldestKey);
|
|
184
|
+
oldest.then((s) => s.destroy(), () => {});
|
|
185
|
+
}
|
|
186
|
+
return created;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function availability(ctx: Ctx): Promise<Availability> {
|
|
190
|
+
const Classifier = api();
|
|
191
|
+
if (!Classifier) return 'unavailable';
|
|
192
|
+
const schema = schemaFor(ctx); // throws for unsupported schemas
|
|
193
|
+
if (sessions.has(keyFor(schema))) return 'available';
|
|
194
|
+
try {
|
|
195
|
+
return await Classifier.availability(schema);
|
|
196
|
+
} catch {
|
|
197
|
+
return 'unavailable';
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
id: 'classifier-api',
|
|
203
|
+
availability,
|
|
204
|
+
|
|
205
|
+
async run(input: string, ctx: Ctx) {
|
|
206
|
+
const schema = schemaFor(ctx);
|
|
207
|
+
if (!sessions.has(keyFor(schema))) {
|
|
208
|
+
// Creating a session while the model is not downloaded would start a download.
|
|
209
|
+
const state = await availability(ctx);
|
|
210
|
+
if (state !== 'available') throw new LocalUnavailableError(`Classifier model is ${state}`);
|
|
211
|
+
}
|
|
212
|
+
const classifier = await session(ctx);
|
|
213
|
+
const result = await classifier.classify(input, ctx.signal ? { signal: ctx.signal } : {});
|
|
214
|
+
const questionId = schema.questions[0]!.id;
|
|
215
|
+
return readDecision(ctx.schema as ClassifierTaskSchema, result?.[questionId], mode);
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
async prepare(ctx: Ctx, prepareOptions?: PrepareOptions) {
|
|
219
|
+
const state = await availability(ctx);
|
|
220
|
+
if (state === 'unavailable') throw new LocalUnavailableError('Classifier model is unavailable on this device');
|
|
221
|
+
await session(ctx, prepareOptions);
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
destroy() {
|
|
225
|
+
for (const s of sessions.values()) s.then((c) => c.destroy(), () => {});
|
|
226
|
+
sessions.clear();
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { classifierApi, readDecision, toClassifierSchema } from './classifier.js';
|
|
2
|
+
export type { ClassifierApiOptions, ClassifierTaskSchema } from './classifier.js';
|
|
3
|
+
export { promptApi } from './prompt.js';
|
|
4
|
+
export type { PromptApiOptions } from './prompt.js';
|
|
5
|
+
export { classifierJudge, DEFAULT_JUDGE_QUESTION } from './judge.js';
|
|
6
|
+
export type { ClassifierJudgeOptions } from './judge.js';
|
|
7
|
+
export type * from './classifier-types.js';
|
|
8
|
+
export type * from './prompt-types.js';
|
package/src/judge.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Judge, PrepareOptions, RunnerContext } from '@swissspidy/belay-core';
|
|
2
|
+
import { classifierApi } from './classifier.js';
|
|
3
|
+
import type { ClassifierExpectedInput, ClassifierStatic } from './classifier-types.js';
|
|
4
|
+
|
|
5
|
+
export interface ClassifierJudgeOptions {
|
|
6
|
+
/** The yes/no question the Classifier answers about the (input, output) pair. */
|
|
7
|
+
question?: string;
|
|
8
|
+
/** Shared context for the judge, e.g. what a good answer looks like for this task. */
|
|
9
|
+
context?: string;
|
|
10
|
+
/** Serializes the pair for the Classifier. Defaults to "Input: … / Proposed answer: …". */
|
|
11
|
+
format?: (input: string, value: unknown) => string;
|
|
12
|
+
/** Truncation limits, because on-device classifiers have small windows (e.g. 256 tokens). */
|
|
13
|
+
maxInputChars?: number;
|
|
14
|
+
maxValueChars?: number;
|
|
15
|
+
classifier?: ClassifierStatic;
|
|
16
|
+
expectedInputs?: ClassifierExpectedInput[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_JUDGE_QUESTION =
|
|
20
|
+
'Is the proposed answer a correct and complete response to the input, with no invented facts?';
|
|
21
|
+
|
|
22
|
+
const truncate = (s: string, max: number) => (s.length > max ? `${s.slice(0, max - 1)}…` : s);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A judge for generation tasks built on the Classifier API: one binary question over the input and
|
|
26
|
+
* the generated output. Its calibrated P(true) becomes the confidence (combined with schema
|
|
27
|
+
* validation by the task; see ADR 0001). Costs one classifier forward pass, not a second LLM call.
|
|
28
|
+
*/
|
|
29
|
+
export function classifierJudge(options: ClassifierJudgeOptions = {}): Judge<unknown> {
|
|
30
|
+
const runner = classifierApi({
|
|
31
|
+
...(options.classifier ? { classifier: options.classifier } : {}),
|
|
32
|
+
...(options.expectedInputs ? { expectedInputs: options.expectedInputs } : {}),
|
|
33
|
+
questionId: 'judgement',
|
|
34
|
+
});
|
|
35
|
+
const schema = { type: 'binary' as const, prompt: options.question ?? DEFAULT_JUDGE_QUESTION };
|
|
36
|
+
const maxInput = options.maxInputChars ?? 600;
|
|
37
|
+
const maxValue = options.maxValueChars ?? 400;
|
|
38
|
+
const format =
|
|
39
|
+
options.format ??
|
|
40
|
+
((input: string, value: unknown) =>
|
|
41
|
+
`Input:\n${truncate(input, maxInput)}\n\nProposed answer:\n${truncate(typeof value === 'string' ? value : JSON.stringify(value), maxValue)}`);
|
|
42
|
+
const ctx = (signal?: AbortSignal): RunnerContext<typeof schema> => ({
|
|
43
|
+
task: 'belay-judge',
|
|
44
|
+
schema,
|
|
45
|
+
...(options.context ? { context: options.context } : {}),
|
|
46
|
+
...(signal ? { signal } : {}),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const judge = (async ({ input, value, signal }) => {
|
|
50
|
+
const out = await runner.run(format(input, value), ctx(signal));
|
|
51
|
+
const pTrue = out.probabilities?.find((p) => p.label === 'true')?.probability;
|
|
52
|
+
if (typeof pTrue === 'number') return pTrue;
|
|
53
|
+
// Fall back to the returned label's probability.
|
|
54
|
+
return out.value === true ? out.confidence! : 1 - out.confidence!;
|
|
55
|
+
}) as Judge<unknown>;
|
|
56
|
+
judge.availability = () => runner.availability(ctx());
|
|
57
|
+
judge.prepare = (prepareOptions?: PrepareOptions) => runner.prepare!(ctx(), prepareOptions);
|
|
58
|
+
judge.destroy = () => runner.destroy?.();
|
|
59
|
+
return judge;
|
|
60
|
+
}
|