@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,467 @@
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.
4
+ //
5
+ // A triggered workflow of the starter workflow `workflow` watches the class `Document` and its
6
+ // field `parse_status` (LBB's parse writes it), and sends the parsed documents here. The workflow
7
+ // reads each document in windows of pages, one window per turn:
8
+ //
9
+ // 1. Define: the reasoning model (`reason`) lists the identifiers each page defines; they are
10
+ // written as `DocEntry` entities `DEFINED_IN` the document. Every defining window of a message
11
+ // runs before its first link window, so the catalogue holds them all.
12
+ // 2. Link: the reasoning model lists the spans of each page that may be codes. Each span gets
13
+ // candidates from the catalogue (every `DocEntry` of the graph) by characters and by meaning
14
+ // (`embed`), and the decision model (`decide`) answers what the span is and which entry it
15
+ // names. A mention is written as a `DocMention` `MENTIONED_IN` the document, with a
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.
18
+ //
19
+ // Each model call runs in a step and passes the step's effect id, so a turn that runs again after
20
+ // a crash pays for nothing twice.
21
+ import {
22
+ entityRef,
23
+ isActivityRefused,
24
+ LbbError,
25
+ splitInHalves,
26
+ triggeredWorkflow,
27
+ type DecideAnswer,
28
+ type LbbActivities,
29
+ type Schemas,
30
+ type WorkflowContext,
31
+ type WorkflowTriggerMessage,
32
+ } from "@littlebigbrain/client";
33
+ import * as link from "./link.js";
34
+
35
+ /** The part of a document: `defines` adds entries, `refers` has its mentions linked, `both` does both. */
36
+ export type Role = "defines" | "refers" | "both";
37
+
38
+ export interface Settings {
39
+ /** A link below this confidence goes to review instead of the graph. */
40
+ gate: number;
41
+ /** The role of each document by file name. A document the map does not name is `both`. */
42
+ roles: Record<string, Role>;
43
+ /** Pages of one window, 1 to 20: one turn reads one window. */
44
+ pagesPerWindow: number;
45
+ }
46
+
47
+ /** The settings: edit them for your documents. The roles are those of the samples. */
48
+ export const defaults: Settings = {
49
+ gate: 0.8,
50
+ roles: {
51
+ "ausstattungsliste.pdf": "defines",
52
+ "baubeschreibung.pdf": "defines",
53
+ "raumblaetter.pdf": "refers",
54
+ },
55
+ pagesPerWindow: 12,
56
+ };
57
+
58
+ /** The workflow type and version the triggered workflow names. */
59
+ export const workflowType = { workflow_type: "documents-link", version: "v1" };
60
+
61
+ /** The triggered workflow that sends the documents here: `client.workflows.starters.use(trigger())`. */
62
+ export function trigger(): Schemas["TriggeredWorkflowRequest"] {
63
+ return {
64
+ starter: "workflow",
65
+ name: "documents-link",
66
+ watch: { class: "Document", fields: ["parse_status"] },
67
+ workflow: workflowType,
68
+ owns: {
69
+ classes: ["DocEntry", "DocMention"],
70
+ relations: ["DEFINED_IN", "MENTIONED_IN", "REFERS_TO"],
71
+ },
72
+ batch: 16,
73
+ };
74
+ }
75
+
76
+ const FIELD_TYPES: Record<string, string> = { context: "text", line_text: "text", page: "i64" };
77
+
78
+ /** The classes, properties and relations the workflow writes: `lbb.evolve(ontology())`. */
79
+ export function ontology(): Schemas["OntologyEvolveOp"][] {
80
+ const fields = [...new Set([...link.ENTRY_FIELDS, ...link.MENTION_FIELDS])];
81
+ return [
82
+ { op: "add_entity_type", name: "Document" },
83
+ { op: "add_entity_type", name: "DocEntry" },
84
+ { op: "add_entity_type", name: "DocMention" },
85
+ ...fields.map((name) => ({
86
+ op: "add_property" as const,
87
+ name,
88
+ value_type: FIELD_TYPES[name] ?? (/^[xy][01]$/.test(name) ? "f64" : "keyword"),
89
+ })),
90
+ { op: "add_relation", name: "DEFINED_IN", domain: ["DocEntry"], range: ["Document"] },
91
+ { op: "add_relation", name: "MENTIONED_IN", domain: ["DocMention"], range: ["Document"] },
92
+ { op: "add_relation", name: "REFERS_TO", domain: ["DocMention"], range: ["DocEntry"] },
93
+ ];
94
+ }
95
+
96
+ /** What the workflow has written, over all its turns. */
97
+ export interface LinkState {
98
+ entries: number;
99
+ mentions: number;
100
+ links: number;
101
+ review: number;
102
+ }
103
+
104
+ /** A trigger message; a continued turn names the window it reads in `cursor`. */
105
+ type LinkMessage = WorkflowTriggerMessage & { cursor?: number };
106
+ interface Doc extends link.Doc {
107
+ pages: number;
108
+ parsed: boolean;
109
+ }
110
+ interface Window {
111
+ stage: "define" | "link";
112
+ doc: number;
113
+ from: number;
114
+ to: number;
115
+ }
116
+
117
+ const P = "PREFIX p: <https://littlebigbrain.com/p/>\nPREFIX r: <https://littlebigbrain.com/r/>\n";
118
+ const ENTRY_CLASS = "https://littlebigbrain.com/class/docentry";
119
+ const MENTION_CLASS = "https://littlebigbrain.com/class/docmention";
120
+ const values = (iris: string[]) =>
121
+ iris
122
+ .filter((iri) => /^[^\s<>"{}|\\^`]+$/.test(iri))
123
+ .map((iri) => `<${iri}>`)
124
+ .join(" ");
125
+ const label = (doc: Doc, page: number, what: string) =>
126
+ `${doc.name.slice(0, 40)} p${page + 1} ${what}`;
127
+ /** The effect id of the `n`th call of one kind in a step: the step's own, then `.1`, `.2`, … */
128
+ const effect = (id: string, n: number) => (n === 0 ? id : `${id}.${n}`);
129
+
130
+ /** The workflow, for a `WorkflowWorker`. `lbb` is `lbbActivities(client)`, or a mock in a test. */
131
+ export function createWorkflow(lbb: LbbActivities, settings: Settings = defaults) {
132
+ if (!(settings.pagesPerWindow >= 1 && settings.pagesPerWindow <= 20))
133
+ throw new RangeError("pagesPerWindow is from 1 to 20: a turn holds 64 steps");
134
+ return triggeredWorkflow<LinkState>({
135
+ name: workflowType.workflow_type,
136
+ version: workflowType.version,
137
+ initialState: { entries: 0, mentions: 0, links: 0, review: 0 },
138
+ async onInstances(ctx, message: LinkMessage, state) {
139
+ const docs = await ctx.step("documents", () => readDocuments(lbb, message));
140
+ if (message.cursor === undefined) {
141
+ const gone = [...message.removed, ...docs.filter((d) => !d.parsed).map((d) => d.iri)];
142
+ if (gone.length) await dropOutputs(ctx, lbb, gone);
143
+ }
144
+ const plan = windows(docs, settings);
145
+ const at = message.cursor ?? 0;
146
+ const w = plan[at];
147
+ if (!w) return { state };
148
+ const doc = docs[w.doc]!;
149
+ const next =
150
+ w.stage === "define"
151
+ ? await define(ctx, lbb, doc, w, state)
152
+ : await linkMentions(ctx, lbb, doc, w, state, settings, message.workflow);
153
+ if (at + 1 === plan.length) return { state: next };
154
+ return ctx.continue(next, { ...message, removed: [], cursor: at + 1 });
155
+ },
156
+ });
157
+ }
158
+
159
+ /** The windows of a message: every defining window first, so the catalogue is complete before a link. */
160
+ function windows(docs: Doc[], settings: Settings): Window[] {
161
+ const roleOf = (doc: Doc) =>
162
+ Object.entries(settings.roles).find(([name]) => name.normalize() === doc.name.normalize())?.[1];
163
+ const of = (stage: Window["stage"], skip: Role) =>
164
+ docs.flatMap((doc, i) => {
165
+ if (!doc.parsed || roleOf(doc) === skip) return [];
166
+ const out: Window[] = [];
167
+ for (let from = 0; from < doc.pages; from += settings.pagesPerWindow)
168
+ out.push({ stage, doc: i, from, to: Math.min(doc.pages, from + settings.pagesPerWindow) });
169
+ return out;
170
+ });
171
+ return [...of("define", "refers"), ...of("link", "defines")];
172
+ }
173
+
174
+ /** The name, file and parse of each document, at the message's commit. */
175
+ async function readDocuments(lbb: LbbActivities, message: WorkflowTriggerMessage): Promise<Doc[]> {
176
+ if (!message.instances.length) return [];
177
+ const query = `# documents-link: documents\n${P}SELECT ?doc ?name ?sha ?status ?pages WHERE {
178
+ VALUES ?doc { ${values(message.instances)} }
179
+ OPTIONAL { ?doc p:name ?name } OPTIONAL { ?doc p:file_sha256 ?sha }
180
+ OPTIONAL { ?doc p:parse_status ?status } OPTIONAL { ?doc p:page_count ?pages }
181
+ }`;
182
+ const { rows } = await lbb.sparql({ query, as_of_commit_seq: message.as_of_commit_seq });
183
+ return message.instances.map((iri) => {
184
+ const row = rows.find((r) => r.doc === iri) ?? {};
185
+ const parsed = row.status === "parsed" && Boolean(row.sha);
186
+ return {
187
+ iri,
188
+ name: row.name ?? iri,
189
+ sha256: row.sha ?? "",
190
+ pages: Number(row.pages ?? 0),
191
+ parsed,
192
+ };
193
+ });
194
+ }
195
+
196
+ /** Retract the entries and mentions of documents that left the class or lost their parse. */
197
+ async function dropOutputs(ctx: WorkflowContext, lbb: LbbActivities, gone: string[]) {
198
+ const found = await ctx.step("drop-find", async () => {
199
+ // Deleting a file retracts the edges to its Document: its entries and mentions are left without one.
200
+ const query = `# documents-link: outputs\n${P}SELECT ?entity ?class WHERE {
201
+ { VALUES ?doc { ${values(gone)} } ?entity r:defined_in|r:mentioned_in ?doc }
202
+ UNION { ?entity a <${ENTRY_CLASS}> FILTER NOT EXISTS { ?entity r:defined_in ?any } }
203
+ UNION { ?entity a <${MENTION_CLASS}> FILTER NOT EXISTS { ?entity r:mentioned_in ?any } }
204
+ ?entity a ?class .
205
+ }`;
206
+ const { rows } = await lbb.sparql(query, { consistency: "strong" });
207
+ return rows
208
+ .filter((r) => r.class === ENTRY_CLASS || r.class === MENTION_CLASS)
209
+ .map((r) => ({ iri: r.entity!, type: r.class === ENTRY_CLASS ? "DocEntry" : "DocMention" }));
210
+ });
211
+ const retract = found.map((e) => entityRef(e.iri, e.type));
212
+ if (retract.length)
213
+ await lbb.writeOwned(ctx, "drop", { subjects: [], retract_entities: retract });
214
+ }
215
+
216
+ /** The pages of the window and the catalogue, read inside the steps that need them, once per attempt. */
217
+ function reader(lbb: LbbActivities, doc: Doc, w: Window) {
218
+ let pages: Promise<link.FilePage[]> | undefined;
219
+ let catalogue: Promise<link.Entry[]> | undefined;
220
+ const query = `# documents-link: catalogue\n${P}SELECT ?identifier ?context ?line ?doc ?name ?sha WHERE {
221
+ ?entry a <${ENTRY_CLASS}> ; p:identifier ?identifier ; r:defined_in ?doc .
222
+ ?doc p:name ?name ; p:file_sha256 ?sha .
223
+ OPTIONAL { ?entry p:context ?context } OPTIONAL { ?entry p:line_id ?line }
224
+ }`;
225
+ const read = {
226
+ pages: () => (pages ??= lbb.pages(doc.sha256, { from: w.from, to: w.to }).then((r) => r.pages)),
227
+ page: async (page: number) => (await read.pages())[page - w.from]!,
228
+ /** Every entry of the graph, this workflow's own writes included, in a stable order. */
229
+ catalogue: () =>
230
+ (catalogue ??= lbb.sparql(query, { consistency: "strong" }).then(({ rows }) =>
231
+ rows
232
+ .map((r) => ({
233
+ identifier: r.identifier!,
234
+ context: r.context ?? r.identifier!,
235
+ line: r.line ?? "",
236
+ document: r.doc!,
237
+ documentName: r.name!,
238
+ sha256: r.sha!,
239
+ }))
240
+ .sort((a, b) => link.order(a).localeCompare(link.order(b))),
241
+ )),
242
+ };
243
+ return read;
244
+ }
245
+
246
+ /** A define window: each page's definitions, then the window's entries, owned per entry. */
247
+ async function define(
248
+ ctx: WorkflowContext,
249
+ lbb: LbbActivities,
250
+ doc: Doc,
251
+ w: Window,
252
+ state: LinkState,
253
+ ): Promise<LinkState> {
254
+ const read = reader(lbb, doc, w);
255
+ const found: link.Definition[] = [];
256
+ for (let page = w.from; page < w.to; page++) {
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" });
259
+ const p = await read.page(page);
260
+ const answer = await lbb.reason(link.definitionsRequest(p), {
261
+ effectId: s.effectId,
262
+ label: label(doc, page, "definitions"),
263
+ });
264
+ return link.checkDefinitions(answer.value, p);
265
+ });
266
+ found.push(...kept);
267
+ }
268
+ // A commit names at most 1,000 entities: the entries go in parts of at most 900.
269
+ const count = new Set(found.map((d) => d.identifier)).size;
270
+ for (let n = 0; n * link.PART < count; n++)
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
+ });
275
+ return { ...state, entries: state.entries + count };
276
+ }
277
+
278
+ /** A link window: each page's mentions, their candidates and answers, the gate, the writes. */
279
+ async function linkMentions(
280
+ ctx: WorkflowContext,
281
+ lbb: LbbActivities,
282
+ doc: Doc,
283
+ w: Window,
284
+ state: LinkState,
285
+ settings: Settings,
286
+ workflow: string,
287
+ ): Promise<LinkState> {
288
+ const read = reader(lbb, doc, w);
289
+ const pages = Array.from({ length: w.to - w.from }, (_, n) => w.from + n);
290
+ const mentions: link.Mention[][] = [];
291
+ for (const page of pages) {
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" });
294
+ const p = await read.page(page);
295
+ const answer = await lbb.reason(link.mentionsRequest(p), {
296
+ effectId: s.effectId,
297
+ label: label(doc, page, "mentions"),
298
+ });
299
+ return link.checkMentions(answer.value, p);
300
+ });
301
+ mentions.push(kept);
302
+ }
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
+ });
307
+ const resolved: link.Resolved[][] = [];
308
+ for (const [n, page] of pages.entries()) {
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
+ });
313
+ resolved.push(answers);
314
+ }
315
+
316
+ // The link rule, then the gate: written, or a review item, or no link.
317
+ const all = mentions.flatMap((list, n) =>
318
+ list.map((m, k) => ({
319
+ m,
320
+ page: pages[n]!,
321
+ ...link.linkRule(resolved[n]![k]!),
322
+ r: resolved[n]![k]!,
323
+ })),
324
+ );
325
+ const written = all.filter((x) => x.target !== null && x.confidence >= settings.gate);
326
+ const below = all.filter((x) => x.target !== null && x.confidence < settings.gate);
327
+ await ctx.step("review", async (s) => {
328
+ const catalogue = await read.catalogue();
329
+ // One item per span and entry: S1.1 on two pages is one item with two links.
330
+ const groups = new Map<string, typeof below>();
331
+ for (const x of below) {
332
+ const key = `${link.identifierKey(x.m.span)}\u0000${x.target}`;
333
+ groups.set(key, [...(groups.get(key) ?? []), x]);
334
+ }
335
+ // An item holds at most 100 links, so its facts stay within the 64 KiB a suggestion takes.
336
+ const items = [...groups.values()].flatMap((group) =>
337
+ Array.from({ length: Math.ceil(group.length / 100) }, (_, n) => ({
338
+ n,
339
+ group: group.slice(n * 100, (n + 1) * 100),
340
+ })),
341
+ );
342
+ for (const [k, { n, group }] of items.entries()) {
343
+ s.progress("filing review items", { done: k, total: items.length, unit: "items" });
344
+ const entry = catalogue[group[0]!.target!]!;
345
+ const span = group[0]!.m.span;
346
+ const lowest = Math.min(...group.map((x) => x.confidence));
347
+ const pages = await read.pages();
348
+ const triplets = group.map((x) => link.refersTo(doc, pages, x.m, entry, x.confidence));
349
+ await lbb.review({
350
+ workflow,
351
+ key: `starter:${workflow}:link:${doc.sha256.slice(0, 16)}:${link.keyPart(span)}:${entry.sha256.slice(0, 16)}:${link.keyPart(entry.identifier)}:${n}`,
352
+ title: `Link "${span}" in ${doc.name} to ${entry.identifier}`.slice(0, 200),
353
+ rationale: `${group.length} mentions of "${span}" in ${doc.name} name the entry ${entry.identifier} with a confidence below the gate ${settings.gate} (lowest ${lowest}).`,
354
+ facts: {
355
+ triplets,
356
+ summary: `${triplets.length} REFERS_TO links from "${span}" to ${entry.identifier}`,
357
+ },
358
+ evidence: {
359
+ records: group.length,
360
+ samples: group
361
+ .slice(0, 20)
362
+ .map((x) => ({ page: x.page, line_id: x.m.line, confidence: x.confidence })),
363
+ },
364
+ });
365
+ }
366
+ return items.length;
367
+ });
368
+ for (let n = 0; n * link.PART < all.length; n++)
369
+ await lbb.writeOwned(ctx, `mentions-${n}`, async (s) => {
370
+ s.progress("writing facts", { done: n * link.PART, total: all.length, unit: "mentions" });
371
+ const [pages, catalogue] = [await read.pages(), await read.catalogue()];
372
+ const links = written.map((x) => ({ ...x, entry: catalogue[x.target!]! }));
373
+ return link.part(link.mentionsWrite(doc, pages, all, links), n);
374
+ });
375
+ return {
376
+ entries: state.entries,
377
+ mentions: state.mentions + all.length,
378
+ links: state.links + written.length,
379
+ review: state.review + below.length,
380
+ };
381
+ }
382
+
383
+ /** The catalogue's entries closest in meaning to each mention (`span — row`), per page. */
384
+ async function meaning(
385
+ effectId: string,
386
+ lbb: LbbActivities,
387
+ doc: Doc,
388
+ read: ReturnType<typeof reader>,
389
+ mentions: link.Mention[][],
390
+ ): Promise<number[][][]> {
391
+ const none = () => mentions.map((list) => list.map(() => []));
392
+ const catalogue = await read.catalogue();
393
+ const queries: string[] = [];
394
+ for (const list of mentions)
395
+ for (const m of list)
396
+ queries.push(
397
+ `${m.span} — ${link.rowOf(
398
+ (await read.pages()).flatMap((p) => p.lines),
399
+ m.line,
400
+ )}`,
401
+ );
402
+ if (!catalogue.length || !queries.length) return none();
403
+ const texts = [...catalogue.map((e) => `${e.identifier}: ${e.context}`), ...queries].map((t) =>
404
+ t.slice(0, 8_000),
405
+ );
406
+ const vectors: number[][] = [];
407
+ try {
408
+ for (let n = 0; n * 256 < texts.length; n++) {
409
+ const chunk = texts.slice(n * 256, (n + 1) * 256);
410
+ const embedded = await lbb.embed(
411
+ { texts: chunk },
412
+ { effectId: effect(effectId, n), label: `${doc.name.slice(0, 40)} meaning` },
413
+ );
414
+ vectors.push(...embedded.vectors);
415
+ }
416
+ } catch (error) {
417
+ // Without an embedding model the candidates come by characters only.
418
+ if (error instanceof LbbError && error.code === "activity_model_unavailable") return none();
419
+ throw error;
420
+ }
421
+ const entries = vectors.slice(0, catalogue.length);
422
+ let next = catalogue.length;
423
+ return mentions.map((list) => list.map(() => link.nearest(vectors[next++]!, entries)));
424
+ }
425
+
426
+ /** The decision model's answers for one page's mentions: a refused request is split in halves. */
427
+ async function decidePage(
428
+ effectId: string,
429
+ lbb: LbbActivities,
430
+ doc: Doc,
431
+ read: ReturnType<typeof reader>,
432
+ page: number,
433
+ mentions: link.Mention[],
434
+ byMeaning: number[][],
435
+ ): Promise<link.Resolved[]> {
436
+ if (!mentions.length) return [];
437
+ const catalogue = await read.catalogue();
438
+ const p = await read.page(page);
439
+ const asked = mentions.map((mention, position) => ({
440
+ position,
441
+ mention,
442
+ lineText: p.lines.find((l) => l.id === mention.line)!.text,
443
+ candidates: link.candidatesFor(mention, doc.iri, catalogue, byMeaning[position]!),
444
+ }));
445
+ const state = link.pageState(doc.name, p, catalogue);
446
+ const answers: Record<string, DecideAnswer> = {};
447
+ const ask = async (part: link.Asked[], id: string): Promise<void> => {
448
+ try {
449
+ const request = link.decideRequest(state, part, catalogue);
450
+ const answered = await lbb.decide(request, {
451
+ effectId: id,
452
+ label: label(doc, page, "resolve"),
453
+ });
454
+ Object.assign(answers, answered.answers);
455
+ } catch (error) {
456
+ if (!isActivityRefused(error)) throw error;
457
+ // Too large for the model: ask each half. One mention the model refuses stays unanswered.
458
+ const halves = splitInHalves(part);
459
+ if (halves) for (const [n, half] of halves.entries()) await ask(half, `${id}.${n}`);
460
+ }
461
+ };
462
+ const requests = link.packRequests(state, asked, catalogue);
463
+ for (const [n, part] of requests.entries()) await ask(part, effect(effectId, n));
464
+ return asked.map(({ position }) =>
465
+ link.resolve(answers[`m${position}-kind`], answers[`m${position}-names`]),
466
+ );
467
+ }
@@ -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.