@littlebigbrain/create 0.0.0-stage → 0.1.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 +71 -2
- package/bin/create-littlebigbrain.mjs +9 -0
- package/lib/create.mjs +238 -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 +490 -0
- package/templates/documents-link/README.md +144 -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 +470 -0
- package/templates/documents-link/workflow.ts +457 -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 +264 -0
- package/templates/ontology-fit/workflow.ts +413 -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 +256 -0
- package/templates/values-extract/workflow.ts +411 -0
- package/templates/versions.json +3 -0
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
// documents-link: find the identifiers the documents of a graph define and the spans that mention
|
|
2
|
+
// them, and link each mention to its entry.
|
|
3
|
+
//
|
|
4
|
+
// A triggered workflow of the starter workflow `workflow` watches the class `Document` and its
|
|
5
|
+
// field `parse_status` (LBB's parse writes it), and sends the parsed documents here. The workflow
|
|
6
|
+
// reads each document in windows of pages, one window per turn:
|
|
7
|
+
//
|
|
8
|
+
// 1. Define: the reasoning model (`reason`) lists the identifiers each page defines; they are
|
|
9
|
+
// written as `DocEntry` entities `DEFINED_IN` the document. Every defining window of a message
|
|
10
|
+
// runs before its first link window, so the catalogue holds them all.
|
|
11
|
+
// 2. Link: the reasoning model lists the spans of each page that may be codes. Each span gets
|
|
12
|
+
// candidates from the catalogue (every `DocEntry` of the graph) by characters and by meaning
|
|
13
|
+
// (`embed`), and the decision model (`decide`) answers what the span is and which entry it
|
|
14
|
+
// names. A mention is written as a `DocMention` `MENTIONED_IN` the document, with a
|
|
15
|
+
// `REFERS_TO` link to its entry when the link's confidence reaches the gate; a link below the
|
|
16
|
+
// gate becomes a review item.
|
|
17
|
+
//
|
|
18
|
+
// Each model call runs in a step and passes the step's effect id, so a turn that runs again after
|
|
19
|
+
// a crash pays for nothing twice.
|
|
20
|
+
import {
|
|
21
|
+
entityRef,
|
|
22
|
+
isActivityRefused,
|
|
23
|
+
LbbError,
|
|
24
|
+
splitInHalves,
|
|
25
|
+
triggeredWorkflow,
|
|
26
|
+
type DecideAnswer,
|
|
27
|
+
type LbbActivities,
|
|
28
|
+
type Schemas,
|
|
29
|
+
type WorkflowContext,
|
|
30
|
+
type WorkflowTriggerMessage,
|
|
31
|
+
} from "@littlebigbrain/client";
|
|
32
|
+
import * as link from "./link.js";
|
|
33
|
+
|
|
34
|
+
/** The part of a document: `defines` adds entries, `refers` has its mentions linked, `both` does both. */
|
|
35
|
+
export type Role = "defines" | "refers" | "both";
|
|
36
|
+
|
|
37
|
+
export interface Settings {
|
|
38
|
+
/** A link below this confidence goes to review instead of the graph. */
|
|
39
|
+
gate: number;
|
|
40
|
+
/** The role of each document by file name. A document the map does not name is `both`. */
|
|
41
|
+
roles: Record<string, Role>;
|
|
42
|
+
/** Pages of one window, 1 to 20: one turn reads one window. */
|
|
43
|
+
pagesPerWindow: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The settings: edit them for your documents. The roles are those of the samples. */
|
|
47
|
+
export const defaults: Settings = {
|
|
48
|
+
gate: 0.8,
|
|
49
|
+
roles: {
|
|
50
|
+
"ausstattungsliste.pdf": "defines",
|
|
51
|
+
"baubeschreibung.pdf": "defines",
|
|
52
|
+
"raumblaetter.pdf": "refers",
|
|
53
|
+
},
|
|
54
|
+
pagesPerWindow: 12,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** The workflow type and version the triggered workflow names. */
|
|
58
|
+
export const workflowType = { workflow_type: "documents-link", version: "v1" };
|
|
59
|
+
|
|
60
|
+
/** The triggered workflow that sends the documents here: `client.workflows.starters.use(trigger())`. */
|
|
61
|
+
export function trigger(): Schemas["TriggeredWorkflowRequest"] {
|
|
62
|
+
return {
|
|
63
|
+
starter: "workflow",
|
|
64
|
+
name: "documents-link",
|
|
65
|
+
watch: { class: "Document", fields: ["parse_status"] },
|
|
66
|
+
workflow: workflowType,
|
|
67
|
+
owns: {
|
|
68
|
+
classes: ["DocEntry", "DocMention"],
|
|
69
|
+
relations: ["DEFINED_IN", "MENTIONED_IN", "REFERS_TO"],
|
|
70
|
+
},
|
|
71
|
+
batch: 16,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const FIELD_TYPES: Record<string, string> = { context: "text", line_text: "text", page: "i64" };
|
|
76
|
+
|
|
77
|
+
/** The classes, properties and relations the workflow writes: `lbb.evolve(ontology())`. */
|
|
78
|
+
export function ontology(): Schemas["OntologyEvolveOp"][] {
|
|
79
|
+
const fields = [...new Set([...link.ENTRY_FIELDS, ...link.MENTION_FIELDS])];
|
|
80
|
+
return [
|
|
81
|
+
{ op: "add_entity_type", name: "Document" },
|
|
82
|
+
{ op: "add_entity_type", name: "DocEntry" },
|
|
83
|
+
{ op: "add_entity_type", name: "DocMention" },
|
|
84
|
+
...fields.map((name) => ({
|
|
85
|
+
op: "add_property" as const,
|
|
86
|
+
name,
|
|
87
|
+
value_type: FIELD_TYPES[name] ?? (/^[xy][01]$/.test(name) ? "f64" : "keyword"),
|
|
88
|
+
})),
|
|
89
|
+
{ op: "add_relation", name: "DEFINED_IN", domain: ["DocEntry"], range: ["Document"] },
|
|
90
|
+
{ op: "add_relation", name: "MENTIONED_IN", domain: ["DocMention"], range: ["Document"] },
|
|
91
|
+
{ op: "add_relation", name: "REFERS_TO", domain: ["DocMention"], range: ["DocEntry"] },
|
|
92
|
+
];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** What the workflow has written, over all its turns. */
|
|
96
|
+
export interface LinkState {
|
|
97
|
+
entries: number;
|
|
98
|
+
mentions: number;
|
|
99
|
+
links: number;
|
|
100
|
+
review: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A trigger message; a continued turn names the window it reads in `cursor`. */
|
|
104
|
+
type LinkMessage = WorkflowTriggerMessage & { cursor?: number };
|
|
105
|
+
interface Doc extends link.Doc {
|
|
106
|
+
pages: number;
|
|
107
|
+
parsed: boolean;
|
|
108
|
+
}
|
|
109
|
+
interface Window {
|
|
110
|
+
stage: "define" | "link";
|
|
111
|
+
doc: number;
|
|
112
|
+
from: number;
|
|
113
|
+
to: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const P = "PREFIX p: <https://littlebigbrain.com/p/>\nPREFIX r: <https://littlebigbrain.com/r/>\n";
|
|
117
|
+
const ENTRY_CLASS = "https://littlebigbrain.com/class/docentry";
|
|
118
|
+
const MENTION_CLASS = "https://littlebigbrain.com/class/docmention";
|
|
119
|
+
const values = (iris: string[]) =>
|
|
120
|
+
iris
|
|
121
|
+
.filter((iri) => /^[^\s<>"{}|\\^`]+$/.test(iri))
|
|
122
|
+
.map((iri) => `<${iri}>`)
|
|
123
|
+
.join(" ");
|
|
124
|
+
const label = (doc: Doc, page: number, what: string) =>
|
|
125
|
+
`${doc.name.slice(0, 40)} p${page + 1} ${what}`;
|
|
126
|
+
/** The effect id of the `n`th call of one kind in a step: the step's own, then `.1`, `.2`, … */
|
|
127
|
+
const effect = (id: string, n: number) => (n === 0 ? id : `${id}.${n}`);
|
|
128
|
+
|
|
129
|
+
/** The workflow, for a `WorkflowWorker`. `lbb` is `lbbActivities(client)`, or a mock in a test. */
|
|
130
|
+
export function createWorkflow(lbb: LbbActivities, settings: Settings = defaults) {
|
|
131
|
+
if (!(settings.pagesPerWindow >= 1 && settings.pagesPerWindow <= 20))
|
|
132
|
+
throw new RangeError("pagesPerWindow is from 1 to 20: a turn holds 64 steps");
|
|
133
|
+
return triggeredWorkflow<LinkState>({
|
|
134
|
+
name: workflowType.workflow_type,
|
|
135
|
+
version: workflowType.version,
|
|
136
|
+
initialState: { entries: 0, mentions: 0, links: 0, review: 0 },
|
|
137
|
+
async onInstances(ctx, message: LinkMessage, state) {
|
|
138
|
+
const docs = await ctx.step("documents", () => readDocuments(lbb, message));
|
|
139
|
+
if (message.cursor === undefined) {
|
|
140
|
+
const gone = [...message.removed, ...docs.filter((d) => !d.parsed).map((d) => d.iri)];
|
|
141
|
+
if (gone.length) await dropOutputs(ctx, lbb, gone);
|
|
142
|
+
}
|
|
143
|
+
const plan = windows(docs, settings);
|
|
144
|
+
const at = message.cursor ?? 0;
|
|
145
|
+
const w = plan[at];
|
|
146
|
+
if (!w) return { state };
|
|
147
|
+
const doc = docs[w.doc]!;
|
|
148
|
+
const next =
|
|
149
|
+
w.stage === "define"
|
|
150
|
+
? await define(ctx, lbb, doc, w, state)
|
|
151
|
+
: await linkMentions(ctx, lbb, doc, w, state, settings, message.workflow);
|
|
152
|
+
if (at + 1 === plan.length) return { state: next };
|
|
153
|
+
return ctx.continue(next, { ...message, removed: [], cursor: at + 1 });
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The windows of a message: every defining window first, so the catalogue is complete before a link. */
|
|
159
|
+
function windows(docs: Doc[], settings: Settings): Window[] {
|
|
160
|
+
const roleOf = (doc: Doc) =>
|
|
161
|
+
Object.entries(settings.roles).find(([name]) => name.normalize() === doc.name.normalize())?.[1];
|
|
162
|
+
const of = (stage: Window["stage"], skip: Role) =>
|
|
163
|
+
docs.flatMap((doc, i) => {
|
|
164
|
+
if (!doc.parsed || roleOf(doc) === skip) return [];
|
|
165
|
+
const out: Window[] = [];
|
|
166
|
+
for (let from = 0; from < doc.pages; from += settings.pagesPerWindow)
|
|
167
|
+
out.push({ stage, doc: i, from, to: Math.min(doc.pages, from + settings.pagesPerWindow) });
|
|
168
|
+
return out;
|
|
169
|
+
});
|
|
170
|
+
return [...of("define", "refers"), ...of("link", "defines")];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The name, file and parse of each document, at the message's commit. */
|
|
174
|
+
async function readDocuments(lbb: LbbActivities, message: WorkflowTriggerMessage): Promise<Doc[]> {
|
|
175
|
+
if (!message.instances.length) return [];
|
|
176
|
+
const query = `# documents-link: documents\n${P}SELECT ?doc ?name ?sha ?status ?pages WHERE {
|
|
177
|
+
VALUES ?doc { ${values(message.instances)} }
|
|
178
|
+
OPTIONAL { ?doc p:name ?name } OPTIONAL { ?doc p:file_sha256 ?sha }
|
|
179
|
+
OPTIONAL { ?doc p:parse_status ?status } OPTIONAL { ?doc p:page_count ?pages }
|
|
180
|
+
}`;
|
|
181
|
+
const { rows } = await lbb.sparql({ query, as_of_commit_seq: message.as_of_commit_seq });
|
|
182
|
+
return message.instances.map((iri) => {
|
|
183
|
+
const row = rows.find((r) => r.doc === iri) ?? {};
|
|
184
|
+
const parsed = row.status === "parsed" && Boolean(row.sha);
|
|
185
|
+
return {
|
|
186
|
+
iri,
|
|
187
|
+
name: row.name ?? iri,
|
|
188
|
+
sha256: row.sha ?? "",
|
|
189
|
+
pages: Number(row.pages ?? 0),
|
|
190
|
+
parsed,
|
|
191
|
+
};
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Retract the entries and mentions of documents that left the class or lost their parse. */
|
|
196
|
+
async function dropOutputs(ctx: WorkflowContext, lbb: LbbActivities, gone: string[]) {
|
|
197
|
+
const found = await ctx.step("drop-find", async () => {
|
|
198
|
+
// Deleting a file retracts the edges to its Document: its entries and mentions are left without one.
|
|
199
|
+
const query = `# documents-link: outputs\n${P}SELECT ?entity ?class WHERE {
|
|
200
|
+
{ VALUES ?doc { ${values(gone)} } ?entity r:defined_in|r:mentioned_in ?doc }
|
|
201
|
+
UNION { ?entity a <${ENTRY_CLASS}> FILTER NOT EXISTS { ?entity r:defined_in ?any } }
|
|
202
|
+
UNION { ?entity a <${MENTION_CLASS}> FILTER NOT EXISTS { ?entity r:mentioned_in ?any } }
|
|
203
|
+
?entity a ?class .
|
|
204
|
+
}`;
|
|
205
|
+
const { rows } = await lbb.sparql(query, { consistency: "strong" });
|
|
206
|
+
return rows
|
|
207
|
+
.filter((r) => r.class === ENTRY_CLASS || r.class === MENTION_CLASS)
|
|
208
|
+
.map((r) => ({ iri: r.entity!, type: r.class === ENTRY_CLASS ? "DocEntry" : "DocMention" }));
|
|
209
|
+
});
|
|
210
|
+
const retract = found.map((e) => entityRef(e.iri, e.type));
|
|
211
|
+
if (retract.length)
|
|
212
|
+
await lbb.writeOwned(ctx, "drop", { subjects: [], retract_entities: retract });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The pages of the window and the catalogue, read inside the steps that need them, once per attempt. */
|
|
216
|
+
function reader(lbb: LbbActivities, doc: Doc, w: Window) {
|
|
217
|
+
let pages: Promise<link.FilePage[]> | undefined;
|
|
218
|
+
let catalogue: Promise<link.Entry[]> | undefined;
|
|
219
|
+
const query = `# documents-link: catalogue\n${P}SELECT ?identifier ?context ?line ?doc ?name ?sha WHERE {
|
|
220
|
+
?entry a <${ENTRY_CLASS}> ; p:identifier ?identifier ; r:defined_in ?doc .
|
|
221
|
+
?doc p:name ?name ; p:file_sha256 ?sha .
|
|
222
|
+
OPTIONAL { ?entry p:context ?context } OPTIONAL { ?entry p:line_id ?line }
|
|
223
|
+
}`;
|
|
224
|
+
const read = {
|
|
225
|
+
pages: () => (pages ??= lbb.pages(doc.sha256, { from: w.from, to: w.to }).then((r) => r.pages)),
|
|
226
|
+
page: async (page: number) => (await read.pages())[page - w.from]!,
|
|
227
|
+
/** Every entry of the graph, this workflow's own writes included, in a stable order. */
|
|
228
|
+
catalogue: () =>
|
|
229
|
+
(catalogue ??= lbb.sparql(query, { consistency: "strong" }).then(({ rows }) =>
|
|
230
|
+
rows
|
|
231
|
+
.map((r) => ({
|
|
232
|
+
identifier: r.identifier!,
|
|
233
|
+
context: r.context ?? r.identifier!,
|
|
234
|
+
line: r.line ?? "",
|
|
235
|
+
document: r.doc!,
|
|
236
|
+
documentName: r.name!,
|
|
237
|
+
sha256: r.sha!,
|
|
238
|
+
}))
|
|
239
|
+
.sort((a, b) => link.order(a).localeCompare(link.order(b))),
|
|
240
|
+
)),
|
|
241
|
+
};
|
|
242
|
+
return read;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** A define window: each page's definitions, then the window's entries, owned per entry. */
|
|
246
|
+
async function define(
|
|
247
|
+
ctx: WorkflowContext,
|
|
248
|
+
lbb: LbbActivities,
|
|
249
|
+
doc: Doc,
|
|
250
|
+
w: Window,
|
|
251
|
+
state: LinkState,
|
|
252
|
+
): Promise<LinkState> {
|
|
253
|
+
const read = reader(lbb, doc, w);
|
|
254
|
+
const found: link.Definition[] = [];
|
|
255
|
+
for (let page = w.from; page < w.to; page++) {
|
|
256
|
+
const kept = await ctx.step(`define-p${page + 1}`, async (s) => {
|
|
257
|
+
const p = await read.page(page);
|
|
258
|
+
const answer = await lbb.reason(link.definitionsRequest(p), {
|
|
259
|
+
effectId: s.effectId,
|
|
260
|
+
label: label(doc, page, "definitions"),
|
|
261
|
+
});
|
|
262
|
+
return link.checkDefinitions(answer.value, p);
|
|
263
|
+
});
|
|
264
|
+
found.push(...kept);
|
|
265
|
+
}
|
|
266
|
+
// A commit names at most 1,000 entities: the entries go in parts of at most 900.
|
|
267
|
+
const count = new Set(found.map((d) => d.identifier)).size;
|
|
268
|
+
for (let n = 0; n * link.PART < count; n++)
|
|
269
|
+
await lbb.writeOwned(ctx, `entries-${n}`, async () =>
|
|
270
|
+
link.part(link.entriesWrite(doc, await read.pages(), found), n),
|
|
271
|
+
);
|
|
272
|
+
return { ...state, entries: state.entries + count };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** A link window: each page's mentions, their candidates and answers, the gate, the writes. */
|
|
276
|
+
async function linkMentions(
|
|
277
|
+
ctx: WorkflowContext,
|
|
278
|
+
lbb: LbbActivities,
|
|
279
|
+
doc: Doc,
|
|
280
|
+
w: Window,
|
|
281
|
+
state: LinkState,
|
|
282
|
+
settings: Settings,
|
|
283
|
+
workflow: string,
|
|
284
|
+
): Promise<LinkState> {
|
|
285
|
+
const read = reader(lbb, doc, w);
|
|
286
|
+
const pages = Array.from({ length: w.to - w.from }, (_, n) => w.from + n);
|
|
287
|
+
const mentions: link.Mention[][] = [];
|
|
288
|
+
for (const page of pages) {
|
|
289
|
+
const kept = await ctx.step(`mention-p${page + 1}`, async (s) => {
|
|
290
|
+
const p = await read.page(page);
|
|
291
|
+
const answer = await lbb.reason(link.mentionsRequest(p), {
|
|
292
|
+
effectId: s.effectId,
|
|
293
|
+
label: label(doc, page, "mentions"),
|
|
294
|
+
});
|
|
295
|
+
return link.checkMentions(answer.value, p);
|
|
296
|
+
});
|
|
297
|
+
mentions.push(kept);
|
|
298
|
+
}
|
|
299
|
+
const byMeaning = await ctx.step("meaning", (s) => meaning(s.effectId, lbb, doc, read, mentions));
|
|
300
|
+
const resolved: link.Resolved[][] = [];
|
|
301
|
+
for (const [n, page] of pages.entries()) {
|
|
302
|
+
const answers = await ctx.step(`decide-p${page + 1}`, (s) =>
|
|
303
|
+
decidePage(s.effectId, lbb, doc, read, page, mentions[n]!, byMeaning[n]!),
|
|
304
|
+
);
|
|
305
|
+
resolved.push(answers);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// The link rule, then the gate: written, or a review item, or no link.
|
|
309
|
+
const all = mentions.flatMap((list, n) =>
|
|
310
|
+
list.map((m, k) => ({
|
|
311
|
+
m,
|
|
312
|
+
page: pages[n]!,
|
|
313
|
+
...link.linkRule(resolved[n]![k]!),
|
|
314
|
+
r: resolved[n]![k]!,
|
|
315
|
+
})),
|
|
316
|
+
);
|
|
317
|
+
const written = all.filter((x) => x.target !== null && x.confidence >= settings.gate);
|
|
318
|
+
const below = all.filter((x) => x.target !== null && x.confidence < settings.gate);
|
|
319
|
+
await ctx.step("review", async () => {
|
|
320
|
+
const catalogue = await read.catalogue();
|
|
321
|
+
// One item per span and entry: S1.1 on two pages is one item with two links.
|
|
322
|
+
const groups = new Map<string, typeof below>();
|
|
323
|
+
for (const x of below) {
|
|
324
|
+
const key = `${link.identifierKey(x.m.span)}\u0000${x.target}`;
|
|
325
|
+
groups.set(key, [...(groups.get(key) ?? []), x]);
|
|
326
|
+
}
|
|
327
|
+
// An item holds at most 100 links, so its facts stay within the 64 KiB a suggestion takes.
|
|
328
|
+
const items = [...groups.values()].flatMap((group) =>
|
|
329
|
+
Array.from({ length: Math.ceil(group.length / 100) }, (_, n) => ({
|
|
330
|
+
n,
|
|
331
|
+
group: group.slice(n * 100, (n + 1) * 100),
|
|
332
|
+
})),
|
|
333
|
+
);
|
|
334
|
+
for (const { n, group } of items) {
|
|
335
|
+
const entry = catalogue[group[0]!.target!]!;
|
|
336
|
+
const span = group[0]!.m.span;
|
|
337
|
+
const lowest = Math.min(...group.map((x) => x.confidence));
|
|
338
|
+
const pages = await read.pages();
|
|
339
|
+
const triplets = group.map((x) => link.refersTo(doc, pages, x.m, entry, x.confidence));
|
|
340
|
+
await lbb.review({
|
|
341
|
+
workflow,
|
|
342
|
+
key: `starter:${workflow}:link:${doc.sha256.slice(0, 16)}:${link.keyPart(span)}:${entry.sha256.slice(0, 16)}:${link.keyPart(entry.identifier)}:${n}`,
|
|
343
|
+
title: `Link "${span}" in ${doc.name} to ${entry.identifier}`.slice(0, 200),
|
|
344
|
+
rationale: `${group.length} mentions of "${span}" in ${doc.name} name the entry ${entry.identifier} with a confidence below the gate ${settings.gate} (lowest ${lowest}).`,
|
|
345
|
+
facts: {
|
|
346
|
+
triplets,
|
|
347
|
+
summary: `${triplets.length} REFERS_TO links from "${span}" to ${entry.identifier}`,
|
|
348
|
+
},
|
|
349
|
+
evidence: {
|
|
350
|
+
records: group.length,
|
|
351
|
+
samples: group
|
|
352
|
+
.slice(0, 20)
|
|
353
|
+
.map((x) => ({ page: x.page, line_id: x.m.line, confidence: x.confidence })),
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
return items.length;
|
|
358
|
+
});
|
|
359
|
+
for (let n = 0; n * link.PART < all.length; n++)
|
|
360
|
+
await lbb.writeOwned(ctx, `mentions-${n}`, async () => {
|
|
361
|
+
const [pages, catalogue] = [await read.pages(), await read.catalogue()];
|
|
362
|
+
const links = written.map((x) => ({ ...x, entry: catalogue[x.target!]! }));
|
|
363
|
+
return link.part(link.mentionsWrite(doc, pages, all, links), n);
|
|
364
|
+
});
|
|
365
|
+
return {
|
|
366
|
+
entries: state.entries,
|
|
367
|
+
mentions: state.mentions + all.length,
|
|
368
|
+
links: state.links + written.length,
|
|
369
|
+
review: state.review + below.length,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** The catalogue's entries closest in meaning to each mention (`span — row`), per page. */
|
|
374
|
+
async function meaning(
|
|
375
|
+
effectId: string,
|
|
376
|
+
lbb: LbbActivities,
|
|
377
|
+
doc: Doc,
|
|
378
|
+
read: ReturnType<typeof reader>,
|
|
379
|
+
mentions: link.Mention[][],
|
|
380
|
+
): Promise<number[][][]> {
|
|
381
|
+
const none = () => mentions.map((list) => list.map(() => []));
|
|
382
|
+
const catalogue = await read.catalogue();
|
|
383
|
+
const queries: string[] = [];
|
|
384
|
+
for (const list of mentions)
|
|
385
|
+
for (const m of list)
|
|
386
|
+
queries.push(
|
|
387
|
+
`${m.span} — ${link.rowOf(
|
|
388
|
+
(await read.pages()).flatMap((p) => p.lines),
|
|
389
|
+
m.line,
|
|
390
|
+
)}`,
|
|
391
|
+
);
|
|
392
|
+
if (!catalogue.length || !queries.length) return none();
|
|
393
|
+
const texts = [...catalogue.map((e) => `${e.identifier}: ${e.context}`), ...queries].map((t) =>
|
|
394
|
+
t.slice(0, 8_000),
|
|
395
|
+
);
|
|
396
|
+
const vectors: number[][] = [];
|
|
397
|
+
try {
|
|
398
|
+
for (let n = 0; n * 256 < texts.length; n++) {
|
|
399
|
+
const chunk = texts.slice(n * 256, (n + 1) * 256);
|
|
400
|
+
const embedded = await lbb.embed(
|
|
401
|
+
{ texts: chunk },
|
|
402
|
+
{ effectId: effect(effectId, n), label: `${doc.name.slice(0, 40)} meaning` },
|
|
403
|
+
);
|
|
404
|
+
vectors.push(...embedded.vectors);
|
|
405
|
+
}
|
|
406
|
+
} catch (error) {
|
|
407
|
+
// Without an embedding model the candidates come by characters only.
|
|
408
|
+
if (error instanceof LbbError && error.code === "activity_model_unavailable") return none();
|
|
409
|
+
throw error;
|
|
410
|
+
}
|
|
411
|
+
const entries = vectors.slice(0, catalogue.length);
|
|
412
|
+
let next = catalogue.length;
|
|
413
|
+
return mentions.map((list) => list.map(() => link.nearest(vectors[next++]!, entries)));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** The decision model's answers for one page's mentions: a refused request is split in halves. */
|
|
417
|
+
async function decidePage(
|
|
418
|
+
effectId: string,
|
|
419
|
+
lbb: LbbActivities,
|
|
420
|
+
doc: Doc,
|
|
421
|
+
read: ReturnType<typeof reader>,
|
|
422
|
+
page: number,
|
|
423
|
+
mentions: link.Mention[],
|
|
424
|
+
byMeaning: number[][],
|
|
425
|
+
): Promise<link.Resolved[]> {
|
|
426
|
+
if (!mentions.length) return [];
|
|
427
|
+
const catalogue = await read.catalogue();
|
|
428
|
+
const p = await read.page(page);
|
|
429
|
+
const asked = mentions.map((mention, position) => ({
|
|
430
|
+
position,
|
|
431
|
+
mention,
|
|
432
|
+
lineText: p.lines.find((l) => l.id === mention.line)!.text,
|
|
433
|
+
candidates: link.candidatesFor(mention, doc.iri, catalogue, byMeaning[position]!),
|
|
434
|
+
}));
|
|
435
|
+
const state = link.pageState(doc.name, p, catalogue);
|
|
436
|
+
const answers: Record<string, DecideAnswer> = {};
|
|
437
|
+
const ask = async (part: link.Asked[], id: string): Promise<void> => {
|
|
438
|
+
try {
|
|
439
|
+
const request = link.decideRequest(state, part, catalogue);
|
|
440
|
+
const answered = await lbb.decide(request, {
|
|
441
|
+
effectId: id,
|
|
442
|
+
label: label(doc, page, "resolve"),
|
|
443
|
+
});
|
|
444
|
+
Object.assign(answers, answered.answers);
|
|
445
|
+
} catch (error) {
|
|
446
|
+
if (!isActivityRefused(error)) throw error;
|
|
447
|
+
// Too large for the model: ask each half. One mention the model refuses stays unanswered.
|
|
448
|
+
const halves = splitInHalves(part);
|
|
449
|
+
if (halves) for (const [n, half] of halves.entries()) await ask(half, `${id}.${n}`);
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
const requests = link.packRequests(state, asked, catalogue);
|
|
453
|
+
for (const [n, part] of requests.entries()) await ask(part, effect(effectId, n));
|
|
454
|
+
return asked.map(({ position }) =>
|
|
455
|
+
link.resolve(answers[`m${position}-kind`], answers[`m${position}-names`]),
|
|
456
|
+
);
|
|
457
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# ontology-fit
|
|
2
|
+
|
|
3
|
+
Reads the text of each new or changed instance of a class and proposes the
|
|
4
|
+
ontology changes the text supports: a class `Mangel` for the defects a set
|
|
5
|
+
of site reports follows, its properties `frist` and `status`, a relation
|
|
6
|
+
`zustaendig` to `Firma`. Each change becomes a review item with its
|
|
7
|
+
evolve operations and the quotes that support it; a person accepts it, and
|
|
8
|
+
the ontology changes.
|
|
9
|
+
|
|
10
|
+
The LBB-run version is the starter workflow `ontology.fit@v1`, the fit
|
|
11
|
+
sources of `/v1/ontology/fit-sources` (`docs/reference/fit-sources.md`).
|
|
12
|
+
|
|
13
|
+
## How it runs
|
|
14
|
+
|
|
15
|
+
The triggered workflow watches the class and its text field. For each
|
|
16
|
+
instance of a trigger message, one step:
|
|
17
|
+
|
|
18
|
+
1. The reasoning model reads the text with the ontology
|
|
19
|
+
(`lbb.ontology()`, as classes, relations with their domain and range,
|
|
20
|
+
and properties) and the changes the earlier texts of the turn proposed,
|
|
21
|
+
and returns up to 12 proposals: `add_class`, `add_property`,
|
|
22
|
+
`add_relation` or `widen_relation`, each with a sentence of the text as
|
|
23
|
+
its quote. The prompt and schema are the LBB version's.
|
|
24
|
+
2. Each proposal becomes a change (`shape`): its key, its title and its
|
|
25
|
+
evolve operations. A class the change names that the ontology lacks is
|
|
26
|
+
added first, and a new relation with the name of an existing one widens
|
|
27
|
+
it. The key follows the LBB version:
|
|
28
|
+
`fit:<workflow name>/add_class/mangel`,
|
|
29
|
+
`fit:<workflow name>/add_relation/mangel/zustaendig/firma`,
|
|
30
|
+
`fit:<workflow name>/widen_relation/betrifft-raum/mangel/x`.
|
|
31
|
+
3. A change is kept when its quote is in the text with at least three
|
|
32
|
+
words, and a dry run (`lbb.evolve(change, { dryRun: true })`) says the
|
|
33
|
+
ontology does not have it yet.
|
|
34
|
+
|
|
35
|
+
A last step files one review item per key, with the quotes of every text of
|
|
36
|
+
the turn that supports it. The same key again updates the item. A turn reads
|
|
37
|
+
at most 48 texts; the rest goes to a new turn.
|
|
38
|
+
|
|
39
|
+
## Settings
|
|
40
|
+
|
|
41
|
+
`defaults` in `workflow.ts`, which fit the samples:
|
|
42
|
+
|
|
43
|
+
| Setting | Default | Meaning |
|
|
44
|
+
| --- | --- | --- |
|
|
45
|
+
| `className` | `Baustellenbericht` | The class whose instances hold the text |
|
|
46
|
+
| `textField` | `text` | The property that holds the text |
|
|
47
|
+
| `context` | the samples' | One sentence about what the text is |
|
|
48
|
+
|
|
49
|
+
`trigger(settings)` names the triggered workflow `berichte`; the name starts
|
|
50
|
+
every key. The workflow writes no graph facts, so it owns nothing.
|
|
51
|
+
|
|
52
|
+
## What the LBB-run version adds
|
|
53
|
+
|
|
54
|
+
- **Support scores.** The decision model scores how well each quote
|
|
55
|
+
supports its change; a change below 0.5 is dropped.
|
|
56
|
+
- **Support across instances.** The instances that support each key are
|
|
57
|
+
counted over every run, and a dismissed change opens again when twice its
|
|
58
|
+
support and three more instances support it.
|
|
59
|
+
- **The proposals of the source.** The open suggestions of the source and
|
|
60
|
+
the names of the dismissed ones go into every prompt, so the model reuses
|
|
61
|
+
names and does not propose a rejected change again.
|
|
62
|
+
- **Name style.** A new relation or property name is written in the
|
|
63
|
+
ontology's style (`works_in` or `WORKS_IN`).
|
|
64
|
+
- **Long texts.** A text past 40,000 characters is read in overlapping
|
|
65
|
+
chunks.
|
|
66
|
+
- Its own monthly budget, a preview with the proposals of a sample, and
|
|
67
|
+
counters per outcome in the source's status.
|
|
68
|
+
|
|
69
|
+
## Samples and test
|
|
70
|
+
|
|
71
|
+
`samples/texts.json` holds five invented site reports and meeting minutes;
|
|
72
|
+
`samples/expected.md` describes the graph before the run, the proposals a
|
|
73
|
+
good run makes with their keys, those it may make, and those it does not
|
|
74
|
+
make. `samples.ts` sets up that graph for `npm run try`.
|
|
75
|
+
|
|
76
|
+
`workflow.test.ts` runs the workflow with a mock reasoning model whose rules
|
|
77
|
+
are fixed (a sentence about a `Mangel` proposes the class, `Frist`, `Status`
|
|
78
|
+
and `Zuständig` its facts, a room widens `betrifft_raum`). The run files
|
|
79
|
+
the five keys of `expected.md` and no other: the relation `anwesend`, which
|
|
80
|
+
the ontology has, is dropped by the dry run, and a proposal whose quote is
|
|
81
|
+
not in its text is dropped by the quote check.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Fit from text samples: what a good run proposes
|
|
2
|
+
|
|
3
|
+
The five texts in `texts.json` are invented site reports and meeting minutes
|
|
4
|
+
of one project, Neubau Grundschule Musterstadt. They follow the defects found
|
|
5
|
+
on site over four weeks: in which room, which company must fix each one, by
|
|
6
|
+
when, and whether it is fixed. The graph has no class for a defect, so a fit
|
|
7
|
+
run over these texts should propose one, with the facts the texts give about
|
|
8
|
+
each defect. Texts are numbered 1 to 5 in the order of `texts.json`.
|
|
9
|
+
|
|
10
|
+
## The graph before the run
|
|
11
|
+
|
|
12
|
+
Each text is an instance of `Baustellenbericht`: its name is the label, its
|
|
13
|
+
text is the property `text`. The ontology holds:
|
|
14
|
+
|
|
15
|
+
| Class | Properties |
|
|
16
|
+
| --- | --- |
|
|
17
|
+
| `Baustellenbericht` | `text` (text), `datum` (datetime) |
|
|
18
|
+
| `Raum` | `nummer` (keyword), `bezeichnung` (text) |
|
|
19
|
+
| `Firma` | `name` (text), `gewerk` (keyword) |
|
|
20
|
+
|
|
21
|
+
| Relation | From | To |
|
|
22
|
+
| --- | --- | --- |
|
|
23
|
+
| `betrifft_raum` | `Baustellenbericht` | `Raum` |
|
|
24
|
+
| `anwesend` | `Baustellenbericht` | `Firma` |
|
|
25
|
+
|
|
26
|
+
The fit source:
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"class": "Baustellenbericht",
|
|
31
|
+
"name": "berichte",
|
|
32
|
+
"from": ["text"],
|
|
33
|
+
"context": "Site reports and meeting minutes of a school building project, in German"
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Proposals a good run makes
|
|
38
|
+
|
|
39
|
+
The names follow the ontology: lower snake_case in ASCII (`zustaendig` for
|
|
40
|
+
"zuständig"). A run can spell a name another way with the same meaning. The
|
|
41
|
+
keys hold for the names in this table (a suggestion key keeps lowercase ASCII
|
|
42
|
+
letters and digits, and writes every other run of characters as one dash; a
|
|
43
|
+
missing class in a widen key is `x`).
|
|
44
|
+
|
|
45
|
+
| Change | Key | Texts | Quote (one of them) |
|
|
46
|
+
| --- | --- | --- | --- |
|
|
47
|
+
| Add class `Mangel` | `fit:berichte/add_class/mangel` | 1 to 5 | "Der Mangel wurde der Fliesenbau Muster GmbH gemeldet" |
|
|
48
|
+
| Add property `frist` (datetime) to `Mangel` | `fit:berichte/add_property/mangel/frist` | 1 to 4 | "die Frist zur Beseitigung endet am 16.09.2026" |
|
|
49
|
+
| Add property `status` (keyword: `offen`, `behoben`) to `Mangel` | `fit:berichte/add_property/mangel/status` | 3 to 5 | "Frist 23.09.2026, Status offen" |
|
|
50
|
+
| Add relation `zustaendig` from `Mangel` to `Firma` | `fit:berichte/add_relation/mangel/zustaendig/firma` | 1 to 4 | "Zuständig ist die Trockenbau Probe GmbH" |
|
|
51
|
+
| Let `betrifft_raum` start at `Mangel` | `fit:berichte/widen_relation/betrifft-raum/mangel/x` | 1 to 5 | "In Raum 0.12 fehlt der Spiegel über dem Waschtisch" |
|
|
52
|
+
|
|
53
|
+
The last row tests reuse. Every defect names its room, and the ontology
|
|
54
|
+
already has a relation to `Raum`, so a good run widens `betrifft_raum`. A new
|
|
55
|
+
relation from `Mangel` to `Raum` with another name (`liegt_in`,
|
|
56
|
+
`befindet_sich_in`) is a synonym of it.
|
|
57
|
+
|
|
58
|
+
## Proposals a good run may make
|
|
59
|
+
|
|
60
|
+
- Add property `einstufung` (keyword: `wesentlich`, `unwesentlich`) to
|
|
61
|
+
`Mangel`. Only text 5 gives it: "die Bauleitung stuft diesen Mangel als
|
|
62
|
+
unwesentlich ein".
|
|
63
|
+
- Add property `beschreibung` (text) to `Mangel`.
|
|
64
|
+
- Add relation `stellt_fest` from `Baustellenbericht` to `Mangel`, for the
|
|
65
|
+
report that records a defect.
|
|
66
|
+
- Add class `Bauteil`, for the part a defect concerns (Bodenfliesen, Spiegel,
|
|
67
|
+
Trennwand).
|
|
68
|
+
- Add class `Abnahme` with a date. Only text 5 names it.
|
|
69
|
+
- Add class `Lieferung` with a `liefertermin` (datetime). Texts 2 and 4 give
|
|
70
|
+
the delivery date of the ceiling lights.
|
|
71
|
+
|
|
72
|
+
## Proposals a good run does not make
|
|
73
|
+
|
|
74
|
+
- A relation from `Baustellenbericht` to `Firma` for the companies present
|
|
75
|
+
("Anwesend: …", "Teilnehmer: …"). `anwesend` holds it; the check drops
|
|
76
|
+
such a change as `already_covered`.
|
|
77
|
+
- A property for the date of a report. `datum` holds it.
|
|
78
|
+
- A class for one company, one room or one defect (`Fliesenbau Muster GmbH`,
|
|
79
|
+
`Flur 0.10`, `Trennwand Kabine 2`). These are instances.
|
|
80
|
+
- A second name for a change the run proposes: `Maengel` or `Defect` beside
|
|
81
|
+
`Mangel`, `deadline` beside `frist`, `verantwortlich` beside `zustaendig`.
|
|
82
|
+
|
|
83
|
+
## How to check a run
|
|
84
|
+
|
|
85
|
+
1. Every change in "Proposals a good run makes" is filed.
|
|
86
|
+
2. No change in "Proposals a good run does not make" is filed.
|
|
87
|
+
3. Every filed quote is in its text, with whitespace runs as one space, and
|
|
88
|
+
holds at least three words.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"name": "Baustellenbericht 12",
|
|
4
|
+
"text": "Baustellenbericht 12 vom 02.09.2026. Begehung des Erdgeschosses mit der Bauleitung. Anwesend: Fliesenbau Muster GmbH, Bodenbau Beispiel GmbH. In Raum 0.14 (WC Mädchen) sind zwei Bodenfliesen gerissen. Der Mangel wurde der Fliesenbau Muster GmbH gemeldet, die Frist zur Beseitigung endet am 16.09.2026. In Raum 0.12 ist der Estrich trocken; die Bodenbau Beispiel GmbH verlegt das Linoleum ab dem 07.09.2026."
|
|
5
|
+
},
|
|
6
|
+
{
|
|
7
|
+
"name": "Baubesprechung 05.09.2026",
|
|
8
|
+
"text": "Protokoll der Baubesprechung vom 05.09.2026. Teilnehmer: Bauleitung, Elektro Beispiel GmbH, Haustechnik Exempel KG. Die Elektro Beispiel GmbH meldet, dass die Deckenleuchten erst am 21.09.2026 geliefert werden. Die Haustechnik Exempel KG hat die Waschtische im Erdgeschoss montiert. Mangel: In Raum 0.12 fehlt der Spiegel über dem Waschtisch. Zuständig ist die Haustechnik Exempel KG, Frist 12.09.2026."
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"name": "Baustellenbericht 13",
|
|
12
|
+
"text": "Baustellenbericht 13 vom 09.09.2026. Mängelbegehung in Raum 0.14: Die gerissenen Bodenfliesen sind ersetzt, der Mangel ist behoben. Neu festgestellt: Die Trennwand der WC-Kabine 2 in Raum 0.14 steht schief. Zuständig ist die Trockenbau Probe GmbH, Frist 23.09.2026, Status offen."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "Baustellenbericht 14",
|
|
16
|
+
"text": "Baustellenbericht 14 vom 16.09.2026. In Raum 0.12 ist der Spiegel montiert, der Mangel ist behoben. Im Flur 0.10 hat das Linoleum an zwei Stellen tiefe Kratzer. Zuständig ist die Bodenbau Beispiel GmbH, Frist 30.09.2026, Status offen. Die Deckenleuchten sind geliefert und werden bis zum 25.09.2026 montiert."
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"name": "Vorbegehung zur Abnahme",
|
|
20
|
+
"text": "Vorbegehung zur Abnahme am 28.09.2026 mit Bauherr und Bauleitung. Die Trennwand der Kabine 2 in Raum 0.14 ist ausgerichtet, der Mangel ist behoben. Offen bleiben die Kratzer im Flur 0.10; die Bauleitung stuft diesen Mangel als unwesentlich ein. Wesentliche Mängel liegen nicht vor. Die Abnahme ist für den 02.10.2026 geplant."
|
|
21
|
+
}
|
|
22
|
+
]
|