@jevable/core 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 +21 -0
- package/dist/cel/check.d.ts +12 -0
- package/dist/cel/check.js +101 -0
- package/dist/cel/env.d.ts +22 -0
- package/dist/cel/env.js +83 -0
- package/dist/cel/values.d.ts +5 -0
- package/dist/cel/values.js +28 -0
- package/dist/engine.d.ts +27 -0
- package/dist/engine.js +79 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +11 -0
- package/dist/program.d.ts +21 -0
- package/dist/program.js +61 -0
- package/dist/testing.d.ts +18 -0
- package/dist/testing.js +46 -0
- package/dist/text.d.ts +14 -0
- package/dist/text.js +55 -0
- package/dist/types.d.ts +21 -0
- package/dist/types.js +1 -0
- package/dist/typesafe/client.d.ts +48 -0
- package/dist/typesafe/client.js +59 -0
- package/package.json +32 -0
package/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# @jevable/core
|
|
2
|
+
|
|
3
|
+
The rule engine behind [`jev`](../cli/README.md): CEL expressions over a
|
|
4
|
+
record (`line`, `json`) or a window (`window`) that can ask
|
|
5
|
+
[Jev](https://docs.typesafe.ai) semantic questions through `judge.boolean`,
|
|
6
|
+
`judge.choice` and `judge.score`. Plain conditions decide first; Jev is asked
|
|
7
|
+
only when they cannot, and the same question on the same material once.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { Client, Engine, parseRecord, toCel } from "@jevable/core";
|
|
11
|
+
|
|
12
|
+
const engine = new Engine(new Client({ apiKey: process.env.TYPESAFE_API_KEY }));
|
|
13
|
+
const rule = engine.compile(`json.user != "bot" && judge.boolean(json.body, "Does this comment ask for a code change?") >= 0.7`);
|
|
14
|
+
|
|
15
|
+
const { line, json } = parseRecord(`{"user": "alice", "body": "can you rename this?"}`);
|
|
16
|
+
const { pass, calls, error } = await rule.match({ line, json: toCel(json) });
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`compile` throws a `RuleError` for a rule that cannot work (unknown variable,
|
|
20
|
+
unknown function, a judge call not compared with a threshold).
|
|
21
|
+
`@jevable/core/testing` serves a fake Jev for tests.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type Planned } from "./env.ts";
|
|
2
|
+
/** A rule that cannot work as written. */
|
|
3
|
+
export declare class RuleError extends Error {
|
|
4
|
+
}
|
|
5
|
+
export declare const HINT = "Variables: line, json (the parsed line) and, with --window, window. Run `jev guide` for functions and examples.";
|
|
6
|
+
/** Throws a RuleError when the parsed expression cannot work as a rule (or key). */
|
|
7
|
+
export declare function checkParsed(expr: unknown, kind: "rule" | "key"): void;
|
|
8
|
+
/** Dry-runs a planned expression with neutral answers; throws a RuleError on what only evaluation shows. */
|
|
9
|
+
export declare function checkPlanned(run: Planned, kind: "rule" | "key"): void;
|
|
10
|
+
/** Is a runtime error a bug in the rule rather than in the record? */
|
|
11
|
+
export declare function isRuleBug(err: Error): boolean;
|
|
12
|
+
export declare function describe(v: unknown): string;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Compile-time checks: catch a rule that cannot work before any record is
|
|
2
|
+
// read, since a rule that silently never passes looks exactly like quiet.
|
|
3
|
+
import { isCelError } from "@bufbuild/cel";
|
|
4
|
+
import { evaluateWith } from "./env.js";
|
|
5
|
+
/** A rule that cannot work as written. */
|
|
6
|
+
export class RuleError extends Error {
|
|
7
|
+
}
|
|
8
|
+
const VARIABLES = new Set(["line", "json", "window"]);
|
|
9
|
+
// Identifiers CEL itself provides: type names usable in `type(x) == int`.
|
|
10
|
+
const BUILTIN_IDENTS = new Set(["int", "uint", "double", "bool", "string", "bytes", "list", "map", "null_type", "type"]);
|
|
11
|
+
export const HINT = "Variables: line, json (the parsed line) and, with --window, window. Run `jev guide` for functions and examples.";
|
|
12
|
+
const COMPARE = `compare it, e.g. judge.boolean(line, "...") >= 0.7`;
|
|
13
|
+
// Values for the dry run, shaped like real ones.
|
|
14
|
+
const PROBE = {
|
|
15
|
+
line: "probe",
|
|
16
|
+
json: new Map(),
|
|
17
|
+
window: new Map([
|
|
18
|
+
["total", 0n], ["prev_total", 0n], ["kinds", 0n], ["seconds", 0n],
|
|
19
|
+
["start", ""], ["end", ""], ["groups", []], ["summary", "probe"],
|
|
20
|
+
]),
|
|
21
|
+
};
|
|
22
|
+
/** Throws a RuleError when the parsed expression cannot work as a rule (or key). */
|
|
23
|
+
export function checkParsed(expr, kind) {
|
|
24
|
+
const unknown = undeclared(expr);
|
|
25
|
+
if (unknown.length)
|
|
26
|
+
throw new RuleError(`unknown variable ${unknown.map((n) => `'${n}'`).join(", ")}\n\n${HINT}`);
|
|
27
|
+
if (kind === "rule" && isJudgeCall(expr))
|
|
28
|
+
throw new RuleError(`the rule must be true or false, but judge.* yields a number — ${COMPARE}`);
|
|
29
|
+
}
|
|
30
|
+
/** Dry-runs a planned expression with neutral answers; throws a RuleError on what only evaluation shows. */
|
|
31
|
+
export function checkPlanned(run, kind) {
|
|
32
|
+
const out = evaluateWith({ answers: null, calls: [], keys: [] }, run, PROBE);
|
|
33
|
+
if (isCelError(out) && /unbound function|no matching overload for 'judge\./.test(out.message)) {
|
|
34
|
+
throw new RuleError(`${out.message}\n\n${HINT}`);
|
|
35
|
+
}
|
|
36
|
+
if (kind === "rule" && !isCelError(out) && typeof out !== "boolean") {
|
|
37
|
+
throw new RuleError(`the rule must be true or false, but it yields ${describe(out)} — ${COMPARE}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Is a runtime error a bug in the rule rather than in the record? */
|
|
41
|
+
export function isRuleBug(err) {
|
|
42
|
+
return /unbound function|unresolved attribute/.test(err.message);
|
|
43
|
+
}
|
|
44
|
+
export function describe(v) {
|
|
45
|
+
if (typeof v === "number" || typeof v === "bigint")
|
|
46
|
+
return `a number (${v})`;
|
|
47
|
+
if (typeof v === "string")
|
|
48
|
+
return "a string";
|
|
49
|
+
return typeof v;
|
|
50
|
+
}
|
|
51
|
+
function isJudgeCall(e) {
|
|
52
|
+
const call = e?.exprKind?.case === "callExpr" ? e.exprKind.value : undefined;
|
|
53
|
+
const target = call?.target?.exprKind;
|
|
54
|
+
return target?.case === "identExpr" && target.value.name === "judge";
|
|
55
|
+
}
|
|
56
|
+
/** Names used as variables that are neither declared nor bound by a macro. */
|
|
57
|
+
function undeclared(root) {
|
|
58
|
+
const found = new Set();
|
|
59
|
+
const walk = (e, scope) => {
|
|
60
|
+
if (!e?.exprKind)
|
|
61
|
+
return;
|
|
62
|
+
const { case: kind, value: v } = e.exprKind;
|
|
63
|
+
switch (kind) {
|
|
64
|
+
case "identExpr":
|
|
65
|
+
if (!scope.has(v.name) && !VARIABLES.has(v.name) && !BUILTIN_IDENTS.has(v.name))
|
|
66
|
+
found.add(v.name);
|
|
67
|
+
return;
|
|
68
|
+
case "selectExpr":
|
|
69
|
+
return walk(v.operand, scope);
|
|
70
|
+
case "callExpr":
|
|
71
|
+
// judge.boolean(...) parses as a call on the identifier `judge`.
|
|
72
|
+
if (!(v.target?.exprKind?.case === "identExpr" && v.target.exprKind.value.name === "judge"))
|
|
73
|
+
walk(v.target, scope);
|
|
74
|
+
for (const a of v.args)
|
|
75
|
+
walk(a, scope);
|
|
76
|
+
return;
|
|
77
|
+
case "listExpr":
|
|
78
|
+
for (const el of v.elements)
|
|
79
|
+
walk(el, scope);
|
|
80
|
+
return;
|
|
81
|
+
case "structExpr":
|
|
82
|
+
for (const en of v.entries) {
|
|
83
|
+
if (en.keyKind?.case === "mapKey")
|
|
84
|
+
walk(en.keyKind.value, scope);
|
|
85
|
+
walk(en.value, scope);
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
case "comprehensionExpr": {
|
|
89
|
+
walk(v.iterRange, scope);
|
|
90
|
+
walk(v.accuInit, scope);
|
|
91
|
+
const inner = new Set([...scope, v.iterVar, v.iterVar2, v.accuVar].filter(Boolean));
|
|
92
|
+
walk(v.loopCondition, inner);
|
|
93
|
+
walk(v.loopStep, inner);
|
|
94
|
+
walk(v.result, inner);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
walk(root, new Set());
|
|
100
|
+
return [...found];
|
|
101
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type CelEnv, type CelInput } from "@bufbuild/cel";
|
|
2
|
+
import type { Answer, Question } from "../typesafe/client.ts";
|
|
3
|
+
import type { Call } from "../types.ts";
|
|
4
|
+
/** Where known answers come from; null in a pass means the compile-time dry run. */
|
|
5
|
+
export interface Answers {
|
|
6
|
+
cached(key: string): Answer | undefined;
|
|
7
|
+
}
|
|
8
|
+
/** One evaluation pass: the answers it may use, the first question it lacked, the calls it made. */
|
|
9
|
+
export interface Pass {
|
|
10
|
+
answers: Answers | null;
|
|
11
|
+
pending?: {
|
|
12
|
+
key: string;
|
|
13
|
+
state: unknown;
|
|
14
|
+
q: Question;
|
|
15
|
+
};
|
|
16
|
+
calls: Call[];
|
|
17
|
+
keys: string[];
|
|
18
|
+
}
|
|
19
|
+
export type Planned = (ctx: Record<string, CelInput>) => unknown;
|
|
20
|
+
/** Runs a planned expression within a pass; a thrown error is returned like a CEL error. */
|
|
21
|
+
export declare function evaluateWith(pass: Pass, run: Planned, ctx: Record<string, CelInput>): unknown;
|
|
22
|
+
export declare const env: CelEnv;
|
package/dist/cel/env.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// The CEL environment: judge.boolean / judge.choice / judge.score and
|
|
2
|
+
// fingerprint, and the evaluation pass they report into.
|
|
3
|
+
//
|
|
4
|
+
// CEL evaluation is synchronous and a Jev call is not, so a rule is
|
|
5
|
+
// evaluated in passes (program.ts): a judge call whose answer is not known
|
|
6
|
+
// yet records the question in the pass and fails it; the question is asked;
|
|
7
|
+
// the rule is evaluated again.
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { celEnv, celFunc, CelScalar, mapType } from "@bufbuild/cel";
|
|
10
|
+
import { strings } from "@bufbuild/cel/ext";
|
|
11
|
+
import { cutLongStrings, fingerprint } from "../text.js";
|
|
12
|
+
import { fromCel } from "./values.js";
|
|
13
|
+
// The pass in progress. Evaluation is synchronous, so one slot is enough.
|
|
14
|
+
let current;
|
|
15
|
+
/** Runs a planned expression within a pass; a thrown error is returned like a CEL error. */
|
|
16
|
+
export function evaluateWith(pass, run, ctx) {
|
|
17
|
+
current = pass;
|
|
18
|
+
try {
|
|
19
|
+
return run(ctx);
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
return err;
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
current = undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const FN = { noul: "boolean", choice: "choice", score: "score" };
|
|
29
|
+
function judge(type, material, question, criteria) {
|
|
30
|
+
const pass = current;
|
|
31
|
+
if (!pass)
|
|
32
|
+
throw new Error("judge.* evaluated outside a rule");
|
|
33
|
+
const q = { type, instructions: question };
|
|
34
|
+
if (criteria !== undefined)
|
|
35
|
+
q.criteria = fromCel(criteria);
|
|
36
|
+
if (!pass.answers)
|
|
37
|
+
return answerValue(type, probeAnswer(type, q));
|
|
38
|
+
const state = cutLongStrings(fromCel(material));
|
|
39
|
+
if (state === null || state === undefined || state === "")
|
|
40
|
+
throw new Error("judge material is empty");
|
|
41
|
+
const key = cacheKey(q, state);
|
|
42
|
+
const ans = pass.answers.cached(key);
|
|
43
|
+
if (!ans) {
|
|
44
|
+
pass.pending ??= { key, state, q };
|
|
45
|
+
throw new Error("waiting for Jev");
|
|
46
|
+
}
|
|
47
|
+
const call = { fn: FN[type], question };
|
|
48
|
+
if (type === "choice")
|
|
49
|
+
call.options = ans.probabilities ?? {};
|
|
50
|
+
else
|
|
51
|
+
call.value = (type === "score" ? ans.score : ans.noul) ?? 0;
|
|
52
|
+
pass.calls.push(call);
|
|
53
|
+
pass.keys.push(key);
|
|
54
|
+
return answerValue(type, ans);
|
|
55
|
+
}
|
|
56
|
+
function answerValue(type, ans) {
|
|
57
|
+
if (type === "choice")
|
|
58
|
+
return new Map(Object.entries(ans.probabilities ?? {}));
|
|
59
|
+
return (type === "score" ? ans.score : ans.noul) ?? 0;
|
|
60
|
+
}
|
|
61
|
+
/** A neutral answer for the compile-time dry run; nothing is sent. */
|
|
62
|
+
function probeAnswer(type, q) {
|
|
63
|
+
if (type === "choice") {
|
|
64
|
+
const options = Object.keys(q.criteria ?? {});
|
|
65
|
+
return { type, probabilities: Object.fromEntries(options.map((o) => [o, 1 / Math.max(options.length, 1)])) };
|
|
66
|
+
}
|
|
67
|
+
return type === "score" ? { type, score: 0 } : { type, noul: 0.5 };
|
|
68
|
+
}
|
|
69
|
+
function cacheKey(q, state) {
|
|
70
|
+
return createHash("sha256").update(JSON.stringify([q.type, q.instructions, q.criteria ?? null, state])).digest("hex");
|
|
71
|
+
}
|
|
72
|
+
const D = CelScalar.DYN;
|
|
73
|
+
const S = CelScalar.STRING;
|
|
74
|
+
export const env = celEnv({
|
|
75
|
+
funcs: [
|
|
76
|
+
...strings,
|
|
77
|
+
celFunc("judge.boolean", [D, S], CelScalar.DOUBLE, (m, q) => judge("noul", m, q)),
|
|
78
|
+
celFunc("judge.boolean", [D, S, D], CelScalar.DOUBLE, (m, q, c) => judge("noul", m, q, c)),
|
|
79
|
+
celFunc("judge.choice", [D, S, D], mapType(S, CelScalar.DOUBLE), (m, q, c) => judge("choice", m, q, c)),
|
|
80
|
+
celFunc("judge.score", [D, S, D], CelScalar.DOUBLE, (m, q, c) => judge("score", m, q, c)),
|
|
81
|
+
celFunc("fingerprint", [S], S, (s) => fingerprint(s)),
|
|
82
|
+
],
|
|
83
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type CelInput, type CelValue } from "@bufbuild/cel";
|
|
2
|
+
/** Turns a CEL value into plain JSON-able values. */
|
|
3
|
+
export declare function fromCel(v: CelValue | unknown): unknown;
|
|
4
|
+
/** Turns parsed JSON into CEL input: objects become maps. */
|
|
5
|
+
export declare function toCel(v: unknown): CelInput;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { isCelList, isCelMap, isCelUint } from "@bufbuild/cel";
|
|
2
|
+
/** Turns a CEL value into plain JSON-able values. */
|
|
3
|
+
export function fromCel(v) {
|
|
4
|
+
if (typeof v === "bigint")
|
|
5
|
+
return Number(v);
|
|
6
|
+
if (isCelUint(v))
|
|
7
|
+
return Number(v.value);
|
|
8
|
+
if (isCelMap(v)) {
|
|
9
|
+
const out = {};
|
|
10
|
+
for (const [k, x] of v)
|
|
11
|
+
out[String(isCelUint(k) ? k.value : k)] = fromCel(x);
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
if (isCelList(v))
|
|
15
|
+
return Array.from(v, fromCel);
|
|
16
|
+
if (v === null || typeof v !== "object")
|
|
17
|
+
return v;
|
|
18
|
+
return String(v);
|
|
19
|
+
}
|
|
20
|
+
/** Turns parsed JSON into CEL input: objects become maps. */
|
|
21
|
+
export function toCel(v) {
|
|
22
|
+
if (Array.isArray(v))
|
|
23
|
+
return v.map(toCel);
|
|
24
|
+
if (v !== null && typeof v === "object") {
|
|
25
|
+
return new Map(Object.entries(v).map(([k, x]) => [k, toCel(x)]));
|
|
26
|
+
}
|
|
27
|
+
return v;
|
|
28
|
+
}
|
package/dist/engine.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type Answers } from "./cel/env.ts";
|
|
2
|
+
import { Program } from "./program.ts";
|
|
3
|
+
import { type Answer, type Client, type Question } from "./typesafe/client.ts";
|
|
4
|
+
export declare const NO_KEY = "no Jev API key: set TYPESAFE_API_KEY (or JEV_API_KEY)";
|
|
5
|
+
/**
|
|
6
|
+
* What every program shares: the Jev client, an answer cache (the same
|
|
7
|
+
* question on the same material is asked once), and counters.
|
|
8
|
+
*/
|
|
9
|
+
export declare class Engine implements Answers {
|
|
10
|
+
readonly client: Client;
|
|
11
|
+
readonly stats: {
|
|
12
|
+
calls: number;
|
|
13
|
+
cacheHits: number;
|
|
14
|
+
tokens: number;
|
|
15
|
+
};
|
|
16
|
+
/** The first error retrying cannot fix (no key, a refused key). */
|
|
17
|
+
fatal: Error | undefined;
|
|
18
|
+
private readonly answers;
|
|
19
|
+
private readonly inflight;
|
|
20
|
+
constructor(client: Client);
|
|
21
|
+
get model(): string;
|
|
22
|
+
/** Compiles a rule (must yield true or false) or a key (anything); throws a RuleError when it cannot work. */
|
|
23
|
+
compile(source: string, kind?: "rule" | "key"): Program;
|
|
24
|
+
cached(key: string): Answer | undefined;
|
|
25
|
+
/** Asks one question unless its answer is known or on its way; reports whether this call asked. */
|
|
26
|
+
ask(key: string, state: unknown, q: Question, signal?: AbortSignal): Promise<boolean>;
|
|
27
|
+
}
|
package/dist/engine.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { parse, plan } from "@bufbuild/cel";
|
|
2
|
+
import { checkParsed, checkPlanned, HINT, RuleError } from "./cel/check.js";
|
|
3
|
+
import { env } from "./cel/env.js";
|
|
4
|
+
import { Program } from "./program.js";
|
|
5
|
+
import { HttpError } from "./typesafe/client.js";
|
|
6
|
+
export const NO_KEY = "no Jev API key: set TYPESAFE_API_KEY (or JEV_API_KEY)";
|
|
7
|
+
/**
|
|
8
|
+
* What every program shares: the Jev client, an answer cache (the same
|
|
9
|
+
* question on the same material is asked once), and counters.
|
|
10
|
+
*/
|
|
11
|
+
export class Engine {
|
|
12
|
+
client;
|
|
13
|
+
stats = { calls: 0, cacheHits: 0, tokens: 0 };
|
|
14
|
+
/** The first error retrying cannot fix (no key, a refused key). */
|
|
15
|
+
fatal;
|
|
16
|
+
answers = new Map();
|
|
17
|
+
inflight = new Map();
|
|
18
|
+
constructor(client) {
|
|
19
|
+
this.client = client;
|
|
20
|
+
}
|
|
21
|
+
get model() {
|
|
22
|
+
return this.client.model;
|
|
23
|
+
}
|
|
24
|
+
/** Compiles a rule (must yield true or false) or a key (anything); throws a RuleError when it cannot work. */
|
|
25
|
+
compile(source, kind = "rule") {
|
|
26
|
+
const text = source.trim();
|
|
27
|
+
if (!text)
|
|
28
|
+
throw new RuleError(`the ${kind} is empty`);
|
|
29
|
+
let parsed;
|
|
30
|
+
try {
|
|
31
|
+
parsed = parse(text);
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
throw new RuleError(`${err.message}\n\n${HINT}`);
|
|
35
|
+
}
|
|
36
|
+
checkParsed(parsed.expr, kind);
|
|
37
|
+
const run = plan(env, parsed);
|
|
38
|
+
checkPlanned(run, kind);
|
|
39
|
+
return new Program(this, text, run);
|
|
40
|
+
}
|
|
41
|
+
cached(key) {
|
|
42
|
+
return this.answers.get(key);
|
|
43
|
+
}
|
|
44
|
+
/** Asks one question unless its answer is known or on its way; reports whether this call asked. */
|
|
45
|
+
async ask(key, state, q, signal) {
|
|
46
|
+
if (this.fatal)
|
|
47
|
+
throw this.fatal;
|
|
48
|
+
if (this.answers.has(key))
|
|
49
|
+
return false;
|
|
50
|
+
const running = this.inflight.get(key);
|
|
51
|
+
if (running) {
|
|
52
|
+
await running;
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
if (!this.client.apiKey)
|
|
56
|
+
throw (this.fatal = new Error(NO_KEY));
|
|
57
|
+
const request = (async () => {
|
|
58
|
+
this.stats.calls++;
|
|
59
|
+
const res = await this.client.ask(state, { q }, signal);
|
|
60
|
+
this.stats.tokens += res.usage?.input_tokens ?? 0;
|
|
61
|
+
return res.answers.q;
|
|
62
|
+
})();
|
|
63
|
+
this.inflight.set(key, request);
|
|
64
|
+
try {
|
|
65
|
+
this.answers.set(key, await request);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
if (err instanceof HttpError && err.fatal) {
|
|
70
|
+
this.fatal ??= new Error(`Jev refused the request (${err.message}) — check the API key and the judge arguments`);
|
|
71
|
+
throw this.fatal;
|
|
72
|
+
}
|
|
73
|
+
throw err;
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
this.inflight.delete(key);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { Engine, NO_KEY } from "./engine.ts";
|
|
2
|
+
export { Program } from "./program.ts";
|
|
3
|
+
export { RuleError, isRuleBug } from "./cel/check.ts";
|
|
4
|
+
export { toCel, fromCel } from "./cel/values.ts";
|
|
5
|
+
export { clip, cutLongStrings, fingerprint, parseRecord } from "./text.ts";
|
|
6
|
+
export { Client, HttpError, DEFAULT_BASE_URL, DEFAULT_MODEL, type Answer, type Question, type QuestionType, type Result } from "./typesafe/client.ts";
|
|
7
|
+
export type { Bindings, Call, Outcome } from "./types.ts";
|
|
8
|
+
export type { CelInput } from "@bufbuild/cel";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// @jevable/core: compile a rule once, match records against it.
|
|
2
|
+
//
|
|
3
|
+
// const engine = new Engine(new Client({ apiKey: process.env.TYPESAFE_API_KEY }));
|
|
4
|
+
// const rule = engine.compile(`judge.boolean(line, "Is this an outage?") >= 0.7`);
|
|
5
|
+
// const { pass, calls } = await rule.match({ line: "checkout returns 500 for everyone" });
|
|
6
|
+
export { Engine, NO_KEY } from "./engine.js";
|
|
7
|
+
export { Program } from "./program.js";
|
|
8
|
+
export { RuleError, isRuleBug } from "./cel/check.js";
|
|
9
|
+
export { toCel, fromCel } from "./cel/values.js";
|
|
10
|
+
export { clip, cutLongStrings, fingerprint, parseRecord } from "./text.js";
|
|
11
|
+
export { Client, HttpError, DEFAULT_BASE_URL, DEFAULT_MODEL } from "./typesafe/client.js";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type Planned } from "./cel/env.ts";
|
|
2
|
+
import type { Engine } from "./engine.ts";
|
|
3
|
+
import type { Bindings, Outcome } from "./types.ts";
|
|
4
|
+
/** A compiled rule or key expression. Safe to share between concurrent evaluations. */
|
|
5
|
+
export declare class Program {
|
|
6
|
+
readonly source: string;
|
|
7
|
+
private readonly engine;
|
|
8
|
+
private readonly run;
|
|
9
|
+
constructor(engine: Engine, source: string, run: Planned);
|
|
10
|
+
/** Evaluates a rule. An evaluation error (a missing field, a failed judge call) means no pass. */
|
|
11
|
+
match(vars: Bindings, signal?: AbortSignal): Promise<Outcome>;
|
|
12
|
+
/** Evaluates a key: strings as they are, anything else as JSON. */
|
|
13
|
+
key(vars: Bindings, signal?: AbortSignal): Promise<string>;
|
|
14
|
+
/**
|
|
15
|
+
* Evaluates in passes until no question is missing: each pass that lacks
|
|
16
|
+
* an answer asks for it, and the next pass starts over with it known. Plain
|
|
17
|
+
* conditions that decide the rule first mean a question is never reached,
|
|
18
|
+
* so never asked.
|
|
19
|
+
*/
|
|
20
|
+
private evaluate;
|
|
21
|
+
}
|
package/dist/program.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { isCelError } from "@bufbuild/cel";
|
|
2
|
+
import { evaluateWith } from "./cel/env.js";
|
|
3
|
+
import { describe } from "./cel/check.js";
|
|
4
|
+
import { fromCel } from "./cel/values.js";
|
|
5
|
+
/** A compiled rule or key expression. Safe to share between concurrent evaluations. */
|
|
6
|
+
export class Program {
|
|
7
|
+
source;
|
|
8
|
+
engine;
|
|
9
|
+
run;
|
|
10
|
+
constructor(engine, source, run) {
|
|
11
|
+
this.engine = engine;
|
|
12
|
+
this.source = source;
|
|
13
|
+
this.run = run;
|
|
14
|
+
}
|
|
15
|
+
/** Evaluates a rule. An evaluation error (a missing field, a failed judge call) means no pass. */
|
|
16
|
+
async match(vars, signal) {
|
|
17
|
+
const r = await this.evaluate(vars, signal);
|
|
18
|
+
if (r.error)
|
|
19
|
+
return { pass: false, calls: r.calls, error: r.error };
|
|
20
|
+
if (typeof r.out !== "boolean")
|
|
21
|
+
return { pass: false, calls: r.calls, error: new Error(`rule yielded ${describe(r.out)}, not true or false`) };
|
|
22
|
+
return { pass: r.out, calls: r.calls };
|
|
23
|
+
}
|
|
24
|
+
/** Evaluates a key: strings as they are, anything else as JSON. */
|
|
25
|
+
async key(vars, signal) {
|
|
26
|
+
const r = await this.evaluate(vars, signal);
|
|
27
|
+
if (r.error)
|
|
28
|
+
throw r.error;
|
|
29
|
+
return typeof r.out === "string" ? r.out : JSON.stringify(fromCel(r.out));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Evaluates in passes until no question is missing: each pass that lacks
|
|
33
|
+
* an answer asks for it, and the next pass starts over with it known. Plain
|
|
34
|
+
* conditions that decide the rule first mean a question is never reached,
|
|
35
|
+
* so never asked.
|
|
36
|
+
*/
|
|
37
|
+
async evaluate(vars, signal) {
|
|
38
|
+
const ctx = { line: vars.line ?? "", json: vars.json ?? null, window: vars.window ?? null };
|
|
39
|
+
const fetched = new Set();
|
|
40
|
+
for (let round = 0; round < 64; round++) {
|
|
41
|
+
const pass = { answers: this.engine, calls: [], keys: [] };
|
|
42
|
+
const out = evaluateWith(pass, this.run, ctx);
|
|
43
|
+
if (!pass.pending) {
|
|
44
|
+
for (const k of pass.keys)
|
|
45
|
+
if (!fetched.has(k))
|
|
46
|
+
this.engine.stats.cacheHits++;
|
|
47
|
+
if (isCelError(out))
|
|
48
|
+
return { calls: pass.calls, error: new Error(out.message) };
|
|
49
|
+
return { out, calls: pass.calls };
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
if (await this.engine.ask(pass.pending.key, pass.pending.state, pass.pending.q, signal))
|
|
53
|
+
fetched.add(pass.pending.key);
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
return { calls: pass.calls, error: err };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { calls: [], error: new Error("rule asked too many questions") };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Engine } from "./engine.ts";
|
|
2
|
+
import { type Answer, type Question } from "./typesafe/client.ts";
|
|
3
|
+
/** Decides one answer; state is the request's state as JSON (a string material arrives quoted). */
|
|
4
|
+
export type Answerer = (state: string, q: Question) => Omit<Answer, "type">;
|
|
5
|
+
export interface FakeJev {
|
|
6
|
+
url: string;
|
|
7
|
+
/** Requests received so far. */
|
|
8
|
+
calls(): number;
|
|
9
|
+
/** A fresh engine talking to this fake with the key it accepts. */
|
|
10
|
+
engine(): Engine;
|
|
11
|
+
close(): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
/** The API key the fake accepts; any other is refused with 401. */
|
|
14
|
+
export declare const FAKE_KEY = "test-key";
|
|
15
|
+
/** Starts a fake that bills 10 tokens per request. */
|
|
16
|
+
export declare function fakeJev(answer: Answerer): Promise<FakeJev>;
|
|
17
|
+
/** Answers yes (0.9) when the material mentions "outage", else 0.1; choice and score likewise. */
|
|
18
|
+
export declare const outage: Answerer;
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// A fake System One endpoint for tests: @jevable/core/testing.
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { Engine } from "./engine.js";
|
|
4
|
+
import { Client } from "./typesafe/client.js";
|
|
5
|
+
/** The API key the fake accepts; any other is refused with 401. */
|
|
6
|
+
export const FAKE_KEY = "test-key";
|
|
7
|
+
/** Starts a fake that bills 10 tokens per request. */
|
|
8
|
+
export async function fakeJev(answer) {
|
|
9
|
+
let calls = 0;
|
|
10
|
+
const server = createServer((req, res) => {
|
|
11
|
+
calls++;
|
|
12
|
+
let body = "";
|
|
13
|
+
req.on("data", (c) => (body += c));
|
|
14
|
+
req.on("end", () => {
|
|
15
|
+
if (req.headers.authorization !== `Bearer ${FAKE_KEY}`) {
|
|
16
|
+
res.writeHead(401).end('{"error":"invalid api key"}');
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const { state, model, questions } = JSON.parse(body);
|
|
20
|
+
const answers = {};
|
|
21
|
+
for (const [k, q] of Object.entries(questions))
|
|
22
|
+
answers[k] = { type: q.type, ...answer(JSON.stringify(state), q) };
|
|
23
|
+
res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({ model, answers, usage: { input_tokens: 10, output_tokens: 0 } }));
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
|
27
|
+
const url = `http://127.0.0.1:${server.address().port}`;
|
|
28
|
+
return {
|
|
29
|
+
url,
|
|
30
|
+
calls: () => calls,
|
|
31
|
+
engine: () => new Engine(new Client({ apiKey: FAKE_KEY, baseUrl: url })),
|
|
32
|
+
close: () => new Promise((r) => {
|
|
33
|
+
server.closeAllConnections();
|
|
34
|
+
server.close(() => r());
|
|
35
|
+
}),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** Answers yes (0.9) when the material mentions "outage", else 0.1; choice and score likewise. */
|
|
39
|
+
export const outage = (state, q) => {
|
|
40
|
+
const hit = state.includes("outage");
|
|
41
|
+
if (q.type === "choice")
|
|
42
|
+
return { probabilities: hit ? { infra: 0.8, other: 0.2 } : { infra: 0.1, other: 0.9 } };
|
|
43
|
+
if (q.type === "score")
|
|
44
|
+
return { score: hit ? 1.8 : 0.2 };
|
|
45
|
+
return { noul: hit ? 0.9 : 0.1 };
|
|
46
|
+
};
|
package/dist/text.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** One input line: the text, and the parsed value when the line is JSON. */
|
|
2
|
+
export declare function parseRecord(line: string): {
|
|
3
|
+
line: string;
|
|
4
|
+
json: unknown;
|
|
5
|
+
};
|
|
6
|
+
export declare function cutLongStrings(v: unknown): unknown;
|
|
7
|
+
/**
|
|
8
|
+
* Reduces a line to its kind: quoted strings, numbers and id-like tokens
|
|
9
|
+
* (anything containing a digit) become placeholders, so `timeout after
|
|
10
|
+
* 3012ms on req_8f2a` and `timeout after 95ms on req_77c1` share a
|
|
11
|
+
* fingerprint. Numbers are gone afterwards — compare them in plain CEL.
|
|
12
|
+
*/
|
|
13
|
+
export declare function fingerprint(s: string): string;
|
|
14
|
+
export declare function clip(s: string, n: number): string;
|
package/dist/text.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Plain-text helpers shared by the engine and its callers.
|
|
2
|
+
/** One input line: the text, and the parsed value when the line is JSON. */
|
|
3
|
+
export function parseRecord(line) {
|
|
4
|
+
const t = line.trim();
|
|
5
|
+
if (t.startsWith("{") || t.startsWith("[")) {
|
|
6
|
+
try {
|
|
7
|
+
return { line, json: JSON.parse(t) };
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
// not JSON after all
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return { line, json: null };
|
|
14
|
+
}
|
|
15
|
+
// Each string sent to Jev is bounded; longer ones keep their head and tail,
|
|
16
|
+
// where the question and the verdict of a long text usually sit.
|
|
17
|
+
const MAX_STRING_CHARS = 2000;
|
|
18
|
+
export function cutLongStrings(v) {
|
|
19
|
+
if (typeof v === "string") {
|
|
20
|
+
const chars = Array.from(v);
|
|
21
|
+
if (chars.length <= MAX_STRING_CHARS)
|
|
22
|
+
return v;
|
|
23
|
+
return chars.slice(0, 1500).join("") + " […] " + chars.slice(-500).join("");
|
|
24
|
+
}
|
|
25
|
+
if (Array.isArray(v))
|
|
26
|
+
return v.map(cutLongStrings);
|
|
27
|
+
if (v !== null && typeof v === "object") {
|
|
28
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, cutLongStrings(x)]));
|
|
29
|
+
}
|
|
30
|
+
return v;
|
|
31
|
+
}
|
|
32
|
+
const QUOTED = /"[^"]*"|'[^']*'/g;
|
|
33
|
+
const TOKEN = /[A-Za-z0-9_.:\-]*[0-9][A-Za-z0-9_.:\-]*/g;
|
|
34
|
+
const NUMBER = /^-?[0-9]+(\.[0-9]+)?([A-Za-z%]{0,3})$/;
|
|
35
|
+
/**
|
|
36
|
+
* Reduces a line to its kind: quoted strings, numbers and id-like tokens
|
|
37
|
+
* (anything containing a digit) become placeholders, so `timeout after
|
|
38
|
+
* 3012ms on req_8f2a` and `timeout after 95ms on req_77c1` share a
|
|
39
|
+
* fingerprint. Numbers are gone afterwards — compare them in plain CEL.
|
|
40
|
+
*/
|
|
41
|
+
export function fingerprint(s) {
|
|
42
|
+
return s
|
|
43
|
+
.replace(QUOTED, '"<s>"')
|
|
44
|
+
.replace(TOKEN, (tok) => {
|
|
45
|
+
const m = NUMBER.exec(tok);
|
|
46
|
+
return m ? `<n>${m[2]}` : "<id>";
|
|
47
|
+
})
|
|
48
|
+
.split(/\s+/)
|
|
49
|
+
.filter(Boolean)
|
|
50
|
+
.join(" ");
|
|
51
|
+
}
|
|
52
|
+
export function clip(s, n) {
|
|
53
|
+
const chars = Array.from(s);
|
|
54
|
+
return chars.length <= n ? s : chars.slice(0, n).join("") + "…";
|
|
55
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { CelInput } from "@bufbuild/cel";
|
|
2
|
+
/** One judge question asked while evaluating a rule, and its answer. */
|
|
3
|
+
export interface Call {
|
|
4
|
+
fn: "boolean" | "choice" | "score";
|
|
5
|
+
question: string;
|
|
6
|
+
/** boolean: probability of yes; score: weighted level. */
|
|
7
|
+
value?: number;
|
|
8
|
+
/** choice: probability of each option. */
|
|
9
|
+
options?: Record<string, number>;
|
|
10
|
+
}
|
|
11
|
+
/** The variables a rule sees; missing ones are empty. */
|
|
12
|
+
export interface Bindings {
|
|
13
|
+
line?: string;
|
|
14
|
+
json?: CelInput;
|
|
15
|
+
window?: CelInput;
|
|
16
|
+
}
|
|
17
|
+
export interface Outcome {
|
|
18
|
+
pass: boolean;
|
|
19
|
+
calls: Call[];
|
|
20
|
+
error?: Error;
|
|
21
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export declare const DEFAULT_BASE_URL = "https://api.typesafe.ai";
|
|
2
|
+
export declare const DEFAULT_MODEL = "jev-1.13.0";
|
|
3
|
+
export type QuestionType = "noul" | "choice" | "score";
|
|
4
|
+
/**
|
|
5
|
+
* One typed question. Criteria: noul — undefined or {true, false};
|
|
6
|
+
* choice — option → description (or null); score — ordered level list.
|
|
7
|
+
*/
|
|
8
|
+
export interface Question {
|
|
9
|
+
type: QuestionType;
|
|
10
|
+
instructions: string;
|
|
11
|
+
criteria?: unknown;
|
|
12
|
+
}
|
|
13
|
+
export interface Answer {
|
|
14
|
+
type: QuestionType;
|
|
15
|
+
noul?: number;
|
|
16
|
+
choice?: string;
|
|
17
|
+
score?: number;
|
|
18
|
+
probabilities?: Record<string, number>;
|
|
19
|
+
confidence?: number;
|
|
20
|
+
}
|
|
21
|
+
export interface Result {
|
|
22
|
+
model: string;
|
|
23
|
+
answers: Record<string, Answer>;
|
|
24
|
+
usage: {
|
|
25
|
+
input_tokens: number;
|
|
26
|
+
output_tokens: number;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** A non-2xx answer from the API. */
|
|
30
|
+
export declare class HttpError extends Error {
|
|
31
|
+
readonly status: number;
|
|
32
|
+
constructor(status: number, body: string);
|
|
33
|
+
/** Retrying cannot help: a bad key or a malformed request. */
|
|
34
|
+
get fatal(): boolean;
|
|
35
|
+
}
|
|
36
|
+
export declare class Client {
|
|
37
|
+
readonly baseUrl: string;
|
|
38
|
+
readonly apiKey: string;
|
|
39
|
+
readonly model: string;
|
|
40
|
+
constructor(opts?: {
|
|
41
|
+
apiKey?: string;
|
|
42
|
+
baseUrl?: string;
|
|
43
|
+
model?: string;
|
|
44
|
+
});
|
|
45
|
+
/** Evaluates every question against state in one call, retrying once on anything but a fatal error. */
|
|
46
|
+
ask(state: unknown, questions: Record<string, Question>, signal?: AbortSignal): Promise<Result>;
|
|
47
|
+
private request;
|
|
48
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// A minimal client for TypeSafe's System One endpoint
|
|
2
|
+
// (https://docs.typesafe.ai/api): one state, a map of typed questions, one
|
|
3
|
+
// typed answer per question.
|
|
4
|
+
export const DEFAULT_BASE_URL = "https://api.typesafe.ai";
|
|
5
|
+
// Pinned rather than `jev-latest`: thresholds are tuned against one model,
|
|
6
|
+
// and an alias moves without a change on our side.
|
|
7
|
+
export const DEFAULT_MODEL = "jev-1.13.0";
|
|
8
|
+
/** A non-2xx answer from the API. */
|
|
9
|
+
export class HttpError extends Error {
|
|
10
|
+
status;
|
|
11
|
+
constructor(status, body) {
|
|
12
|
+
super(`typesafe API returned ${status}: ${body.slice(0, 512)}`);
|
|
13
|
+
this.status = status;
|
|
14
|
+
}
|
|
15
|
+
/** Retrying cannot help: a bad key or a malformed request. */
|
|
16
|
+
get fatal() {
|
|
17
|
+
return this.status === 401 || this.status === 403 || this.status === 422;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export class Client {
|
|
21
|
+
baseUrl;
|
|
22
|
+
apiKey;
|
|
23
|
+
model;
|
|
24
|
+
constructor(opts = {}) {
|
|
25
|
+
this.apiKey = opts.apiKey ?? "";
|
|
26
|
+
this.baseUrl = (opts.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
27
|
+
this.model = opts.model || DEFAULT_MODEL;
|
|
28
|
+
}
|
|
29
|
+
/** Evaluates every question against state in one call, retrying once on anything but a fatal error. */
|
|
30
|
+
async ask(state, questions, signal) {
|
|
31
|
+
try {
|
|
32
|
+
return await this.request(state, questions, signal);
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
if ((err instanceof HttpError && err.fatal) || signal?.aborted)
|
|
36
|
+
throw err;
|
|
37
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
38
|
+
return this.request(state, questions, signal);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async request(state, questions, signal) {
|
|
42
|
+
const timeout = AbortSignal.timeout(15_000);
|
|
43
|
+
const res = await fetch(`${this.baseUrl}/v1/systemone`, {
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers: { Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json" },
|
|
46
|
+
body: JSON.stringify({ state, model: this.model, questions }),
|
|
47
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
48
|
+
});
|
|
49
|
+
const text = await res.text();
|
|
50
|
+
if (!res.ok)
|
|
51
|
+
throw new HttpError(res.status, text);
|
|
52
|
+
const out = JSON.parse(text);
|
|
53
|
+
for (const key of Object.keys(questions)) {
|
|
54
|
+
if (!out.answers?.[key])
|
|
55
|
+
throw new Error(`typesafe: answer ${JSON.stringify(key)} missing`);
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jevable/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The jev rule engine: CEL rules that ask Jev, TypeSafe's classification model, semantic questions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"development": "./src/index.ts",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./testing": {
|
|
13
|
+
"development": "./src/testing.ts",
|
|
14
|
+
"types": "./dist/testing.d.ts",
|
|
15
|
+
"default": "./dist/testing.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": ["dist"],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc -p tsconfig.build.json",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"test": "node --conditions=development --test test/*.test.ts",
|
|
26
|
+
"prepack": "npm run build"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@bufbuild/cel": "0.6.1"
|
|
30
|
+
},
|
|
31
|
+
"license": "MIT"
|
|
32
|
+
}
|