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