@spendgraph/harness 0.1.0 → 0.2.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 +87 -160
- package/dist/cascade/cascade.d.ts +13 -0
- package/dist/cascade/cascade.js +91 -0
- package/dist/cascade/index.d.ts +3 -0
- package/dist/cascade/index.js +2 -0
- package/dist/cascade/tier.d.ts +31 -0
- package/dist/cascade/tier.js +53 -0
- package/dist/cascade/types.d.ts +98 -0
- package/dist/chain/chain.d.ts +9 -0
- package/dist/chain/chain.js +58 -0
- package/dist/chain/gate.d.ts +16 -0
- package/dist/chain/gate.js +33 -0
- package/dist/chain/index.d.ts +3 -0
- package/dist/chain/index.js +2 -0
- package/dist/chain/step.d.ts +9 -0
- package/dist/chain/step.js +14 -0
- package/dist/chain/types.d.ts +50 -0
- package/dist/index.d.ts +16 -9
- package/dist/index.js +8 -7
- package/dist/loop/act.d.ts +21 -0
- package/dist/loop/act.js +43 -0
- package/dist/loop/compact.d.ts +49 -0
- package/dist/loop/compact.js +59 -0
- package/dist/loop/hooks.d.ts +43 -0
- package/dist/loop/hooks.js +41 -0
- package/dist/loop/index.d.ts +6 -0
- package/dist/loop/index.js +5 -0
- package/dist/loop/loop.d.ts +13 -0
- package/dist/loop/loop.js +120 -0
- package/dist/loop/turn.d.ts +6 -0
- package/dist/loop/turn.js +17 -0
- package/dist/loop/types.d.ts +140 -0
- package/dist/loop/types.js +1 -0
- package/dist/orchestrate/index.d.ts +4 -0
- package/dist/orchestrate/index.js +3 -0
- package/dist/orchestrate/orchestrate.d.ts +12 -0
- package/dist/orchestrate/orchestrate.js +68 -0
- package/dist/orchestrate/plan.d.ts +18 -0
- package/dist/orchestrate/plan.js +38 -0
- package/dist/orchestrate/types.d.ts +75 -0
- package/dist/orchestrate/types.js +1 -0
- package/dist/orchestrate/work.d.ts +13 -0
- package/dist/orchestrate/work.js +35 -0
- package/dist/parallel/index.d.ts +4 -0
- package/dist/parallel/index.js +3 -0
- package/dist/parallel/merge.d.ts +26 -0
- package/dist/parallel/merge.js +52 -0
- package/dist/parallel/parallel.d.ts +12 -0
- package/dist/parallel/parallel.js +66 -0
- package/dist/parallel/task.d.ts +13 -0
- package/dist/parallel/task.js +45 -0
- package/dist/parallel/types.d.ts +61 -0
- package/dist/parallel/types.js +1 -0
- package/dist/refine/attempt.d.ts +22 -0
- package/dist/refine/attempt.js +38 -0
- package/dist/refine/index.d.ts +4 -0
- package/dist/refine/index.js +3 -0
- package/dist/refine/judge.d.ts +15 -0
- package/dist/refine/judge.js +25 -0
- package/dist/refine/refine.d.ts +10 -0
- package/dist/refine/refine.js +67 -0
- package/dist/refine/types.d.ts +90 -0
- package/dist/refine/types.js +1 -0
- package/dist/route/classify.d.ts +52 -0
- package/dist/route/classify.js +57 -0
- package/dist/route/dispatch.d.ts +12 -0
- package/dist/route/dispatch.js +33 -0
- package/dist/route/index.d.ts +3 -0
- package/dist/route/index.js +2 -0
- package/dist/route/route.d.ts +11 -0
- package/dist/route/route.js +31 -0
- package/dist/route/types.d.ts +53 -0
- package/dist/route/types.js +1 -0
- package/dist/stream/graph.d.ts +9 -0
- package/dist/stream/graph.js +16 -0
- package/dist/stream/index.d.ts +2 -0
- package/dist/stream/index.js +1 -0
- package/dist/stream/stream.d.ts +19 -0
- package/dist/stream/stream.js +65 -0
- package/dist/stream/types.d.ts +31 -0
- package/dist/stream/types.js +1 -0
- package/docs/README.md +41 -0
- package/docs/cascade.md +165 -0
- package/docs/chain.md +134 -0
- package/docs/loop.md +318 -0
- package/docs/orchestrate.md +128 -0
- package/docs/parallel.md +139 -0
- package/docs/refine.md +206 -0
- package/docs/route.md +212 -0
- package/package.json +18 -14
- package/bin/harness.mjs +0 -76
- package/dist/core/budget.d.ts +0 -46
- package/dist/core/budget.js +0 -72
- package/dist/core/cache.d.ts +0 -119
- package/dist/core/cache.js +0 -206
- package/dist/core/client.d.ts +0 -33
- package/dist/core/client.js +0 -98
- package/dist/core/errors.d.ts +0 -22
- package/dist/core/errors.js +0 -28
- package/dist/core/index.d.ts +0 -5
- package/dist/core/index.js +0 -5
- package/dist/core/pull.d.ts +0 -17
- package/dist/core/pull.js +0 -38
- package/dist/core/schema/serialize.d.ts +0 -12
- package/dist/core/schema/serialize.js +0 -42
- package/dist/core/schema/types.d.ts +0 -58
- package/dist/core/schema/validate.d.ts +0 -28
- package/dist/core/schema/validate.js +0 -98
- package/dist/harness.d.ts +0 -128
- package/dist/harness.js +0 -187
- package/dist/prompt/codegen.d.ts +0 -37
- package/dist/prompt/codegen.js +0 -97
- package/dist/prompt/render.d.ts +0 -35
- package/dist/prompt/render.js +0 -66
- package/dist/prompt/run.d.ts +0 -38
- package/dist/prompt/run.js +0 -96
- package/dist/prompt/types.d.ts +0 -156
- /package/dist/{core/schema → cascade}/types.js +0 -0
- /package/dist/{prompt → chain}/types.js +0 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { RolloutStep } from "@spendgraph/sdk";
|
|
2
|
+
import type { Emit } from "../stream/index.js";
|
|
3
|
+
/** One branch of the fan-out. */
|
|
4
|
+
export interface Task {
|
|
5
|
+
/** Unique in the run, and the name its step is recorded under. */
|
|
6
|
+
name: string;
|
|
7
|
+
run(values: Record<string, unknown>, emit: Emit): Promise<unknown> | unknown;
|
|
8
|
+
}
|
|
9
|
+
/** What one task produced, or why it did not. */
|
|
10
|
+
export interface TaskResult {
|
|
11
|
+
name: string;
|
|
12
|
+
status: "completed" | "failed";
|
|
13
|
+
value?: unknown;
|
|
14
|
+
error?: string;
|
|
15
|
+
inputTokens: number;
|
|
16
|
+
outputTokens: number;
|
|
17
|
+
}
|
|
18
|
+
export interface ParallelOptions {
|
|
19
|
+
tasks: Task[];
|
|
20
|
+
/**
|
|
21
|
+
* Combines what came back into the answer.
|
|
22
|
+
*
|
|
23
|
+
* Only the tasks that succeeded are passed. Sectioning usually joins them;
|
|
24
|
+
* voting usually counts them — `majority` is exported for that.
|
|
25
|
+
*/
|
|
26
|
+
merge(results: TaskResult[], values: Record<string, unknown>): unknown;
|
|
27
|
+
/**
|
|
28
|
+
* Where the tokens go while this runs.
|
|
29
|
+
*
|
|
30
|
+
* Passed down to whatever makes the model call; omit it and nothing is
|
|
31
|
+
* emitted. `streamed()` supplies one.
|
|
32
|
+
*/
|
|
33
|
+
emit?: Emit;
|
|
34
|
+
/** Most tasks in flight at once. Default 4. */
|
|
35
|
+
concurrency?: number;
|
|
36
|
+
/**
|
|
37
|
+
* How many must succeed before merging is worth doing. Defaults to all.
|
|
38
|
+
*
|
|
39
|
+
* Sectioning wants all of them: a missing section is a hole in the answer.
|
|
40
|
+
* Voting wants a majority, and is right to ignore the one call that timed out.
|
|
41
|
+
*/
|
|
42
|
+
minSuccess?: number;
|
|
43
|
+
/** Called as each task lands, so a slow fan-out is not silent. */
|
|
44
|
+
onTask?: (result: TaskResult) => void;
|
|
45
|
+
}
|
|
46
|
+
export interface ParallelResult {
|
|
47
|
+
status: "completed" | "failed";
|
|
48
|
+
output: string;
|
|
49
|
+
error?: string;
|
|
50
|
+
steps: RolloutStep[];
|
|
51
|
+
outputs: Record<string, unknown>;
|
|
52
|
+
latencyMs: number;
|
|
53
|
+
inputTokens: number;
|
|
54
|
+
outputTokens: number;
|
|
55
|
+
/** Every task, in the order they were declared — never the order they landed. */
|
|
56
|
+
results: TaskResult[];
|
|
57
|
+
succeeded: string[];
|
|
58
|
+
failed: string[];
|
|
59
|
+
/** What `merge` returned. Undefined when too few tasks succeeded. */
|
|
60
|
+
merged?: unknown;
|
|
61
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { RolloutStep } from "@spendgraph/sdk";
|
|
2
|
+
import type { Attempt } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* What one attempt consumed.
|
|
5
|
+
*
|
|
6
|
+
* Both halves, because a refine loop's cost is dominated by the input it resends
|
|
7
|
+
* every round — the draft, the critique and the instruction to try again.
|
|
8
|
+
*/
|
|
9
|
+
export declare function tokensOf(attempt: Attempt): number;
|
|
10
|
+
/**
|
|
11
|
+
* One attempt as a step.
|
|
12
|
+
*
|
|
13
|
+
* Recorded whether it was accepted or not: the rejected drafts are the evidence
|
|
14
|
+
* for whether refining paid at all, and a loop that keeps only the winner cannot
|
|
15
|
+
* say what the answer cost.
|
|
16
|
+
*/
|
|
17
|
+
export declare function attemptStep(index: number, round: number, attempt: Attempt): RolloutStep;
|
|
18
|
+
/** Both halves of every attempt so far, for the result's totals. */
|
|
19
|
+
export declare function totalTokens(steps: RolloutStep[]): {
|
|
20
|
+
inputTokens: number;
|
|
21
|
+
outputTokens: number;
|
|
22
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What one attempt consumed.
|
|
3
|
+
*
|
|
4
|
+
* Both halves, because a refine loop's cost is dominated by the input it resends
|
|
5
|
+
* every round — the draft, the critique and the instruction to try again.
|
|
6
|
+
*/
|
|
7
|
+
export function tokensOf(attempt) {
|
|
8
|
+
return (attempt.inputTokens ?? 0) + (attempt.outputTokens ?? 0);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* One attempt as a step.
|
|
12
|
+
*
|
|
13
|
+
* Recorded whether it was accepted or not: the rejected drafts are the evidence
|
|
14
|
+
* for whether refining paid at all, and a loop that keeps only the winner cannot
|
|
15
|
+
* say what the answer cost.
|
|
16
|
+
*/
|
|
17
|
+
export function attemptStep(index, round, attempt) {
|
|
18
|
+
return {
|
|
19
|
+
index,
|
|
20
|
+
source: `attempt_${round}`,
|
|
21
|
+
model: attempt.model,
|
|
22
|
+
output: attempt.output,
|
|
23
|
+
status: attempt.status ?? "completed",
|
|
24
|
+
error: attempt.error,
|
|
25
|
+
inputTokens: attempt.inputTokens,
|
|
26
|
+
outputTokens: attempt.outputTokens,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** Both halves of every attempt so far, for the result's totals. */
|
|
30
|
+
export function totalTokens(steps) {
|
|
31
|
+
let inputTokens = 0;
|
|
32
|
+
let outputTokens = 0;
|
|
33
|
+
for (const step of steps) {
|
|
34
|
+
inputTokens += step.inputTokens ?? 0;
|
|
35
|
+
outputTokens += step.outputTokens ?? 0;
|
|
36
|
+
}
|
|
37
|
+
return { inputTokens, outputTokens };
|
|
38
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { RolloutStep } from "@spendgraph/sdk";
|
|
2
|
+
import type { RoundRecord, Verdict } from "./types.js";
|
|
3
|
+
/** One verdict as a step, so a run says why each draft was turned down. */
|
|
4
|
+
export declare function judgeStep(index: number, round: number, verdict: Verdict): RolloutStep;
|
|
5
|
+
/**
|
|
6
|
+
* The attempt to hand back when nothing was accepted.
|
|
7
|
+
*
|
|
8
|
+
* Highest score wins, and the last attempt wins when nothing was scored — a loop
|
|
9
|
+
* that improves each round has its best work at the end, and returning the first
|
|
10
|
+
* draft after three revisions would throw the work away.
|
|
11
|
+
*
|
|
12
|
+
* Here rather than in the loop because the choice is the judge's: it is made on
|
|
13
|
+
* the verdicts, and a judge that never scores gets the fallback.
|
|
14
|
+
*/
|
|
15
|
+
export declare function best(history: RoundRecord[]): RoundRecord | undefined;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** One verdict as a step, so a run says why each draft was turned down. */
|
|
2
|
+
export function judgeStep(index, round, verdict) {
|
|
3
|
+
return {
|
|
4
|
+
index,
|
|
5
|
+
source: `judge_${round}`,
|
|
6
|
+
output: JSON.stringify(verdict),
|
|
7
|
+
status: "completed",
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The attempt to hand back when nothing was accepted.
|
|
12
|
+
*
|
|
13
|
+
* Highest score wins, and the last attempt wins when nothing was scored — a loop
|
|
14
|
+
* that improves each round has its best work at the end, and returning the first
|
|
15
|
+
* draft after three revisions would throw the work away.
|
|
16
|
+
*
|
|
17
|
+
* Here rather than in the loop because the choice is the judge's: it is made on
|
|
18
|
+
* the verdicts, and a judge that never scores gets the fallback.
|
|
19
|
+
*/
|
|
20
|
+
export function best(history) {
|
|
21
|
+
const scored = history.filter((r) => typeof r.verdict.score === "number");
|
|
22
|
+
if (scored.length === 0)
|
|
23
|
+
return history.at(-1);
|
|
24
|
+
return scored.reduce((a, b) => ((b.verdict.score ?? 0) > (a.verdict.score ?? 0) ? b : a));
|
|
25
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { RefineOptions, RefineResult } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Draft, judge, revise, until it is good enough or the budget is out.
|
|
4
|
+
*
|
|
5
|
+
* The pattern worth naming when there is a clear criterion and a first draft
|
|
6
|
+
* rarely meets it. Every attempt and every judgement is recorded, the rejected
|
|
7
|
+
* ones included: a loop that keeps only the winner cannot say what the answer
|
|
8
|
+
* cost, and the rejected drafts are the evidence for whether refining paid.
|
|
9
|
+
*/
|
|
10
|
+
export declare function refine(opts: RefineOptions): Promise<RefineResult>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { attemptStep, tokensOf, totalTokens } from "./attempt.js";
|
|
2
|
+
import { best, judgeStep } from "./judge.js";
|
|
3
|
+
const DEFAULT_ROUNDS = 3;
|
|
4
|
+
/**
|
|
5
|
+
* Draft, judge, revise, until it is good enough or the budget is out.
|
|
6
|
+
*
|
|
7
|
+
* The pattern worth naming when there is a clear criterion and a first draft
|
|
8
|
+
* rarely meets it. Every attempt and every judgement is recorded, the rejected
|
|
9
|
+
* ones included: a loop that keeps only the winner cannot say what the answer
|
|
10
|
+
* cost, and the rejected drafts are the evidence for whether refining paid.
|
|
11
|
+
*/
|
|
12
|
+
export async function refine(opts) {
|
|
13
|
+
const startedAt = Date.now();
|
|
14
|
+
const rounds = Math.max(1, opts.rounds ?? DEFAULT_ROUNDS);
|
|
15
|
+
const steps = [];
|
|
16
|
+
const history = [];
|
|
17
|
+
let feedback = null;
|
|
18
|
+
let spent = 0;
|
|
19
|
+
let stoppedBy = "rounds";
|
|
20
|
+
for (let round = 0; round < rounds; round++) {
|
|
21
|
+
if (opts.maxTokens !== undefined && spent >= opts.maxTokens) {
|
|
22
|
+
stoppedBy = "tokens";
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
const attempt = await opts.attempt(feedback, round, (text, stage) => opts.emit?.(text, stage ?? `round ${round}`));
|
|
26
|
+
steps.push(attemptStep(steps.length, round, attempt));
|
|
27
|
+
spent += tokensOf(attempt);
|
|
28
|
+
if (attempt.status === "failed") {
|
|
29
|
+
return {
|
|
30
|
+
status: "failed",
|
|
31
|
+
output: "",
|
|
32
|
+
error: attempt.error ?? "The attempt failed.",
|
|
33
|
+
steps,
|
|
34
|
+
outputs: { history },
|
|
35
|
+
latencyMs: Date.now() - startedAt,
|
|
36
|
+
...totalTokens(steps),
|
|
37
|
+
accepted: false,
|
|
38
|
+
rounds: round + 1,
|
|
39
|
+
stoppedBy: "failed",
|
|
40
|
+
history,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const verdict = await opts.judge(attempt, round);
|
|
44
|
+
steps.push(judgeStep(steps.length, round, verdict));
|
|
45
|
+
const record = { round, attempt, verdict, tokens: tokensOf(attempt) };
|
|
46
|
+
history.push(record);
|
|
47
|
+
opts.onRound?.(record);
|
|
48
|
+
if (verdict.accepted) {
|
|
49
|
+
stoppedBy = "accepted";
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
feedback = verdict.feedback ?? null;
|
|
53
|
+
}
|
|
54
|
+
const winner = best(history);
|
|
55
|
+
return {
|
|
56
|
+
status: "completed",
|
|
57
|
+
output: winner?.attempt.output ?? "",
|
|
58
|
+
steps,
|
|
59
|
+
outputs: { history },
|
|
60
|
+
latencyMs: Date.now() - startedAt,
|
|
61
|
+
...totalTokens(steps),
|
|
62
|
+
accepted: stoppedBy === "accepted",
|
|
63
|
+
rounds: history.length,
|
|
64
|
+
stoppedBy,
|
|
65
|
+
history,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { RolloutStep } from "@spendgraph/sdk";
|
|
2
|
+
import type { Emit } from "../stream/index.js";
|
|
3
|
+
/** What one attempt produced. Structurally the `TraceOutcome` a prompt accepts. */
|
|
4
|
+
export interface Attempt {
|
|
5
|
+
output: string;
|
|
6
|
+
model: string;
|
|
7
|
+
status?: "completed" | "failed";
|
|
8
|
+
error?: string;
|
|
9
|
+
inputTokens?: number;
|
|
10
|
+
outputTokens?: number;
|
|
11
|
+
}
|
|
12
|
+
/** What the judge decided about an attempt. */
|
|
13
|
+
export interface Verdict {
|
|
14
|
+
/** True stops the loop. */
|
|
15
|
+
accepted: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* What to fix, handed to the next attempt.
|
|
18
|
+
*
|
|
19
|
+
* A rejection with no feedback asks the model to try again with nothing new,
|
|
20
|
+
* which is how a refine loop spends three rounds producing the same draft.
|
|
21
|
+
*/
|
|
22
|
+
feedback?: string;
|
|
23
|
+
/** Higher is better. Used to pick a winner when nothing was accepted. */
|
|
24
|
+
score?: number;
|
|
25
|
+
}
|
|
26
|
+
export interface RefineOptions {
|
|
27
|
+
/**
|
|
28
|
+
* A draft. `feedback` is null on the first round and the judge's note after.
|
|
29
|
+
*
|
|
30
|
+
* One callback rather than separate generate and revise, because in practice
|
|
31
|
+
* they are the same prompt with one extra paragraph.
|
|
32
|
+
*/
|
|
33
|
+
attempt(feedback: string | null, round: number, emit: Emit): Promise<Attempt> | Attempt;
|
|
34
|
+
/** Whether that will do, and what is wrong with it if not. */
|
|
35
|
+
judge(attempt: Attempt, round: number): Promise<Verdict> | Verdict;
|
|
36
|
+
/**
|
|
37
|
+
* Where the tokens go while this runs.
|
|
38
|
+
*
|
|
39
|
+
* Passed down to whatever makes the model call; omit it and nothing is
|
|
40
|
+
* emitted. `streamed()` supplies one.
|
|
41
|
+
*/
|
|
42
|
+
emit?: Emit;
|
|
43
|
+
/** Most attempts to make. Default 3. */
|
|
44
|
+
rounds?: number;
|
|
45
|
+
/**
|
|
46
|
+
* Stop once the attempts have consumed this many tokens.
|
|
47
|
+
*
|
|
48
|
+
* Tokens rather than money because this package cannot price a call — that
|
|
49
|
+
* happens on the server, after the fact. Checked before each round, so the
|
|
50
|
+
* ceiling is never crossed rather than merely noticed.
|
|
51
|
+
*/
|
|
52
|
+
maxTokens?: number;
|
|
53
|
+
/** Called after each verdict, so a long loop is not silent. */
|
|
54
|
+
onRound?: (round: RoundRecord) => void;
|
|
55
|
+
}
|
|
56
|
+
export interface RoundRecord {
|
|
57
|
+
round: number;
|
|
58
|
+
attempt: Attempt;
|
|
59
|
+
verdict: Verdict;
|
|
60
|
+
tokens: number;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* What the loop produced, in the shape of a rollout.
|
|
64
|
+
*
|
|
65
|
+
* `steps` holds every attempt and every judgement, including the rejected ones.
|
|
66
|
+
* A loop that recorded only the winner could not say what the answer cost, which
|
|
67
|
+
* is the whole point of recording it here.
|
|
68
|
+
*/
|
|
69
|
+
export interface RefineResult {
|
|
70
|
+
status: "completed" | "failed";
|
|
71
|
+
output: string;
|
|
72
|
+
error?: string;
|
|
73
|
+
steps: RolloutStep[];
|
|
74
|
+
outputs: Record<string, unknown>;
|
|
75
|
+
latencyMs: number;
|
|
76
|
+
/**
|
|
77
|
+
* Summed across **every** attempt, the rejected ones included.
|
|
78
|
+
*
|
|
79
|
+
* The only question a refine loop has to answer is whether refining paid, and
|
|
80
|
+
* a total that counted the winner alone would always say yes.
|
|
81
|
+
*/
|
|
82
|
+
inputTokens: number;
|
|
83
|
+
outputTokens: number;
|
|
84
|
+
/** False when the rounds or the token ceiling ran out first. */
|
|
85
|
+
accepted: boolean;
|
|
86
|
+
rounds: number;
|
|
87
|
+
/** Why it ended: a verdict, the round limit, or the token ceiling. */
|
|
88
|
+
stoppedBy: "accepted" | "rounds" | "tokens" | "failed";
|
|
89
|
+
history: RoundRecord[];
|
|
90
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Classification, RouteOptions } from "./types.js";
|
|
2
|
+
/** The classifier's node name, and the key its decision is stored under. */
|
|
3
|
+
export declare const CLASSIFY = "classify";
|
|
4
|
+
/**
|
|
5
|
+
* The branch that runs when no other one does.
|
|
6
|
+
*
|
|
7
|
+
* Declared here rather than beside the dispatch it names, because `decide` is
|
|
8
|
+
* what produces it — and the other way round leaves the two files importing
|
|
9
|
+
* each other, which is the thing a reader has to stop and untangle.
|
|
10
|
+
*/
|
|
11
|
+
export declare const FALLBACK = "fallback";
|
|
12
|
+
/** What the classifier said, once a bare string has been expanded. */
|
|
13
|
+
export type Said = Exclude<Classification, string>;
|
|
14
|
+
/**
|
|
15
|
+
* What the classifier said, and what will run because of it.
|
|
16
|
+
*
|
|
17
|
+
* The two are kept apart on purpose: a router that recorded only the branch it
|
|
18
|
+
* ran could not tell "the classifier said `legal` and we have no such branch"
|
|
19
|
+
* from "it was unsure about `billing`". The first is a missing branch, the
|
|
20
|
+
* second a miscalibrated one, and they want opposite fixes.
|
|
21
|
+
*/
|
|
22
|
+
export interface Decision {
|
|
23
|
+
said: string;
|
|
24
|
+
taken: string;
|
|
25
|
+
confidence?: number;
|
|
26
|
+
reason?: string;
|
|
27
|
+
}
|
|
28
|
+
/** A bare string is shorthand for `{ route }`. */
|
|
29
|
+
export declare function normalise(value: Classification): Said;
|
|
30
|
+
/**
|
|
31
|
+
* Which branch runs.
|
|
32
|
+
*
|
|
33
|
+
* A class no branch handles falls back, and so does one the classifier is not
|
|
34
|
+
* sure enough about — a hedged guess sent to the wrong specialist is worse than
|
|
35
|
+
* the general path.
|
|
36
|
+
*/
|
|
37
|
+
export declare function decide(said: Said, names: string[], minConfidence?: number): Decision;
|
|
38
|
+
/** A step's worth of outcome, with the decision the edges will read. */
|
|
39
|
+
export interface ClassifyOutput extends Decision {
|
|
40
|
+
output: string;
|
|
41
|
+
model?: string;
|
|
42
|
+
inputTokens?: number;
|
|
43
|
+
outputTokens?: number;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The entry node.
|
|
47
|
+
*
|
|
48
|
+
* Its return is what the edges read, so the decision travels through the graph's
|
|
49
|
+
* own context rather than a variable closed over from outside — which means a
|
|
50
|
+
* finished run can be read apart from `outputs.classify` alone.
|
|
51
|
+
*/
|
|
52
|
+
export declare function classifier(opts: RouteOptions, names: string[]): import("@spendgraph/graph").Node<import("@spendgraph/graph").ArgsOf<readonly import("@spendgraph/graph").ArgSpec[]>>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { node } from "@spendgraph/graph";
|
|
2
|
+
/** The classifier's node name, and the key its decision is stored under. */
|
|
3
|
+
export const CLASSIFY = "classify";
|
|
4
|
+
/**
|
|
5
|
+
* The branch that runs when no other one does.
|
|
6
|
+
*
|
|
7
|
+
* Declared here rather than beside the dispatch it names, because `decide` is
|
|
8
|
+
* what produces it — and the other way round leaves the two files importing
|
|
9
|
+
* each other, which is the thing a reader has to stop and untangle.
|
|
10
|
+
*/
|
|
11
|
+
export const FALLBACK = "fallback";
|
|
12
|
+
/** A bare string is shorthand for `{ route }`. */
|
|
13
|
+
export function normalise(value) {
|
|
14
|
+
return typeof value === "string" ? { route: value } : value;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Which branch runs.
|
|
18
|
+
*
|
|
19
|
+
* A class no branch handles falls back, and so does one the classifier is not
|
|
20
|
+
* sure enough about — a hedged guess sent to the wrong specialist is worse than
|
|
21
|
+
* the general path.
|
|
22
|
+
*/
|
|
23
|
+
export function decide(said, names, minConfidence) {
|
|
24
|
+
const known = names.includes(said.route);
|
|
25
|
+
const confident = minConfidence === undefined ||
|
|
26
|
+
said.confidence === undefined ||
|
|
27
|
+
said.confidence >= minConfidence;
|
|
28
|
+
return {
|
|
29
|
+
said: said.route,
|
|
30
|
+
taken: known && confident ? said.route : FALLBACK,
|
|
31
|
+
confidence: said.confidence,
|
|
32
|
+
reason: said.reason,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The entry node.
|
|
37
|
+
*
|
|
38
|
+
* Its return is what the edges read, so the decision travels through the graph's
|
|
39
|
+
* own context rather than a variable closed over from outside — which means a
|
|
40
|
+
* finished run can be read apart from `outputs.classify` alone.
|
|
41
|
+
*/
|
|
42
|
+
export function classifier(opts, names) {
|
|
43
|
+
return node({
|
|
44
|
+
name: CLASSIFY,
|
|
45
|
+
run: async (input, ctx) => {
|
|
46
|
+
const said = normalise(await opts.classify(input, ctx));
|
|
47
|
+
return {
|
|
48
|
+
...decide(said, names, opts.minConfidence),
|
|
49
|
+
// The step records what the classifier said, not what we did about it.
|
|
50
|
+
output: said.route,
|
|
51
|
+
model: said.model,
|
|
52
|
+
inputTokens: said.inputTokens,
|
|
53
|
+
outputTokens: said.outputTokens,
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type Edge } from "@spendgraph/graph";
|
|
2
|
+
import type { RouteOptions } from "./types.js";
|
|
3
|
+
/** One node per branch, plus the fallback. */
|
|
4
|
+
export declare function branches(opts: RouteOptions, names: string[]): import("@spendgraph/graph").Node<import("@spendgraph/graph").ArgsOf<readonly import("@spendgraph/graph").ArgSpec[]>>[];
|
|
5
|
+
/**
|
|
6
|
+
* Classify, then exactly one branch, then stop.
|
|
7
|
+
*
|
|
8
|
+
* Conditional edges first and the fallback unconditional last, because `graph`
|
|
9
|
+
* refuses the other order — an unconditional edge ahead of a conditional one is
|
|
10
|
+
* a router whose default hides a branch, and it would never be reached.
|
|
11
|
+
*/
|
|
12
|
+
export declare function edgesTo(names: string[]): Edge[];
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { edge, end, node } from "@spendgraph/graph";
|
|
2
|
+
import { FALLBACK } from "./classify.js";
|
|
3
|
+
function handler(name, run) {
|
|
4
|
+
return node({
|
|
5
|
+
name,
|
|
6
|
+
run: (input, ctx) => run(input, ctx),
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
/** One node per branch, plus the fallback. */
|
|
10
|
+
export function branches(opts, names) {
|
|
11
|
+
return [
|
|
12
|
+
...names.map((name) => handler(name, opts.routes[name])),
|
|
13
|
+
handler(FALLBACK, opts.fallback),
|
|
14
|
+
];
|
|
15
|
+
}
|
|
16
|
+
function taken(ctx) {
|
|
17
|
+
return ctx.outputs.classify?.taken ?? FALLBACK;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Classify, then exactly one branch, then stop.
|
|
21
|
+
*
|
|
22
|
+
* Conditional edges first and the fallback unconditional last, because `graph`
|
|
23
|
+
* refuses the other order — an unconditional edge ahead of a conditional one is
|
|
24
|
+
* a router whose default hides a branch, and it would never be reached.
|
|
25
|
+
*/
|
|
26
|
+
export function edgesTo(names) {
|
|
27
|
+
return [
|
|
28
|
+
...names.map((name) => edge("classify", name, (ctx) => taken(ctx) === name)),
|
|
29
|
+
edge("classify", FALLBACK),
|
|
30
|
+
...names.map((name) => end(name)),
|
|
31
|
+
end(FALLBACK),
|
|
32
|
+
];
|
|
33
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { RouteOptions, RouteResult } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Classify once, then hand the work to the branch built for it.
|
|
4
|
+
*
|
|
5
|
+
* Compiled to a graph rather than looped here: `@spendgraph/graph` already
|
|
6
|
+
* validates that every branch is reachable and that no default sits in front of
|
|
7
|
+
* one, already records a step per node with its tokens, and already returns the
|
|
8
|
+
* rollout shape. A second execution engine in this package would be a second
|
|
9
|
+
* thing to keep right.
|
|
10
|
+
*/
|
|
11
|
+
export declare function route(values: Record<string, unknown>, opts: RouteOptions): Promise<RouteResult>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { graph } from "@spendgraph/graph";
|
|
2
|
+
import { runGraph } from "../stream/graph.js";
|
|
3
|
+
import { CLASSIFY, classifier, FALLBACK } from "./classify.js";
|
|
4
|
+
import { branches, edgesTo } from "./dispatch.js";
|
|
5
|
+
/**
|
|
6
|
+
* Classify once, then hand the work to the branch built for it.
|
|
7
|
+
*
|
|
8
|
+
* Compiled to a graph rather than looped here: `@spendgraph/graph` already
|
|
9
|
+
* validates that every branch is reachable and that no default sits in front of
|
|
10
|
+
* one, already records a step per node with its tokens, and already returns the
|
|
11
|
+
* rollout shape. A second execution engine in this package would be a second
|
|
12
|
+
* thing to keep right.
|
|
13
|
+
*/
|
|
14
|
+
export async function route(values, opts) {
|
|
15
|
+
const names = Object.keys(opts.routes);
|
|
16
|
+
const flow = graph({
|
|
17
|
+
entry: CLASSIFY,
|
|
18
|
+
maxSteps: opts.maxSteps,
|
|
19
|
+
nodes: [classifier(opts, names), ...branches(opts, names)],
|
|
20
|
+
edges: edgesTo(names),
|
|
21
|
+
}, { now: opts.now });
|
|
22
|
+
const result = await runGraph(flow, values, opts.emit);
|
|
23
|
+
const decision = result.outputs[CLASSIFY];
|
|
24
|
+
return {
|
|
25
|
+
...result,
|
|
26
|
+
route: !decision || decision.taken === FALLBACK ? null : decision.taken,
|
|
27
|
+
classified: decision?.said ?? "",
|
|
28
|
+
confidence: decision?.confidence,
|
|
29
|
+
reason: decision?.reason,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { GraphContext, GraphResult } from "@spendgraph/graph";
|
|
2
|
+
import type { Emit } from "../stream/index.js";
|
|
3
|
+
/** What the classifier decided. A bare string is shorthand for `{ route }`. */
|
|
4
|
+
export type Classification = string | {
|
|
5
|
+
route: string;
|
|
6
|
+
/** Below `minConfidence` the fallback runs instead. */
|
|
7
|
+
confidence?: number;
|
|
8
|
+
reason?: string;
|
|
9
|
+
model?: string;
|
|
10
|
+
inputTokens?: number;
|
|
11
|
+
outputTokens?: number;
|
|
12
|
+
};
|
|
13
|
+
/** A branch, or the fallback. Whatever it returns becomes the run's output. */
|
|
14
|
+
export type Handler = (input: Record<string, unknown>, ctx: GraphContext) => Promise<unknown> | unknown;
|
|
15
|
+
export interface RouteOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Which branch this input belongs to.
|
|
18
|
+
*
|
|
19
|
+
* Often better as plain code than a model — a regex on a subject line is free
|
|
20
|
+
* and never invents a category.
|
|
21
|
+
*/
|
|
22
|
+
classify(input: Record<string, unknown>, ctx: GraphContext): Promise<Classification> | Classification;
|
|
23
|
+
/** One handler per branch, keyed by what `classify` returns. */
|
|
24
|
+
routes: Record<string, Handler>;
|
|
25
|
+
/**
|
|
26
|
+
* Runs when the classification matches no branch, or is not confident enough.
|
|
27
|
+
*
|
|
28
|
+
* Required: a router that can fail to route fails in production at 3am, and
|
|
29
|
+
* "unknown" is a class every classifier eventually returns.
|
|
30
|
+
*/
|
|
31
|
+
fallback: Handler;
|
|
32
|
+
/**
|
|
33
|
+
* Where the tokens go while this runs.
|
|
34
|
+
*
|
|
35
|
+
* Passed down to whatever makes the model call; omit it and nothing is
|
|
36
|
+
* emitted. `streamed()` supplies one.
|
|
37
|
+
*/
|
|
38
|
+
emit?: Emit;
|
|
39
|
+
/** Below this the fallback runs. Ignored when the classifier reports none. */
|
|
40
|
+
minConfidence?: number;
|
|
41
|
+
/** Ceiling on total steps, nested ones included. Default 25. */
|
|
42
|
+
maxSteps?: number;
|
|
43
|
+
/** Injectable clock, so tests do not measure real time. */
|
|
44
|
+
now?: () => number;
|
|
45
|
+
}
|
|
46
|
+
export interface RouteResult extends GraphResult {
|
|
47
|
+
/** The branch that ran. `null` when the fallback did. */
|
|
48
|
+
route: string | null;
|
|
49
|
+
/** What the classifier said, whether or not it was used. */
|
|
50
|
+
classified: string;
|
|
51
|
+
confidence?: number;
|
|
52
|
+
reason?: string;
|
|
53
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { GraphResult, graph } from "@spendgraph/graph";
|
|
2
|
+
import type { Emit } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Runs a compiled graph, forwarding what its nodes emit.
|
|
5
|
+
*
|
|
6
|
+
* Streamed only when someone is listening: an unread stream is a queue nothing
|
|
7
|
+
* drains, and `execute` is the same run without one.
|
|
8
|
+
*/
|
|
9
|
+
export declare function runGraph(flow: ReturnType<typeof graph>, values: Record<string, unknown>, emit?: Emit): Promise<GraphResult>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs a compiled graph, forwarding what its nodes emit.
|
|
3
|
+
*
|
|
4
|
+
* Streamed only when someone is listening: an unread stream is a queue nothing
|
|
5
|
+
* drains, and `execute` is the same run without one.
|
|
6
|
+
*/
|
|
7
|
+
export async function runGraph(flow, values, emit) {
|
|
8
|
+
if (!emit)
|
|
9
|
+
return flow.execute(values);
|
|
10
|
+
const running = flow.stream(values);
|
|
11
|
+
for await (const event of running) {
|
|
12
|
+
if (event.type === "token")
|
|
13
|
+
emit(event.text, event.node);
|
|
14
|
+
}
|
|
15
|
+
return running.result;
|
|
16
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { streamed } from "./stream.js";
|