@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
@@ -0,0 +1,1199 @@
1
+ // documents-map: map the links of documents-link into the graph's own ontology, as proposals.
2
+ //
3
+ // A triggered workflow of the starter workflow `workflow` watches the class `DocMention` and its
4
+ // links `REFERS_TO` and `SAME_ITEM_AS` (documents.link, or the documents-link template, writes
5
+ // them). A message is a signal: each one maps every link of the graph again, in one turn.
6
+ //
7
+ // 1. Ground the entries. Each entry a link points to, and each entry a mention's line belongs to
8
+ // (the item a page is about), gets candidates: the instances whose identifier or label is the
9
+ // entry's identifier. The decision model (`decide`) answers which instance the entry is: a
10
+ // candidate, a new instance, or none. A new instance gets a second question: its class.
11
+ // 2. Type the links. For a link whose two ends are instances with a class, the decision model
12
+ // answers which statement the line makes: a property whose domain and range fit the two
13
+ // classes (either way round), the same item, no fact, or a property the ontology lacks.
14
+ // 3. Propose. Each statement becomes a `FactProposal` (written with `writeOwned`): a typed link
15
+ // with the link's confidence times the answer's probability, an entry's `DENOTES` link to its
16
+ // instance, and a new instance's type, label and identifier. "No class fits" and "a property
17
+ // the ontology lacks" become ontology suggestions, one per pattern.
18
+ //
19
+ // A `review` workflow (LBB's, or the review template) decides which proposals become facts.
20
+ //
21
+ // The questions are asked under an effect id made from their content, so a question asked again
22
+ // in a later message gets its first answer at no cost: only new or changed questions cost.
23
+ import {
24
+ entityRef,
25
+ isActivityRefused,
26
+ LbbError,
27
+ splitInHalves,
28
+ triggeredWorkflow,
29
+ WorkflowError,
30
+ type DecideRequest,
31
+ type FilePages,
32
+ type LbbActivities,
33
+ type Schemas,
34
+ type WorkflowContext,
35
+ type WorkflowTriggerMessage,
36
+ } from "@littlebigbrain/client";
37
+ import * as map from "./map.js";
38
+
39
+ /** A declared mapping: a link from an instance of `from` to one of `to` states `property`, and no model is asked. */
40
+ export interface Mapping {
41
+ /** `document` limits it to the links of one referring document. */
42
+ from: { document?: string; entry_class: string };
43
+ to: { entry_class: string };
44
+ property: string;
45
+ }
46
+
47
+ export interface Settings {
48
+ /** Declared mappings, by class and relation name. */
49
+ mappings: Mapping[];
50
+ }
51
+
52
+ /** The settings: edit them for your graph. The samples need no declared mapping. */
53
+ export const defaults: Settings = { mappings: [] };
54
+
55
+ /** The workflow type and version the triggered workflow names. */
56
+ export const workflowType = { workflow_type: "documents-map", version: "v1" };
57
+
58
+ /** The triggered workflow that sends the changed links here: `client.workflows.starters.use(trigger())`. */
59
+ export function trigger(): Schemas["TriggeredWorkflowRequest"] {
60
+ return {
61
+ starter: "workflow",
62
+ name: "documents-map",
63
+ watch: { class: "DocMention", fields: ["REFERS_TO", "SAME_ITEM_AS"] },
64
+ workflow: workflowType,
65
+ owns: { classes: [map.FACT_PROPOSAL], properties: Object.keys(map.PROPOSAL_FIELDS) },
66
+ batch: 64,
67
+ };
68
+ }
69
+
70
+ /** The classes, properties and relation the workflow and the review need: `lbb.evolve(ontology())`. */
71
+ export function ontology(): Schemas["OntologyEvolveOp"][] {
72
+ return [
73
+ { op: "add_entity_type", name: "DocEntry" },
74
+ { op: "add_entity_type", name: "DocMention" },
75
+ { op: "add_entity_type", name: map.FACT_PROPOSAL },
76
+ ...Object.entries({ ...map.PROPOSAL_FIELDS, identifier: "keyword" }).map(
77
+ ([name, value_type]) => ({ op: "add_property" as const, name, value_type }),
78
+ ),
79
+ // The trace from an entry (or a page's head mention) to its instance: open on both sides.
80
+ { op: "add_relation", name: map.DENOTES, domain: [], range: [] },
81
+ ];
82
+ }
83
+
84
+ /** What the last run found, and the runs so far. */
85
+ export interface MapState {
86
+ runs: number;
87
+ links: number;
88
+ entries: number;
89
+ proposals: number;
90
+ suggestions: number;
91
+ /** Links that made no proposal and back no filed suggestion. */
92
+ unmapped: number;
93
+ /**
94
+ * The first 50 of them, by document, page and line: each with its `document`, `page`,
95
+ * `line_id`, `line`, `box`, `span`, `subject`, `object`, `outcome`, `reason`, and the typing
96
+ * answer (`choice`, `answer`, `probability`, `options`) when it has one.
97
+ */
98
+ unmappedLinks: Record<string, unknown>[];
99
+ }
100
+
101
+ /** Unmapped links the state lists, at most. */
102
+ const UNMAPPED_MAX = 50;
103
+
104
+ /** Links one message maps at most: one turn holds the answers. The LBB version maps any number. */
105
+ const MAX_LINKS = 1_000;
106
+ /** Proposals one commit names at most: the route takes 1,000 entities. */
107
+ const PART = 900;
108
+ const P =
109
+ "PREFIX p: <https://littlebigbrain.com/p/>\nPREFIX r: <https://littlebigbrain.com/r/>\n" +
110
+ "PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>\nPREFIX lbb: <https://littlebigbrain.com/ns#>\n";
111
+ const PLAIN_IRI = /^[^\s<>"{}|\\^`]+$/;
112
+ const values = (iris: Iterable<string>) =>
113
+ [...iris]
114
+ .filter((iri) => PLAIN_IRI.test(iri))
115
+ .map((iri) => `<${iri}>`)
116
+ .join(" ");
117
+ const num = (text: string | undefined, fallback = 0) => {
118
+ const n = Number(text);
119
+ return text !== undefined && Number.isFinite(n) ? n : fallback;
120
+ };
121
+
122
+ // ---- the trace ---------------------------------------------------------------------------
123
+
124
+ interface Doc {
125
+ iri: string;
126
+ name: string;
127
+ sha256: string;
128
+ }
129
+ interface Entry extends map.Placed {
130
+ iri: string;
131
+ doc: string;
132
+ identifier: string;
133
+ context: string;
134
+ line: string;
135
+ }
136
+ interface Link {
137
+ relation: "REFERS_TO" | "SAME_ITEM_AS";
138
+ entry: string;
139
+ confidence: number;
140
+ }
141
+ interface Mention extends map.Placed {
142
+ iri: string;
143
+ doc: string;
144
+ span: string;
145
+ line: string;
146
+ text: string;
147
+ links: Link[];
148
+ }
149
+ /** One link to map: its mention, the entry it names, and the key of its subject. */
150
+ interface Unit {
151
+ mention: Mention;
152
+ link: Link;
153
+ /** An entry IRI, or a mention IRI for a page entry made from a mention. */
154
+ subject: string;
155
+ }
156
+ /** An end of a link: an entry, or a page entry made from a mention of the page's head. */
157
+ interface End {
158
+ key: string;
159
+ doc: Doc;
160
+ view: map.EntryView;
161
+ line: string;
162
+ box: number[];
163
+ mint: string;
164
+ }
165
+ interface Trace {
166
+ docs: Map<string, Doc>;
167
+ units: Unit[];
168
+ ends: Map<string, End>;
169
+ /** The links whose mention has no subject: no entry of the page or the line. */
170
+ noSubject: { mention: Mention; link: Link }[];
171
+ }
172
+
173
+ const box = (row: Record<string, string>) => ["x0", "y0", "x1", "y1"].map((k) => num(row[k]));
174
+
175
+ /** The links of the graph, the mentions and entries of their documents, at the message's commit. */
176
+ async function readTrace(lbb: LbbActivities, message: WorkflowTriggerMessage): Promise<Trace> {
177
+ const at = { as_of_commit_seq: message.as_of_commit_seq };
178
+ const linked = await lbb.sparql({
179
+ ...at,
180
+ query: `# documents-map: links\n${P}SELECT ?mention ?relation ?entry ?doc ?confidence WHERE {
181
+ VALUES ?relation { r:refers_to r:same_item_as }
182
+ ?mention ?relation ?entry ; r:mentioned_in ?doc .
183
+ OPTIONAL { ?st rdf:subject ?mention ; rdf:predicate ?relation ; rdf:object ?entry ; lbb:confidence ?confidence }
184
+ }`,
185
+ });
186
+ const links = new Map<string, Link[]>();
187
+ for (const row of linked.rows) {
188
+ const relation = row.relation!.endsWith("/same_item_as") ? "SAME_ITEM_AS" : "REFERS_TO";
189
+ const list = links.get(row.mention!) ?? [];
190
+ const found = list.find((l) => l.relation === relation && l.entry === row.entry);
191
+ const confidence = num(row.confidence, 1);
192
+ if (found) found.confidence = Math.max(found.confidence, confidence);
193
+ else list.push({ relation, entry: row.entry!, confidence });
194
+ links.set(row.mention!, list);
195
+ }
196
+ if (links.size > MAX_LINKS)
197
+ throw new WorkflowError(
198
+ `The graph has links from ${links.size} mentions; the template maps at most ${MAX_LINKS} in one turn. Use the LBB-run documents.map.`,
199
+ );
200
+ const referring = new Set(linked.rows.map((r) => r.doc!));
201
+ const targets = new Set(linked.rows.map((r) => r.entry!));
202
+ if (!links.size) return { docs: new Map(), units: [], ends: new Map(), noSubject: [] };
203
+ const { rows: mentionRows } = await lbb.sparql({
204
+ ...at,
205
+ query: `# documents-map: mentions\n${P}SELECT ?mention ?doc ?span ?line ?text ?page ?x0 ?y0 ?x1 ?y1 WHERE {
206
+ VALUES ?doc { ${values(referring)} }
207
+ ?mention r:mentioned_in ?doc ; p:span ?span ; p:page ?page .
208
+ OPTIONAL { ?mention p:line_id ?line } OPTIONAL { ?mention p:line_text ?text }
209
+ OPTIONAL { ?mention p:x0 ?x0 ; p:y0 ?y0 ; p:x1 ?x1 ; p:y1 ?y1 }
210
+ }`,
211
+ });
212
+ const { rows: entryRows } = await lbb.sparql({
213
+ ...at,
214
+ query: `# documents-map: entries\n${P}SELECT ?entry ?doc ?identifier ?context ?line ?page ?x0 ?y0 ?x1 ?y1 WHERE {
215
+ { VALUES ?doc { ${values(referring)} } ?entry r:defined_in ?doc }
216
+ UNION { VALUES ?entry { ${values(targets)} } ?entry r:defined_in ?doc }
217
+ ?entry p:identifier ?identifier .
218
+ OPTIONAL { ?entry p:context ?context } OPTIONAL { ?entry p:line_id ?line } OPTIONAL { ?entry p:page ?page }
219
+ OPTIONAL { ?entry p:x0 ?x0 ; p:y0 ?y0 ; p:x1 ?x1 ; p:y1 ?y1 }
220
+ }`,
221
+ });
222
+ const entries = new Map<string, Entry>();
223
+ for (const r of entryRows)
224
+ entries.set(r.entry!, {
225
+ iri: r.entry!,
226
+ doc: r.doc!,
227
+ identifier: r.identifier!,
228
+ context: r.context ?? r.identifier!,
229
+ line: r.line ?? "",
230
+ page: num(r.page),
231
+ box: box(r),
232
+ });
233
+ const { rows: docRows } = await lbb.sparql({
234
+ ...at,
235
+ query: `# documents-map: documents\n${P}SELECT ?doc ?name ?sha WHERE {
236
+ VALUES ?doc { ${values(new Set([...referring, ...[...entries.values()].map((e) => e.doc)]))} }
237
+ OPTIONAL { ?doc p:name ?name } OPTIONAL { ?doc p:file_sha256 ?sha }
238
+ }`,
239
+ });
240
+ const docs = new Map<string, Doc>(
241
+ docRows.map((r) => [r.doc!, { iri: r.doc!, name: r.name ?? r.doc!, sha256: r.sha ?? r.doc! }]),
242
+ );
243
+ const doc = (iri: string) => docs.get(iri) ?? { iri, name: iri, sha256: iri };
244
+
245
+ // The subject of each mention's links, per referring document.
246
+ const units: Unit[] = [];
247
+ const ends = new Map<string, End>();
248
+ const noSubject: Trace["noSubject"] = [];
249
+ const entryEnd = (e: Entry): End => ({
250
+ key: e.iri,
251
+ doc: doc(e.doc),
252
+ view: {
253
+ identifier: e.identifier,
254
+ description: map.clip(e.context, 200),
255
+ context: e.context,
256
+ document: doc(e.doc).name,
257
+ page: e.page + 1,
258
+ },
259
+ line: e.line,
260
+ box: e.box,
261
+ mint: map.mintedIri(doc(e.doc).sha256, e.identifier),
262
+ });
263
+ for (const d of [...referring].sort()) {
264
+ const mentions: Mention[] = mentionRows
265
+ .filter((r) => r.doc === d)
266
+ .map((r) => ({
267
+ iri: r.mention!,
268
+ doc: d,
269
+ span: r.span!,
270
+ line: r.line ?? "",
271
+ text: r.text ?? r.span!,
272
+ page: num(r.page),
273
+ box: box(r),
274
+ links: links.get(r.mention!) ?? [],
275
+ }))
276
+ .sort((a, b) => a.page - b.page || a.box[1]! - b.box[1]! || a.box[0]! - b.box[0]!);
277
+ const own = [...entries.values()]
278
+ .filter((e) => e.doc === d)
279
+ .map((e) => ({ entry: e.iri, identifier: e.identifier, page: e.page, box: e.box }));
280
+ const subjects = map.subjects(
281
+ own,
282
+ mentions.map((m) => ({ ...m, target: m.links[0]?.entry ?? null })),
283
+ );
284
+ mentions.forEach((m, n) => {
285
+ const s = subjects[n];
286
+ if (!s) {
287
+ noSubject.push(...m.links.map((link) => ({ mention: m, link })));
288
+ return;
289
+ }
290
+ let end: End;
291
+ if ("entry" in s) {
292
+ const entry = entries.get(s.entry);
293
+ if (!entry) {
294
+ noSubject.push(...m.links.map((link) => ({ mention: m, link })));
295
+ return;
296
+ }
297
+ end = entryEnd(entry);
298
+ } else {
299
+ // A page entry made from the mention of the page's head: its span the identifier, the
300
+ // rest of its line the description.
301
+ const head = mentions[s.mention]!;
302
+ end = {
303
+ key: head.iri,
304
+ doc: doc(d),
305
+ view: {
306
+ identifier: head.span.trim(),
307
+ description: map.pageEntryDescription(head.span, head.text),
308
+ context: head.text,
309
+ document: doc(d).name,
310
+ page: head.page + 1,
311
+ },
312
+ line: head.line,
313
+ box: head.box,
314
+ mint: map.mintedIri(doc(d).sha256, head.span),
315
+ };
316
+ }
317
+ for (const link of m.links) {
318
+ const entry = entries.get(link.entry);
319
+ if (!entry || entry.iri === end.key) continue;
320
+ ends.set(end.key, end);
321
+ ends.set(entry.iri, entryEnd(entry));
322
+ units.push({ mention: m, link, subject: end.key });
323
+ }
324
+ });
325
+ }
326
+ return { docs, units, ends, noSubject };
327
+ }
328
+
329
+ // ---- candidates ------------------------------------------------------------------------------
330
+
331
+ const IDENTIFYING = [
332
+ "https://littlebigbrain.com/p/identifier",
333
+ map.RDFS_LABEL,
334
+ "http://www.w3.org/2004/02/skos/core#notation",
335
+ "http://www.w3.org/2004/02/skos/core#prefLabel",
336
+ "http://schema.org/identifier",
337
+ "https://schema.org/identifier",
338
+ "http://purl.org/dc/terms/identifier",
339
+ ];
340
+
341
+ /** Each end's candidates: the instances whose identifier, label or notation is the end's identifier as written. */
342
+ async function findCandidates(
343
+ lbb: LbbActivities,
344
+ message: WorkflowTriggerMessage,
345
+ ends: End[],
346
+ onto: map.MapOntology,
347
+ ): Promise<Map<string, map.CandidateView[]>> {
348
+ const at = { as_of_commit_seq: message.as_of_commit_seq };
349
+ const found = new Map<string, Set<string>>(ends.map((e) => [e.key, new Set<string>()]));
350
+ for (let n = 0; n < ends.length; n += 64) {
351
+ const part = ends.slice(n, n + 64);
352
+ const literals = new Set(part.flatMap((e) => map.identifierVariants(e.view.identifier)));
353
+ if (!literals.size) continue;
354
+ const { rows } = await lbb.sparql({
355
+ ...at,
356
+ query: `# documents-map: candidates\nSELECT DISTINCT ?s ?v WHERE {
357
+ VALUES ?v { ${[...literals].map((l) => JSON.stringify(l)).join(" ")} }
358
+ VALUES ?p { ${values(IDENTIFYING)} }
359
+ ?s ?p ?v .
360
+ }`,
361
+ });
362
+ for (const row of rows)
363
+ for (const e of part)
364
+ if (map.identifierKey(row.v!) === map.identifierKey(e.view.identifier) && row.s !== e.mint)
365
+ found.get(e.key)!.add(row.s!);
366
+ }
367
+ const iris = new Set([...found.values()].flatMap((s) => [...s]));
368
+ const details = new Map<string, map.CandidateView>();
369
+ const trace = new Set(map.TRACE_CLASSES.map((c) => map.classIri(c)));
370
+ const list = [...iris].filter((iri) => PLAIN_IRI.test(iri)).sort();
371
+ for (let n = 0; n < list.length; n += 64) {
372
+ const { rows } = await lbb.sparql({
373
+ ...at,
374
+ query: `# documents-map: details\nSELECT ?s ?p ?o WHERE {
375
+ VALUES ?s { ${values(list.slice(n, n + 64))} }
376
+ ?s ?p ?o FILTER(?p = <${map.RDF_TYPE}> || isLiteral(?o))
377
+ }`,
378
+ });
379
+ const types = new Map<string, string[]>();
380
+ const literals = new Map<string, [string, string][]>();
381
+ for (const r of rows) {
382
+ if (r.p === map.RDF_TYPE) types.set(r.s!, [...(types.get(r.s!) ?? []), r.o!]);
383
+ else literals.set(r.s!, [...(literals.get(r.s!) ?? []), [r.p!, r.o!]]);
384
+ }
385
+ for (const [iri, classes] of types) {
386
+ // An instance of LBB's own trace (an entry, a mention) is no candidate.
387
+ if (classes.some((c) => trace.has(c))) continue;
388
+ const facts = (literals.get(iri) ?? []).sort((a, b) => (a.join() < b.join() ? -1 : 1));
389
+ const label =
390
+ facts.find(([p]) => p === map.RDFS_LABEL)?.[1] ??
391
+ facts.find(([p]) => /name|label|title/i.test(map.localName(p)))?.[1] ??
392
+ map.localName(iri);
393
+ const cls = classes.sort().find((c) => onto.classes.has(c));
394
+ details.set(iri, {
395
+ iri,
396
+ ...(cls ? { class: cls } : {}),
397
+ classLabel: cls ? onto.classLabel(cls) : "thing",
398
+ label,
399
+ facts: facts
400
+ .filter(([p, v]) => p !== map.RDFS_LABEL && v !== label)
401
+ .slice(0, 4)
402
+ .map(([p, v]) => [map.localName(p), v]),
403
+ });
404
+ }
405
+ }
406
+ return new Map(
407
+ ends.map((e) => [
408
+ e.key,
409
+ [...found.get(e.key)!]
410
+ .sort()
411
+ .flatMap((iri) => details.get(iri) ?? [])
412
+ .slice(0, map.GROUND_CANDIDATES),
413
+ ]),
414
+ );
415
+ }
416
+
417
+ // ---- the page rows -----------------------------------------------------------------------------
418
+
419
+ type FileLine = FilePages["pages"][number]["lines"][number];
420
+
421
+ /** The lines of a page by baseline, top to bottom, each row left to right. */
422
+ function layoutRows(lines: FileLine[]): FileLine[][] {
423
+ const rows: { top: number; bottom: number; lines: FileLine[] }[] = [];
424
+ for (const line of [...lines].sort((a, b) => a.box[1]! - b.box[1]! || a.box[0]! - b.box[0]!)) {
425
+ const middle = (line.box[1]! + line.box[3]!) / 2;
426
+ const row = rows.find((r) => r.top <= middle && middle <= r.bottom);
427
+ if (row) row.lines.push(line);
428
+ else rows.push({ top: line.box[1]!, bottom: line.box[3]!, lines: [line] });
429
+ }
430
+ return rows.map((r) => r.lines.sort((a, b) => a.box[0]! - b.box[0]!));
431
+ }
432
+
433
+ /** The rows of one page of a referring document, as `[line id] text | …`; without a parse, the mentions' lines. */
434
+ function pageReader(lbb: LbbActivities, trace: Trace) {
435
+ const read = new Map<string, Promise<FileLine[]>>();
436
+ return async (doc: Doc, page: number) => {
437
+ const key = `${doc.sha256}\u0000${page}`;
438
+ if (!read.has(key))
439
+ read.set(
440
+ key,
441
+ lbb.pages(doc.sha256, { from: page, to: page + 1 }).then(
442
+ (pages) => pages.pages[0]?.lines ?? [],
443
+ (error: unknown) => {
444
+ if (!(error instanceof LbbError && error.status === 404)) throw error;
445
+ const seen = new Map<string, FileLine>();
446
+ for (const u of trace.units)
447
+ if (u.mention.doc === doc.iri && u.mention.page === page)
448
+ seen.set(u.mention.line, {
449
+ id: u.mention.line,
450
+ text: u.mention.text,
451
+ box: u.mention.box,
452
+ words: [],
453
+ } as FileLine);
454
+ return [...seen.values()];
455
+ },
456
+ ),
457
+ );
458
+ const lines = await read.get(key)!;
459
+ const rows = layoutRows(lines);
460
+ return {
461
+ rows: rows.map((row) => row.map((l) => `[${l.id}] ${l.text}`).join(" | ")),
462
+ rowOf: (id: string) =>
463
+ (rows.find((row) => row.some((l) => l.id === id)) ?? []).map((l) => l.text).join(" | "),
464
+ };
465
+ };
466
+ }
467
+
468
+ // ---- asking ----------------------------------------------------------------------------------------
469
+
470
+ /** One decide plan: questions that share a state, and the label of their calls. */
471
+ interface Plan {
472
+ label: string;
473
+ state: Record<string, unknown>;
474
+ key: string;
475
+ members: map.Member[];
476
+ }
477
+
478
+ /** The requests of the plans, in order. */
479
+ const requestsOf = (plans: Plan[]) =>
480
+ plans.flatMap((plan) =>
481
+ map.packRequests(plan.state, plan.key, plan.members).map((members) => ({ plan, members })),
482
+ );
483
+
484
+ /**
485
+ * Ask one request under an effect id made from its content: the same request
486
+ * in any later message gets its kept answer at no cost. A refused request is
487
+ * asked in halves; a question the model refuses alone stays unanswered.
488
+ */
489
+ async function ask(
490
+ lbb: LbbActivities,
491
+ plan: Plan,
492
+ members: map.Member[],
493
+ ): Promise<Record<string, map.Kept>> {
494
+ const request: DecideRequest = map.buildRequest(plan.state, plan.key, members);
495
+ const effectId = `documents-map/m1/${map.hash(JSON.stringify(request)).slice(0, 48)}`;
496
+ try {
497
+ const { answers } = await lbb.decide(request, { effectId, label: plan.label.slice(0, 64) });
498
+ return map.keep(answers);
499
+ } catch (error) {
500
+ if (!isActivityRefused(error)) throw error;
501
+ const halves = splitInHalves(members);
502
+ if (!halves) return {};
503
+ return { ...(await ask(lbb, plan, halves[0])), ...(await ask(lbb, plan, halves[1])) };
504
+ }
505
+ }
506
+
507
+ /** The phase each stage reports, the words of the LBB-run documents.map. */
508
+ const STAGE_PHASE: Record<string, string> = {
509
+ ground: "grounding entries",
510
+ class: "choosing classes",
511
+ type: "typing links",
512
+ };
513
+
514
+ /** The steps of one stage, one per request: `<stage>-<n>`. Throws when the turn cannot hold them. */
515
+ async function askStage(
516
+ ctx: WorkflowContext,
517
+ lbb: LbbActivities,
518
+ stage: string,
519
+ plans: Plan[],
520
+ steps: { used: number },
521
+ ): Promise<Record<string, map.Kept>> {
522
+ const requests = requestsOf(plans);
523
+ // A turn holds 64 steps; keep room for the suggestions and the writes.
524
+ if (steps.used + requests.length > 56)
525
+ throw new WorkflowError(
526
+ `The ${stage} questions need ${requests.length} requests, more than one turn of the template holds. Use the LBB-run documents.map.`,
527
+ );
528
+ const answers: Record<string, map.Kept> = {};
529
+ for (const [n, { plan, members }] of requests.entries()) {
530
+ steps.used += 1;
531
+ Object.assign(
532
+ answers,
533
+ await ctx.step(`${stage}-${n}`, (s) => {
534
+ s.progress(STAGE_PHASE[stage] ?? stage, {
535
+ done: n,
536
+ total: requests.length,
537
+ unit: "questions",
538
+ });
539
+ return ask(lbb, plan, members);
540
+ }),
541
+ );
542
+ }
543
+ return answers;
544
+ }
545
+
546
+ // ---- the workflow ---------------------------------------------------------------------------------
547
+
548
+ /** How an end was grounded. */
549
+ interface Grounded {
550
+ kind: "existing" | "new" | "none" | "unanswered";
551
+ iri?: string;
552
+ class?: string;
553
+ p: number;
554
+ options: Record<string, number>;
555
+ candidates: map.CandidateView[];
556
+ classP?: number;
557
+ classOptions?: Record<string, number>;
558
+ noClass?: number;
559
+ }
560
+
561
+ /** The id of a question about an end or a link: from its key, so it stays the same from run to run. */
562
+ const qid = (key: string) => map.hash(key).slice(0, 12);
563
+
564
+ /** Where a link is: its mention on its line and the two ends it names (a review item's sample shape). */
565
+ const mentionSample = (doc: Doc, mention: Mention, subject: string, object: string) => ({
566
+ kind: "link",
567
+ document: doc.name,
568
+ page: mention.page + 1,
569
+ line_id: mention.line,
570
+ line: map.clip(mention.text, 200),
571
+ box: mention.box,
572
+ span: mention.span,
573
+ subject,
574
+ object,
575
+ });
576
+
577
+ /** Why an end has no instance, or no class, in words. */
578
+ function endState(id: string, g: Grounded | undefined): string {
579
+ if (!g) return `${id} was not grounded`;
580
+ if (g.kind === "unanswered") return `${id} got no grounding answer`;
581
+ if (g.kind === "none")
582
+ return `the decision model found that ${id} is no thing of the graph (none, ${g.p.toFixed(2)})`;
583
+ if (g.kind === "new" && !g.class) {
584
+ if (g.noClass === undefined) return `${id} has no class: its class question got no answer`;
585
+ return g.noClass >= map.LACK_SUGGEST_AT
586
+ ? `${id} has no class: no class of the ontology fits it (${g.noClass.toFixed(2)}), and it waits for the class suggestion`
587
+ : `${id} has no class: no class fits it (${g.noClass.toFixed(2)}), below ${map.LACK_SUGGEST_AT}`;
588
+ }
589
+ if (g.kind === "existing" && !g.class)
590
+ return `${id} is an instance with no class of the ontology`;
591
+ return `${id} is grounded`;
592
+ }
593
+
594
+ /** The workflow, for a `WorkflowWorker`. `lbb` is `lbbActivities(client)`, or a mock in a test. */
595
+ export function createWorkflow(lbb: LbbActivities, settings: Settings = defaults) {
596
+ return triggeredWorkflow<MapState>({
597
+ name: workflowType.workflow_type,
598
+ version: workflowType.version,
599
+ initialState: {
600
+ runs: 0,
601
+ links: 0,
602
+ entries: 0,
603
+ proposals: 0,
604
+ suggestions: 0,
605
+ unmapped: 0,
606
+ unmappedLinks: [],
607
+ },
608
+ async onInstances(ctx, message, state) {
609
+ const proposedBy = `workflow/${message.workflow}`;
610
+ const steps = { used: 0 };
611
+ const onto = await ctx.step("ontology", async () => {
612
+ const read = map.MapOntology.fromView(await lbb.ontology());
613
+ return { classes: [...read.classes.values()], properties: read.properties };
614
+ });
615
+ steps.used += 1;
616
+ const ontology = new map.MapOntology(onto.classes, onto.properties);
617
+ const trace = await readTrace(lbb, message);
618
+ const ends = [...trace.ends.values()].sort((a, b) => (a.key < b.key ? -1 : 1));
619
+
620
+ // 1. Ground the ends: which instance is each entry?
621
+ const candidates = await findCandidates(lbb, message, ends, ontology);
622
+ const byDoc = <T>(list: T[], docOf: (item: T) => Doc) => {
623
+ const out = new Map<string, T[]>();
624
+ for (const item of list)
625
+ out.set(docOf(item).iri, [...(out.get(docOf(item).iri) ?? []), item]);
626
+ return [...out.values()];
627
+ };
628
+ const groundPlans: Plan[] = byDoc(ends, (e) => e.doc).map((list) => ({
629
+ label: `${list[0]!.doc.name.slice(0, 40)} entries`,
630
+ state: {
631
+ task: map.GROUND_TASK,
632
+ document: `${list[0]!.doc.name}, the document that defines the entries.`,
633
+ },
634
+ key: "entries",
635
+ members: list.map((e) => map.groundQuestion(qid(e.key), e.view, candidates.get(e.key)!)),
636
+ }));
637
+ const groundAnswers = await askStage(ctx, lbb, "ground", groundPlans, steps);
638
+ const grounded = new Map<string, Grounded>();
639
+ for (const e of ends) {
640
+ const list = candidates.get(e.key)!;
641
+ const answer = groundAnswers[`g${qid(e.key)}`];
642
+ const [choice, p, options] = answer ?? ["", 0, {}];
643
+ const picked = /^c\d+$/.test(choice) ? list[Number(choice.slice(1))] : undefined;
644
+ grounded.set(e.key, {
645
+ kind: !answer
646
+ ? "unanswered"
647
+ : choice === map.NEW_INSTANCE
648
+ ? "new"
649
+ : picked
650
+ ? "existing"
651
+ : choice === map.NO_INSTANCE
652
+ ? "none"
653
+ : "unanswered",
654
+ ...(picked ? { iri: picked.iri, ...(picked.class ? { class: picked.class } : {}) } : {}),
655
+ ...(choice === map.NEW_INSTANCE ? { iri: e.mint } : {}),
656
+ p,
657
+ options,
658
+ candidates: list,
659
+ });
660
+ }
661
+
662
+ // The class of each new instance.
663
+ const keys = ontology.classKeys();
664
+ const fresh = ends.filter((e) => grounded.get(e.key)!.kind === "new");
665
+ const classPlans: Plan[] = ontology.classes.size
666
+ ? byDoc(fresh, (e) => e.doc).map((list) => ({
667
+ label: `${list[0]!.doc.name.slice(0, 40)} classes`,
668
+ state: {
669
+ task: map.CLASS_TASK,
670
+ document: `${list[0]!.doc.name}, the document that defines the entries.`,
671
+ },
672
+ key: "entries",
673
+ members: list.map((e) => {
674
+ const g = grounded.get(e.key)!;
675
+ const options = map.classOptions(
676
+ ontology,
677
+ g.candidates.flatMap((c) => c.class ?? []),
678
+ `${e.view.identifier} ${e.view.description} ${e.view.context}`,
679
+ );
680
+ return map.classQuestion(qid(e.key), e.view, options, ontology, keys);
681
+ }),
682
+ }))
683
+ : [];
684
+ const classAnswers = await askStage(ctx, lbb, "class", classPlans, steps);
685
+ const classOfKey = new Map([...keys].map(([iri, key]) => [key, iri]));
686
+ for (const e of fresh) {
687
+ const g = grounded.get(e.key)!;
688
+ const answer = classAnswers[`k${qid(e.key)}`];
689
+ if (!answer) continue;
690
+ g.classOptions = answer[2];
691
+ if (answer[0] === map.NO_CLASS) g.noClass = answer[1];
692
+ else if (classOfKey.has(answer[0])) {
693
+ g.class = classOfKey.get(answer[0])!;
694
+ g.classP = answer[1];
695
+ }
696
+ }
697
+ const instanceOf = (key: string): map.MapInstance | undefined => {
698
+ const [e, g] = [trace.ends.get(key)!, grounded.get(key)!];
699
+ if (g.kind === "existing") {
700
+ const c = g.candidates.find((c) => c.iri === g.iri);
701
+ return {
702
+ iri: g.iri!,
703
+ ...(g.class ? { class: g.class } : {}),
704
+ classLabel: g.class ? ontology.classLabel(g.class) : "thing",
705
+ identifier: e.view.identifier,
706
+ label: c?.label ?? e.view.identifier,
707
+ description: e.view.description,
708
+ isNew: false,
709
+ };
710
+ }
711
+ if (g.kind !== "new") return undefined;
712
+ return {
713
+ iri: g.iri!,
714
+ ...(g.class ? { class: g.class } : {}),
715
+ classLabel: g.class ? ontology.classLabel(g.class) : "thing",
716
+ identifier: e.view.identifier,
717
+ label: map.newInstanceLabel(e.view.identifier, e.view.description),
718
+ description: e.view.description,
719
+ isNew: true,
720
+ };
721
+ };
722
+
723
+ // 2. Type the links whose two ends are instances with a class.
724
+ const rows = pageReader(lbb, trace);
725
+ const typed: {
726
+ unit: Unit;
727
+ subject: map.MapInstance;
728
+ object: map.MapInstance;
729
+ options: map.PropertyOption[];
730
+ declared?: string;
731
+ id: string;
732
+ }[] = [];
733
+ // The links that make no proposal and back no filed suggestion stay in the state.
734
+ const unmapped: Record<string, unknown>[] = trace.noSubject.map(({ mention, link }) => ({
735
+ ...mentionSample(
736
+ trace.docs.get(mention.doc)!,
737
+ mention,
738
+ "",
739
+ trace.ends.get(link.entry)?.view.identifier ?? "",
740
+ ),
741
+ outcome: "no_subject",
742
+ reason: "no entry of its page or line is the subject of the link",
743
+ }));
744
+ for (const unit of trace.units) {
745
+ const [subject, object] = [instanceOf(unit.subject), instanceOf(unit.link.entry)];
746
+ const doc = trace.docs.get(unit.mention.doc)!;
747
+ const [s, o] = [trace.ends.get(unit.subject)!, trace.ends.get(unit.link.entry)!];
748
+ if (!subject || !object || !subject.class || !object.class || subject.iri === object.iri) {
749
+ const why = (end: End) => endState(end.view.identifier, grounded.get(end.key));
750
+ const [outcome, reason] =
751
+ !subject || !object
752
+ ? ["ungrounded", why(subject ? o : s)]
753
+ : subject.iri === object.iri
754
+ ? ["same_instance", `its two ends are one instance, ${map.short(subject)}`]
755
+ : ["unclassed", why(subject.class ? o : s)];
756
+ unmapped.push({
757
+ ...mentionSample(doc, unit.mention, s.view.identifier, o.view.identifier),
758
+ outcome,
759
+ reason,
760
+ });
761
+ continue;
762
+ }
763
+ const id = qid(`${unit.mention.iri}\u0000${unit.link.relation}\u0000${unit.link.entry}`);
764
+ const [up, down] = [ontology.ancestors(subject.class), ontology.ancestors(object.class)];
765
+ const declared = settings.mappings.find(
766
+ (m) =>
767
+ (!m.from.document || m.from.document.normalize() === doc.name.normalize()) &&
768
+ up.has(classNamed(ontology, m.from.entry_class) ?? "") &&
769
+ down.has(classNamed(ontology, m.to.entry_class) ?? ""),
770
+ );
771
+ const property =
772
+ declared &&
773
+ ontology.properties.find(
774
+ (p) =>
775
+ p.iri === declared.property ||
776
+ p.name.toLowerCase() === declared.property.toLowerCase(),
777
+ );
778
+ if (property) {
779
+ typed.push({ unit, subject, object, options: [], declared: property.iri, id });
780
+ continue;
781
+ }
782
+ const options = ontology.propertyOptions(subject.class, object.class, unit.mention.text);
783
+ typed.push({ unit, subject, object, options, id });
784
+ }
785
+ const typePlans: Plan[] = [];
786
+ const asked = typed.filter((t) => !t.declared);
787
+ for (const docIri of [...new Set(asked.map((t) => t.unit.mention.doc))].sort()) {
788
+ const doc = trace.docs.get(docIri)!;
789
+ const onDoc = asked.filter((t) => t.unit.mention.doc === docIri);
790
+ for (const page of [...new Set(onDoc.map((t) => t.unit.mention.page))].sort(
791
+ (a, b) => a - b,
792
+ )) {
793
+ const list = onDoc.filter((t) => t.unit.mention.page === page);
794
+ const listing = await rows(doc, page);
795
+ const classes = new Set(
796
+ list.flatMap((t) => [
797
+ ...ontology.ancestors(t.subject.class!),
798
+ ...ontology.ancestors(t.object.class!),
799
+ ]),
800
+ );
801
+ const properties = new Set(list.flatMap((t) => t.options.map((o) => o.property)));
802
+ typePlans.push({
803
+ label: `${doc.name.slice(0, 40)} p${page + 1} links`,
804
+ state: {
805
+ task: map.TYPING_TASK,
806
+ ontology: ontology.stateSubset(classes, properties),
807
+ document: `${doc.name}. One page. Each row holds the lines that share a baseline, left to right, as '[line id] text'.`,
808
+ page: page + 1,
809
+ rows: listing.rows,
810
+ },
811
+ key: "instances",
812
+ members: list.map((t) =>
813
+ map.typingQuestion(
814
+ t.id,
815
+ {
816
+ document: doc.name,
817
+ page: page + 1,
818
+ lineId: t.unit.mention.line,
819
+ lineText: t.unit.mention.text,
820
+ rowText: listing.rowOf(t.unit.mention.line) || t.unit.mention.text,
821
+ span: t.unit.mention.span,
822
+ subject: t.subject,
823
+ object: t.object,
824
+ },
825
+ t.options,
826
+ ),
827
+ ),
828
+ });
829
+ }
830
+ }
831
+ const typeAnswers = await askStage(ctx, lbb, "type", typePlans, steps);
832
+
833
+ // 3. The proposals and the patterns the ontology lacks.
834
+ const proposals = new Map<string, map.Proposal>();
835
+ const lacks: map.Lack[] = [];
836
+ const classed: map.ClassedEntry[] = [];
837
+ let links = 0;
838
+ for (const t of typed) {
839
+ const { unit, subject, object } = t;
840
+ const answer: map.Kept | undefined = t.declared
841
+ ? ["declared", 1, {}]
842
+ : typeAnswers[`t${t.id}`];
843
+ const doc = trace.docs.get(unit.mention.doc)!;
844
+ const sample = mentionSample(doc, unit.mention, map.short(subject), map.short(object));
845
+ if (!answer) {
846
+ unmapped.push({
847
+ ...sample,
848
+ outcome: "unanswered",
849
+ reason: "its typing question got no answer",
850
+ });
851
+ continue;
852
+ }
853
+ const [choice, p, options] = answer;
854
+ const option = t.options.find((o) => o.key === choice);
855
+ const property = t.declared ?? option?.property;
856
+ const answered = {
857
+ ...sample,
858
+ choice,
859
+ answer: map.answerWords(choice, property),
860
+ probability: p,
861
+ options,
862
+ };
863
+ const evidence = {
864
+ mention: unit.mention.iri,
865
+ document: doc.name,
866
+ document_iri: doc.iri,
867
+ page: unit.mention.page + 1,
868
+ line_id: unit.mention.line,
869
+ span: unit.mention.span,
870
+ line: unit.mention.text,
871
+ box: unit.mention.box,
872
+ link: {
873
+ relation: unit.link.relation,
874
+ confidence: unit.link.confidence,
875
+ subject_entry: trace.ends.get(unit.subject)!.view.identifier,
876
+ object_entry: trace.ends.get(unit.link.entry)!.view.identifier,
877
+ object_document: trace.ends.get(unit.link.entry)!.doc.name,
878
+ },
879
+ subject: map.short(subject),
880
+ object: map.short(object),
881
+ choice,
882
+ p,
883
+ options,
884
+ };
885
+ const confidence = unit.link.confidence * p;
886
+ if (property) {
887
+ links += 1;
888
+ const reverse = option?.reverse ?? false;
889
+ map.addProposal(proposals, proposedBy, {
890
+ statement: reverse
891
+ ? { subject: object.iri, predicate: property, object: subject.iri }
892
+ : { subject: subject.iri, predicate: property, object: object.iri },
893
+ kind: "fact",
894
+ name: `${map.short(subject)} · ${map.humanize(map.localName(property))} · ${map.short(object)}`,
895
+ confidence,
896
+ evidence,
897
+ });
898
+ } else if (choice === map.SAME_ITEM) {
899
+ links += 1;
900
+ map.addProposal(proposals, proposedBy, {
901
+ statement: { subject: subject.iri, predicate: map.OWL_SAME_AS, object: object.iri },
902
+ kind: "same_item",
903
+ name: `${map.short(subject)} · same as · ${map.short(object)}`,
904
+ confidence,
905
+ evidence,
906
+ });
907
+ } else if (choice === map.MISSING_PROPERTY && p >= map.LACK_SUGGEST_AT) {
908
+ // The words of the two ends and of the object's document name the ends, not the relation.
909
+ const skip = new Set(
910
+ [trace.ends.get(unit.link.entry)!.doc.name, subject, object].flatMap((end) =>
911
+ typeof end === "string"
912
+ ? [...map.nameWords(end)]
913
+ : [end.identifier ?? "", end.label, end.description ?? ""].flatMap((text) => [
914
+ ...map.nameWords(text),
915
+ ]),
916
+ ),
917
+ );
918
+ lacks.push({
919
+ kind: "property",
920
+ subjectClass: subject.class!,
921
+ objectClass: object.class!,
922
+ document: doc.name,
923
+ sample: answered,
924
+ p,
925
+ options,
926
+ offered: Object.fromEntries(t.options.map((o) => [o.key, o.property])),
927
+ phrase: map.spanPhrase(unit.mention.text, unit.mention.span, skip),
928
+ quote: `p. ${unit.mention.page + 1}, "${map.clip(unit.mention.text, 120)}" (${map.short(subject)} → ${map.short(object)}, ${p.toFixed(2)})`,
929
+ });
930
+ } else
931
+ unmapped.push({
932
+ ...answered,
933
+ outcome: choice === map.MISSING_PROPERTY ? "lacks" : "no_fact",
934
+ reason:
935
+ choice === map.MISSING_PROPERTY
936
+ ? `the decision model chose a property the ontology lacks at ${p.toFixed(2)}, below ${map.LACK_SUGGEST_AT}: no suggestion`
937
+ : "the decision model found that the line states no fact about the two",
938
+ });
939
+ }
940
+ // The groundings' own proposals: DENOTES, and a new instance's type, label and identifier.
941
+ for (const e of ends) {
942
+ const g = grounded.get(e.key)!;
943
+ const evidence = {
944
+ entry: e.key,
945
+ document: e.doc.name,
946
+ page: e.view.page,
947
+ line_id: e.line,
948
+ identifier: e.view.identifier,
949
+ description: map.clip(e.view.description, 200),
950
+ box: e.box,
951
+ p: g.p,
952
+ options: g.options,
953
+ candidates: Object.fromEntries(
954
+ g.candidates.map((c, n) => [`c${n}`, { iri: c.iri, label: c.label }]),
955
+ ),
956
+ ...(g.classOptions ? { class_p: g.classP ?? null, class_options: g.classOptions } : {}),
957
+ };
958
+ const id = e.view.identifier;
959
+ const denotes = map.relationIri(map.DENOTES);
960
+ if (g.kind === "existing")
961
+ map.addProposal(proposals, proposedBy, {
962
+ statement: { subject: e.key, predicate: denotes, object: g.iri! },
963
+ kind: "denotes",
964
+ name: `${id} · denotes · ${map.localName(g.iri!)}`,
965
+ confidence: g.p,
966
+ evidence,
967
+ });
968
+ else if (g.kind === "new" && g.class) {
969
+ const label = map.newInstanceLabel(id, e.view.description);
970
+ const literal = { datatype: map.XSD_STRING };
971
+ classed.push({
972
+ document: e.doc.name,
973
+ shape: map.codeShape(id),
974
+ name: label,
975
+ classLabel: ontology.classLabel(g.class),
976
+ p: g.classP ?? 0,
977
+ });
978
+ for (const proposal of [
979
+ {
980
+ statement: { subject: g.iri!, predicate: map.RDF_TYPE, object: g.class },
981
+ name: `${id} · a · ${ontology.classLabel(g.class)}`,
982
+ confidence: g.p * (g.classP ?? 0),
983
+ kind: "instance",
984
+ },
985
+ {
986
+ statement: { subject: g.iri!, predicate: map.RDFS_LABEL, object: label, ...literal },
987
+ name: `${id} · label · ${label}`,
988
+ confidence: g.p,
989
+ kind: "instance",
990
+ },
991
+ {
992
+ statement: {
993
+ subject: g.iri!,
994
+ predicate: map.propertyIri("identifier"),
995
+ object: id,
996
+ ...literal,
997
+ },
998
+ name: `${id} · identifier · ${id}`,
999
+ confidence: g.p,
1000
+ kind: "instance",
1001
+ },
1002
+ {
1003
+ statement: { subject: e.key, predicate: denotes, object: g.iri! },
1004
+ name: `${id} · denotes · ${id}`,
1005
+ confidence: g.p,
1006
+ kind: "denotes",
1007
+ },
1008
+ ])
1009
+ map.addProposal(proposals, proposedBy, { ...proposal, evidence });
1010
+ } else if (g.kind === "new" && (g.noClass ?? 0) >= map.LACK_SUGGEST_AT) {
1011
+ // No class fits: the entry proposes nothing (no type, label, identifier or trace) until
1012
+ // a class exists; it backs a class suggestion.
1013
+ const name = map.newInstanceLabel(id, e.view.description);
1014
+ lacks.push({
1015
+ kind: "class",
1016
+ document: e.doc.name,
1017
+ sample: {
1018
+ kind: "entry",
1019
+ document: e.doc.name,
1020
+ page: e.view.page,
1021
+ line_id: e.line,
1022
+ line: map.clip(e.view.context || `${id} ${e.view.description}`, 200),
1023
+ box: e.box,
1024
+ identifier: id,
1025
+ description: map.clip(e.view.description, 160),
1026
+ choice: map.NO_CLASS,
1027
+ answer: map.answerWords(map.NO_CLASS),
1028
+ probability: g.noClass!,
1029
+ options: g.classOptions ?? {},
1030
+ },
1031
+ p: g.noClass!,
1032
+ options: g.classOptions ?? {},
1033
+ quote: `${map.clip(name, 80)} (p. ${e.view.page}, ${g.noClass!.toFixed(2)})`,
1034
+ entry: { name, shape: map.codeShape(id) },
1035
+ });
1036
+ }
1037
+ }
1038
+
1039
+ // Ontology suggestions, one per pattern; then the writes, in parts of 900 proposals.
1040
+ const current = [...proposals.entries()].sort(([a], [b]) => (a < b ? -1 : 1));
1041
+ if (steps.used + 3 + Math.ceil(current.length / PART) > 64)
1042
+ throw new WorkflowError(
1043
+ `${current.length} proposals need more steps than one turn of the template holds. Use the LBB-run documents.map.`,
1044
+ );
1045
+ const filed = await ctx.step("lacks", (s) =>
1046
+ suggest(lbb, message.workflow, ontology, lacks, classed, (done, total) =>
1047
+ s.progress("filing review items", { done, total, unit: "items" }),
1048
+ ),
1049
+ );
1050
+ const left = [...unmapped, ...filed.unmapped].sort(
1051
+ (a, b) =>
1052
+ String(a.document).localeCompare(String(b.document)) ||
1053
+ Number(a.page) - Number(b.page) ||
1054
+ String(a.line_id).localeCompare(String(b.line_id)),
1055
+ );
1056
+ // The proposals: write what changed, retract what this workflow no longer proposes.
1057
+ const existing = proposalReader(lbb, proposedBy);
1058
+ const stale = await ctx.step("stale", async () => {
1059
+ const have = await existing();
1060
+ return [...have.keys()].filter((key) => !proposals.has(key)).length;
1061
+ });
1062
+ for (let n = 0; n * PART < current.length; n++)
1063
+ await lbb.writeOwned(ctx, `write-${n}`, async (s) => {
1064
+ s.progress("writing proposals", {
1065
+ done: n * PART,
1066
+ total: current.length,
1067
+ unit: "proposals",
1068
+ });
1069
+ const have = await existing();
1070
+ const changed = current
1071
+ .slice(n * PART, (n + 1) * PART)
1072
+ .filter(([key, p]) => have.get(key)?.content !== content(p))
1073
+ .map(([key, p]) => map.proposalRow(key, p, proposedBy));
1074
+ return {
1075
+ subjects: changed.map(({ type, name, key }) => ({ type, name, key })),
1076
+ properties: Object.keys(map.PROPOSAL_FIELDS),
1077
+ entity_properties: changed,
1078
+ };
1079
+ });
1080
+ for (let n = 0; n * PART < stale; n++)
1081
+ await lbb.writeOwned(ctx, `retract-${n}`, async (s) => {
1082
+ s.progress("writing proposals", { done: n * PART, total: stale, unit: "proposals" });
1083
+ const have = await existing();
1084
+ const gone = [...have.entries()].filter(([key]) => !proposals.has(key)).slice(0, PART);
1085
+ return {
1086
+ subjects: [],
1087
+ retract_entities: gone.map(([, p]) => entityRef(p.iri, map.FACT_PROPOSAL)),
1088
+ };
1089
+ });
1090
+ return {
1091
+ state: {
1092
+ runs: state.runs + 1,
1093
+ links,
1094
+ entries: [...grounded.values()].filter((g) => g.kind === "existing" || g.kind === "new")
1095
+ .length,
1096
+ proposals: proposals.size,
1097
+ suggestions: filed.suggestions,
1098
+ unmapped: left.length,
1099
+ unmappedLinks: left.slice(0, UNMAPPED_MAX),
1100
+ },
1101
+ };
1102
+ },
1103
+ });
1104
+ }
1105
+
1106
+ /** The class an ontology name or IRI names. */
1107
+ function classNamed(ontology: map.MapOntology, name: string): string | undefined {
1108
+ if (ontology.classes.has(name)) return name;
1109
+ return [...ontology.classes.values()].find(
1110
+ (c) => c.name.toLowerCase() === name.trim().toLowerCase(),
1111
+ )?.iri;
1112
+ }
1113
+
1114
+ /** What a proposal's fields hold, to tell a changed proposal from one the graph has already. */
1115
+ const content = (p: map.Proposal) => JSON.stringify([p.confidence, map.evidenceText(p)]);
1116
+
1117
+ /** This workflow's proposals in the graph, by key, read with the commits not yet published. */
1118
+ function proposalReader(lbb: LbbActivities, proposedBy: string) {
1119
+ return async () => {
1120
+ const { rows } = await lbb.sparql(
1121
+ `# documents-map: proposals\n${P}SELECT ?x ?s ?p ?o ?dt ?c ?ev WHERE {
1122
+ ?x a <${map.classIri(map.FACT_PROPOSAL)}> ; p:proposed_by ${JSON.stringify(proposedBy)} ;
1123
+ p:fact_subject ?s ; p:fact_predicate ?p ; p:fact_object ?o .
1124
+ OPTIONAL { ?x p:fact_datatype ?dt } OPTIONAL { ?x p:fact_confidence ?c } OPTIONAL { ?x p:fact_evidence ?ev }
1125
+ }`,
1126
+ { consistency: "strong" },
1127
+ );
1128
+ const out = new Map<string, { iri: string; content: string }>();
1129
+ for (const r of rows) {
1130
+ const statement = {
1131
+ subject: r.s!,
1132
+ predicate: r.p!,
1133
+ object: r.o!,
1134
+ ...(r.dt ? { datatype: r.dt } : {}),
1135
+ };
1136
+ out.set(map.proposalKey(statement, proposedBy), {
1137
+ iri: r.x!,
1138
+ content: JSON.stringify([num(r.c), r.ev ?? ""]),
1139
+ });
1140
+ }
1141
+ return out;
1142
+ };
1143
+ }
1144
+
1145
+ /**
1146
+ * File one ontology suggestion per pattern the ontology lacks (`map.relationDraft`,
1147
+ * `map.classDraft`). Returns how many it filed, and the lines no suggestion backs as unmapped
1148
+ * links.
1149
+ */
1150
+ async function suggest(
1151
+ lbb: LbbActivities,
1152
+ workflow: string,
1153
+ ontology: map.MapOntology,
1154
+ lacks: map.Lack[],
1155
+ classed: map.ClassedEntry[],
1156
+ report: (done: number, total: number) => void = () => {},
1157
+ ): Promise<{ suggestions: number; unmapped: Record<string, unknown>[] }> {
1158
+ const patterns = new Map<string, map.Lack[]>();
1159
+ for (const lack of lacks) {
1160
+ const key = [lack.kind, lack.subjectClass ?? "", lack.objectClass ?? "", lack.document].join(
1161
+ "\u0000",
1162
+ );
1163
+ patterns.set(key, [...(patterns.get(key) ?? []), lack]);
1164
+ }
1165
+ let suggestions = 0;
1166
+ const unmapped: Record<string, unknown>[] = [];
1167
+ for (const [n, [key, list]] of [...patterns].entries()) {
1168
+ report(n, patterns.size);
1169
+ const members = [...list].sort(
1170
+ (a, b) => b.p - a.p || (a.quote < b.quote ? -1 : a.quote > b.quote ? 1 : 0),
1171
+ );
1172
+ const first = members[0]!;
1173
+ let draft: map.SuggestionDraft;
1174
+ if (first.kind === "property") {
1175
+ const drafted = map.relationDraft(
1176
+ ontology,
1177
+ first.subjectClass!,
1178
+ first.objectClass!,
1179
+ first.document,
1180
+ members,
1181
+ );
1182
+ if ("unmapped" in drafted) {
1183
+ unmapped.push(...drafted.unmapped);
1184
+ continue;
1185
+ }
1186
+ draft = drafted.draft;
1187
+ } else draft = map.classDraft(ontology, first.document, members, classed);
1188
+ await lbb.review({
1189
+ workflow,
1190
+ key: `starter:${workflow}:lack:${map.hash(key).slice(0, 24)}`,
1191
+ title: draft.title.slice(0, 200),
1192
+ rationale: draft.rationale.slice(0, 4_000),
1193
+ change: draft.change,
1194
+ evidence: { records: draft.records, samples: draft.samples },
1195
+ });
1196
+ suggestions += 1;
1197
+ }
1198
+ return { suggestions, unmapped };
1199
+ }