@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.
- package/LICENSE +55 -0
- package/README.md +73 -2
- package/bin/create-littlebigbrain.mjs +9 -0
- package/lib/create.mjs +244 -0
- package/package.json +46 -4
- package/project/setup.ts +18 -0
- package/project/try.ts +30 -0
- package/project/worker.ts +23 -0
- package/templates/_shared/testing.ts +521 -0
- package/templates/documents-link/README.md +146 -0
- package/templates/documents-link/link.ts +580 -0
- package/templates/documents-link/samples/ausstattungsliste.pdf +76 -0
- package/templates/documents-link/samples/baubeschreibung.pdf +54 -0
- package/templates/documents-link/samples/expected.json +404 -0
- package/templates/documents-link/samples/pages/ausstattungsliste.json +1150 -0
- package/templates/documents-link/samples/pages/baubeschreibung.json +802 -0
- package/templates/documents-link/samples/pages/raumblaetter.json +1580 -0
- package/templates/documents-link/samples/raumblaetter.pdf +109 -0
- package/templates/documents-link/samples.ts +31 -0
- package/templates/documents-link/workflow.test.ts +497 -0
- package/templates/documents-link/workflow.ts +467 -0
- package/templates/documents-map/README.md +212 -0
- package/templates/documents-map/map.ts +1135 -0
- package/templates/documents-map/samples/ausstattungsliste.pdf +76 -0
- package/templates/documents-map/samples/baubeschreibung.pdf +54 -0
- package/templates/documents-map/samples/expected.json +50 -0
- package/templates/documents-map/samples/pages/raumblaetter.json +1580 -0
- package/templates/documents-map/samples/raumblaetter.pdf +109 -0
- package/templates/documents-map/samples/trace.json +613 -0
- package/templates/documents-map/samples.ts +182 -0
- package/templates/documents-map/workflow.test.ts +770 -0
- package/templates/documents-map/workflow.ts +1199 -0
- package/templates/ontology-fit/README.md +81 -0
- package/templates/ontology-fit/samples/expected.md +88 -0
- package/templates/ontology-fit/samples/texts.json +22 -0
- package/templates/ontology-fit/samples.ts +40 -0
- package/templates/ontology-fit/workflow.test.ts +270 -0
- package/templates/ontology-fit/workflow.ts +416 -0
- package/templates/review/README.md +158 -0
- package/templates/review/decision.ts +483 -0
- package/templates/review/samples/expected.json +37 -0
- package/templates/review/samples/proposals.json +35 -0
- package/templates/review/samples.ts +154 -0
- package/templates/review/workflow.test.ts +438 -0
- package/templates/review/workflow.ts +459 -0
- package/templates/values-extract/README.md +81 -0
- package/templates/values-extract/samples/expected.json +148 -0
- package/templates/values-extract/samples/records.json +26 -0
- package/templates/values-extract/samples.ts +27 -0
- package/templates/values-extract/workflow.test.ts +279 -0
- package/templates/values-extract/workflow.ts +419 -0
- 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.
|