@littlebigbrain/create 0.0.0-stage → 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/LICENSE +55 -0
- package/README.md +71 -2
- package/bin/create-littlebigbrain.mjs +9 -0
- package/lib/create.mjs +238 -0
- package/package.json +46 -4
- package/project/setup.ts +18 -0
- package/project/try.ts +30 -0
- package/project/worker.ts +23 -0
- package/templates/_shared/testing.ts +490 -0
- package/templates/documents-link/README.md +144 -0
- package/templates/documents-link/link.ts +580 -0
- package/templates/documents-link/samples/ausstattungsliste.pdf +76 -0
- package/templates/documents-link/samples/baubeschreibung.pdf +54 -0
- package/templates/documents-link/samples/expected.json +404 -0
- package/templates/documents-link/samples/pages/ausstattungsliste.json +1150 -0
- package/templates/documents-link/samples/pages/baubeschreibung.json +802 -0
- package/templates/documents-link/samples/pages/raumblaetter.json +1580 -0
- package/templates/documents-link/samples/raumblaetter.pdf +109 -0
- package/templates/documents-link/samples.ts +31 -0
- package/templates/documents-link/workflow.test.ts +470 -0
- package/templates/documents-link/workflow.ts +457 -0
- package/templates/ontology-fit/README.md +81 -0
- package/templates/ontology-fit/samples/expected.md +88 -0
- package/templates/ontology-fit/samples/texts.json +22 -0
- package/templates/ontology-fit/samples.ts +40 -0
- package/templates/ontology-fit/workflow.test.ts +264 -0
- package/templates/ontology-fit/workflow.ts +413 -0
- package/templates/values-extract/README.md +81 -0
- package/templates/values-extract/samples/expected.json +148 -0
- package/templates/values-extract/samples/records.json +26 -0
- package/templates/values-extract/samples.ts +27 -0
- package/templates/values-extract/workflow.test.ts +256 -0
- package/templates/values-extract/workflow.ts +411 -0
- package/templates/versions.json +3 -0
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
// Run a template's workflow on its samples without a server: a workflow
|
|
2
|
+
// context that saves each step as the workflow engine does, a graph in
|
|
3
|
+
// memory that applies owned writes, and a mock of the LBB activities whose
|
|
4
|
+
// model answers come from deterministic rules. Each template's
|
|
5
|
+
// workflow.test.ts gives the rules.
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import {
|
|
11
|
+
ENTITY_ID_KEY_SENTINEL,
|
|
12
|
+
ENTITY_IRI_NAMESPACE,
|
|
13
|
+
LbbError,
|
|
14
|
+
type ActivityOptions,
|
|
15
|
+
type DecideRequest,
|
|
16
|
+
type DecideResult,
|
|
17
|
+
type EmbedRequest,
|
|
18
|
+
type EmbedResult,
|
|
19
|
+
type FilePages,
|
|
20
|
+
type LbbActivities,
|
|
21
|
+
type OwnedWrite,
|
|
22
|
+
type ReasonRequest,
|
|
23
|
+
type ReasonResult,
|
|
24
|
+
type ReviewRequest,
|
|
25
|
+
type Schemas,
|
|
26
|
+
type SparqlResults,
|
|
27
|
+
type WorkflowContext,
|
|
28
|
+
type WorkflowDefinition,
|
|
29
|
+
type WorkflowTriggerMessage,
|
|
30
|
+
} from "@littlebigbrain/client";
|
|
31
|
+
|
|
32
|
+
/** The `samples/` folder of a template, from its test file: `samplesDir(import.meta.url, "documents-link")`. */
|
|
33
|
+
export function samplesDir(testFile: string, template: string): string {
|
|
34
|
+
let dir = dirname(fileURLToPath(testFile));
|
|
35
|
+
for (let up = 0; up < 6; up++, dir = dirname(dir))
|
|
36
|
+
for (const candidate of [join(dir, "samples"), join(dir, template, "samples")])
|
|
37
|
+
if (
|
|
38
|
+
existsSync(join(candidate, "..", "workflow.ts")) ||
|
|
39
|
+
existsSync(join(candidate, "..", "README.md"))
|
|
40
|
+
)
|
|
41
|
+
if (existsSync(candidate)) return candidate;
|
|
42
|
+
throw new Error(`no samples folder for ${template} above ${testFile}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const readJson = <T>(path: string): T => JSON.parse(readFileSync(path, "utf8")) as T;
|
|
46
|
+
|
|
47
|
+
/** The 32-hex id a mock entity gets from its class and key. */
|
|
48
|
+
export const mockId = (text: string) =>
|
|
49
|
+
createHash("sha256").update(text).digest("hex").slice(0, 32);
|
|
50
|
+
|
|
51
|
+
// ---- a graph in memory ---------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
export interface MemoryEntity {
|
|
54
|
+
iri: string;
|
|
55
|
+
type: string;
|
|
56
|
+
name: string;
|
|
57
|
+
properties: Record<string, unknown>;
|
|
58
|
+
}
|
|
59
|
+
export interface MemoryEdge {
|
|
60
|
+
source: string;
|
|
61
|
+
relation: string;
|
|
62
|
+
target: string;
|
|
63
|
+
confidence?: number | null;
|
|
64
|
+
evidence?: unknown;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Entities and edges, written by owned writes with the semantics of `replace_owned`. */
|
|
68
|
+
export class MemoryGraph {
|
|
69
|
+
readonly entities = new Map<string, MemoryEntity>();
|
|
70
|
+
edges: MemoryEdge[] = [];
|
|
71
|
+
commits = 0;
|
|
72
|
+
|
|
73
|
+
/** The IRI an entity reference names: by id with the sentinel key, else by class and key (or name). */
|
|
74
|
+
iriOf(ref: Schemas["NamedEntityInput"] | Schemas["EntityPropertiesInput"]): string {
|
|
75
|
+
const type = "type" in ref ? ref.type : "";
|
|
76
|
+
if (ref.key?.startsWith(ENTITY_ID_KEY_SENTINEL))
|
|
77
|
+
return ENTITY_IRI_NAMESPACE + ref.key.slice(ENTITY_ID_KEY_SENTINEL.length);
|
|
78
|
+
return ENTITY_IRI_NAMESPACE + mockId(`${type}\u0000${ref.key ?? `name:${ref.name}`}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
entity(ref: Schemas["NamedEntityInput"]): MemoryEntity {
|
|
82
|
+
const iri = this.iriOf(ref);
|
|
83
|
+
let entity = this.entities.get(iri);
|
|
84
|
+
if (!entity)
|
|
85
|
+
this.entities.set(iri, (entity = { iri, type: ref.type, name: ref.name, properties: {} }));
|
|
86
|
+
return entity;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Apply one owned write, as `POST /v1/graph/commit` with `replace_owned` does. */
|
|
90
|
+
apply(write: OwnedWrite): void {
|
|
91
|
+
this.commits += 1;
|
|
92
|
+
const relations = new Set(write.relations ?? []);
|
|
93
|
+
for (const subject of write.subjects) {
|
|
94
|
+
const iri = this.iriOf(subject);
|
|
95
|
+
this.edges = this.edges.filter((e) => !(e.source === iri && relations.has(e.relation)));
|
|
96
|
+
const entity = this.entities.get(iri);
|
|
97
|
+
if (entity) for (const p of write.properties ?? []) delete entity.properties[p];
|
|
98
|
+
}
|
|
99
|
+
for (const gone of write.retract_entities ?? []) {
|
|
100
|
+
const iri = this.iriOf(gone);
|
|
101
|
+
this.entities.delete(iri);
|
|
102
|
+
this.edges = this.edges.filter((e) => e.source !== iri && e.target !== iri);
|
|
103
|
+
}
|
|
104
|
+
for (const row of write.entity_properties ?? []) {
|
|
105
|
+
const entity = this.entity({ type: row.type, name: row.name, key: row.key });
|
|
106
|
+
const properties = Array.isArray(row.properties)
|
|
107
|
+
? Object.fromEntries(row.properties.map((p) => [p.field, Object.values(p.value)[0]]))
|
|
108
|
+
: (row.properties ?? {});
|
|
109
|
+
Object.assign(entity.properties, properties);
|
|
110
|
+
}
|
|
111
|
+
for (const t of write.triplets ?? []) {
|
|
112
|
+
const [source, target] = [this.entity(t.source).iri, this.entity(t.target).iri];
|
|
113
|
+
const same = (e: MemoryEdge) =>
|
|
114
|
+
e.source === source && e.relation === t.relation && e.target === target;
|
|
115
|
+
this.edges = [
|
|
116
|
+
...this.edges.filter((e) => !same(e)),
|
|
117
|
+
{ source, relation: t.relation, target, confidence: t.confidence, evidence: t.evidence },
|
|
118
|
+
];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
ofType(type: string): MemoryEntity[] {
|
|
123
|
+
return [...this.entities.values()].filter((e) => e.type === type);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
edgesOf(relation: string): MemoryEdge[] {
|
|
127
|
+
return this.edges.filter((e) => e.relation === relation);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---- the activities, mocked ------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
/** The rules a test gives the mock. A rule left out throws when the workflow calls it. */
|
|
134
|
+
export interface MockRules {
|
|
135
|
+
reason?: (request: ReasonRequest) => unknown;
|
|
136
|
+
decide?: (request: DecideRequest) => DecideResult["answers"];
|
|
137
|
+
embed?: (texts: string[]) => number[][];
|
|
138
|
+
sparql?: (
|
|
139
|
+
query: string,
|
|
140
|
+
graph: MemoryGraph,
|
|
141
|
+
request: Schemas["SparqlTextRequest"],
|
|
142
|
+
) => Record<string, string>[];
|
|
143
|
+
/** Whether a model call is refused (`422 activity_request_refused`). */
|
|
144
|
+
refuses?: (kind: "reason" | "decide" | "embed", request: unknown) => boolean;
|
|
145
|
+
/** The status a review item gets (default `open`). */
|
|
146
|
+
reviewStatus?: (request: ReviewRequest) => Schemas["OntologyChangeSuggestionStatus"];
|
|
147
|
+
pages?: FilePages[];
|
|
148
|
+
ontology?: Schemas["OntologyView"];
|
|
149
|
+
/** What a dry-run evolve answers (default: a change that is not a no-op). */
|
|
150
|
+
evolve?: (ops: Schemas["OntologyEvolveOp"][]) => Partial<Schemas["OntologyEvolveResponse"]>;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface MockCall {
|
|
154
|
+
kind: "reason" | "decide" | "embed";
|
|
155
|
+
effectId?: string;
|
|
156
|
+
label?: string;
|
|
157
|
+
cached: boolean;
|
|
158
|
+
refused: boolean;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The LBB activities on a {@link MemoryGraph}, with the effect cache of the
|
|
163
|
+
* server: a repeat of a request under its effect id returns the kept answer
|
|
164
|
+
* (`cached: true`), and another request under the same effect id fails with
|
|
165
|
+
* `409 activity_effect_conflict`.
|
|
166
|
+
*/
|
|
167
|
+
export class MockActivities implements LbbActivities {
|
|
168
|
+
readonly graph = new MemoryGraph();
|
|
169
|
+
readonly calls: MockCall[] = [];
|
|
170
|
+
readonly reviews = new Map<string, ReviewRequest & { status: string }>();
|
|
171
|
+
readonly evolved: Schemas["OntologyEvolveOp"][][] = [];
|
|
172
|
+
private readonly kept = new Map<string, { request: string; answer: unknown }>();
|
|
173
|
+
private readonly written = new Set<string>();
|
|
174
|
+
|
|
175
|
+
constructor(readonly rules: MockRules = {}) {}
|
|
176
|
+
|
|
177
|
+
private model<T>(
|
|
178
|
+
kind: MockCall["kind"],
|
|
179
|
+
request: object,
|
|
180
|
+
options: ActivityOptions,
|
|
181
|
+
answer: () => T,
|
|
182
|
+
): T {
|
|
183
|
+
const body = JSON.stringify(request);
|
|
184
|
+
const cacheKey = `${kind}\u0000${options.effectId}`;
|
|
185
|
+
const kept = options.effectId ? this.kept.get(cacheKey) : undefined;
|
|
186
|
+
if (kept) {
|
|
187
|
+
if (kept.request !== body)
|
|
188
|
+
throw new LbbError(409, "{}", {
|
|
189
|
+
code: "activity_effect_conflict",
|
|
190
|
+
message: `${options.effectId} keeps another request`,
|
|
191
|
+
});
|
|
192
|
+
this.calls.push({
|
|
193
|
+
kind,
|
|
194
|
+
effectId: options.effectId,
|
|
195
|
+
label: options.label,
|
|
196
|
+
cached: true,
|
|
197
|
+
refused: false,
|
|
198
|
+
});
|
|
199
|
+
return { ...(kept.answer as object), cached: true, tokens_in: 0, cost_micro_usd: 0 } as T;
|
|
200
|
+
}
|
|
201
|
+
const refused = this.rules.refuses?.(kind, request) ?? false;
|
|
202
|
+
this.calls.push({
|
|
203
|
+
kind,
|
|
204
|
+
effectId: options.effectId,
|
|
205
|
+
label: options.label,
|
|
206
|
+
cached: false,
|
|
207
|
+
refused,
|
|
208
|
+
});
|
|
209
|
+
if (refused)
|
|
210
|
+
throw new LbbError(422, "{}", { code: "activity_request_refused", message: "too large" });
|
|
211
|
+
const result = answer();
|
|
212
|
+
if (options.effectId) this.kept.set(cacheKey, { request: body, answer: result });
|
|
213
|
+
return result;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async reason<T = unknown>(
|
|
217
|
+
request: ReasonRequest,
|
|
218
|
+
options: ActivityOptions = {},
|
|
219
|
+
): Promise<ReasonResult<T>> {
|
|
220
|
+
const rule = this.rules.reason ?? (() => assertNever("reason"));
|
|
221
|
+
return this.model("reason", request, options, () => ({
|
|
222
|
+
value: JSON.parse(JSON.stringify(rule(request))) as T,
|
|
223
|
+
model: "mock-reasoning",
|
|
224
|
+
tokens_in: Math.ceil(request.prompt.length / 4),
|
|
225
|
+
tokens_out: 0,
|
|
226
|
+
cost_micro_usd: 1_000,
|
|
227
|
+
cached: false,
|
|
228
|
+
}));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async decide(request: DecideRequest, options: ActivityOptions = {}): Promise<DecideResult> {
|
|
232
|
+
const rule = this.rules.decide ?? (() => assertNever("decide"));
|
|
233
|
+
if (Object.keys(request.questions).length > 64)
|
|
234
|
+
throw new LbbError(400, "{}", {
|
|
235
|
+
code: "invalid_request",
|
|
236
|
+
param: "questions",
|
|
237
|
+
message: "more than 64 questions",
|
|
238
|
+
});
|
|
239
|
+
return this.model("decide", request, options, () => ({
|
|
240
|
+
answers: rule(request),
|
|
241
|
+
model: "mock-decision",
|
|
242
|
+
tokens_in: 200,
|
|
243
|
+
tokens_out: 0,
|
|
244
|
+
cost_micro_usd: 0,
|
|
245
|
+
cached: false,
|
|
246
|
+
}));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async embed(request: EmbedRequest, options: ActivityOptions = {}): Promise<EmbedResult> {
|
|
250
|
+
const rule = this.rules.embed;
|
|
251
|
+
if (!rule)
|
|
252
|
+
throw new LbbError(503, "{}", {
|
|
253
|
+
code: "activity_model_unavailable",
|
|
254
|
+
message: "no embedding model",
|
|
255
|
+
retryable: false,
|
|
256
|
+
});
|
|
257
|
+
if (request.texts.length > 256)
|
|
258
|
+
throw new LbbError(400, "{}", {
|
|
259
|
+
code: "invalid_request",
|
|
260
|
+
param: "texts",
|
|
261
|
+
message: "more than 256 texts",
|
|
262
|
+
});
|
|
263
|
+
return this.model("embed", request, options, () => {
|
|
264
|
+
const vectors = rule(request.texts);
|
|
265
|
+
return {
|
|
266
|
+
vectors,
|
|
267
|
+
model: "mock-embedding",
|
|
268
|
+
dim: vectors[0]?.length ?? 0,
|
|
269
|
+
tokens_in: 1,
|
|
270
|
+
cost_micro_usd: 0,
|
|
271
|
+
cached: false,
|
|
272
|
+
};
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async pages(sha256: string, options: { from?: number; to?: number } = {}): Promise<FilePages> {
|
|
277
|
+
const file = this.rules.pages?.find((f) => f.sha256 === sha256);
|
|
278
|
+
if (!file)
|
|
279
|
+
throw new LbbError(404, "{}", { code: "file_not_parsed", message: `no pages for ${sha256}` });
|
|
280
|
+
const from = options.from ?? 0;
|
|
281
|
+
const to = Math.min(options.to ?? file.page_count, file.page_count);
|
|
282
|
+
return { ...file, from, to, pages: file.pages.slice(from, to) };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async sparql(request: string | Schemas["SparqlTextRequest"]): Promise<SparqlResults> {
|
|
286
|
+
const body = typeof request === "string" ? { query: request } : request;
|
|
287
|
+
const rows = (this.rules.sparql ?? (() => assertNever("sparql")))(body.query, this.graph, body);
|
|
288
|
+
return { vars: Object.keys(rows[0] ?? {}), boolean: null, bindings: [], rows, snapshot: null };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
writeOwned(
|
|
292
|
+
ctx: WorkflowContext,
|
|
293
|
+
stepKey: string,
|
|
294
|
+
write: OwnedWrite | (() => OwnedWrite | Promise<OwnedWrite>),
|
|
295
|
+
) {
|
|
296
|
+
return ctx.step(stepKey, async ({ effectId }) => {
|
|
297
|
+
const replayed = this.written.has(effectId);
|
|
298
|
+
if (!replayed) {
|
|
299
|
+
this.graph.apply(typeof write === "function" ? await write() : write);
|
|
300
|
+
this.written.add(effectId);
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
commit_seq: this.graph.commits,
|
|
304
|
+
idempotency_key: effectId,
|
|
305
|
+
replayed,
|
|
306
|
+
owned: null,
|
|
307
|
+
entities_written: 0,
|
|
308
|
+
edges_written: 0,
|
|
309
|
+
};
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async review(request: ReviewRequest): Promise<Schemas["OntologyChangeSuggestion"]> {
|
|
314
|
+
const key = request.key ?? `review-${this.reviews.size}`;
|
|
315
|
+
const status = this.reviews.get(key)?.status ?? this.rules.reviewStatus?.(request) ?? "open";
|
|
316
|
+
this.reviews.set(key, { ...request, status });
|
|
317
|
+
return {
|
|
318
|
+
suggestion_id: key,
|
|
319
|
+
status,
|
|
320
|
+
facts: request.facts ?? null,
|
|
321
|
+
} as unknown as Schemas["OntologyChangeSuggestion"];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async evolve(ops: Schemas["OntologyEvolveOp"][], options: { dryRun?: boolean } = {}) {
|
|
325
|
+
if (!options.dryRun) this.evolved.push(ops);
|
|
326
|
+
const answer = this.rules.evolve?.(ops) ?? {};
|
|
327
|
+
return {
|
|
328
|
+
dry_run: Boolean(options.dryRun),
|
|
329
|
+
no_op: false,
|
|
330
|
+
publishable: true,
|
|
331
|
+
messages: [],
|
|
332
|
+
applied: [],
|
|
333
|
+
...answer,
|
|
334
|
+
} as Schemas["OntologyEvolveResponse"];
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async ontology(): Promise<Schemas["OntologyView"]> {
|
|
338
|
+
return this.rules.ontology ?? assertNever("ontology");
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function assertNever(what: string): never {
|
|
343
|
+
throw new Error(`the test gives the mock no ${what} rule`);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ---- turns -------------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
/** One turn: its step keys, their saved outputs, and the model calls of its steps. */
|
|
349
|
+
export interface Turn {
|
|
350
|
+
message: WorkflowTriggerMessage;
|
|
351
|
+
keys: string[];
|
|
352
|
+
outputs: unknown[];
|
|
353
|
+
bytes: number;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** What a turn returns: its state, and a continuation when the work goes on in another turn. */
|
|
357
|
+
export interface TurnResult<S> {
|
|
358
|
+
state: S;
|
|
359
|
+
continuation?: { message: WorkflowTriggerMessage };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Thrown by {@link runTurn} to stop a turn after a number of steps, as a crash would. `turn` holds the steps saved before it. */
|
|
363
|
+
export class Crash extends Error {
|
|
364
|
+
constructor(
|
|
365
|
+
message: string,
|
|
366
|
+
readonly turn: Turn,
|
|
367
|
+
) {
|
|
368
|
+
super(message);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const STEP_KEY = /^[A-Za-z0-9_.-]{1,128}$/;
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Run one turn of `definition` as the workflow engine does: each step runs
|
|
376
|
+
* once and its output is saved; with `saved`, the saved steps replay without
|
|
377
|
+
* running. The engine's limits hold: 64 steps, 64 KiB per step output and
|
|
378
|
+
* 256 KiB per turn. `crashAfter` stops the turn after that many new steps.
|
|
379
|
+
*/
|
|
380
|
+
export async function runTurn<S>(
|
|
381
|
+
definition: WorkflowDefinition<S, WorkflowTriggerMessage>,
|
|
382
|
+
state: unknown,
|
|
383
|
+
message: WorkflowTriggerMessage,
|
|
384
|
+
options: { prefix?: string; saved?: Turn; crashAfter?: number } = {},
|
|
385
|
+
): Promise<{ result: TurnResult<S>; turn: Turn }> {
|
|
386
|
+
const prefix = options.prefix ?? mockId(JSON.stringify(message));
|
|
387
|
+
const turn: Turn = { message, keys: [], outputs: [], bytes: 0 };
|
|
388
|
+
let busy = false;
|
|
389
|
+
let ran = 0;
|
|
390
|
+
const ctx = {
|
|
391
|
+
signal: new AbortController().signal,
|
|
392
|
+
workflowId: "starter.test",
|
|
393
|
+
messageId: "starter-test-1",
|
|
394
|
+
turn: 1,
|
|
395
|
+
async step<T>(
|
|
396
|
+
key: string,
|
|
397
|
+
effect: (s: {
|
|
398
|
+
effectId: string;
|
|
399
|
+
signal: AbortSignal;
|
|
400
|
+
checkpoint: unknown;
|
|
401
|
+
heartbeat(): Promise<void>;
|
|
402
|
+
}) => Promise<T>,
|
|
403
|
+
) {
|
|
404
|
+
if (busy) throw new Error("steps run one at a time: await each step");
|
|
405
|
+
if (!STEP_KEY.test(key) || turn.keys.includes(key))
|
|
406
|
+
throw new Error(`step key ${key} is not valid or not unique`);
|
|
407
|
+
if (turn.keys.length >= 64) throw new Error("a turn holds at most 64 steps");
|
|
408
|
+
const index = turn.keys.length;
|
|
409
|
+
turn.keys.push(key);
|
|
410
|
+
const saved = options.saved?.keys[index];
|
|
411
|
+
if (saved !== undefined) {
|
|
412
|
+
if (saved !== key)
|
|
413
|
+
throw new Error(`step ${index} was ${saved}, now ${key}: the step order changed`);
|
|
414
|
+
turn.outputs.push(options.saved!.outputs[index]);
|
|
415
|
+
return JSON.parse(JSON.stringify(options.saved!.outputs[index])) as T;
|
|
416
|
+
}
|
|
417
|
+
if (options.crashAfter !== undefined && ran >= options.crashAfter)
|
|
418
|
+
throw new Crash(`crash before ${key}`, { ...turn, keys: turn.keys.slice(0, -1) });
|
|
419
|
+
busy = true;
|
|
420
|
+
try {
|
|
421
|
+
const output = await effect({
|
|
422
|
+
effectId: `${prefix}/${key}`,
|
|
423
|
+
signal: new AbortController().signal,
|
|
424
|
+
checkpoint: null,
|
|
425
|
+
heartbeat: async () => {},
|
|
426
|
+
});
|
|
427
|
+
const json = JSON.stringify(output ?? null);
|
|
428
|
+
if (json.length > 64 * 1024)
|
|
429
|
+
throw new Error(`step ${key} saves ${json.length} bytes, more than 64 KiB`);
|
|
430
|
+
turn.bytes += json.length;
|
|
431
|
+
if (turn.bytes > 256 * 1024) throw new Error("a turn saves at most 256 KiB");
|
|
432
|
+
turn.outputs.push(JSON.parse(json));
|
|
433
|
+
ran += 1;
|
|
434
|
+
return JSON.parse(json) as T;
|
|
435
|
+
} finally {
|
|
436
|
+
busy = false;
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
async waitForSignal(): Promise<never> {
|
|
440
|
+
throw new Error("not in the templates");
|
|
441
|
+
},
|
|
442
|
+
async sleep(): Promise<never> {
|
|
443
|
+
throw new Error("not in the templates");
|
|
444
|
+
},
|
|
445
|
+
continue: <T, M>(next: T, nextMessage: M) => ({
|
|
446
|
+
state: next,
|
|
447
|
+
continuation: { message: nextMessage },
|
|
448
|
+
}),
|
|
449
|
+
} as unknown as WorkflowContext;
|
|
450
|
+
const result = (await definition.execute(ctx, state, message)) as TurnResult<S>;
|
|
451
|
+
return { result, turn };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Run a message and every continuation it leads to, from the empty state the
|
|
456
|
+
* server creates the instance with. Returns the final state and the turns.
|
|
457
|
+
*/
|
|
458
|
+
export async function runMessage<S>(
|
|
459
|
+
definition: WorkflowDefinition<S, WorkflowTriggerMessage>,
|
|
460
|
+
message: WorkflowTriggerMessage,
|
|
461
|
+
state: unknown = {},
|
|
462
|
+
) {
|
|
463
|
+
const turns: Turn[] = [];
|
|
464
|
+
let next: WorkflowTriggerMessage | undefined = message;
|
|
465
|
+
while (next) {
|
|
466
|
+
if (turns.length > 100) throw new Error("more than 100 turns");
|
|
467
|
+
const ran: { result: TurnResult<S>; turn: Turn } = await runTurn(definition, state, next);
|
|
468
|
+
turns.push(ran.turn);
|
|
469
|
+
state = ran.result.state;
|
|
470
|
+
next = ran.result.continuation?.message;
|
|
471
|
+
}
|
|
472
|
+
return { state: state as S, turns };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** A trigger message of the triggered workflow `workflow`. */
|
|
476
|
+
export function triggerMessage(
|
|
477
|
+
workflow: string,
|
|
478
|
+
instances: string[],
|
|
479
|
+
removed: string[] = [],
|
|
480
|
+
): WorkflowTriggerMessage {
|
|
481
|
+
return {
|
|
482
|
+
type: "instances",
|
|
483
|
+
workflow,
|
|
484
|
+
graph: "samples",
|
|
485
|
+
as_of_commit_seq: 7,
|
|
486
|
+
reason: "backfill",
|
|
487
|
+
instances,
|
|
488
|
+
removed,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# documents-link
|
|
2
|
+
|
|
3
|
+
Finds the identifiers the documents of a graph define (the codes of an
|
|
4
|
+
equipment list, the sections of a specification) and the spans of other
|
|
5
|
+
documents that mention them, and links each mention to its entry. A room
|
|
6
|
+
sheet's `S1.1` gets a `REFERS_TO` link to `S 1.1` of the equipment list,
|
|
7
|
+
with the line as evidence and the span's box on the page as its region.
|
|
8
|
+
|
|
9
|
+
The LBB-run version is the starter workflow `documents.link@v1`
|
|
10
|
+
(`docs/reference/starter-workflows.md`, "Document workflows"). This
|
|
11
|
+
template is the same pipeline in TypeScript: the workflow in `workflow.ts`, its
|
|
12
|
+
prompts, rules and facts in `link.ts`.
|
|
13
|
+
|
|
14
|
+
## How it runs
|
|
15
|
+
|
|
16
|
+
The triggered workflow watches the class `Document` and its field
|
|
17
|
+
`parse_status`, which LBB's `documents.parse` writes when it has read a
|
|
18
|
+
PDF's text layer. Each trigger message names the documents to read. The
|
|
19
|
+
workflow reads them in windows of pages (`pagesPerWindow`, default 12),
|
|
20
|
+
one window per turn, and continues with the next window in a new turn
|
|
21
|
+
(`ctx.continue`). A turn holds at most 64 steps, and a window of 12 pages
|
|
22
|
+
uses 28.
|
|
23
|
+
|
|
24
|
+
1. **Define** (`workflow.ts`, `define`). Per page, a step asks the reasoning
|
|
25
|
+
model which identifiers the page defines. The prompt and its schema come
|
|
26
|
+
from the LBB link stage: the page as rows of `[line id] text`, lines on
|
|
27
|
+
one baseline joined by ` | `. A definition is kept when its line holds
|
|
28
|
+
the identifier. The window's entries are written as `DocEntry` entities
|
|
29
|
+
(`identifier`, `context`, `line_id`, `page` and the line's box)
|
|
30
|
+
`DEFINED_IN` the document, owned per entry. Every defining window of a
|
|
31
|
+
message runs before its first link window.
|
|
32
|
+
2. **Mentions.** Per page, a step asks the reasoning model which spans may
|
|
33
|
+
be codes. A span is kept when its line holds it.
|
|
34
|
+
3. **Candidates.** The catalogue is every `DocEntry` of the graph, read with
|
|
35
|
+
a strong SPARQL read so it holds the entries this workflow wrote a turn
|
|
36
|
+
ago. A mention's candidates are the 30 entries closest in characters
|
|
37
|
+
(first the entries with the span's identifier key: no whitespace, no
|
|
38
|
+
case, and no dot between leading letters and a digit, so `S1.1`, `s 1.1`
|
|
39
|
+
and `S.1.1` find `S 1.1`), up to 10 whose context holds the span as a
|
|
40
|
+
word, and the 10 closest in meaning: one step embeds the entries (`identifier: context`) and the
|
|
41
|
+
mentions (`span — row`). A mention never links to the entry its own line
|
|
42
|
+
defines.
|
|
43
|
+
4. **Decide.** Per page, a step asks the decision model two questions per
|
|
44
|
+
mention: its kind (`identifier`, `abbreviation`, `value`, `own_number`,
|
|
45
|
+
`external`) and which candidate it is a name for, or none. A request
|
|
46
|
+
holds at most 32 mentions and 60,000 characters. A request the model
|
|
47
|
+
refuses (`422 activity_request_refused`) is split in halves, each with
|
|
48
|
+
its own effect id (`<step effect id>.0`, `.1`), down to one mention.
|
|
49
|
+
5. **Link rule and gate.** A mention links when `identifier` and
|
|
50
|
+
`abbreviation` together have a probability of at least 0.5 and the names
|
|
51
|
+
answer chose an entry. The confidence is the smaller of the two. A link
|
|
52
|
+
at or above the gate (default 0.8) is written; one below the gate
|
|
53
|
+
becomes a review item with the link as its facts, one per document,
|
|
54
|
+
span and entry, with at most 100 links per item.
|
|
55
|
+
6. **Write.** The window's mentions are written as `DocMention` entities
|
|
56
|
+
(`span`, `kind`, `line_id`, `line_text`, `page` and the span's box)
|
|
57
|
+
`MENTIONED_IN` the document, with their `REFERS_TO` links, owned per
|
|
58
|
+
mention: a rerun replaces the earlier links. A commit names at most
|
|
59
|
+
1,000 entities, so the entries and the mentions of a window are written
|
|
60
|
+
in parts of 900.
|
|
61
|
+
|
|
62
|
+
A document that leaves the graph (a deleted file) or loses its parse loses
|
|
63
|
+
the entries and mentions this workflow wrote for it.
|
|
64
|
+
|
|
65
|
+
## Settings
|
|
66
|
+
|
|
67
|
+
`defaults` in `workflow.ts`:
|
|
68
|
+
|
|
69
|
+
| Setting | Default | Meaning |
|
|
70
|
+
| --- | --- | --- |
|
|
71
|
+
| `gate` | 0.8 | A link below this confidence goes to review |
|
|
72
|
+
| `roles` | the samples' | The part of each document by file name: `defines` (its entries join the catalogue, its mentions are not read), `refers` (its mentions are linked, it adds no entries) or `both` (a document the map does not name) |
|
|
73
|
+
| `pagesPerWindow` | 12 | Pages one turn reads, 1 to 20 |
|
|
74
|
+
|
|
75
|
+
The triggered workflow (`trigger()`) is named `documents-link` and owns the
|
|
76
|
+
classes `DocEntry` and `DocMention` and the relations `DEFINED_IN`,
|
|
77
|
+
`MENTIONED_IN` and `REFERS_TO`. Do not run it beside an LBB-run
|
|
78
|
+
`documents.link` on the same graph: they own the same relations.
|
|
79
|
+
|
|
80
|
+
## What the LBB-run version adds
|
|
81
|
+
|
|
82
|
+
- **Retries of empty answers.** An answer whose items all fail the line
|
|
83
|
+
check is asked once more, with a note on what went wrong; an answer of
|
|
84
|
+
blank items says the page holds nothing.
|
|
85
|
+
- **Span repair.** A span cited on the wrong line moves to the one line of
|
|
86
|
+
its row that holds it.
|
|
87
|
+
- **Contents entries.** The entries of a table of contents join the
|
|
88
|
+
catalogue only when no body entry has their identifier.
|
|
89
|
+
- **Uses versus definitions.** An identifier that several documents define
|
|
90
|
+
belongs to the one that defines it on the fewest lines; the others use
|
|
91
|
+
it.
|
|
92
|
+
- **Running header rows.** A row that repeats on many pages (a header, a
|
|
93
|
+
page footer) is left out of the contexts.
|
|
94
|
+
- **Short documents in one call.** A document of up to 6 pages is read in
|
|
95
|
+
one definitions call.
|
|
96
|
+
- **Contexts across pages.** An entry's context runs to the next
|
|
97
|
+
definition across the whole document; here it stops at the end of the
|
|
98
|
+
window.
|
|
99
|
+
- **Number tokens and page heads.** Candidates also come from the number
|
|
100
|
+
tokens of a span (`Pos.-Nr. 2.4` finds `RB-Pos. 2.4`), and an own number
|
|
101
|
+
gets candidates by the meaning of its page head.
|
|
102
|
+
- **Roles as a setting.** `params.roles` of the triggered workflow, so a
|
|
103
|
+
request changes them without new code.
|
|
104
|
+
- **Same-item questions.** A span that is the number of the item its page
|
|
105
|
+
is about (a room sheet's own position) is asked which entry describes the
|
|
106
|
+
same item, and links with `SAME_ITEM_AS`. Here `own_number` does not
|
|
107
|
+
link.
|
|
108
|
+
- **Request size estimate.** A decide request is packed by an estimate of
|
|
109
|
+
its tokens (letters, digits, spaces and other characters at their own
|
|
110
|
+
rates), within 28,000 tokens.
|
|
111
|
+
- **No-link review.** A span that can link but named no entry, below the
|
|
112
|
+
gate, goes to review with the entry the model gave the highest
|
|
113
|
+
probability.
|
|
114
|
+
- **Accepted items.** An accepted review item's links are written again on
|
|
115
|
+
every later load.
|
|
116
|
+
- **Stored stage outputs.** The outputs of each stage are stored under the
|
|
117
|
+
file once per link version and model, so a rerun pays only for what is
|
|
118
|
+
missing.
|
|
119
|
+
- **The catalogue follows.** When the catalogue changes, the documents
|
|
120
|
+
already loaded are resolved again from their stored mentions. Here a
|
|
121
|
+
document read before the entries it names keeps its mentions unlinked
|
|
122
|
+
until it is read again (`client.workflows.triggered.refresh`).
|
|
123
|
+
- **Stale outputs.** A rerun retracts the entries and mentions an earlier
|
|
124
|
+
load wrote that this one does not write again, and supersedes stale
|
|
125
|
+
review items.
|
|
126
|
+
- **Budget and status.** A monthly budget per workflow, and counters per
|
|
127
|
+
stage in the workflow's status.
|
|
128
|
+
|
|
129
|
+
## Samples and test
|
|
130
|
+
|
|
131
|
+
`samples/` holds three invented PDFs of a school building tender and the
|
|
132
|
+
answer key `expected.json` ([`../_samples/README.md`](../_samples/README.md)).
|
|
133
|
+
`samples/pages/` holds their parsed pages, as `GET /v1/files/pages` returns
|
|
134
|
+
them; `node sdk/templates/_samples/make-pages.mjs` makes them again with
|
|
135
|
+
`pdftotext`.
|
|
136
|
+
|
|
137
|
+
`workflow.test.ts` runs the workflow on the pages with mock models: a row
|
|
138
|
+
that starts with a code and a capitalized word defines the code, every code
|
|
139
|
+
of a line is a mention, a span with a digit is an identifier, and the names
|
|
140
|
+
question picks the candidate with the same identifier key. With the roles of
|
|
141
|
+
the answer key, the run makes the 15 links of the key and no other. The test
|
|
142
|
+
also checks a turn that runs again (every request repeats under its effect
|
|
143
|
+
id), a turn stopped part way, a 30-page document over five turns, the gate,
|
|
144
|
+
a refused request split in halves, and a deleted document.
|