@startsimpli/llm 0.4.30 → 0.4.31
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 +39 -0
- package/package.json +5 -2
- package/src/__tests__/fixtures/captions-golden.json +1881 -0
- package/src/__tests__/fixtures/voices-golden.json +614 -0
- package/src/__tests__/refine.test.ts +193 -0
- package/src/__tests__/speech-captions-cli.test.ts +37 -0
- package/src/__tests__/speech-captions.test.ts +136 -0
- package/src/__tests__/speech-voices-cli.test.ts +45 -0
- package/src/__tests__/speech-voices.test.ts +109 -0
- package/src/index.ts +3 -0
- package/src/refine/errors.ts +23 -0
- package/src/refine/gates.ts +55 -0
- package/src/refine/index.ts +11 -0
- package/src/refine/llm-step.ts +35 -0
- package/src/refine/refine.ts +81 -0
- package/src/refine/types.ts +54 -0
- package/src/speech/captions/cues.ts +108 -0
- package/src/speech/captions/formats.ts +33 -0
- package/src/speech/captions/index.ts +6 -0
- package/src/speech/captions/types.ts +18 -0
- package/src/speech/captions/words.ts +91 -0
- package/src/speech/captions-cli.ts +50 -0
- package/src/speech/cli.ts +13 -2
- package/src/speech/index.ts +2 -0
- package/src/speech/repr.ts +14 -0
- package/src/speech/voices/cast-voices.json +127 -0
- package/src/speech/voices/cast-voices.ts +169 -0
- package/src/speech/voices/emotion.ts +51 -0
- package/src/speech/voices/errors.ts +14 -0
- package/src/speech/voices/index.ts +12 -0
- package/src/speech/voices/schema.ts +53 -0
- package/src/speech/voices/tags.ts +11 -0
- package/src/speech/voices-cli.ts +43 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import type { ILLMProvider } from '../providers/interface';
|
|
4
|
+
import type { GenerateOptions, LLMResponse } from '../types';
|
|
5
|
+
import {
|
|
6
|
+
InvalidTriesError, RefineStepError, UnknownGateError, createGateRegistry, llmRefineStep, refineWithGates,
|
|
7
|
+
} from '../refine';
|
|
8
|
+
import type { RefineStep, StepInput } from '../refine';
|
|
9
|
+
|
|
10
|
+
type Doc = { title: string; screens: number };
|
|
11
|
+
|
|
12
|
+
/** Gates in the shape the anim loop has: a schema-ish one and a policy one. */
|
|
13
|
+
const registry = () => {
|
|
14
|
+
const gates = createGateRegistry<Doc, { kind: string }>();
|
|
15
|
+
gates.register('title', (d) => (d.title.length > 3 ? ['title is too long'] : []));
|
|
16
|
+
gates.register('screens', (d) => (d.screens > 0 ? [`a skit expects no screens, found ${d.screens}`] : []));
|
|
17
|
+
return gates;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** A step that answers call i with replies[i] (the last repeats) and records what it was handed. */
|
|
21
|
+
const sequenced = (...replies: Array<Doc | Error | { result: Doc | null; errors?: string[]; costUsd?: number }>) => {
|
|
22
|
+
const calls: StepInput<Doc>[] = [];
|
|
23
|
+
const step: RefineStep<Doc> = async (input) => {
|
|
24
|
+
calls.push(structuredClone(input));
|
|
25
|
+
const r = replies[Math.min(calls.length - 1, replies.length - 1)];
|
|
26
|
+
if (r instanceof Error) throw r;
|
|
27
|
+
return 'title' in r ? { result: r, costUsd: 0.01, usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150 }, model: 'm' } : r;
|
|
28
|
+
};
|
|
29
|
+
return { step, calls };
|
|
30
|
+
};
|
|
31
|
+
const good: Doc = { title: 'Ok', screens: 0 };
|
|
32
|
+
const withScreen: Doc = { title: 'Ok', screens: 2 };
|
|
33
|
+
const run = (step: RefineStep<Doc>, tries: number | null, extra: Record<string, unknown> = {}) =>
|
|
34
|
+
refineWithGates<Doc, { kind: string }>({ step, gates: registry(), tries, context: { kind: 'skit' }, ...extra });
|
|
35
|
+
|
|
36
|
+
describe('refineWithGates: the loop', () => {
|
|
37
|
+
it('a result that passes every gate costs exactly one step call', async () => {
|
|
38
|
+
const { step, calls } = sequenced(good);
|
|
39
|
+
const out = await run(step, 3);
|
|
40
|
+
expect(out.passed).toBe(true);
|
|
41
|
+
expect(calls).toHaveLength(1);
|
|
42
|
+
expect(out.attempts).toHaveLength(1);
|
|
43
|
+
expect(out.attempts[0]).toMatchObject({ n: 1, report: [], result: good, passed: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('the step receives the previous result and the gates failures as data', async () => {
|
|
47
|
+
const { step, calls } = sequenced(withScreen, good);
|
|
48
|
+
const out = await run(step, 3);
|
|
49
|
+
expect(out.passed).toBe(true);
|
|
50
|
+
expect(calls[0]).toMatchObject({ n: 1, previous: null, failures: [] });
|
|
51
|
+
expect(calls[1].previous).toEqual(withScreen);
|
|
52
|
+
expect(calls[1].failures).toEqual([{ gate: 'screens', failures: ['a skit expects no screens, found 2'] }]);
|
|
53
|
+
expect(out.attempts[0].passed).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('tries=N makes N calls at most and returns every attempt', async () => {
|
|
57
|
+
const { step, calls } = sequenced(withScreen);
|
|
58
|
+
const out = await run(step, 3);
|
|
59
|
+
expect(out.passed).toBe(false);
|
|
60
|
+
expect(calls).toHaveLength(3);
|
|
61
|
+
expect(out.attempts.map((a) => a.n)).toEqual([1, 2, 3]);
|
|
62
|
+
expect(out.result).toEqual(withScreen);
|
|
63
|
+
expect((await run(sequenced(withScreen, good).step, 1)).passed).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('tries=null loops until every gate passes, with no hidden cap', async () => {
|
|
67
|
+
const { step, calls } = sequenced(...Array(25).fill(withScreen), good);
|
|
68
|
+
const out = await run(step, null);
|
|
69
|
+
expect(out.passed).toBe(true);
|
|
70
|
+
expect(calls).toHaveLength(26);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('a bad tries value is refused, not clamped, before any call', async () => {
|
|
74
|
+
const { step, calls } = sequenced(good);
|
|
75
|
+
for (const bad of [0, -1, 1.5, true as any, 'x' as any, undefined as any]) await expect(run(step, bad)).rejects.toBeInstanceOf(InvalidTriesError);
|
|
76
|
+
expect(calls).toHaveLength(0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('every attempt carries {result, report, cost} and the outcome tallies usage', async () => {
|
|
80
|
+
const { step } = sequenced(withScreen, good);
|
|
81
|
+
const out = await run(step, 3);
|
|
82
|
+
expect(out.attempts[0].cost).toMatchObject({ usd: 0.01, promptTokens: 100, completionTokens: 50 });
|
|
83
|
+
expect(out.cost.usd).toBeCloseTo(0.02, 6);
|
|
84
|
+
expect(out.tally).toMatchObject({ calls: 2, promptTokens: 200, completionTokens: 100 });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('a step that throws is surfaced with the attempts so far, not retried', async () => {
|
|
88
|
+
const boom = new Error('network down');
|
|
89
|
+
const { step, calls } = sequenced(withScreen, boom, good);
|
|
90
|
+
const error = await run(step, 5).catch((e) => e);
|
|
91
|
+
expect(error).toBeInstanceOf(RefineStepError);
|
|
92
|
+
expect(error.cause).toBe(boom);
|
|
93
|
+
expect(error.attempts).toHaveLength(1);
|
|
94
|
+
expect(error.n).toBe(2);
|
|
95
|
+
expect(calls).toHaveLength(2);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('a reply the step rejected, or no result, is a failure under the rejected-gate name and the loop goes on', async () => {
|
|
99
|
+
const { step, calls } = sequenced({ result: null, errors: ['not json'] }, good);
|
|
100
|
+
const out = await run(step, 2);
|
|
101
|
+
expect(out.passed).toBe(true);
|
|
102
|
+
expect(out.attempts[0].report).toEqual([{ gate: 'step', failures: ['not json'] }]);
|
|
103
|
+
expect(calls[1]).toMatchObject({ previous: null, failures: [{ gate: 'step', failures: ['not json'] }] });
|
|
104
|
+
const named = await run(sequenced({ result: null }).step, 1, { rejectedGate: 'schema' });
|
|
105
|
+
expect(named.attempts[0].report[0]).toMatchObject({ gate: 'schema' });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('calls a step made in one try (e.g. per-scene regeneration) are counted', async () => {
|
|
109
|
+
const out = await run(async () => ({ result: good, calls: 3 }), 1);
|
|
110
|
+
expect(out.attempts[0].calls).toBe(3);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('refineWithGates: gates', () => {
|
|
115
|
+
it('are registered by name; the context reaches them; a new one plugs in; a named subset runs', async () => {
|
|
116
|
+
const seen: string[] = [];
|
|
117
|
+
const gates = registry();
|
|
118
|
+
gates.register('ctx', (_d, ctx) => { seen.push(ctx.kind); return []; });
|
|
119
|
+
const out = await refineWithGates({ step: sequenced(withScreen).step, gates, tries: 1, context: { kind: 'skit' } });
|
|
120
|
+
expect(seen).toEqual(['skit']);
|
|
121
|
+
expect(out.attempts[0].report.map((r) => r.gate)).toEqual(['screens']);
|
|
122
|
+
const only = await refineWithGates({ step: sequenced(withScreen).step, gates, tries: 1, context: { kind: 'skit' }, only: ['title'] });
|
|
123
|
+
expect(only.passed).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('an unknown gate name throws before any step call', async () => {
|
|
127
|
+
const { step, calls } = sequenced(good);
|
|
128
|
+
await expect(run(step, 1, { only: ['nope'] })).rejects.toBeInstanceOf(UnknownGateError);
|
|
129
|
+
expect(calls).toHaveLength(0);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('opt-in gates are registered but run only when named', async () => {
|
|
133
|
+
const gates = registry();
|
|
134
|
+
gates.register('storyboard', () => ['nobody on stage'], { optIn: true });
|
|
135
|
+
expect(gates.defaultNames()).toEqual(['title', 'screens']);
|
|
136
|
+
const base = { step: sequenced(good).step, gates, tries: 1, context: { kind: 'k' } };
|
|
137
|
+
expect((await refineWithGates(base)).passed).toBe(true);
|
|
138
|
+
expect((await refineWithGates({ ...base, step: sequenced(good).step, only: ['storyboard'] })).passed).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('informational lines never fail a gate and are kept on the attempt', async () => {
|
|
142
|
+
const gates = createGateRegistry<Doc>();
|
|
143
|
+
gates.register('lint', () => [{ text: 'unrealized data: screens', level: 'info' }, { text: 'note', level: 'info' }]);
|
|
144
|
+
const out = await refineWithGates({ step: sequenced(good).step, gates, tries: 2 });
|
|
145
|
+
expect(out.passed).toBe(true);
|
|
146
|
+
expect(out.attempts[0].notes).toEqual([{ gate: 'lint', text: 'unrealized data: screens' }, { gate: 'lint', text: 'note' }]);
|
|
147
|
+
gates.register('mixed', () => [{ text: 'fine', level: 'info' }, { text: 'broken', level: 'defect' }]);
|
|
148
|
+
const failed = await refineWithGates({ step: sequenced(good).step, gates, tries: 1 });
|
|
149
|
+
expect(failed.attempts[0].report).toEqual([{ gate: 'mixed', failures: ['broken'] }]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('async gates work, and replace=false refuses a duplicate name', async () => {
|
|
153
|
+
const gates = createGateRegistry<Doc>();
|
|
154
|
+
gates.register('slow', async () => ['late']);
|
|
155
|
+
expect(() => gates.register('slow', () => [], { replace: false })).toThrow(/already registered/);
|
|
156
|
+
const out = await refineWithGates({ step: sequenced(good).step, gates, tries: 1 });
|
|
157
|
+
expect(out.attempts[0].report).toEqual([{ gate: 'slow', failures: ['late'] }]);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('llmRefineStep: an injectable client', () => {
|
|
162
|
+
const provider = (answers: string[]) => {
|
|
163
|
+
const generate = vi.fn(async (_u: string, _s: string, _o?: GenerateOptions): Promise<LLMResponse> => ({
|
|
164
|
+
content: answers.shift() ?? '{}', model: 'fake', usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 },
|
|
165
|
+
}));
|
|
166
|
+
return { name: 'fake', availableModels: ['fake'], defaultModel: 'fake', generate, isAvailable: () => true } as ILLMProvider & { generate: typeof generate };
|
|
167
|
+
};
|
|
168
|
+
const validate = (json: any) => (typeof json?.title === 'string'
|
|
169
|
+
? { valid: true as const, data: { title: json.title, screens: json.screens ?? 0 } }
|
|
170
|
+
: { valid: false as const, errors: ['title: expected string'] });
|
|
171
|
+
|
|
172
|
+
it('sends the brief, previous result and failures as data, and feeds a rejected reply back as a failure', async () => {
|
|
173
|
+
const p = provider(['not json', JSON.stringify({ title: 'Ok' })]);
|
|
174
|
+
const step = llmRefineStep<Doc>({ provider: p, systemPrompt: 'sys', brief: 'a skit', validate });
|
|
175
|
+
const out = await refineWithGates({ step, gates: createGateRegistry<Doc>(), tries: 3 });
|
|
176
|
+
expect(out.passed).toBe(true);
|
|
177
|
+
expect(out.attempts[0].report[0].gate).toBe('step');
|
|
178
|
+
expect(p.generate).toHaveBeenCalledTimes(2);
|
|
179
|
+
const first = JSON.parse(p.generate.mock.calls[0][0]);
|
|
180
|
+
const second = JSON.parse(p.generate.mock.calls[1][0]);
|
|
181
|
+
expect(first).toEqual({ brief: 'a skit' });
|
|
182
|
+
expect(second.brief).toBe('a skit');
|
|
183
|
+
expect(second.gate_failures[0].gate).toBe('step');
|
|
184
|
+
expect(out.attempts[1].cost).toMatchObject({ promptTokens: 10, completionTokens: 5 });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('a provider that throws is surfaced as a step error', async () => {
|
|
188
|
+
const p = provider([]);
|
|
189
|
+
p.generate.mockRejectedValueOnce(new Error('401'));
|
|
190
|
+
const step = llmRefineStep<Doc>({ provider: p, systemPrompt: 's', brief: 'b', validate });
|
|
191
|
+
await expect(refineWithGates({ step, gates: createGateRegistry<Doc>(), tries: 3 })).rejects.toBeInstanceOf(RefineStepError);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { runCli } from '../speech/cli';
|
|
6
|
+
|
|
7
|
+
const golden: Array<{ fn: string; input: any; expected: any }> = JSON.parse(readFileSync(join(__dirname, 'fixtures', 'captions-golden.json'), 'utf8'));
|
|
8
|
+
const run = (argv: string[], body: unknown) => runCli(argv, { readStdin: async () => JSON.stringify(body) });
|
|
9
|
+
|
|
10
|
+
describe('startsimpli-speech captions', () => {
|
|
11
|
+
it.each(golden.map((g, n) => [n, g] as const).filter(([, g]) => g.fn === 'cues_for_beat' || (g.fn === 'cues_from_words' && g.input.words.length)))(
|
|
12
|
+
'json format equals the Python cues (case %i)', async (_n, g) => {
|
|
13
|
+
const i = g.input;
|
|
14
|
+
const words = i.words?.map(({ word, ...r }: any) => ({ text: word, ...r }));
|
|
15
|
+
const res = await run(['captions', '--format', 'json'], {
|
|
16
|
+
words, text: i.text, duration: i.duration, offset: i.offset, clauseBreaks: i.clause_breaks, maxWords: i.max_words, maxChars: i.max_chars, maxGap: i.max_gap,
|
|
17
|
+
});
|
|
18
|
+
expect(res.exitCode).toBe(0);
|
|
19
|
+
expect((res.output as any).cues).toEqual(g.expected);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('srt and vtt return the file text; holdGap and fps are applied', async () => {
|
|
23
|
+
const body = { words: [{ text: 'a', start: 0, end: 1 }, { text: 'b', start: 1.2, end: 2 }], maxWords: 1, holdGapS: 0.5, fps: 10 };
|
|
24
|
+
const srt = await run(['captions', '--format', 'srt'], body);
|
|
25
|
+
expect(srt.exitCode).toBe(0);
|
|
26
|
+
expect((srt.output as any).content).toContain('00:00:00,000 --> 00:00:01,200');
|
|
27
|
+
expect((srt.output as any).frames).toEqual([[0, 12], [12, 20]]);
|
|
28
|
+
expect(((await run(['captions', '--format', 'vtt'], body)).output as any).content.startsWith('WEBVTT')).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('usage errors exit 2; unusable words exit 1 with a named error', async () => {
|
|
32
|
+
expect((await run(['captions', '--format', 'pdf'], { text: 'x', duration: 1 })).exitCode).toBe(2);
|
|
33
|
+
expect((await run(['captions', '--format', 'json'], {})).exitCode).toBe(2);
|
|
34
|
+
const bad = await run(['captions', '--format', 'json'], { words: [{ text: 'a', start: 2, end: 1 }] });
|
|
35
|
+
expect(bad).toMatchObject({ exitCode: 1, output: { error: { code: 'INVALID_WORDS' } } });
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
InvalidWordsError, assertValidWords, captionTiming, cueFrameRange, cuesForBeat, cuesFromText, cuesFromWords,
|
|
7
|
+
holdGaps, splitWhitespaceTokens, toSrt, toVtt, wordsAreProportional,
|
|
8
|
+
} from '../speech';
|
|
9
|
+
import type { TranscriptWord } from '../speech';
|
|
10
|
+
|
|
11
|
+
type Golden = { fn: string; input: any; expected: any };
|
|
12
|
+
const golden: Golden[] = JSON.parse(readFileSync(join(__dirname, 'fixtures', 'captions-golden.json'), 'utf8'));
|
|
13
|
+
|
|
14
|
+
/** The Python code names a word's text `word`. */
|
|
15
|
+
const toWords = (ws: any[] | null | undefined): TranscriptWord[] | undefined =>
|
|
16
|
+
ws?.map(({ word, ...rest }) => ({ text: word, ...rest }));
|
|
17
|
+
const camel = (o: Record<string, any>, drop: string[]) => ({
|
|
18
|
+
...(o.max_words !== undefined && { maxWords: o.max_words }),
|
|
19
|
+
...(o.max_chars !== undefined && { maxChars: o.max_chars }),
|
|
20
|
+
...(o.max_gap !== undefined && { maxGap: o.max_gap }),
|
|
21
|
+
...(o.clause_breaks !== undefined && { clauseBreaks: o.clause_breaks }),
|
|
22
|
+
...(o.offset !== undefined && { offset: o.offset }),
|
|
23
|
+
...Object.fromEntries(Object.entries(o).filter(([k]) => drop.includes(k))),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const replay: Record<string, (i: any) => unknown> = {
|
|
27
|
+
cues_from_words: (i) => cuesFromWords(toWords(i.words)!, camel(i, [])),
|
|
28
|
+
cues_from_text: (i) => cuesFromText(i.text, i.duration, camel(i, [])),
|
|
29
|
+
cues_for_beat: (i) => cuesForBeat(toWords(i.words), i.text, i.duration, camel(i, [])),
|
|
30
|
+
hold_gaps: (i) => holdGaps(i.cues, i.max_gap_s),
|
|
31
|
+
cue_frame_range: (i) => cueFrameRange(i.cue, i.fps),
|
|
32
|
+
words_are_proportional: (i) => wordsAreProportional(toWords(i.words)!.map((w, n) => ({ ...w, ...(i.flagged ? { alignment: i.flagged } : {}), ...(i.words[n].alignment ? { alignment: i.words[n].alignment } : {}) })), { stepMax: i.step_max, minWords: i.min_words }),
|
|
33
|
+
check_words: (i) => {
|
|
34
|
+
try { assertValidWords(toWords(i.words)!); return { ok: true }; } catch (e) {
|
|
35
|
+
if (!(e instanceof InvalidWordsError)) throw e;
|
|
36
|
+
return { error: e.message };
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
describe('captions: parity with the Python implementation (golden)', () => {
|
|
42
|
+
it('has golden cases', () => expect(golden.length).toBeGreaterThanOrEqual(35));
|
|
43
|
+
it.each(golden.map((g, n) => [`${n} ${g.fn}`, g] as const))('%s', (_n, g) => {
|
|
44
|
+
expect(replay[g.fn](g.input)).toEqual(g.expected);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const w = (text: string, step = 0.3): TranscriptWord[] => text.split(' ').map((t, i) => ({ text: t, start: i * step, end: i * step + 0.25 }));
|
|
49
|
+
|
|
50
|
+
describe('captions: cue building', () => {
|
|
51
|
+
it('splits at a clause end with at least two tokens before the cut, never mid-clause when it can avoid it', () => {
|
|
52
|
+
const text = 'Today the frog will hop three feet to the right, and the elephant will watch.';
|
|
53
|
+
expect(cuesFromWords(w(text), { clauseBreaks: true }).map((c) => c.text))
|
|
54
|
+
.toEqual(['Today the frog will hop three feet', 'to the right,', 'and the elephant will watch.']);
|
|
55
|
+
expect(cuesFromWords(w('I watched. It was a very good three feet.'), { clauseBreaks: true }).map((c) => c.text))
|
|
56
|
+
.toEqual(['I watched.', 'It was a very good three feet.']);
|
|
57
|
+
// a single-token head is not a cut ("Hi, there ..." keeps the limit split)
|
|
58
|
+
expect(cuesFromWords(w('Hi, one two three four five six seven'), { clauseBreaks: true }).map((c) => c.text)[0]).toBe('Hi, one two three four five six');
|
|
59
|
+
});
|
|
60
|
+
it('holdGaps fills gaps up to the limit and never moves a start or mutates its input', () => {
|
|
61
|
+
const cues = [{ start: 0, end: 1, text: 'a' }, { start: 1.2, end: 2, text: 'b' }, { start: 3.5, end: 4, text: 'c' }];
|
|
62
|
+
expect(holdGaps(cues, 0.6).map((c) => c.end)).toEqual([1.2, 2, 4]);
|
|
63
|
+
expect(holdGaps(cues, 0)).toEqual(cues);
|
|
64
|
+
expect(cues[0].end).toBe(1);
|
|
65
|
+
});
|
|
66
|
+
it('cueFrameRange rounds half to even like Python', () => {
|
|
67
|
+
expect(cueFrameRange({ start: 1.25, end: 2.75, text: '' }, 10)).toEqual([12, 28]);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('captions: word checks', () => {
|
|
72
|
+
it('proportional detection: evenly spread characters yes, real vendor alignment no', () => {
|
|
73
|
+
const even = 'one two three four five six seven eight nine ten'.split(' ').map((t, i, a) => {
|
|
74
|
+
const start = a.slice(0, i).reduce((s, x) => s + 0.06 * x.length + 0.06, 0);
|
|
75
|
+
return { text: t, start, end: start + 0.06 * t.length };
|
|
76
|
+
});
|
|
77
|
+
expect(wordsAreProportional(even)).toBe(true);
|
|
78
|
+
const real = [[0, .31], [.35, .52], [.55, 1.1], [1.4, 1.5], [1.52, 2.4], [2.45, 2.6], [2.9, 3.0], [3.02, 3.9]].map(([s, e], i) => ({ text: `word${i}`, start: s, end: e }));
|
|
79
|
+
expect(wordsAreProportional(real)).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
it('assertValidWords rejects empty and disordered transcripts with a named error', () => {
|
|
82
|
+
expect(() => assertValidWords([])).toThrow(InvalidWordsError);
|
|
83
|
+
expect(() => assertValidWords([{ text: 'a', start: 1, end: 0.5 }])).toThrow(/ends before it starts/);
|
|
84
|
+
expect(() => assertValidWords([{ text: 'a', start: 1, end: 2 }, { text: 'b', start: 0.5, end: 3 }])).toThrow(/not in time order/);
|
|
85
|
+
expect(assertValidWords(w('a b'))).toHaveLength(2);
|
|
86
|
+
});
|
|
87
|
+
it('splitWhitespaceTokens shares a multi-word token time evenly', () => {
|
|
88
|
+
expect(splitWhitespaceTokens([{ text: 'a b', start: 0, end: 1 }, { text: ' ', start: 1, end: 2 }])).toEqual([
|
|
89
|
+
{ text: 'a', start: 0, end: 0.5 }, { text: 'b', start: 0.5, end: 1 },
|
|
90
|
+
]);
|
|
91
|
+
});
|
|
92
|
+
it('captionTiming is not_measured for proportional words, else reports start errors', () => {
|
|
93
|
+
const real = [[0, .31], [.35, .52], [.55, 1.1], [1.4, 1.5], [1.52, 2.4], [2.45, 2.6], [2.9, 3.0], [3.02, 3.9]].map(([s, e], i) => ({ text: `w${i}`, start: s, end: e }));
|
|
94
|
+
const exact = captionTiming([{ start: 0, end: 1.1, text: 'w0 w1 w2' }], real);
|
|
95
|
+
expect(exact).toMatchObject({ measured: true, n: 1, pass: true, max_abs_start_err_ms: 0 });
|
|
96
|
+
expect(captionTiming([{ start: 0.5, end: 1.1, text: 'w0 w1 w2' }], real)).toMatchObject({ pass: false });
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
/** A strict reader for the two formats, independent of the writer. */
|
|
101
|
+
function parseCues(text: string, kind: 'srt' | 'vtt') {
|
|
102
|
+
const stamp = kind === 'srt' ? /^(\d\d):([0-5]\d):([0-5]\d),(\d{3})$/ : /^(\d\d):([0-5]\d):([0-5]\d)\.(\d{3})$/;
|
|
103
|
+
const ms = (s: string) => { const m = stamp.exec(s); if (!m) throw new Error(`bad timestamp ${s}`); return ((+m[1] * 60 + +m[2]) * 60 + +m[3]) * 1000 + +m[4]; };
|
|
104
|
+
let body = text;
|
|
105
|
+
if (kind === 'vtt') { if (!body.startsWith('WEBVTT\n\n')) throw new Error('missing WEBVTT header'); body = body.slice(8); }
|
|
106
|
+
if (!body.endsWith('\n')) throw new Error('no trailing newline');
|
|
107
|
+
return body.trimEnd().split('\n\n').map((block, n) => {
|
|
108
|
+
const lines = block.split('\n');
|
|
109
|
+
let i = 0;
|
|
110
|
+
if (kind === 'srt') { if (lines[i++] !== String(n + 1)) throw new Error(`bad index in ${block}`); }
|
|
111
|
+
const [a, arrow, b] = lines[i++].split(' ');
|
|
112
|
+
if (arrow !== '-->') throw new Error(`bad timing line in ${block}`);
|
|
113
|
+
const start = ms(a); const end = ms(b);
|
|
114
|
+
if (end <= start) throw new Error('end before start');
|
|
115
|
+
const cueText = lines.slice(i).join('\n');
|
|
116
|
+
if (!cueText || cueText.includes('\n\n')) throw new Error('empty cue text');
|
|
117
|
+
return { start: start / 1000, end: end / 1000, text: cueText };
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
describe('captions: SRT and VTT output', () => {
|
|
122
|
+
const cues = [{ start: 0, end: 1.2345, text: 'Hello there,' }, { start: 3661.5, end: 3662, text: 'second --> cue' }, { start: 3662, end: 3662.0004, text: 'x' }];
|
|
123
|
+
it.each([['srt', toSrt], ['vtt', toVtt]] as const)('%s parses back to the same cues (millisecond precision)', (kind, write) => {
|
|
124
|
+
const back = parseCues(write(cues), kind);
|
|
125
|
+
expect(back).toHaveLength(2 + 1);
|
|
126
|
+
expect(back[0]).toEqual({ start: 0, end: 1.235, text: 'Hello there,' });
|
|
127
|
+
expect(back[1].start).toBe(3661.5);
|
|
128
|
+
});
|
|
129
|
+
it('formats carry the right separators and header', () => {
|
|
130
|
+
expect(toSrt(cues)).toContain('00:00:00,000 --> 00:00:01,235');
|
|
131
|
+
expect(toVtt(cues).startsWith('WEBVTT\n\n00:00:00.000 --> 00:00:01.235')).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
it('an end that rounds to its start is lifted one millisecond so the cue stays valid', () => {
|
|
134
|
+
expect(parseCues(toSrt(cues), 'srt')[2]).toEqual({ start: 3662, end: 3662.001, text: 'x' });
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { defaultCastLibrary } from '../speech';
|
|
6
|
+
import { runCli } from '../speech/cli';
|
|
7
|
+
|
|
8
|
+
/** Produced by the Python resolver (blender-spike anim/voice/export_golden.py): the CLI must agree with it. */
|
|
9
|
+
const golden: Array<{ name: string; request: any; expected: any }> = JSON.parse(
|
|
10
|
+
readFileSync(join(__dirname, 'fixtures', 'voices-golden.json'), 'utf8'),
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
const run = (argv: string[], stdin: string) => runCli(argv, { readStdin: async () => stdin });
|
|
14
|
+
|
|
15
|
+
describe('startsimpli-speech voices resolve', () => {
|
|
16
|
+
it('has golden cases from the Python resolver', () => expect(golden.length).toBeGreaterThanOrEqual(15));
|
|
17
|
+
|
|
18
|
+
it.each(golden.map((g) => [g.name, g] as const))('golden: %s', async (_name, g) => {
|
|
19
|
+
const { libraryPatch, ...request } = g.request;
|
|
20
|
+
const body = { ...request, ...(libraryPatch ? { library: { ...defaultCastLibrary, ...libraryPatch } } : {}) };
|
|
21
|
+
const res = await run(['voices', 'resolve'], JSON.stringify(body));
|
|
22
|
+
if (g.expected.error) {
|
|
23
|
+
expect(res.exitCode).toBe(1);
|
|
24
|
+
// Python knows two kinds; TypeScript names more, all of them "not_resolvable" to Python.
|
|
25
|
+
const error = (res.output as any).error;
|
|
26
|
+
expect(error.message).toBe(g.expected.error.message);
|
|
27
|
+
if (g.expected.error.kind === 'unfilled') expect(error.kind).toBe('unfilled');
|
|
28
|
+
expect((res.output as any).warnings).toEqual(g.expected.warnings);
|
|
29
|
+
} else {
|
|
30
|
+
expect(res.exitCode).toBe(0);
|
|
31
|
+
expect(res.output).toEqual({ voices: g.expected.voices, warnings: g.expected.warnings });
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('flags override the request, and --in reads a file', async () => {
|
|
36
|
+
const res = await run(['voices', 'resolve', '--model', 'eleven_multilingual_v2', '--expiring', 'allow', '--today', '2027-01-01'], '{"cast":[{"id":"a","voice":"presenter"}]}');
|
|
37
|
+
expect((res.output as any).voices.a.model).toBe('eleven_multilingual_v2');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('usage errors exit 2', async () => {
|
|
41
|
+
expect((await run(['voices'], '{}')).exitCode).toBe(2);
|
|
42
|
+
expect((await run(['voices', 'resolve'], '{}')).exitCode).toBe(2);
|
|
43
|
+
expect((await run(['voices', 'resolve', '--expiring', 'sometimes'], '{"cast":[]}')).exitCode).toBe(2);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
DuplicateVoiceError, ExpiringVoiceError, RetiredVoiceError, UnfilledVoiceSlotError, UnknownLanguageError,
|
|
5
|
+
UnknownModelError, VoiceNotResolvableError, createCastVoices, defaultCastLibrary, directEmotion, stripTags,
|
|
6
|
+
validateCastLibrary,
|
|
7
|
+
} from '../speech';
|
|
8
|
+
|
|
9
|
+
const lib = () => structuredClone(defaultCastLibrary) as typeof defaultCastLibrary;
|
|
10
|
+
const cast = (o: Record<string, unknown> = {}) => createCastVoices(lib(), { expiring: 'allow', ...o } as any);
|
|
11
|
+
const ES = 'A'.repeat(20);
|
|
12
|
+
const EXPIRING = 'cgSgspJ2msm6clMCkdW9';
|
|
13
|
+
|
|
14
|
+
describe('voice cast library: resolution', () => {
|
|
15
|
+
it('a library id resolves to that entry; a real-looking voice id passes through; an unknown alias is ignored', () => {
|
|
16
|
+
const cv = cast();
|
|
17
|
+
expect(cv.resolve({ id: 'x', voice: 'manager' }).voiceId).toBe(lib().roles.manager.voice_id);
|
|
18
|
+
const passthrough = cv.resolve({ id: 'x', voice: 'AbCdEfGhIjKlMnOpQrSt' });
|
|
19
|
+
expect(passthrough).toMatchObject({ voiceId: 'AbCdEfGhIjKlMnOpQrSt', role: null, settings: {} });
|
|
20
|
+
expect(() => cv.resolve({ id: 'maya', voice: 'maya' })).toThrow(VoiceNotResolvableError);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('members without a voice take default_pool in order, counting only voiceless members', () => {
|
|
24
|
+
const got = cast().resolveCast([{ id: 'a', voice: 'clone_1' }, { id: 'maya', voice: 'maya' }, { id: 'b' }]);
|
|
25
|
+
expect(got.a.role).toBe('clone_1');
|
|
26
|
+
expect(got.maya.role).toBe('presenter');
|
|
27
|
+
expect(got.b.role).toBe('engineer');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('unfilled slot, retired id, unknown model and a shared voice each throw a named error', () => {
|
|
31
|
+
const cv = cast();
|
|
32
|
+
expect(() => cv.resolve({ id: 's', voice: 'open_slot_1' })).toThrow(UnfilledVoiceSlotError);
|
|
33
|
+
expect(() => cv.resolve({ id: 's', voice: 'open_slot_1' })).toThrow(/open_slot_1.*bspike-448/);
|
|
34
|
+
expect(() => cv.resolve({ id: 'x', voice: '21m00Tcm4TlvDq8ikWAM' })).toThrow(RetiredVoiceError);
|
|
35
|
+
expect(() => cv.resolve({ id: 'x', voice: 'presenter' }, { model: 'nope' })).toThrow(UnknownModelError);
|
|
36
|
+
expect(() => cv.resolveCast([{ id: 'a', voice: 'presenter' }, { id: 'b', voice: 'presenter' }])).toThrow(DuplicateVoiceError);
|
|
37
|
+
expect(() => cv.resolve({ id: 'z', voice: 'zz' }, {})).toThrow(/no voice reference/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('a per-language entry selects model and voice ids; no language is the unchanged path', () => {
|
|
41
|
+
const data = lib() as any;
|
|
42
|
+
data.languages.es = { model: 'eleven_multilingual_v2', rhubarb_recognizer: 'phonetic', roles: { presenter: { voice_id: ES } } };
|
|
43
|
+
const cv = createCastVoices(data, { expiring: 'allow' });
|
|
44
|
+
const base = cv.resolve({ id: 'm', voice: 'presenter' });
|
|
45
|
+
expect(cv.resolve({ id: 'm', voice: 'presenter' }, { language: 'en-GB' })).toEqual(base);
|
|
46
|
+
expect(base.language).toBeNull();
|
|
47
|
+
const es = cv.resolve({ id: 'm', voice: 'presenter' }, { language: 'es-MX' });
|
|
48
|
+
expect(es).toMatchObject({ voiceId: ES, model: 'eleven_multilingual_v2', language: 'es' });
|
|
49
|
+
expect(cv.resolve({ id: 'm', voice: 'engineer' }, { language: 'es' }).voiceId).toBe(lib().roles.engineer.voice_id);
|
|
50
|
+
expect(cv.resolve({ id: 'm', voice: 'presenter' }, { language: 'es', model: 'eleven_v3' }).model).toBe('eleven_v3');
|
|
51
|
+
expect(() => cv.resolve({ id: 'm', voice: 'presenter' }, { language: 'xx' })).toThrow(UnknownLanguageError);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('voice cast library: expiring Default voices (clock injected)', () => {
|
|
56
|
+
const member = { id: 'x', voice: EXPIRING };
|
|
57
|
+
it('warns before 2026-12-31 (once per voice) and errors from the deadline', () => {
|
|
58
|
+
const onWarn = vi.fn();
|
|
59
|
+
const cv = createCastVoices(lib(), { today: '2026-11-02', onWarn });
|
|
60
|
+
cv.resolve(member); cv.resolve(member);
|
|
61
|
+
expect(onWarn).toHaveBeenCalledTimes(1);
|
|
62
|
+
expect(onWarn.mock.calls[0][0]).toMatch(/Jessica.*expires 2026-12-31.*59 days left/);
|
|
63
|
+
expect(cv.warnings).toHaveLength(1);
|
|
64
|
+
expect(() => createCastVoices(lib(), { today: '2026-12-31' }).resolve(member)).toThrow(ExpiringVoiceError);
|
|
65
|
+
});
|
|
66
|
+
it('allow never complains; error always does; anything else is rejected', () => {
|
|
67
|
+
const onWarn = vi.fn();
|
|
68
|
+
createCastVoices(lib(), { today: '2027-06-01', expiring: 'allow', onWarn }).resolve(member);
|
|
69
|
+
expect(onWarn).not.toHaveBeenCalled();
|
|
70
|
+
expect(() => createCastVoices(lib(), { today: '2026-01-01', expiring: 'error' }).resolve(member)).toThrow(ExpiringVoiceError);
|
|
71
|
+
expect(() => createCastVoices(lib(), { expiring: 'sometimes' as any })).toThrow(/expiring must be/);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('voice cast library: emotion and tags', () => {
|
|
76
|
+
const spec = (model: string, role = 'presenter') => cast().resolve({ id: 'm', voice: role }, { model });
|
|
77
|
+
it('tag models get a prepended tag and untouched settings; the tag is not spoken text', () => {
|
|
78
|
+
const s = spec('eleven_v3');
|
|
79
|
+
const d = directEmotion(cast(), s, 'Hello there.', 'excited');
|
|
80
|
+
expect(d).toMatchObject({ textSent: '[excited] Hello there.', spokenText: 'Hello there.', settings: s.settings });
|
|
81
|
+
});
|
|
82
|
+
it('non-tag models get a clamped settings delta and no tag', () => {
|
|
83
|
+
const s = spec('eleven_multilingual_v2');
|
|
84
|
+
const d = directEmotion(cast(), s, 'Hello there.', 'excited');
|
|
85
|
+
expect(d.textSent).toBe('Hello there.');
|
|
86
|
+
expect(d.settings).toEqual({ stability: 0.25, similarity_boost: 0.75, style: 0.7, speed: 1.04 });
|
|
87
|
+
});
|
|
88
|
+
it('neutral, null and unknown emotions change nothing; a role can opt out; mode off/cli', () => {
|
|
89
|
+
const s = spec('eleven_v3');
|
|
90
|
+
for (const e of ['neutral', null, 'grumpy']) expect(directEmotion(cast(), s, 'Hi.', e)).toMatchObject({ textSent: 'Hi.', settings: s.settings });
|
|
91
|
+
expect(directEmotion(cast(), spec('eleven_v3', 'ai_assistant'), 'Hi.', 'excited').textSent).toBe('Hi.');
|
|
92
|
+
expect(directEmotion(cast(), s, 'Hi.', 'excited', 'off')).toMatchObject({ textSent: 'Hi.', cliEmotion: null });
|
|
93
|
+
expect(directEmotion(cast(), s, 'Hi.', 'excited', 'cli')).toMatchObject({ textSent: 'Hi.', cliEmotion: 'excited' });
|
|
94
|
+
expect(() => directEmotion(cast(), s, 'Hi.', 'excited', 'bogus' as any)).toThrow(/emotion mode/);
|
|
95
|
+
});
|
|
96
|
+
it('already-tagged text is not double tagged; stripTags collapses whitespace', () => {
|
|
97
|
+
const d = directEmotion(cast(), spec('eleven_v3'), '[laughs] Hello.', 'excited');
|
|
98
|
+
expect(d).toMatchObject({ textSent: '[laughs] Hello.', spokenText: 'Hello.' });
|
|
99
|
+
expect(stripTags('[a] hi [b] you ')).toBe('hi you');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('voice cast library: schema', () => {
|
|
104
|
+
it('the shipped library validates, holds no secret, and rejects a broken one', () => {
|
|
105
|
+
expect(() => validateCastLibrary(defaultCastLibrary)).not.toThrow();
|
|
106
|
+
expect(JSON.stringify(defaultCastLibrary)).not.toMatch(/sk_[0-9a-f]{20,}|xi-api-key/i);
|
|
107
|
+
expect(() => validateCastLibrary({ ...defaultCastLibrary, roles: undefined })).toThrow();
|
|
108
|
+
});
|
|
109
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -75,3 +75,6 @@ export type {
|
|
|
75
75
|
export { registerLLMProvider, llmProviders } from './service';
|
|
76
76
|
export { ProviderRegistry, pickProvider } from './providers/registry';
|
|
77
77
|
export type { Selectable, ProviderFactory } from './providers/registry';
|
|
78
|
+
|
|
79
|
+
// Refine-with-gates loop (also at `@startsimpli/llm/refine`)
|
|
80
|
+
export * from './refine';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { RefineAttempt } from './types';
|
|
2
|
+
|
|
3
|
+
export class InvalidTriesError extends RangeError {
|
|
4
|
+
constructor(tries: unknown) {
|
|
5
|
+
super(`tries must be an integer >= 1, or null to loop until the gates pass; got ${String(tries)}`);
|
|
6
|
+
this.name = 'InvalidTriesError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class UnknownGateError extends Error {
|
|
11
|
+
constructor(readonly missing: string[], registered: string[]) {
|
|
12
|
+
super(`unknown gate(s) ${JSON.stringify(missing)}; registered: ${JSON.stringify([...registered].sort())}`);
|
|
13
|
+
this.name = 'UnknownGateError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The step threw. Not retried: `attempts` holds the tries completed before it, `n` the try that failed. */
|
|
18
|
+
export class RefineStepError<T = unknown> extends Error {
|
|
19
|
+
constructor(readonly n: number, readonly attempts: RefineAttempt<T>[], readonly cause: unknown) {
|
|
20
|
+
super(`refine step failed on try ${n}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
21
|
+
this.name = 'RefineStepError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { UnknownGateError } from './errors';
|
|
2
|
+
import type { RefineGate, GateFailure, GateNote } from './types';
|
|
3
|
+
|
|
4
|
+
export interface RegisterOptions {
|
|
5
|
+
/** Default true: a second registration of a name replaces the first. false throws instead. */
|
|
6
|
+
replace?: boolean;
|
|
7
|
+
/** Registered, but run only when named (`only: [...]`). */
|
|
8
|
+
optIn?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Gates by name. A module adds one by registering it; the loop runs them in registration order. */
|
|
12
|
+
export interface GateRegistry<T, C = unknown> {
|
|
13
|
+
register(name: string, gate: RefineGate<T, C>, options?: RegisterOptions): void;
|
|
14
|
+
names(): string[];
|
|
15
|
+
/** Registered gates that run when none are named (opt-in ones left out). */
|
|
16
|
+
defaultNames(): string[];
|
|
17
|
+
/** Throws `UnknownGateError` for a name that is not registered; returns the names to run. */
|
|
18
|
+
select(only?: string[]): string[];
|
|
19
|
+
/** The gates that failed, plus the informational lines of all of them. */
|
|
20
|
+
run(result: T, context: C, only?: string[]): Promise<{ report: GateFailure[]; notes: GateNote[] }>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createGateRegistry<T, C = unknown>(): GateRegistry<T, C> {
|
|
24
|
+
const gates = new Map<string, RefineGate<T, C>>();
|
|
25
|
+
const optIn = new Set<string>();
|
|
26
|
+
const registry: GateRegistry<T, C> = {
|
|
27
|
+
register(name, gate, options = {}) {
|
|
28
|
+
if (gates.has(name) && options.replace === false) throw new Error(`gate ${JSON.stringify(name)} is already registered`);
|
|
29
|
+
gates.set(name, gate);
|
|
30
|
+
if (options.optIn) optIn.add(name); else optIn.delete(name);
|
|
31
|
+
},
|
|
32
|
+
names: () => [...gates.keys()],
|
|
33
|
+
defaultNames: () => [...gates.keys()].filter((n) => !optIn.has(n)),
|
|
34
|
+
select(only) {
|
|
35
|
+
if (!only) return registry.defaultNames();
|
|
36
|
+
const missing = only.filter((n) => !gates.has(n));
|
|
37
|
+
if (missing.length) throw new UnknownGateError(missing, registry.names());
|
|
38
|
+
return [...only];
|
|
39
|
+
},
|
|
40
|
+
async run(result, context, only) {
|
|
41
|
+
const report: GateFailure[] = [];
|
|
42
|
+
const notes: GateNote[] = [];
|
|
43
|
+
for (const name of registry.select(only)) {
|
|
44
|
+
const failures: string[] = [];
|
|
45
|
+
for (const finding of await gates.get(name)!(result, context)) {
|
|
46
|
+
const { text, level } = typeof finding === 'string' ? { text: finding, level: 'defect' as const } : { text: finding.text, level: finding.level ?? 'defect' };
|
|
47
|
+
if (level === 'info') notes.push({ gate: name, text: String(text) }); else failures.push(String(text));
|
|
48
|
+
}
|
|
49
|
+
if (failures.length) report.push({ gate: name, failures });
|
|
50
|
+
}
|
|
51
|
+
return { report, notes };
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
return registry;
|
|
55
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Refine-with-gates: a result is checked by named gates and the failures go back to a model step until they pass. */
|
|
2
|
+
export { refineWithGates } from './refine';
|
|
3
|
+
export type { RefineOptions, RefineOutcome } from './refine';
|
|
4
|
+
export { createGateRegistry } from './gates';
|
|
5
|
+
export type { GateRegistry, RegisterOptions } from './gates';
|
|
6
|
+
export { llmRefineStep } from './llm-step';
|
|
7
|
+
export type { LLMRefineStepOptions } from './llm-step';
|
|
8
|
+
export { InvalidTriesError, RefineStepError, UnknownGateError } from './errors';
|
|
9
|
+
export type {
|
|
10
|
+
RefineAttempt, RefineCost, RefineGate, GateFailure, GateFinding, GateNote, RefineStep, StepInput, StepOutput,
|
|
11
|
+
} from './types';
|