@littlebigbrain/create 0.1.0 → 0.3.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 (56) hide show
  1. package/README.md +6 -2
  2. package/lib/create.mjs +33 -5
  3. package/package.json +1 -1
  4. package/templates/_shared/testing.ts +53 -9
  5. package/templates/documents-link/README.md +9 -7
  6. package/templates/documents-link/workflow.test.ts +28 -1
  7. package/templates/documents-link/workflow.ts +24 -14
  8. package/templates/documents-map/README.md +212 -0
  9. package/templates/documents-map/map.ts +1135 -0
  10. package/templates/documents-map/samples/ausstattungsliste.pdf +76 -0
  11. package/templates/documents-map/samples/baubeschreibung.pdf +54 -0
  12. package/templates/documents-map/samples/expected.json +50 -0
  13. package/templates/documents-map/samples/pages/raumblaetter.json +1580 -0
  14. package/templates/documents-map/samples/raumblaetter.pdf +109 -0
  15. package/templates/documents-map/samples/trace.json +613 -0
  16. package/templates/documents-map/samples.ts +182 -0
  17. package/templates/documents-map/workflow.test.ts +770 -0
  18. package/templates/documents-map/workflow.ts +1199 -0
  19. package/templates/link/README.md +203 -0
  20. package/templates/link/candidates.ts +432 -0
  21. package/templates/link/decide.test.ts +368 -0
  22. package/templates/link/decide.ts +340 -0
  23. package/templates/link/fixture.ts +727 -0
  24. package/templates/link/grade-run.ts +53 -0
  25. package/templates/link/grade.test.ts +113 -0
  26. package/templates/link/grade.ts +335 -0
  27. package/templates/link/items.ts +333 -0
  28. package/templates/link/names.test.ts +260 -0
  29. package/templates/link/names.ts +590 -0
  30. package/templates/link/reads.ts +302 -0
  31. package/templates/link/samples/gold.json +108 -0
  32. package/templates/link/samples/records.json +48 -0
  33. package/templates/link/samples.ts +64 -0
  34. package/templates/link/settings.ts +162 -0
  35. package/templates/link/setup.ts +18 -0
  36. package/templates/link/source-turn.ts +894 -0
  37. package/templates/link/sources/records.ts +429 -0
  38. package/templates/link/things-turn.ts +984 -0
  39. package/templates/link/things.test.ts +204 -0
  40. package/templates/link/things.ts +426 -0
  41. package/templates/link/try.ts +28 -0
  42. package/templates/link/worker.ts +24 -0
  43. package/templates/link/workflow.test.ts +484 -0
  44. package/templates/link/workflow.ts +202 -0
  45. package/templates/ontology-fit/workflow.test.ts +7 -1
  46. package/templates/ontology-fit/workflow.ts +5 -2
  47. package/templates/review/README.md +158 -0
  48. package/templates/review/decision.ts +483 -0
  49. package/templates/review/samples/expected.json +37 -0
  50. package/templates/review/samples/proposals.json +35 -0
  51. package/templates/review/samples.ts +154 -0
  52. package/templates/review/workflow.test.ts +438 -0
  53. package/templates/review/workflow.ts +459 -0
  54. package/templates/values-extract/workflow.test.ts +24 -1
  55. package/templates/values-extract/workflow.ts +11 -3
  56. package/templates/versions.json +1 -1
package/README.md CHANGED
@@ -15,9 +15,12 @@ npm test
15
15
 
16
16
  | Template | Does |
17
17
  | --- | --- |
18
- | `documents-link` | Finds the identifiers the documents define and the spans that mention them, and links each mention to its entry |
18
+ | `documents-link` | Connects mentions of the same thing across the documents: each reference links to where that thing is defined or described |
19
19
  | `values-extract` | Fills properties of a class from the text of its instances, each value with a verbatim quote |
20
20
  | `ontology-fit` | Proposes the ontology changes the texts of a class support, as review items |
21
+ | `documents-map` | Maps the links of `documents-link` into the graph's own ontology, as fact proposals |
22
+ | `review` | Writes the fact proposals at or above a gate as facts and gives the others to a person |
23
+ | `link` | Connects the mentions of one thing across the records of a graph into one thing, and proposes records of one thing as an identity |
21
24
 
22
25
  A template id is the id of its starter workflow, with dashes for dots:
23
26
  `documents-link` is the code of `documents.link`.
@@ -29,7 +32,8 @@ A template id is the id of its starter workflow, with dashes for dots:
29
32
  | `src/workflow.ts` | The workflow, its settings, the triggered workflow that sends it the instances (`trigger()`) and the ontology it writes (`ontology()`) |
30
33
  | `src/*.ts` | The template's helpers and `samples.ts`, which puts the samples on a graph |
31
34
  | `src/workflow.test.ts`, `src/testing.ts` | The workflow on its samples, with mock models and no server |
32
- | `src/worker.ts`, `src/setup.ts`, `src/try.ts` | `npm start`, `npm run setup` and `npm run try` |
35
+ | `src/worker.ts`, `src/setup.ts`, `src/try.ts` | `npm start`, `npm run setup` and `npm run try`; a template that has its own (`link`) keeps them |
36
+ | `src/grade-run.ts` | `npm run grade`, in a template that grades a run against a gold file (`link`) |
33
37
  | `samples/` | The samples and their answer key |
34
38
 
35
39
  `npm run try` creates a graph (`LBB_GRAPH`, default `try-<template>`), puts
package/lib/create.mjs CHANGED
@@ -15,7 +15,14 @@ import { basename, dirname, join, resolve } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
 
17
17
  /** The templates a project can start from: the folder names under `sdk/templates`. */
18
- export const TEMPLATES = ["documents-link", "values-extract", "ontology-fit"];
18
+ export const TEMPLATES = [
19
+ "documents-link",
20
+ "documents-map",
21
+ "review",
22
+ "values-extract",
23
+ "ontology-fit",
24
+ "link",
25
+ ];
19
26
 
20
27
  const PACKAGE = resolve(dirname(fileURLToPath(import.meta.url)), "..");
21
28
 
@@ -113,15 +120,23 @@ export async function create({ template, dir, client, root = PACKAGE }) {
113
120
  const src = join(dir, "src");
114
121
  await mkdir(src, { recursive: true });
115
122
 
116
- // The template's code, its test and the test support, all under src/.
117
- for (const file of await readdir(from)) {
118
- if (!file.endsWith(".ts")) continue;
123
+ // The template's code, its test and the test support, all under src/, the
124
+ // template's folders (`sources/`) as they are.
125
+ const code = await templateCode(from);
126
+ for (const file of code) {
119
127
  const text = await readFile(join(from, file), "utf8");
120
- await writeFile(join(src, file), text.replaceAll("../_shared/testing.js", "./testing.js"));
128
+ const up = "../".repeat(file.split("/").length - 1);
129
+ await mkdir(dirname(join(src, file)), { recursive: true });
130
+ await writeFile(
131
+ join(src, file),
132
+ text.replaceAll(`../${up}_shared/testing.js`, `${up || "./"}testing.js`),
133
+ );
121
134
  }
122
135
  await cp(join(templates, "_shared", "testing.ts"), join(src, "testing.ts"));
123
136
  await cp(join(from, "samples"), join(dir, "samples"), { recursive: true });
137
+ // A template's own commands win: a workflow of several parts sets itself up.
124
138
  for (const file of ["worker.ts", "setup.ts", "try.ts"]) {
139
+ if (code.includes(file)) continue;
125
140
  const text = await readFile(join(root, "project", file), "utf8");
126
141
  await writeFile(join(src, file), text.replaceAll("__TEMPLATE__", template));
127
142
  }
@@ -138,6 +153,7 @@ export async function create({ template, dir, client, root = PACKAGE }) {
138
153
  try: "tsx src/try.ts",
139
154
  test: "tsx --test src/workflow.test.ts",
140
155
  typecheck: "tsc --noEmit",
156
+ ...(code.includes("grade-run.ts") ? { grade: "tsx src/grade-run.ts" } : {}),
141
157
  },
142
158
  dependencies: { "@littlebigbrain/client": client ?? `^${clientVersion(templates)}` },
143
159
  devDependencies: { "@types/node": "^22", tsx: "^4", typescript: "^5.6" },
@@ -203,6 +219,18 @@ ${projectReadme(readme).trim()}
203
219
  return listFiles(dir);
204
220
  }
205
221
 
222
+ /**
223
+ * The TypeScript files of a template folder, relative, in its folders too;
224
+ * the samples are copied on their own.
225
+ * @param {string} from
226
+ * @returns {Promise<string[]>}
227
+ */
228
+ async function templateCode(from) {
229
+ return (await listFiles(from)).filter(
230
+ (file) => file.endsWith(".ts") && !file.startsWith("samples/"),
231
+ );
232
+ }
233
+
206
234
  /** Every file under `dir`, relative and sorted. @param {string} dir @returns {Promise<string[]>} */
207
235
  async function listFiles(dir, prefix = "") {
208
236
  /** @type {string[]} */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@littlebigbrain/create",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Create a project from a little big brain starter workflow template: npm create @littlebigbrain",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -26,6 +26,8 @@ import {
26
26
  type SparqlResults,
27
27
  type WorkflowContext,
28
28
  type WorkflowDefinition,
29
+ type WorkflowProgressCounts,
30
+ type WorkflowStepContext,
29
31
  type WorkflowTriggerMessage,
30
32
  } from "@littlebigbrain/client";
31
33
 
@@ -291,12 +293,13 @@ export class MockActivities implements LbbActivities {
291
293
  writeOwned(
292
294
  ctx: WorkflowContext,
293
295
  stepKey: string,
294
- write: OwnedWrite | (() => OwnedWrite | Promise<OwnedWrite>),
296
+ write: OwnedWrite | ((step: WorkflowStepContext) => OwnedWrite | Promise<OwnedWrite>),
295
297
  ) {
296
- return ctx.step(stepKey, async ({ effectId }) => {
298
+ return ctx.step(stepKey, async (step) => {
299
+ const { effectId } = step;
297
300
  const replayed = this.written.has(effectId);
298
301
  if (!replayed) {
299
- this.graph.apply(typeof write === "function" ? await write() : write);
302
+ this.graph.apply(typeof write === "function" ? await write(step) : write);
300
303
  this.written.add(effectId);
301
304
  }
302
305
  return {
@@ -345,12 +348,33 @@ function assertNever(what: string): never {
345
348
 
346
349
  // ---- turns -------------------------------------------------------------------------
347
350
 
348
- /** One turn: its step keys, their saved outputs, and the model calls of its steps. */
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. */
349
358
  export interface Turn {
350
359
  message: WorkflowTriggerMessage;
351
360
  keys: string[];
352
361
  outputs: unknown[];
353
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 };
354
378
  }
355
379
 
356
380
  /** What a turn returns: its state, and a continuation when the work goes on in another turn. */
@@ -381,17 +405,24 @@ export async function runTurn<S>(
381
405
  definition: WorkflowDefinition<S, WorkflowTriggerMessage>,
382
406
  state: unknown,
383
407
  message: WorkflowTriggerMessage,
384
- options: { prefix?: string; saved?: Turn; crashAfter?: number } = {},
408
+ options: {
409
+ prefix?: string;
410
+ saved?: Turn;
411
+ crashAfter?: number;
412
+ /** The engine identity that runs the turn (default `starter.test`), and the turn's number. */
413
+ workflowId?: string;
414
+ turn?: number;
415
+ } = {},
385
416
  ): Promise<{ result: TurnResult<S>; turn: Turn }> {
386
417
  const prefix = options.prefix ?? mockId(JSON.stringify(message));
387
- const turn: Turn = { message, keys: [], outputs: [], bytes: 0 };
418
+ const turn: Turn = { message, keys: [], outputs: [], bytes: 0, progress: [] };
388
419
  let busy = false;
389
420
  let ran = 0;
390
421
  const ctx = {
391
422
  signal: new AbortController().signal,
392
- workflowId: "starter.test",
423
+ workflowId: options.workflowId ?? "starter.test",
393
424
  messageId: "starter-test-1",
394
- turn: 1,
425
+ turn: options.turn ?? 1,
395
426
  async step<T>(
396
427
  key: string,
397
428
  effect: (s: {
@@ -399,6 +430,7 @@ export async function runTurn<S>(
399
430
  signal: AbortSignal;
400
431
  checkpoint: unknown;
401
432
  heartbeat(): Promise<void>;
433
+ progress(phase: string, counts: WorkflowProgressCounts): void;
402
434
  }) => Promise<T>,
403
435
  ) {
404
436
  if (busy) throw new Error("steps run one at a time: await each step");
@@ -423,6 +455,7 @@ export async function runTurn<S>(
423
455
  signal: new AbortController().signal,
424
456
  checkpoint: null,
425
457
  heartbeat: async () => {},
458
+ progress: (phase, counts) => turn.progress.push(progressCall(key, phase, counts)),
426
459
  });
427
460
  const json = JSON.stringify(output ?? null);
428
461
  if (json.length > 64 * 1024)
@@ -454,17 +487,23 @@ export async function runTurn<S>(
454
487
  /**
455
488
  * Run a message and every continuation it leads to, from the empty state the
456
489
  * server creates the instance with. Returns the final state and the turns.
490
+ * With `workflowId` and `turn`, the turns run as that identity, numbered
491
+ * from `turn` on.
457
492
  */
458
493
  export async function runMessage<S>(
459
494
  definition: WorkflowDefinition<S, WorkflowTriggerMessage>,
460
495
  message: WorkflowTriggerMessage,
461
496
  state: unknown = {},
497
+ options: { workflowId?: string; turn?: number } = {},
462
498
  ) {
463
499
  const turns: Turn[] = [];
464
500
  let next: WorkflowTriggerMessage | undefined = message;
465
501
  while (next) {
466
502
  if (turns.length > 100) throw new Error("more than 100 turns");
467
- const ran: { result: TurnResult<S>; turn: Turn } = await runTurn(definition, state, next);
503
+ const ran: { result: TurnResult<S>; turn: Turn } = await runTurn(definition, state, next, {
504
+ ...(options.workflowId ? { workflowId: options.workflowId } : {}),
505
+ ...(options.turn !== undefined ? { turn: options.turn + turns.length } : {}),
506
+ });
468
507
  turns.push(ran.turn);
469
508
  state = ran.result.state;
470
509
  next = ran.result.continuation?.message;
@@ -472,6 +511,11 @@ export async function runMessage<S>(
472
511
  return { state: state as S, turns };
473
512
  }
474
513
 
514
+ /** The phases the steps of `turns` reported, each once, in the order they first came. */
515
+ export function phases(turns: Turn[]): string[] {
516
+ return [...new Set(turns.flatMap((t) => t.progress.map((p) => p.phase)))];
517
+ }
518
+
475
519
  /** A trigger message of the triggered workflow `workflow`. */
476
520
  export function triggerMessage(
477
521
  workflow: string,
@@ -1,10 +1,12 @@
1
1
  # documents-link
2
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.
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.
8
10
 
9
11
  The LBB-run version is the starter workflow `documents.link@v1`
10
12
  (`docs/reference/starter-workflows.md`, "Document workflows"). This
@@ -97,12 +99,12 @@ classes `DocEntry` and `DocMention` and the relations `DEFINED_IN`,
97
99
  definition across the whole document; here it stops at the end of the
98
100
  window.
99
101
  - **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
102
+ tokens of a span (`Section 2.4` finds `Ref. 2.4`), and an own number
101
103
  gets candidates by the meaning of its page head.
102
104
  - **Roles as a setting.** `params.roles` of the triggered workflow, so a
103
105
  request changes them without new code.
104
106
  - **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
107
+ is about (a product sheet's own number) is asked which entry describes the
106
108
  same item, and links with `SAME_ITEM_AS`. Here `own_number` does not
107
109
  link.
108
110
  - **Request size estimate.** A decide request is packed by an estimate of
@@ -17,6 +17,7 @@ import {
17
17
  Crash,
18
18
  MockActivities,
19
19
  mockId,
20
+ phases,
20
21
  readJson,
21
22
  runMessage,
22
23
  runTurn,
@@ -362,8 +363,34 @@ test("a long document is read in windows over several turns", async () => {
362
363
  assert.equal(lbb.graph.edgesOf("REFERS_TO").length, 225);
363
364
  });
364
365
 
366
+ test("each step reports its phase as the LBB-run documents.link words it", async () => {
367
+ const { turns } = await run();
368
+ assert.deepEqual(phases(turns), [
369
+ "reading pages",
370
+ "writing facts",
371
+ "embedding the catalogue",
372
+ "linking mentions",
373
+ ]);
374
+ const first = turns[0]!.progress[0]!;
375
+ assert.deepEqual(
376
+ [first.step, first.phase, first.done, first.unit],
377
+ ["define-p1", "reading pages", 0, "pages"],
378
+ );
379
+ const linking = turns.at(-1)!.progress.filter((p) => p.phase === "linking mentions");
380
+ assert.deepEqual(
381
+ linking.map((p) => p.done),
382
+ linking.map((_, n) => n),
383
+ );
384
+ assert.ok(linking.every((p) => p.total === linking.length && p.unit === "pages"));
385
+ });
386
+
365
387
  test("links below the gate go to review instead of the graph", async () => {
366
- const { lbb, docs, state } = await run({ gate: 0.99 });
388
+ const { lbb, docs, state, turns } = await run({ gate: 0.99 });
389
+ const filing = turns.flatMap((t) => t.progress).filter((p) => p.phase === "filing review items");
390
+ assert.deepEqual(
391
+ filing.map((p) => [p.done, p.total, p.unit]),
392
+ filing.map((_, k) => [k, 13, "items"]),
393
+ );
367
394
  const reviewed = [...lbb.reviews.values()];
368
395
  const linked = reviewed.filter((r) => r.key!.includes(":link:"));
369
396
  // One item per document, span and entry: S1.1 and S 2.1 are on both room sheets.
@@ -1,5 +1,6 @@
1
- // documents-link: find the identifiers the documents of a graph define and the spans that mention
2
- // them, and link each mention to its entry.
1
+ // documents-link: connect mentions of the same thing across the documents of a graph. Find the
2
+ // identifiers the documents define and the spans that refer to them, and link each mention to the
3
+ // entry it names.
3
4
  //
4
5
  // A triggered workflow of the starter workflow `workflow` watches the class `Document` and its
5
6
  // field `parse_status` (LBB's parse writes it), and sends the parsed documents here. The workflow
@@ -12,8 +13,8 @@
12
13
  // candidates from the catalogue (every `DocEntry` of the graph) by characters and by meaning
13
14
  // (`embed`), and the decision model (`decide`) answers what the span is and which entry it
14
15
  // names. A mention is written as a `DocMention` `MENTIONED_IN` the document, with a
15
- // `REFERS_TO` link to its entry when the link's confidence reaches the gate; a link below the
16
- // gate becomes a review item.
16
+ // `REFERS_TO` link to the entry it names when the link's confidence reaches the gate; a link
17
+ // below the gate becomes a review item.
17
18
  //
18
19
  // Each model call runs in a step and passes the step's effect id, so a turn that runs again after
19
20
  // a crash pays for nothing twice.
@@ -254,6 +255,7 @@ async function define(
254
255
  const found: link.Definition[] = [];
255
256
  for (let page = w.from; page < w.to; page++) {
256
257
  const kept = await ctx.step(`define-p${page + 1}`, async (s) => {
258
+ s.progress("reading pages", { done: page - w.from, total: w.to - w.from, unit: "pages" });
257
259
  const p = await read.page(page);
258
260
  const answer = await lbb.reason(link.definitionsRequest(p), {
259
261
  effectId: s.effectId,
@@ -266,9 +268,10 @@ async function define(
266
268
  // A commit names at most 1,000 entities: the entries go in parts of at most 900.
267
269
  const count = new Set(found.map((d) => d.identifier)).size;
268
270
  for (let n = 0; n * link.PART < count; n++)
269
- await lbb.writeOwned(ctx, `entries-${n}`, async () =>
270
- link.part(link.entriesWrite(doc, await read.pages(), found), n),
271
- );
271
+ await lbb.writeOwned(ctx, `entries-${n}`, async (s) => {
272
+ s.progress("writing facts", { done: n * link.PART, total: count, unit: "items" });
273
+ return link.part(link.entriesWrite(doc, await read.pages(), found), n);
274
+ });
272
275
  return { ...state, entries: state.entries + count };
273
276
  }
274
277
 
@@ -287,6 +290,7 @@ async function linkMentions(
287
290
  const mentions: link.Mention[][] = [];
288
291
  for (const page of pages) {
289
292
  const kept = await ctx.step(`mention-p${page + 1}`, async (s) => {
293
+ s.progress("reading pages", { done: page - w.from, total: w.to - w.from, unit: "pages" });
290
294
  const p = await read.page(page);
291
295
  const answer = await lbb.reason(link.mentionsRequest(p), {
292
296
  effectId: s.effectId,
@@ -296,12 +300,16 @@ async function linkMentions(
296
300
  });
297
301
  mentions.push(kept);
298
302
  }
299
- const byMeaning = await ctx.step("meaning", (s) => meaning(s.effectId, lbb, doc, read, mentions));
303
+ const byMeaning = await ctx.step("meaning", (s) => {
304
+ s.progress("embedding the catalogue", { done: 0, unit: "items" });
305
+ return meaning(s.effectId, lbb, doc, read, mentions);
306
+ });
300
307
  const resolved: link.Resolved[][] = [];
301
308
  for (const [n, page] of pages.entries()) {
302
- const answers = await ctx.step(`decide-p${page + 1}`, (s) =>
303
- decidePage(s.effectId, lbb, doc, read, page, mentions[n]!, byMeaning[n]!),
304
- );
309
+ const answers = await ctx.step(`decide-p${page + 1}`, (s) => {
310
+ s.progress("linking mentions", { done: n, total: pages.length, unit: "pages" });
311
+ return decidePage(s.effectId, lbb, doc, read, page, mentions[n]!, byMeaning[n]!);
312
+ });
305
313
  resolved.push(answers);
306
314
  }
307
315
 
@@ -316,7 +324,7 @@ async function linkMentions(
316
324
  );
317
325
  const written = all.filter((x) => x.target !== null && x.confidence >= settings.gate);
318
326
  const below = all.filter((x) => x.target !== null && x.confidence < settings.gate);
319
- await ctx.step("review", async () => {
327
+ await ctx.step("review", async (s) => {
320
328
  const catalogue = await read.catalogue();
321
329
  // One item per span and entry: S1.1 on two pages is one item with two links.
322
330
  const groups = new Map<string, typeof below>();
@@ -331,7 +339,8 @@ async function linkMentions(
331
339
  group: group.slice(n * 100, (n + 1) * 100),
332
340
  })),
333
341
  );
334
- for (const { n, group } of items) {
342
+ for (const [k, { n, group }] of items.entries()) {
343
+ s.progress("filing review items", { done: k, total: items.length, unit: "items" });
335
344
  const entry = catalogue[group[0]!.target!]!;
336
345
  const span = group[0]!.m.span;
337
346
  const lowest = Math.min(...group.map((x) => x.confidence));
@@ -357,7 +366,8 @@ async function linkMentions(
357
366
  return items.length;
358
367
  });
359
368
  for (let n = 0; n * link.PART < all.length; n++)
360
- await lbb.writeOwned(ctx, `mentions-${n}`, async () => {
369
+ await lbb.writeOwned(ctx, `mentions-${n}`, async (s) => {
370
+ s.progress("writing facts", { done: n * link.PART, total: all.length, unit: "mentions" });
361
371
  const [pages, catalogue] = [await read.pages(), await read.catalogue()];
362
372
  const links = written.map((x) => ({ ...x, entry: catalogue[x.target!]! }));
363
373
  return link.part(link.mentionsWrite(doc, pages, all, links), n);
@@ -0,0 +1,212 @@
1
+ # documents-map
2
+
3
+ Maps the links of `documents-link` into the graph's own ontology. A
4
+ manual's line `Replace the filter P114 every 500 hours` links the span
5
+ `P114` to the entry `P 114` of the parts list. This workflow asks which
6
+ instance each entry is and which statement the line makes, and proposes
7
+ the fact `procedure 4.2 USES_PART part P 114`. It writes each statement as a
8
+ `FactProposal`; a `review` workflow (LBB's `review`, or the
9
+ [`review`](../review/README.md) template) decides which become facts.
10
+
11
+ The LBB-run version is the starter workflow `documents.map@v1`
12
+ (`docs/reference/starter-workflows.md`, `documents.map`; design
13
+ `docs/architecture/document-output-mapping.md`). This template is the same
14
+ pipeline in TypeScript: the workflow in `workflow.ts`, its questions, rules
15
+ and proposals in `map.ts`.
16
+
17
+ ## How it runs
18
+
19
+ The triggered workflow watches the class `DocMention` and its links
20
+ `REFERS_TO` and `SAME_ITEM_AS`. A new, changed or withdrawn link sends a
21
+ message. A message is a signal: each one maps every link of the graph
22
+ again, in one turn. The decision model's calls take an effect id made from
23
+ the content of their request, so a question asked again in a later message
24
+ gets its first answer from LBB's effect cache at no cost. Only new and
25
+ changed questions cost.
26
+
27
+ 1. **Read the trace** (`readTrace`). The links with their confidence (on the
28
+ link's evidence statement node), the mentions of the documents that hold
29
+ them, and the entries, with SPARQL at the message's commit.
30
+ 2. **The subject of each link** (`map.subjects`). The object is the entry
31
+ the span names. The subject is what the line belongs to, in the line's
32
+ own document. The first mention of a page heads the page when all of
33
+ these are true: the page has at least two mentions, the mention starts in
34
+ the top 30% of the page, no entry the document defines on the page is on
35
+ its row or above it, and it is written unlike most of the page's other
36
+ mentions (a product sheet's number above the codes it uses). Then its
37
+ link's entry, or else a page entry made from it (the span is the
38
+ identifier, the rest of its line the description), is the subject of
39
+ every other mention of the page, and of the next page when that has
40
+ neither a head nor an entry. Otherwise the subject is the last anchor at
41
+ or before the line (the document's own entries written like its first
42
+ one, such as the positions of a bill).
43
+ 3. **Ground the entries.** Each end of a link gets candidates: the
44
+ instances whose `identifier`, `rdfs:label`, `skos:notation`,
45
+ `skos:prefLabel`, `schema:identifier` or `dcterms:identifier` is the
46
+ identifier as written, without spaces, or with one space after its
47
+ letters (at most 10, each with its class, label and four facts). The
48
+ decision model answers which instance the entry is: a candidate,
49
+ `new_instance` or `none`. A new instance gets a second question, its
50
+ class among at most 12 classes, or `no_class`. Its IRI is minted from the
51
+ document and the identifier, so the same entry always proposes the same
52
+ instance.
53
+ 4. **Type the links.** For a link whose two ends are instances with a
54
+ class, the options are the relations whose domain and range fit the two
55
+ classes (superclasses included, both ways round, an inverse written the
56
+ right way round), at most 12, and `same_item`, `no_fact` and
57
+ `missing_property`. The state holds the page's rows (the parsed page, or
58
+ the lines of its mentions when the file has no parse), the two instances
59
+ and the part of the ontology the questions use. A declared mapping types
60
+ its links without a question.
61
+ 5. **Propose.** One `FactProposal` per statement (its key is
62
+ `proposal:<hash>` of the statement and the proposer, `proposed_by` is
63
+ `workflow/<name>`): a typed link with the link's confidence times the
64
+ typing answer's probability; `same_item` as `owl:sameAs`; an entry
65
+ grounded to an instance as `DENOTES` from the entry (or from the mention
66
+ of a page head) to the instance; a new instance with a class as its
67
+ `rdf:type` (the grounding answer's probability times the class answer's),
68
+ its `rdfs:label`, its `identifier` and its `DENOTES`. `fact_evidence` holds
69
+ the line, the page, the box, the link and every option with its
70
+ probability. An entry whose class question chose `no_class` proposes
71
+ nothing (no type, label, identifier or `DENOTES`) until the ontology has a
72
+ class for it, and its links are not typed.
73
+ 6. **Suggest.** The answers `no_class` and `missing_property` with a
74
+ probability of at least 0.5 become ontology suggestions, one per pattern
75
+ (see below).
76
+ 7. **Write.** The proposals that changed since the graph's copy, owned with
77
+ `replace_owned` over the proposal's seven fields, in parts of 900. The
78
+ proposals of this workflow that no link states any more are retracted.
79
+
80
+ ## Ontology suggestions
81
+
82
+ Each suggestion explains itself. Its rationale says what the decision model
83
+ found, from how many lines or entries, and quotes them (page, line, the two
84
+ ends and the probability). Its samples have the shape of a review item's
85
+ (`document`, `page` from 1, `line`, `box`, plus `line_id`, `span`, `subject`
86
+ and `object`, or `identifier` and `description` for an entry), with the
87
+ answer: `choice`, `answer` in words, `probability` and every option's
88
+ probability in `options`.
89
+
90
+ - **A relation** (`Add a relation from Room to SpecSection: SIEHE_KAP`). Its
91
+ name comes from the words next to the span on its lines: the two nearest
92
+ words before the span (else after it) that are made of letters, at least
93
+ three long, and not words of the two ends or of the object's document. The
94
+ phrase most lines share wins, in upper case ASCII. Only when the lines have
95
+ no such words is it `HAS_<CLASS>`. A taken name gets a number.
96
+ - **The overlap rule.** When an existing relation fits the two classes
97
+ (either way round), a line backs the suggestion only when its typing
98
+ question offered that relation and the answer chose `missing_property`
99
+ over it. The suggestion is then filed only with at least 2 such lines, or
100
+ one at 0.8 or more (`OVERLAP_LINES`, `OVERLAP_ALONE_AT` in `map.ts`), and
101
+ its rationale names the relation and the most probability the answers gave
102
+ it. Otherwise nothing is filed, and the lines are unmapped links.
103
+ - **A class** (`Add a class for 3.1 Bodenbeläge`, or `Add a class for 2
104
+ entries of baubeschreibung.pdf: 3.1 …, 3.2 …`). Its title and rationale
105
+ name its entries; the rationale lists the classes the answers weighed and
106
+ the entries of the same document, written the same way, that got a class
107
+ of the ontology. A person who finds those belong to the new class too
108
+ dismisses their types in the review.
109
+
110
+ Edit the name, then accept (the accept request's `change`, or **Edit, then
111
+ accept** in the console): the next message maps the links of the pattern
112
+ again.
113
+
114
+ **Unmapped links.** A link that makes no proposal and backs no filed
115
+ suggestion stays in the workflow's state: `unmapped` counts them and
116
+ `unmappedLinks` holds the first 50, by document, page and line, each with
117
+ its line, the two ends, its `outcome` (`no_subject`, `ungrounded`,
118
+ `same_instance`, `unclassed`, `unanswered`, `no_fact`, `lacks`), a `reason`
119
+ in words and the typing answer when it has one.
120
+
121
+ The questions go in requests of at most 32 questions and 60,000 characters:
122
+ grounding and classes per defining document, typing per page. A refused
123
+ request is asked in halves.
124
+
125
+ ## Settings
126
+
127
+ `defaults` in `workflow.ts`:
128
+
129
+ | Setting | Default | Meaning |
130
+ | --- | --- | --- |
131
+ | `mappings` | none | Declared mappings: `{ from: { document?, entry_class }, to: { entry_class }, property }`. A link from an instance of `from.entry_class` (in `document`) to one of `to.entry_class` (subclasses too) states `property`, with the link's confidence, and no model is asked |
132
+
133
+ `trigger()` names the triggered workflow `documents-map` and owns the class
134
+ `FactProposal` and its seven proposer fields. `ontology()` adds
135
+ `FactProposal` and its fields, `identifier`, and the relation `DENOTES`,
136
+ open on both sides (from an entry or a page's head mention to any
137
+ instance), as LBB's `documents.map` declares it. Do not run it beside an
138
+ LBB-run
139
+ `documents.map` on the same graph: they own the same class.
140
+
141
+ ## What the LBB-run version adds
142
+
143
+ - **Links below the gate.** It reads the stored outputs of the graph's
144
+ `documents.link` workflows, so it maps the links held for review too, with
145
+ their confidence. The template reads the links in the graph.
146
+ - **The RDF schema.** It reads the RDFS and OWL schema of the published
147
+ generation beside the records ontology, and shows each term's
148
+ `rdfs:label` and `rdfs:comment` in the options. The template reads the
149
+ records ontology (`GET /v1/ontology`).
150
+ - **More candidates.** The name index of `POST /v1/query/ask` and search by
151
+ meaning over the graph's embeddings; class options by meaning. The
152
+ template finds candidates by identifier and orders classes by shared
153
+ words.
154
+ - **Descriptions.** An entry's description comes from the definitions
155
+ model. The graph holds the entry's context, which can run on to the next
156
+ rows; the template uses it.
157
+ - **Both graph kinds by name.** It proposes the records relation
158
+ `DENOTES` (`https://littlebigbrain.com/r/denotes`) on a records graph, and
159
+ `lbb:denotes` (`https://littlebigbrain.com/ns#denotes`) and the schema's
160
+ own property IRIs on an RDF-native graph. The template proposes the
161
+ records relations (`https://littlebigbrain.com/r/<name>`), `DENOTES`
162
+ included, on both graph kinds.
163
+ - **Scopes per document.** On an RDF-native graph it owns its proposals per
164
+ document (a proposal from several documents in the scope of the smallest
165
+ SHA-256), in one owned write per step. The template writes the proposals
166
+ with `replace_owned` on both graph kinds.
167
+ - **Any number of links.** It runs in time-limited steps over any number of
168
+ documents and keeps its typed links per document. The template maps at
169
+ most 1,000 linked mentions in one turn and fails the turn (no retry) when
170
+ a graph needs more steps.
171
+ - **Requests by tokens.** Requests are packed by an estimate of their
172
+ tokens (28,000), per page, four at once.
173
+ - **A new ontology version maps again**, and a new decision model asks
174
+ again (its version holds the model). The template maps on a message: call
175
+ `client.workflows.triggered.refresh` after an ontology change, and change
176
+ `m1` in the effect id to ask a new model.
177
+ - **Stale suggestions.** It supersedes the open suggestions a later run no
178
+ longer finds.
179
+ - **Status and budget.** Counters per outcome, the `map` model job on the
180
+ Models page, a monthly budget and a preview. The template's calls count
181
+ as `decide` activities.
182
+
183
+ ## Samples and test
184
+
185
+ `samples/` holds the three invented PDFs of `documents-link`, the parsed
186
+ pages of the room sheets, `trace.json` (what a correct `documents-link` run
187
+ writes on them: 15 entries, 21 mentions, 15 links) and the answer key
188
+ `expected.json` ([`../_samples/README.md`](../_samples/README.md)).
189
+ `node sdk/templates/_samples/make-trace.mjs` writes `trace.json` and the
190
+ copies from the samples of `documents-link`.
191
+
192
+ `npm run try` uploads the three PDFs and waits until LBB's
193
+ `documents.parse` has read them, so the typing questions show the rows of
194
+ each page. Then it adds the ontology of the samples (`Room`, `Equipment`,
195
+ `SpecSection`, `HAS_EQUIPMENT`, `SPECIFIED_IN`) and commits the trace
196
+ itself. This is the simplest way to run the map on known links: a link run
197
+ first would need its own models and give links that vary from run to run.
198
+ On your graph, run `documents.link` (or the `documents-link` template)
199
+ first.
200
+
201
+ `workflow.test.ts` runs the workflow on the trace with mock models: the
202
+ grounding question picks a candidate whose label holds the identifier, else
203
+ a new instance; the class question picks `Room` for the room sheets,
204
+ `SpecSection` for a section and `Equipment` for the rest; the typing
205
+ question picks the relation whose range is the object's class. The run makes
206
+ the 15 facts of the answer key and the 60 statements of the 15 new
207
+ instances, and nothing for `M 7`, `S 1.3` or `3.3`. The test also checks the
208
+ confidences, a second message answered from the effect cache, a turn
209
+ stopped part way, an existing instance, the ontology suggestions (their
210
+ titles, rationales, samples, names from the lines, the overlap rule, and
211
+ the links of an entry without a class as unmapped), a withdrawn link, a
212
+ refused request split in halves, a declared mapping, and the subject rules.