@byokit/decide 0.1.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.
- package/README.md +52 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +68 -0
- package/dist/eval.d.ts +39 -0
- package/dist/eval.js +50 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.js +97 -0
- package/dist/jev.d.ts +8 -0
- package/dist/jev.js +41 -0
- package/evals/example-urgent.jsonl +6 -0
- package/package.json +17 -0
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# @byokit/decide
|
|
2
|
+
|
|
3
|
+
Typed questions in, a typed answer with confidence out, and an abstain below a floor, so your app takes its safe
|
|
4
|
+
default (ask the person) instead of guessing. Backends: your own `rules`, and [Jev](https://openrouter.ai/docs/guides/community/jev)
|
|
5
|
+
over TypeSafe's API or OpenRouter.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { decide, jev, rules } from '@byokit/decide';
|
|
9
|
+
|
|
10
|
+
const backends = [
|
|
11
|
+
rules((s) => (/^(thanks|thank you)\b/i.test(s.text) ? 'chat' : undefined)), // the obvious cases, free
|
|
12
|
+
jev({ key: hostConfig.jevKey }), // or jev({ key: openRouterKey, via: 'openrouter' })
|
|
13
|
+
];
|
|
14
|
+
const { intent } = await decide({ text: 'can you check if the plumber replied?' }, {
|
|
15
|
+
intent: { kind: 'choice', options: { task: 'Something new to do', followup: 'About an earlier job', chat: 'Just talking' } },
|
|
16
|
+
}, { privacy: 'may-leave', backends });
|
|
17
|
+
|
|
18
|
+
if (intent.abstained) askThePerson(); else route(intent.answer);
|
|
19
|
+
// { answer: 'followup', confidence: 0.91, probabilities: {...}, abstained: false, by: 'jev', ms: 214 }
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
- **Questions**: `choice` (options with a one-line description each), `yesno`, and `score` (an ordered rubric, lowest
|
|
23
|
+
first; the answer is the level's index).
|
|
24
|
+
- **The floors are code, not a prompt** (ported from firstmate's dispatch resolver): a 0.6 floor on the answer's
|
|
25
|
+
confidence by default (`floor` per question); a choice option can declare its own floor (`floors`), checked against its
|
|
26
|
+
own probability, and a pick under it falls to the most probable other option that clears its own; a tie abstains.
|
|
27
|
+
An answer whose probabilities are missing an option, out of range or don't sum to 1 is an abstain, never an error.
|
|
28
|
+
- **Backends** are tried in order for the questions still unanswered. A backend that fails or takes longer than
|
|
29
|
+
`timeoutMs` (default 5 s) answers nothing. `privacy: 'stays-here'` skips every backend the state would leave the
|
|
30
|
+
device for (Jev), so private text never goes to one.
|
|
31
|
+
- **Keys**: `jev()` takes the key your host read from its own environment or config. The kit never reads an environment
|
|
32
|
+
variable, and the key goes only into the one request header. Never ship a key inside an app: keep it on the home
|
|
33
|
+
computer and let paired devices ask it.
|
|
34
|
+
|
|
35
|
+
## Evals
|
|
36
|
+
|
|
37
|
+
Each decision gets a labelled file, `evals/<decision>.jsonl`: a header `{ decision, question, note }`, then one case per
|
|
38
|
+
line, `{ state, expect, jev, ms }`. `expect` is the right answer, a list of right answers, or `null` when only an abstain
|
|
39
|
+
is right; `jev` is a Jev-shaped answer, replayed offline so CI never calls a model. The included example is hand-made,
|
|
40
|
+
not a live recording.
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
npx --package=@byokit/decide byokit-eval evals/intent.jsonl # replay stored answers
|
|
44
|
+
npx --package=@byokit/decide byokit-eval evals/intent.jsonl --floor 0.7 # try another floor
|
|
45
|
+
TYPESAFE_API_KEY=… npx --package=@byokit/decide byokit-eval evals/intent.jsonl --live typesafe --record
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Agreement counts right answers and correctly expected abstentions; abstentions are also reported separately. Clear-but-wrong
|
|
49
|
+
(answered, and wrong) is the number that must stay near 0; the command exits 1 when its rate is above
|
|
50
|
+
`--max-clear-wrong` (default 0). Set floors from the eval, not by guessing. `evaluate()` in `@byokit/decide/eval` runs the
|
|
51
|
+
same report over any backends, including your rules. `--record` keeps previous answers when a live refresh fails and
|
|
52
|
+
marks a partially refreshed file as such. `evals/example-urgent.jsonl` shows the format.
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// byokit-eval <file.jsonl>... [--floor N] [--max-clear-wrong RATE] [--live typesafe|openrouter [--record]]
|
|
3
|
+
// Offline by default: replays stored Jev-shaped answers. --live asks Jev with the caller's TYPESAFE_API_KEY
|
|
4
|
+
// (or OPENROUTER_API_KEY with --live openrouter); --record writes successful refreshes back into the file.
|
|
5
|
+
// Exits 1 when a file's clear-but-wrong rate is above --max-clear-wrong (default 0).
|
|
6
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { parseArgs } from 'node:util';
|
|
8
|
+
import { decide, jev } from "./index.js";
|
|
9
|
+
import { evaluate, format, parse, replay, summary } from "./eval.js";
|
|
10
|
+
const { values: o, positionals: files } = (() => {
|
|
11
|
+
try {
|
|
12
|
+
return parseArgs({
|
|
13
|
+
allowPositionals: true,
|
|
14
|
+
options: { floor: { type: 'string' }, 'max-clear-wrong': { type: 'string', default: '0' }, live: { type: 'string' }, record: { type: 'boolean' } },
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
console.error('invalid arguments');
|
|
19
|
+
process.exit(2);
|
|
20
|
+
}
|
|
21
|
+
})();
|
|
22
|
+
const validRate = (value) => value.trim() !== '' && Number.isFinite(Number(value)) && Number(value) >= 0 && Number(value) <= 1;
|
|
23
|
+
if (!files.length || (o.live && o.live !== 'typesafe' && o.live !== 'openrouter') ||
|
|
24
|
+
(o.floor !== undefined && !validRate(o.floor)) || !validRate(o['max-clear-wrong'])) {
|
|
25
|
+
console.error('usage: byokit-eval <file.jsonl>... [--floor N] [--max-clear-wrong RATE] [--live typesafe|openrouter [--record]]');
|
|
26
|
+
process.exit(2);
|
|
27
|
+
}
|
|
28
|
+
const via = o.live;
|
|
29
|
+
const key = via && process.env[via === 'typesafe' ? 'TYPESAFE_API_KEY' : 'OPENROUTER_API_KEY'];
|
|
30
|
+
if (via && !key) {
|
|
31
|
+
console.error(`--live ${via} needs ${via === 'typesafe' ? 'TYPESAFE_API_KEY' : 'OPENROUTER_API_KEY'} set for this one command`);
|
|
32
|
+
process.exit(2);
|
|
33
|
+
}
|
|
34
|
+
let failed = false;
|
|
35
|
+
for (const path of files) {
|
|
36
|
+
const f = parse(readFileSync(path, 'utf8'));
|
|
37
|
+
const q = o.floor ? { ...f.question, floor: Number(o.floor) } : f.question;
|
|
38
|
+
let ask = replay(q);
|
|
39
|
+
let refreshed = 0;
|
|
40
|
+
if (via && key) {
|
|
41
|
+
let last;
|
|
42
|
+
// Keeps Jev's own answer for --record; the key stays inside jev().
|
|
43
|
+
const keep = async (url, init) => {
|
|
44
|
+
const res = await fetch(url, init);
|
|
45
|
+
last = res.ok ? (await res.clone().json())?.answers?.[f.decision] : undefined;
|
|
46
|
+
return res;
|
|
47
|
+
};
|
|
48
|
+
const backend = jev({ key, via, fetch: keep });
|
|
49
|
+
ask = async (c) => {
|
|
50
|
+
last = undefined;
|
|
51
|
+
const a = (await decide(c.state, { [f.decision]: q }, { privacy: 'may-leave', backends: [backend] }))[f.decision];
|
|
52
|
+
if (o.record && a.probabilities) {
|
|
53
|
+
Object.assign(c, { jev: last, ms: a.ms });
|
|
54
|
+
refreshed++;
|
|
55
|
+
}
|
|
56
|
+
return a;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const r = await evaluate(f.cases, ask);
|
|
60
|
+
console.log(summary(`${path}: ${f.decision}`, via ? `jev via ${via}` : 'jev, recorded', r));
|
|
61
|
+
if (via && o.record && refreshed)
|
|
62
|
+
writeFileSync(path, format({ ...f, note: refreshed === f.cases.length
|
|
63
|
+
? `answers recorded live from Jev via ${via}, ${new Date().toISOString().slice(0, 10)}`
|
|
64
|
+
: `partial live refresh ${refreshed}/${f.cases.length} via ${via}; ${f.note ?? 'previous provenance unknown'}` }));
|
|
65
|
+
if (r.cases && r.clearWrong / r.cases > Number(o['max-clear-wrong']))
|
|
66
|
+
failed = true;
|
|
67
|
+
}
|
|
68
|
+
process.exit(failed ? 1 : 0);
|
package/dist/eval.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type Answer, type Question } from './index.ts';
|
|
2
|
+
export type Case = {
|
|
3
|
+
state: unknown;
|
|
4
|
+
expect: string | boolean | number | null | Array<string | boolean | number>;
|
|
5
|
+
jev?: unknown;
|
|
6
|
+
ms?: number;
|
|
7
|
+
};
|
|
8
|
+
/** `note` says where the stored answers came from. */
|
|
9
|
+
export type EvalFile = {
|
|
10
|
+
decision: string;
|
|
11
|
+
question: Question;
|
|
12
|
+
note?: string;
|
|
13
|
+
cases: Case[];
|
|
14
|
+
};
|
|
15
|
+
export type Report = {
|
|
16
|
+
cases: number;
|
|
17
|
+
agree: number;
|
|
18
|
+
abstained: number;
|
|
19
|
+
/** Answered, and not a right answer: the number that must stay near 0. */
|
|
20
|
+
clearWrong: number;
|
|
21
|
+
ms: {
|
|
22
|
+
min: number;
|
|
23
|
+
median: number;
|
|
24
|
+
max: number;
|
|
25
|
+
};
|
|
26
|
+
wrong: Array<{
|
|
27
|
+
line: number;
|
|
28
|
+
expect: Case['expect'];
|
|
29
|
+
got: Answer['answer'];
|
|
30
|
+
confidence: number;
|
|
31
|
+
}>;
|
|
32
|
+
};
|
|
33
|
+
export declare function parse(text: string): EvalFile;
|
|
34
|
+
export declare function format(f: EvalFile): string;
|
|
35
|
+
/** Scores `ask` over the cases, in order. */
|
|
36
|
+
export declare function evaluate(cases: Case[], ask: (c: Case) => Promise<Answer>): Promise<Report>;
|
|
37
|
+
/** A stored Jev-shaped answer through the same floors a live answer takes. */
|
|
38
|
+
export declare function replay(q: Question): (c: Case) => Promise<Answer>;
|
|
39
|
+
export declare function summary(name: string, by: string, r: Report): string;
|
package/dist/eval.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// An eval file is JSONL: a header line `{ decision, question, note? }`, then one labelled case per line,
|
|
2
|
+
// `{ state, expect, jev?, ms? }`. `expect` is the right answer, a list of right answers, or null when only an abstain is
|
|
3
|
+
// right. `jev` is a stored Jev-shaped answer replayed offline (CI never calls a model); --live --record refreshes it.
|
|
4
|
+
import { resolve } from "./index.js";
|
|
5
|
+
import { raw } from "./jev.js";
|
|
6
|
+
export function parse(text) {
|
|
7
|
+
const [head, ...rest] = text.split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l));
|
|
8
|
+
if (!head?.decision || !head.question)
|
|
9
|
+
throw new Error('the first line needs decision and question');
|
|
10
|
+
return { ...head, cases: rest };
|
|
11
|
+
}
|
|
12
|
+
export function format(f) {
|
|
13
|
+
const { cases, ...head } = f;
|
|
14
|
+
return [head, ...cases].map((l) => JSON.stringify(l)).join('\n') + '\n';
|
|
15
|
+
}
|
|
16
|
+
/** Scores `ask` over the cases, in order. */
|
|
17
|
+
export async function evaluate(cases, ask) {
|
|
18
|
+
const r = { cases: cases.length, agree: 0, abstained: 0, clearWrong: 0, ms: { min: 0, median: 0, max: 0 }, wrong: [] };
|
|
19
|
+
const ms = [];
|
|
20
|
+
for (const [i, c] of cases.entries()) {
|
|
21
|
+
const a = await ask(c);
|
|
22
|
+
ms.push(a.ms);
|
|
23
|
+
const right = c.expect === null ? [] : Array.isArray(c.expect) ? c.expect : [c.expect];
|
|
24
|
+
if (a.abstained) {
|
|
25
|
+
r.abstained++;
|
|
26
|
+
if (c.expect === null)
|
|
27
|
+
r.agree++;
|
|
28
|
+
}
|
|
29
|
+
else if (right.includes(a.answer))
|
|
30
|
+
r.agree++;
|
|
31
|
+
else
|
|
32
|
+
r.clearWrong++, r.wrong.push({ line: i + 2, expect: c.expect, got: a.answer, confidence: a.confidence });
|
|
33
|
+
}
|
|
34
|
+
ms.sort((a, b) => a - b);
|
|
35
|
+
if (ms.length)
|
|
36
|
+
r.ms = { min: ms[0], median: ms[Math.floor((ms.length - 1) / 2)], max: ms[ms.length - 1] };
|
|
37
|
+
return r;
|
|
38
|
+
}
|
|
39
|
+
/** A stored Jev-shaped answer through the same floors a live answer takes. */
|
|
40
|
+
export function replay(q) {
|
|
41
|
+
return async (c) => ({ ...resolve(q, raw(q, c.jev)), by: 'jev (recorded)', ms: c.ms ?? 0 });
|
|
42
|
+
}
|
|
43
|
+
export function summary(name, by, r) {
|
|
44
|
+
const pct = (n) => `${r.cases ? Math.round((n / r.cases) * 100) : 0}%`;
|
|
45
|
+
return [
|
|
46
|
+
`${name} (${by}): ${r.cases} cases`,
|
|
47
|
+
` agree ${r.agree}/${r.cases} clear-but-wrong ${r.clearWrong} (${pct(r.clearWrong)}) abstained ${r.abstained} (${pct(r.abstained)}) ms min/median/max ${r.ms.min}/${r.ms.median}/${r.ms.max}`,
|
|
48
|
+
...r.wrong.map((w) => ` wrong: line ${w.line} expected ${JSON.stringify(w.expect)}, got ${JSON.stringify(w.got)} at ${w.confidence}`),
|
|
49
|
+
].join('\n');
|
|
50
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export { jev } from './jev.ts';
|
|
2
|
+
export type Question =
|
|
3
|
+
/** Pick one option; `floors` holds an option's own floor, checked against that option's probability. */
|
|
4
|
+
{
|
|
5
|
+
kind: 'choice';
|
|
6
|
+
options: Record<string, string>;
|
|
7
|
+
instructions?: string;
|
|
8
|
+
floor?: number;
|
|
9
|
+
floors?: Record<string, number>;
|
|
10
|
+
} | {
|
|
11
|
+
kind: 'yesno';
|
|
12
|
+
question: string;
|
|
13
|
+
yes?: string;
|
|
14
|
+
no?: string;
|
|
15
|
+
floor?: number;
|
|
16
|
+
}
|
|
17
|
+
/** An ordered rubric, lowest first. The answer is the most probable level's index. */
|
|
18
|
+
| {
|
|
19
|
+
kind: 'score';
|
|
20
|
+
levels: string[];
|
|
21
|
+
instructions?: string;
|
|
22
|
+
floor?: number;
|
|
23
|
+
};
|
|
24
|
+
export type Answer = {
|
|
25
|
+
/** null when abstained: the app takes its safe default (ask a person). */
|
|
26
|
+
answer: string | boolean | number | null;
|
|
27
|
+
confidence: number;
|
|
28
|
+
probabilities?: Record<string, number>;
|
|
29
|
+
abstained: boolean;
|
|
30
|
+
/** Why it abstained, or which runner-up it fell to. For logs, not for people. */
|
|
31
|
+
reason?: string;
|
|
32
|
+
by: string;
|
|
33
|
+
ms: number;
|
|
34
|
+
};
|
|
35
|
+
/** A backend's answer before the floors: every option's probability, keyed as options (choice), 'true'/'false'
|
|
36
|
+
* (yesno) or level indexes (score). A missing or malformed one is an abstain. */
|
|
37
|
+
export type Raw = {
|
|
38
|
+
probabilities: Record<string, number>;
|
|
39
|
+
confidence?: number;
|
|
40
|
+
pick?: string;
|
|
41
|
+
};
|
|
42
|
+
export type Backend = {
|
|
43
|
+
name: string;
|
|
44
|
+
/** Whether the state leaves this device. Such a backend is skipped for `privacy: 'stays-here'`. */
|
|
45
|
+
leaves: boolean;
|
|
46
|
+
ask(state: unknown, questions: Record<string, Question>, signal: AbortSignal): Promise<Record<string, Raw | undefined>>;
|
|
47
|
+
};
|
|
48
|
+
export type Options = {
|
|
49
|
+
privacy: 'stays-here' | 'may-leave';
|
|
50
|
+
backends: Backend[];
|
|
51
|
+
timeoutMs?: number;
|
|
52
|
+
};
|
|
53
|
+
export declare const FLOOR = 0.6;
|
|
54
|
+
/** Asks each backend in order for the questions still unanswered; a failed or slow backend answers nothing. */
|
|
55
|
+
export declare function decide(state: unknown, questions: Record<string, Question>, opts: Options): Promise<Record<string, Answer>>;
|
|
56
|
+
/** The floors on one raw answer. Exported for apps that hold a recorded answer. */
|
|
57
|
+
export declare function resolve(q: Question, raw: Raw | undefined): Omit<Answer, 'by' | 'ms'>;
|
|
58
|
+
/** The app's own function as a backend: return the answer when the case is obvious, undefined otherwise. Stays here. */
|
|
59
|
+
export declare function rules(fn: (state: any, name: string, q: Question) => string | boolean | number | undefined): Backend;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Typed questions in, a typed answer with confidence out, abstaining below a floor. The floor, the per-option floors,
|
|
2
|
+
// the runner-up and the tie are code, never a prompt: ported from firstmate's bin/fm-dispatch-resolve.sh.
|
|
3
|
+
export { jev } from "./jev.js";
|
|
4
|
+
export const FLOOR = 0.6;
|
|
5
|
+
/** Asks each backend in order for the questions still unanswered; a failed or slow backend answers nothing. */
|
|
6
|
+
export async function decide(state, questions, opts) {
|
|
7
|
+
const out = Object.create(null);
|
|
8
|
+
const open = () => Object.fromEntries(Object.entries(questions).filter(([k]) => !Object.hasOwn(out, k) || out[k].abstained));
|
|
9
|
+
for (const b of opts.backends) {
|
|
10
|
+
if (b.leaves && opts.privacy !== 'may-leave')
|
|
11
|
+
continue;
|
|
12
|
+
const todo = open();
|
|
13
|
+
if (!Object.keys(todo).length)
|
|
14
|
+
break;
|
|
15
|
+
const t0 = Date.now();
|
|
16
|
+
let raws = {};
|
|
17
|
+
let failed = '';
|
|
18
|
+
const controller = new AbortController();
|
|
19
|
+
let timer;
|
|
20
|
+
const deadline = new Promise((_, reject) => {
|
|
21
|
+
timer = setTimeout(() => { controller.abort(); reject(new Error('timed out')); }, opts.timeoutMs ?? 5000);
|
|
22
|
+
});
|
|
23
|
+
try {
|
|
24
|
+
raws = await Promise.race([b.ask(state, todo, controller.signal), deadline]);
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
failed = `${b.name} failed: ${e.message}`;
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
}
|
|
32
|
+
const ms = Date.now() - t0;
|
|
33
|
+
for (const [k, q] of Object.entries(todo)) {
|
|
34
|
+
const raw = Object.hasOwn(raws, k) ? raws[k] : undefined;
|
|
35
|
+
const a = { ...resolve(q, raw), by: b.name, ms };
|
|
36
|
+
if (!raw && failed)
|
|
37
|
+
a.reason = failed;
|
|
38
|
+
if (!Object.hasOwn(out, k) || !a.abstained || a.probabilities)
|
|
39
|
+
out[k] = a;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
for (const k of Object.keys(questions))
|
|
43
|
+
if (!Object.hasOwn(out, k))
|
|
44
|
+
out[k] = { answer: null, confidence: 0, abstained: true, reason: 'no backend answered', by: 'none', ms: 0 };
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
/** The floors on one raw answer. Exported for apps that hold a recorded answer. */
|
|
48
|
+
export function resolve(q, raw) {
|
|
49
|
+
const keys = q.kind === 'choice' ? Object.keys(q.options) : q.kind === 'yesno' ? ['true', 'false'] : q.levels.map((_, i) => String(i));
|
|
50
|
+
const p = raw?.probabilities;
|
|
51
|
+
const ok = p && Object.keys(p).length === keys.length && keys.every((k) => Object.hasOwn(p, k) && typeof p[k] === 'number' && p[k] >= 0 && p[k] <= 1)
|
|
52
|
+
&& Math.abs(keys.reduce((s, k) => s + p[k], 0) - 1) <= 0.01
|
|
53
|
+
&& (raw.confidence === undefined || (raw.confidence >= 0 && raw.confidence <= 1))
|
|
54
|
+
&& (raw.pick === undefined || keys.includes(raw.pick));
|
|
55
|
+
if (!ok)
|
|
56
|
+
return { answer: null, confidence: 0, abstained: true, reason: raw ? 'malformed answer' : 'no answer' };
|
|
57
|
+
const ranked = [...keys].sort((a, b) => p[b] - p[a]);
|
|
58
|
+
const picked = raw.pick ?? ranked[0];
|
|
59
|
+
const confidence = raw.confidence ?? p[picked];
|
|
60
|
+
const floor = q.floor ?? FLOOR;
|
|
61
|
+
const own = (k) => (q.kind === 'choice' && q.floors && Object.hasOwn(q.floors, k) ? q.floors[k] : undefined) ?? floor;
|
|
62
|
+
const typed = (k) => (q.kind === 'choice' ? k : q.kind === 'yesno' ? k === 'true' : Number(k));
|
|
63
|
+
const done = (k, reason) => ({ answer: typed(k), confidence: k === picked ? confidence : p[k], probabilities: p, abstained: false, ...(reason && { reason }) });
|
|
64
|
+
const abstain = (reason) => ({ answer: null, confidence, probabilities: p, abstained: true, reason });
|
|
65
|
+
if (p[ranked[0]] === p[ranked[1]])
|
|
66
|
+
return abstain('tie');
|
|
67
|
+
// Without a declared floor on the pick, the one floor applies to the answer's confidence, exactly as firstmate's.
|
|
68
|
+
if (!(q.kind === 'choice' && q.floors && Object.hasOwn(q.floors, picked)))
|
|
69
|
+
return confidence >= floor ? done(picked) : abstain(`confidence ${confidence} below floor ${floor}`);
|
|
70
|
+
if (p[picked] >= own(picked))
|
|
71
|
+
return done(picked);
|
|
72
|
+
// A runner-up never needs weaker support than it would as the pick: it must clear its own floor.
|
|
73
|
+
const clear = ranked.filter((k) => k !== picked && p[k] >= own(k));
|
|
74
|
+
if (!clear.length)
|
|
75
|
+
return abstain(`${picked} probability ${p[picked]} below its floor ${own(picked)}; no other option clears its own`);
|
|
76
|
+
if (clear.length > 1 && p[clear[0]] === p[clear[1]])
|
|
77
|
+
return abstain('runner-up tie');
|
|
78
|
+
return done(clear[0], `fell to ${clear[0]}: ${picked} probability ${p[picked]} below its floor ${own(picked)}`);
|
|
79
|
+
}
|
|
80
|
+
/** The app's own function as a backend: return the answer when the case is obvious, undefined otherwise. Stays here. */
|
|
81
|
+
export function rules(fn) {
|
|
82
|
+
return {
|
|
83
|
+
name: 'rules',
|
|
84
|
+
leaves: false,
|
|
85
|
+
async ask(state, questions) {
|
|
86
|
+
const out = Object.create(null);
|
|
87
|
+
for (const [k, q] of Object.entries(questions)) {
|
|
88
|
+
const a = fn(state, k, q);
|
|
89
|
+
if (a === undefined)
|
|
90
|
+
continue;
|
|
91
|
+
const keys = q.kind === 'choice' ? Object.keys(q.options) : q.kind === 'yesno' ? ['true', 'false'] : q.levels.map((_, i) => String(i));
|
|
92
|
+
out[k] = { probabilities: Object.fromEntries(keys.map((o) => [o, o === String(a) ? 1 : 0])) };
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
package/dist/jev.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Backend, Question, Raw } from './index.ts';
|
|
2
|
+
export declare function jev(opts: {
|
|
3
|
+
key: string;
|
|
4
|
+
via?: 'typesafe' | 'openrouter';
|
|
5
|
+
fetch?: typeof fetch;
|
|
6
|
+
}): Backend;
|
|
7
|
+
/** Jev's answer as a Raw; anything off-shape is undefined, which the floors treat as an abstain. */
|
|
8
|
+
export declare function raw(q: Question, a: any): Raw | undefined;
|
package/dist/jev.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const BASE = { typesafe: 'https://api.typesafe.ai', openrouter: 'https://openrouter.ai/api' };
|
|
2
|
+
export function jev(opts) {
|
|
3
|
+
const { key, via = 'typesafe', fetch: f = globalThis.fetch } = opts;
|
|
4
|
+
if (!key)
|
|
5
|
+
throw new Error('jev needs a key');
|
|
6
|
+
return {
|
|
7
|
+
name: 'jev',
|
|
8
|
+
leaves: true,
|
|
9
|
+
async ask(state, questions, signal) {
|
|
10
|
+
const res = await f(`${BASE[via]}/v1/systemone`, {
|
|
11
|
+
method: 'POST',
|
|
12
|
+
signal,
|
|
13
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
|
|
14
|
+
body: JSON.stringify({ model: 'jev-latest', state, questions: Object.fromEntries(Object.entries(questions).map(([k, q]) => [k, wire(q)])) }),
|
|
15
|
+
});
|
|
16
|
+
if (!res.ok)
|
|
17
|
+
throw new Error(`http ${res.status}`);
|
|
18
|
+
const answers = (await res.json())?.answers ?? {};
|
|
19
|
+
return Object.fromEntries(Object.entries(questions).map(([k, q]) => [k, raw(q, Object.hasOwn(answers, k) ? answers[k] : undefined)]));
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function wire(q) {
|
|
24
|
+
if (q.kind === 'choice')
|
|
25
|
+
return { type: 'choice', instructions: q.instructions ?? 'Which option fits the state?', criteria: q.options };
|
|
26
|
+
if (q.kind === 'yesno')
|
|
27
|
+
return { type: 'noul', instructions: q.question, ...(q.yes && q.no && { criteria: { true: q.yes, false: q.no } }) };
|
|
28
|
+
return { type: 'score', instructions: q.instructions ?? 'Where does the state fall on this scale?', criteria: q.levels };
|
|
29
|
+
}
|
|
30
|
+
/** Jev's answer as a Raw; anything off-shape is undefined, which the floors treat as an abstain. */
|
|
31
|
+
export function raw(q, a) {
|
|
32
|
+
if (!a || typeof a !== 'object')
|
|
33
|
+
return undefined;
|
|
34
|
+
if (q.kind === 'yesno')
|
|
35
|
+
return typeof a.noul === 'number' ? { probabilities: { true: a.noul, false: 1 - a.noul } } : undefined;
|
|
36
|
+
if (typeof a.probabilities !== 'object' || typeof a.confidence !== 'number')
|
|
37
|
+
return undefined;
|
|
38
|
+
if (q.kind === 'choice')
|
|
39
|
+
return typeof a.choice === 'string' ? { probabilities: a.probabilities, confidence: a.confidence, pick: a.choice } : undefined;
|
|
40
|
+
return { probabilities: a.probabilities, confidence: a.confidence };
|
|
41
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{"decision":"urgent","question":{"kind":"yesno","question":"Does this message need an answer today?","yes":"Something is time-sensitive today","no":"It can wait"},"note":"example: hand-made answers in Jev's shape, not recorded; refresh with byokit-eval --live typesafe --record"}
|
|
2
|
+
{"state":"The school called, Maya has a fever, can someone pick her up before 2?","expect":true,"jev":{"type":"noul","noul":0.97},"ms":180}
|
|
3
|
+
{"state":"When you get a chance, send me that lasagna recipe.","expect":false,"jev":{"type":"noul","noul":0.04},"ms":165}
|
|
4
|
+
{"state":"Dinner at grandma's is still on for Sunday, right?","expect":false,"jev":{"type":"noul","noul":0.31},"ms":172}
|
|
5
|
+
{"state":"The plumber is here and wants to know if he can turn the water off now.","expect":true,"jev":{"type":"noul","noul":0.93},"ms":190}
|
|
6
|
+
{"state":"Can you look at the car insurance renewal sometime?","expect":[true,false],"jev":{"type":"noul","noul":0.52},"ms":201}
|
package/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@byokit/decide",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Typed questions in, a typed answer with confidence out, abstaining below a floor. Rules and Jev backends, and an eval runner.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": { "type": "git", "url": "git+https://github.com/umeranjum17/byokit.git", "directory": "packages/decide" },
|
|
8
|
+
"engines": { "node": ">=22.18" },
|
|
9
|
+
"exports": {
|
|
10
|
+
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
|
|
11
|
+
"./eval": { "types": "./dist/eval.d.ts", "default": "./dist/eval.js" }
|
|
12
|
+
},
|
|
13
|
+
"bin": { "byokit-eval": "dist/cli.js" },
|
|
14
|
+
"files": ["dist", "evals"],
|
|
15
|
+
"scripts": { "prepack": "tsc -b" },
|
|
16
|
+
"publishConfig": { "access": "public" }
|
|
17
|
+
}
|