@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.
Files changed (34) hide show
  1. package/LICENSE +55 -0
  2. package/README.md +71 -2
  3. package/bin/create-littlebigbrain.mjs +9 -0
  4. package/lib/create.mjs +238 -0
  5. package/package.json +46 -4
  6. package/project/setup.ts +18 -0
  7. package/project/try.ts +30 -0
  8. package/project/worker.ts +23 -0
  9. package/templates/_shared/testing.ts +490 -0
  10. package/templates/documents-link/README.md +144 -0
  11. package/templates/documents-link/link.ts +580 -0
  12. package/templates/documents-link/samples/ausstattungsliste.pdf +76 -0
  13. package/templates/documents-link/samples/baubeschreibung.pdf +54 -0
  14. package/templates/documents-link/samples/expected.json +404 -0
  15. package/templates/documents-link/samples/pages/ausstattungsliste.json +1150 -0
  16. package/templates/documents-link/samples/pages/baubeschreibung.json +802 -0
  17. package/templates/documents-link/samples/pages/raumblaetter.json +1580 -0
  18. package/templates/documents-link/samples/raumblaetter.pdf +109 -0
  19. package/templates/documents-link/samples.ts +31 -0
  20. package/templates/documents-link/workflow.test.ts +470 -0
  21. package/templates/documents-link/workflow.ts +457 -0
  22. package/templates/ontology-fit/README.md +81 -0
  23. package/templates/ontology-fit/samples/expected.md +88 -0
  24. package/templates/ontology-fit/samples/texts.json +22 -0
  25. package/templates/ontology-fit/samples.ts +40 -0
  26. package/templates/ontology-fit/workflow.test.ts +264 -0
  27. package/templates/ontology-fit/workflow.ts +413 -0
  28. package/templates/values-extract/README.md +81 -0
  29. package/templates/values-extract/samples/expected.json +148 -0
  30. package/templates/values-extract/samples/records.json +26 -0
  31. package/templates/values-extract/samples.ts +27 -0
  32. package/templates/values-extract/workflow.test.ts +256 -0
  33. package/templates/values-extract/workflow.ts +411 -0
  34. 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
+ ]