@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,413 @@
|
|
|
1
|
+
// ontology-fit: read the text of each new or changed instance of a class
|
|
2
|
+
// and propose the ontology changes the text supports, as review items.
|
|
3
|
+
//
|
|
4
|
+
// A triggered workflow of the starter workflow `workflow` watches a class and
|
|
5
|
+
// its text field, and sends the new and changed instances to this workflow.
|
|
6
|
+
// For each instance the workflow asks the reasoning model (`reason`) which
|
|
7
|
+
// classes, properties and relations the text needs that the ontology lacks,
|
|
8
|
+
// each with a sentence of the text as its quote. It keeps a proposal when the
|
|
9
|
+
// quote is in the text and a dry run of the change (`evolve` with `dryRun`)
|
|
10
|
+
// says the ontology does not have it yet. Each kept change becomes a review
|
|
11
|
+
// item with the change: a person accepts it, and the ontology changes.
|
|
12
|
+
//
|
|
13
|
+
// A change's key names it (`fit:<workflow>/add_class/mangel`), so the same
|
|
14
|
+
// change from several texts is one review item.
|
|
15
|
+
import {
|
|
16
|
+
LbbError,
|
|
17
|
+
triggeredWorkflow,
|
|
18
|
+
type LbbActivities,
|
|
19
|
+
type Schemas,
|
|
20
|
+
type WorkflowTriggerMessage,
|
|
21
|
+
} from "@littlebigbrain/client";
|
|
22
|
+
|
|
23
|
+
export interface Settings {
|
|
24
|
+
/** The class whose instances hold the text. */
|
|
25
|
+
className: string;
|
|
26
|
+
/** The property that holds each instance's text. */
|
|
27
|
+
textField: string;
|
|
28
|
+
/** One sentence about what the text is, for the model. */
|
|
29
|
+
context: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The settings: edit them for your class. These fit the samples. */
|
|
33
|
+
export const defaults: Settings = {
|
|
34
|
+
className: "Baustellenbericht",
|
|
35
|
+
textField: "text",
|
|
36
|
+
context: "Site reports and meeting minutes of a school building project, in German",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** The workflow type and version the triggered workflow names. */
|
|
40
|
+
export const workflowType = { workflow_type: "ontology-fit", version: "v1" };
|
|
41
|
+
|
|
42
|
+
/** The triggered workflow that sends the texts to this workflow. Its name starts every key. */
|
|
43
|
+
export function trigger(settings: Settings = defaults): Schemas["TriggeredWorkflowRequest"] {
|
|
44
|
+
return {
|
|
45
|
+
starter: "workflow",
|
|
46
|
+
name: "berichte",
|
|
47
|
+
watch: { class: settings.className, fields: [settings.textField] },
|
|
48
|
+
workflow: workflowType,
|
|
49
|
+
batch: 8,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The class the workflow reads, for `lbb.evolve`. */
|
|
54
|
+
export function ontology(settings: Settings = defaults): Schemas["OntologyEvolveOp"][] {
|
|
55
|
+
return [
|
|
56
|
+
{ op: "add_entity_type", name: settings.className },
|
|
57
|
+
{ op: "add_property", name: settings.textField, value_type: "text" },
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface FitState {
|
|
62
|
+
texts: number;
|
|
63
|
+
proposals: number;
|
|
64
|
+
filed: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One proposal as the model gives it: every field a string, empty when the kind does not use it. */
|
|
68
|
+
interface Raw {
|
|
69
|
+
kind: string;
|
|
70
|
+
name: string;
|
|
71
|
+
parent: string;
|
|
72
|
+
class: string;
|
|
73
|
+
value_type: string;
|
|
74
|
+
domain: string;
|
|
75
|
+
range: string;
|
|
76
|
+
quote: string;
|
|
77
|
+
reason: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A proposal as a change: its key, title and evolve operations, and the quote that supports it. */
|
|
81
|
+
export interface Change {
|
|
82
|
+
key: string;
|
|
83
|
+
title: string;
|
|
84
|
+
anchor: Schemas["SuggestionAnchor"];
|
|
85
|
+
change: Schemas["OntologyEvolveOp"][];
|
|
86
|
+
quote: string;
|
|
87
|
+
reason: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const SYSTEM = `You read text from a knowledge graph and propose changes to its ontology. The ontology has classes, relations between classes, and properties. Propose only changes that the text supports and that the ontology does not have yet:
|
|
91
|
+
- add_class: a new kind of thing the text talks about, with its parent class when one in the ontology fits (\`name\`, \`parent\`).
|
|
92
|
+
- add_property: a new fact that instances of a class carry (\`name\`, \`class\`, \`value_type\`).
|
|
93
|
+
- add_relation: a new link between two classes (\`name\`, \`domain\`, \`range\`).
|
|
94
|
+
- widen_relation: an existing relation that should also start at (\`domain\`) or point to (\`range\`) another class (\`name\` is the relation).
|
|
95
|
+
Rules:
|
|
96
|
+
- Propose kinds of things and links that many texts like this one hold. Do not propose one person, one date, one number, or one event as a class.
|
|
97
|
+
- Each proposal quotes one sentence of the text in \`quote\`, copied exactly, character for character. A proposal without an exact quote is dropped.
|
|
98
|
+
- Use the ontology's names for existing classes and relations. Name new classes as singular nouns, as the ontology names its classes. Name new relations as verbs and new properties as nouns, in the naming style the ontology states (UPPER_SNAKE_CASE relations and snake_case properties when it states none).
|
|
99
|
+
- A list may follow of the changes already proposed and waiting for review. When the text supports one of them, propose it again with exactly the same names: it adds evidence. Never propose a synonym of a relation or property the ontology has or the list holds, and never the inverse of one.
|
|
100
|
+
- \`reason\` is one short sentence.
|
|
101
|
+
- Leave the fields a kind does not use empty.
|
|
102
|
+
- Propose at most 12 changes. An empty list is a good answer when the ontology covers the text.`;
|
|
103
|
+
|
|
104
|
+
const text = (description: string) => ({ type: "string", description });
|
|
105
|
+
const SCHEMA = {
|
|
106
|
+
type: "object",
|
|
107
|
+
additionalProperties: false,
|
|
108
|
+
required: ["proposals"],
|
|
109
|
+
properties: {
|
|
110
|
+
proposals: {
|
|
111
|
+
type: "array",
|
|
112
|
+
items: {
|
|
113
|
+
type: "object",
|
|
114
|
+
additionalProperties: false,
|
|
115
|
+
required: [
|
|
116
|
+
"kind",
|
|
117
|
+
"name",
|
|
118
|
+
"parent",
|
|
119
|
+
"class",
|
|
120
|
+
"value_type",
|
|
121
|
+
"domain",
|
|
122
|
+
"range",
|
|
123
|
+
"quote",
|
|
124
|
+
"reason",
|
|
125
|
+
],
|
|
126
|
+
properties: {
|
|
127
|
+
kind: {
|
|
128
|
+
type: "string",
|
|
129
|
+
enum: ["add_class", "add_property", "add_relation", "widen_relation"],
|
|
130
|
+
},
|
|
131
|
+
name: text(
|
|
132
|
+
"The new class, property or relation; for widen_relation the existing relation.",
|
|
133
|
+
),
|
|
134
|
+
parent: text("add_class: the parent class, or empty."),
|
|
135
|
+
class: text("add_property: the class whose instances carry the property."),
|
|
136
|
+
value_type: {
|
|
137
|
+
type: "string",
|
|
138
|
+
enum: ["", "text", "keyword", "i64", "f64", "bool", "date_time"],
|
|
139
|
+
description: "add_property: the type of the values.",
|
|
140
|
+
},
|
|
141
|
+
domain: text("add_relation and widen_relation: the source class."),
|
|
142
|
+
range: text("add_relation and widen_relation: the target class."),
|
|
143
|
+
quote: text("One sentence of the text, copied exactly."),
|
|
144
|
+
reason: text("Why the text needs this change, one short sentence."),
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/** Texts one turn reads: one step each. */
|
|
152
|
+
const TURN_INSTANCES = 48;
|
|
153
|
+
const TEXT_CHARS = 40_000;
|
|
154
|
+
const PLAIN_IRI = /^[^\s<>"{}|\\^`]+$/;
|
|
155
|
+
const normalize = (value: string) => value.split(/\s+/).filter(Boolean).join(" ");
|
|
156
|
+
|
|
157
|
+
/** A part of a key: lowercase ASCII letters and digits, every other run as one dash. */
|
|
158
|
+
export function keyPart(value: string): string {
|
|
159
|
+
const part = value
|
|
160
|
+
.toLowerCase()
|
|
161
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
162
|
+
.replace(/^-+|-+$/g, "")
|
|
163
|
+
.slice(0, 48)
|
|
164
|
+
.replace(/-+$/, "");
|
|
165
|
+
return part || "x";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The ontology as the model reads it, and its names by lower case. */
|
|
169
|
+
export function describe(view: Schemas["OntologyView"]) {
|
|
170
|
+
const types = (view.entity_type_defs ?? []).filter((t) => t.removed_version == null);
|
|
171
|
+
const relations = (view.relation_defs ?? []).filter((r) => r.removed_version == null);
|
|
172
|
+
const properties = view.property_defs ?? [];
|
|
173
|
+
const byId = new Map(types.map((t) => [t.stable_id, t.name]));
|
|
174
|
+
const side = (names: string[]) => (names.length ? names.join(" | ") : "any");
|
|
175
|
+
const lines = [
|
|
176
|
+
"Classes (name: parent classes):",
|
|
177
|
+
...types.map(
|
|
178
|
+
(t) =>
|
|
179
|
+
`- ${t.name}${t.super_types?.length ? `: ${t.super_types.map((id) => byId.get(id) ?? id).join(", ")}` : ""}`,
|
|
180
|
+
),
|
|
181
|
+
"Relations (name: domain -> range):",
|
|
182
|
+
...relations.map((r) => `- ${r.name}: ${side(r.src_types)} -> ${side(r.dst_types)}`),
|
|
183
|
+
"Properties (graph-wide fields, name: value type):",
|
|
184
|
+
...properties.map((p) => `- ${p.name}: ${p.value_type}`),
|
|
185
|
+
];
|
|
186
|
+
const index = (names: string[]) => new Map(names.map((n) => [n.toLowerCase(), n]));
|
|
187
|
+
return {
|
|
188
|
+
text: lines.join("\n"),
|
|
189
|
+
classes: index(types.map((t) => t.name)),
|
|
190
|
+
relations: index(relations.map((r) => r.name)),
|
|
191
|
+
properties: index(properties.map((p) => p.name)),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
type Ontology = ReturnType<typeof describe>;
|
|
195
|
+
|
|
196
|
+
/** A raw proposal as a change with its key, or null when it has no shape. Adapted from fit_sources.rs. */
|
|
197
|
+
export function shape(source: string, raw: Raw, onto: Ontology): Change | null {
|
|
198
|
+
const name = raw.name.trim();
|
|
199
|
+
if (!name || !["add_class", "add_property", "add_relation", "widen_relation"].includes(raw.kind))
|
|
200
|
+
return null;
|
|
201
|
+
const cls = (n: string) => onto.classes.get(n.trim().toLowerCase()) ?? n.trim();
|
|
202
|
+
const declare = (names: string[]): Schemas["OntologyEvolveOp"][] =>
|
|
203
|
+
[...new Set(names.filter((n) => n && !onto.classes.has(n.toLowerCase())))].map((n) => ({
|
|
204
|
+
op: "add_entity_type",
|
|
205
|
+
name: n,
|
|
206
|
+
}));
|
|
207
|
+
const base = { quote: raw.quote, reason: raw.reason.trim() };
|
|
208
|
+
// A new relation with the name of an existing one widens it.
|
|
209
|
+
const relation = onto.relations.get(name.toLowerCase());
|
|
210
|
+
const kind = raw.kind === "add_relation" && relation ? "widen_relation" : raw.kind;
|
|
211
|
+
const prefix = `fit:${source}/${kind}`;
|
|
212
|
+
if (kind === "add_class") {
|
|
213
|
+
const parent =
|
|
214
|
+
raw.parent.trim() && raw.parent.trim().toLowerCase() !== name.toLowerCase()
|
|
215
|
+
? cls(raw.parent)
|
|
216
|
+
: "";
|
|
217
|
+
const change: Schemas["OntologyEvolveOp"][] = [
|
|
218
|
+
{ op: "add_entity_type", name: cls(name) },
|
|
219
|
+
...declare([parent]),
|
|
220
|
+
];
|
|
221
|
+
if (parent)
|
|
222
|
+
change.push({ op: "add_super_types", entity_type: cls(name), super_types: [parent] });
|
|
223
|
+
return {
|
|
224
|
+
...base,
|
|
225
|
+
key: `${prefix}/${keyPart(cls(name))}`,
|
|
226
|
+
title: parent ? `Add class ${cls(name)} under ${parent}` : `Add class ${cls(name)}`,
|
|
227
|
+
anchor: { kind: "class", name: cls(name) },
|
|
228
|
+
change,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
if (kind === "add_property") {
|
|
232
|
+
if (!raw.class.trim()) return null;
|
|
233
|
+
const property = onto.properties.get(name.toLowerCase()) ?? name;
|
|
234
|
+
const type = ["text", "keyword", "i64", "f64", "bool", "date_time"].includes(raw.value_type)
|
|
235
|
+
? raw.value_type
|
|
236
|
+
: "text";
|
|
237
|
+
return {
|
|
238
|
+
...base,
|
|
239
|
+
key: `${prefix}/${keyPart(cls(raw.class))}/${keyPart(property)}`,
|
|
240
|
+
title: `Add property ${property} to ${cls(raw.class)}`,
|
|
241
|
+
anchor: { kind: "property", name: property },
|
|
242
|
+
change: [{ op: "add_property", name: property, value_type: type }],
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
const [domain, range] = [
|
|
246
|
+
raw.domain.trim() ? cls(raw.domain) : "",
|
|
247
|
+
raw.range.trim() ? cls(raw.range) : "",
|
|
248
|
+
];
|
|
249
|
+
if (kind === "add_relation") {
|
|
250
|
+
if (!domain || !range) return null;
|
|
251
|
+
return {
|
|
252
|
+
...base,
|
|
253
|
+
key: `${prefix}/${keyPart(domain)}/${keyPart(name)}/${keyPart(range)}`,
|
|
254
|
+
title: `Add relation ${name} from ${domain} to ${range}`,
|
|
255
|
+
anchor: { kind: "relation", name },
|
|
256
|
+
change: [
|
|
257
|
+
...declare([domain, range]),
|
|
258
|
+
{ op: "add_relation", name, domain: [domain], range: [range] },
|
|
259
|
+
],
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
if (!domain && !range) return null;
|
|
263
|
+
const widened = relation ?? name;
|
|
264
|
+
const title =
|
|
265
|
+
domain && range
|
|
266
|
+
? `Let ${widened} link ${domain} to ${range}`
|
|
267
|
+
: domain
|
|
268
|
+
? `Let ${widened} start at ${domain}`
|
|
269
|
+
: `Let ${widened} point to ${range}`;
|
|
270
|
+
const widen: Schemas["OntologyEvolveOp"] = {
|
|
271
|
+
op: "widen_relation",
|
|
272
|
+
relation: widened,
|
|
273
|
+
add_domain: domain ? [domain] : [],
|
|
274
|
+
add_range: range ? [range] : [],
|
|
275
|
+
};
|
|
276
|
+
return {
|
|
277
|
+
...base,
|
|
278
|
+
key: `${prefix}/${keyPart(widened)}/${keyPart(domain || "-")}/${keyPart(range || "-")}`,
|
|
279
|
+
title,
|
|
280
|
+
anchor: { kind: "relation", name: widened },
|
|
281
|
+
change: [...declare([domain, range]), widen],
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** A quote must be in the text, whitespace normalized, with at least three words. */
|
|
286
|
+
export const quoteInText = (quote: string, value: string) =>
|
|
287
|
+
normalize(quote).split(" ").length >= 3 && normalize(value).includes(normalize(quote));
|
|
288
|
+
|
|
289
|
+
/** The workflow, for a `WorkflowWorker`. `lbb` is `lbbActivities(client)`, or a mock in a test. */
|
|
290
|
+
export function createWorkflow(lbb: LbbActivities, settings: Settings = defaults) {
|
|
291
|
+
return triggeredWorkflow<FitState>({
|
|
292
|
+
name: workflowType.workflow_type,
|
|
293
|
+
version: workflowType.version,
|
|
294
|
+
initialState: { texts: 0, proposals: 0, filed: 0 },
|
|
295
|
+
async onInstances(ctx, message, state) {
|
|
296
|
+
const batch = message.instances.slice(0, TURN_INSTANCES);
|
|
297
|
+
const texts = reader(message, batch);
|
|
298
|
+
let onto: Promise<Ontology> | undefined;
|
|
299
|
+
const kept: Change[][] = [];
|
|
300
|
+
for (const [i, iri] of batch.entries()) {
|
|
301
|
+
kept.push(
|
|
302
|
+
await ctx.step(`propose-${i}`, async (s) => {
|
|
303
|
+
const record = (await texts())[iri];
|
|
304
|
+
if (!record?.text.trim()) return [];
|
|
305
|
+
const ontology = await (onto ??= lbb.ontology().then(describe));
|
|
306
|
+
// The changes the earlier texts of this turn proposed: the model proposes them again by the same names.
|
|
307
|
+
const earlier = [...new Set(kept.flat().map((c) => c.title))];
|
|
308
|
+
const system = [
|
|
309
|
+
SYSTEM,
|
|
310
|
+
`The ontology now:\n${ontology.text}`,
|
|
311
|
+
...(earlier.length
|
|
312
|
+
? [
|
|
313
|
+
`Changes already proposed and waiting for review:\n${earlier.map((t) => `- ${t}`).join("\n")}`,
|
|
314
|
+
]
|
|
315
|
+
: []),
|
|
316
|
+
].join("\n\n");
|
|
317
|
+
const prompt = JSON.stringify({
|
|
318
|
+
class: settings.className,
|
|
319
|
+
context: settings.context,
|
|
320
|
+
instance: record.label,
|
|
321
|
+
text: record.text,
|
|
322
|
+
});
|
|
323
|
+
const answer = await lbb.reason<{ proposals: Raw[] }>(
|
|
324
|
+
{ system, prompt, schema: SCHEMA, max_tokens: 16_000 },
|
|
325
|
+
{ effectId: s.effectId, label: `${record.label.slice(0, 50)} fit` },
|
|
326
|
+
);
|
|
327
|
+
const out: Change[] = [];
|
|
328
|
+
for (const raw of (answer.value.proposals ?? []).slice(0, 12)) {
|
|
329
|
+
const change = shape(message.workflow, raw, ontology);
|
|
330
|
+
if (
|
|
331
|
+
!change ||
|
|
332
|
+
out.some((c) => c.key === change.key) ||
|
|
333
|
+
!quoteInText(change.quote, record.text)
|
|
334
|
+
)
|
|
335
|
+
continue;
|
|
336
|
+
// A dry run: a change the ontology has already is a no-op.
|
|
337
|
+
const preview = await lbb
|
|
338
|
+
.evolve(change.change, { dryRun: true })
|
|
339
|
+
.catch((error: unknown) => {
|
|
340
|
+
if (error instanceof LbbError && error.status === 400) return null;
|
|
341
|
+
throw error;
|
|
342
|
+
});
|
|
343
|
+
if (preview && !preview.no_op && preview.publishable) out.push(change);
|
|
344
|
+
}
|
|
345
|
+
return out;
|
|
346
|
+
}),
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
const filed = await ctx.step("review", async () => {
|
|
350
|
+
const records = await texts();
|
|
351
|
+
const byKey = new Map<
|
|
352
|
+
string,
|
|
353
|
+
{ change: Change; samples: { entity: string; label: string; quote: string }[] }
|
|
354
|
+
>();
|
|
355
|
+
for (const [i, changes] of kept.entries())
|
|
356
|
+
for (const change of changes) {
|
|
357
|
+
const group = byKey.get(change.key) ?? { change, samples: [] };
|
|
358
|
+
group.samples.push({
|
|
359
|
+
entity: batch[i]!,
|
|
360
|
+
label: records[batch[i]!]?.label ?? "",
|
|
361
|
+
quote: change.quote.slice(0, 600),
|
|
362
|
+
});
|
|
363
|
+
byKey.set(change.key, group);
|
|
364
|
+
}
|
|
365
|
+
for (const { change, samples } of byKey.values())
|
|
366
|
+
await lbb.review({
|
|
367
|
+
workflow: message.workflow,
|
|
368
|
+
key: change.key,
|
|
369
|
+
title: change.title.slice(0, 200),
|
|
370
|
+
rationale: `${change.reason} "${change.quote.slice(0, 600)}"`.trim().slice(0, 4_000),
|
|
371
|
+
anchor: change.anchor,
|
|
372
|
+
change: change.change,
|
|
373
|
+
evidence: { records: samples.length, samples: samples.slice(0, 20) },
|
|
374
|
+
});
|
|
375
|
+
return byKey.size;
|
|
376
|
+
});
|
|
377
|
+
const next = {
|
|
378
|
+
texts: state.texts + batch.length,
|
|
379
|
+
proposals: state.proposals + kept.flat().length,
|
|
380
|
+
filed: state.filed + filed,
|
|
381
|
+
};
|
|
382
|
+
const rest = message.instances.slice(TURN_INSTANCES);
|
|
383
|
+
return rest.length
|
|
384
|
+
? ctx.continue(next, { ...message, instances: rest, removed: [] })
|
|
385
|
+
: { state: next };
|
|
386
|
+
},
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
/** The label and text of each instance of the turn, read once per attempt at the message's commit. */
|
|
390
|
+
function reader(message: WorkflowTriggerMessage, iris: string[]) {
|
|
391
|
+
let read: Promise<Record<string, { label: string; text: string }>> | undefined;
|
|
392
|
+
return () =>
|
|
393
|
+
(read ??= (async () => {
|
|
394
|
+
const query = `# ontology-fit: texts
|
|
395
|
+
SELECT ?s ?label ?text WHERE {
|
|
396
|
+
VALUES ?s { ${iris
|
|
397
|
+
.filter((iri) => PLAIN_IRI.test(iri))
|
|
398
|
+
.map((iri) => `<${iri}>`)
|
|
399
|
+
.join(" ")} }
|
|
400
|
+
OPTIONAL { ?s <http://www.w3.org/2000/01/rdf-schema#label> ?label }
|
|
401
|
+
OPTIONAL { ?s <https://littlebigbrain.com/p/${encodeURIComponent(settings.textField.toLowerCase())}> ?text }
|
|
402
|
+
}`;
|
|
403
|
+
const { rows } = await lbb.sparql({ query, as_of_commit_seq: message.as_of_commit_seq });
|
|
404
|
+
const out: Record<string, { label: string; text: string }> = {};
|
|
405
|
+
for (const row of rows) {
|
|
406
|
+
const record = (out[row.s!] ??= { label: row.label ?? row.s!, text: "" });
|
|
407
|
+
if (row.text && !record.text.includes(row.text))
|
|
408
|
+
record.text = `${record.text}\n${row.text}`.trim().slice(0, TEXT_CHARS);
|
|
409
|
+
}
|
|
410
|
+
return out;
|
|
411
|
+
})());
|
|
412
|
+
}
|
|
413
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# values-extract
|
|
2
|
+
|
|
3
|
+
Fills properties of a class from the text of its instances. Each value
|
|
4
|
+
comes with the words of the text that state it: `width_cm` 60 and
|
|
5
|
+
`depth_cm` 40 from `ca. 60x40 cm`. The value is written on the instance,
|
|
6
|
+
with an edge `HAS_VALUE_SOURCE` to a `ValueSource` entity whose evidence is
|
|
7
|
+
the quote.
|
|
8
|
+
|
|
9
|
+
The LBB-run version is the starter workflow `values.extract@v1`
|
|
10
|
+
(`docs/reference/starter-workflows.md`, "Values from text").
|
|
11
|
+
|
|
12
|
+
## How it runs
|
|
13
|
+
|
|
14
|
+
The triggered workflow watches the class and its text field. For each
|
|
15
|
+
instance of a trigger message, two steps:
|
|
16
|
+
|
|
17
|
+
1. **Values.** The reasoning model reads the text and returns
|
|
18
|
+
`{values: [{property, value, quote}]}`; the schema allows only the
|
|
19
|
+
settings' property names. The prompt is the LBB version's: the values of
|
|
20
|
+
the instance's own item in its base version, not those of a part, an
|
|
21
|
+
accessory, an alternative or a variant under a condition; an approximate
|
|
22
|
+
value is stated. Then the checks without a model: a value takes its
|
|
23
|
+
property's type (`typed`), its quote is in the text with at least three
|
|
24
|
+
words or the value in it (`quoteHolds`), and one value per property.
|
|
25
|
+
2. **Support.** The decision model scores how well each quote supports its
|
|
26
|
+
value: `supported`, `weak` or `not_supported`. The support is the
|
|
27
|
+
probability of `supported`.
|
|
28
|
+
|
|
29
|
+
Then the gate: a value at or above `gate` is written, one from 0.5 to the
|
|
30
|
+
gate becomes a review item with the value as its facts, one below 0.5 is
|
|
31
|
+
dropped. One owned write per turn replaces the values of the turn's
|
|
32
|
+
instances: a property the workflow does not write again loses its value and
|
|
33
|
+
its source. An accepted review item's value is written with the others the
|
|
34
|
+
next time its instance is read. An instance that leaves the class loses the
|
|
35
|
+
values this workflow wrote.
|
|
36
|
+
|
|
37
|
+
A turn reads at most 24 instances; the rest of a message goes to a new turn
|
|
38
|
+
(`ctx.continue`).
|
|
39
|
+
|
|
40
|
+
## Settings
|
|
41
|
+
|
|
42
|
+
`defaults` in `workflow.ts`, which fit the samples:
|
|
43
|
+
|
|
44
|
+
| Setting | Default | Meaning |
|
|
45
|
+
| --- | --- | --- |
|
|
46
|
+
| `className` | `EquipmentItem` | The class whose instances are read |
|
|
47
|
+
| `textField` | `text` | The property that holds the text |
|
|
48
|
+
| `properties` | `width_cm`, `depth_cm` (`f64`), `material` (`text`), `water_supply` (`keyword`) | The properties to fill, with their types (`text`, `keyword`, `i64`, `f64`, `bool`, `date_time`) |
|
|
49
|
+
| `context` | the samples' | One sentence about what the text is |
|
|
50
|
+
| `gate` | 0.8 | The support a value needs to be written |
|
|
51
|
+
|
|
52
|
+
`trigger(settings)` names the triggered workflow `values-extract` and
|
|
53
|
+
owns the class `ValueSource`, the relation `HAS_VALUE_SOURCE` and the
|
|
54
|
+
properties. `ontology(settings)` adds the class, the properties and the
|
|
55
|
+
relation.
|
|
56
|
+
|
|
57
|
+
## What the LBB-run version adds
|
|
58
|
+
|
|
59
|
+
- `params.where`: a SPARQL pattern that limits the instances it reads.
|
|
60
|
+
- Texts built from several watched fields, as fit sources build them, and
|
|
61
|
+
the fields chosen for you when the watch names none.
|
|
62
|
+
- The instance's page box as the region of each value's evidence, for an
|
|
63
|
+
instance that has one (a `DocEntry`).
|
|
64
|
+
- A check at creation that each property is in the property registry with
|
|
65
|
+
a type it can write.
|
|
66
|
+
- Its own monthly budget, a preview with the values of up to 3 instances,
|
|
67
|
+
and counters per outcome in the workflow's status.
|
|
68
|
+
|
|
69
|
+
## Samples and test
|
|
70
|
+
|
|
71
|
+
`samples/records.json` holds six invented entries of a sanitary equipment
|
|
72
|
+
list; `samples/expected.json` gives the values a correct run writes, and the
|
|
73
|
+
ones it leaves out ([`../_samples/README.md`](../_samples/README.md)).
|
|
74
|
+
|
|
75
|
+
`workflow.test.ts` runs the workflow with mock models: the reasoning model
|
|
76
|
+
reads the first size, material and water supply of each text, and the
|
|
77
|
+
decision model says a quote from a sentence about an accessory (`Zubehör`)
|
|
78
|
+
or a conditional variant (`Bei Einsatz`) does not support its value. The run
|
|
79
|
+
writes the values of the answer key and none it leaves out. The test also
|
|
80
|
+
checks the review path, the checks of type and quote, an instance that
|
|
81
|
+
leaves the class, and a message larger than a turn.
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
{
|
|
2
|
+
"about": [
|
|
3
|
+
"The answer key for the values-extract samples: the values a correct run reads from each record, with the quote that supports each, and the values it must leave out.",
|
|
4
|
+
"All text is invented. make-samples.mjs checks that every quote is in its record's text (whitespace runs as one space) and that a number's quote holds the number.",
|
|
5
|
+
"Numbers compare as numbers. A text value compares in lower case, trimmed, against accept. A run may quote more or less of the text, as long as its quote is in the text and holds the evidence.",
|
|
6
|
+
"leave_out lists values a correct run does not write. With value null, the run writes no value for that property at all."
|
|
7
|
+
],
|
|
8
|
+
"install": {
|
|
9
|
+
"properties": {
|
|
10
|
+
"width_cm": "f64",
|
|
11
|
+
"depth_cm": "f64",
|
|
12
|
+
"material": "text",
|
|
13
|
+
"water_supply": "keyword"
|
|
14
|
+
},
|
|
15
|
+
"context": "Entries of the sanitary equipment list of a school building tender, in German."
|
|
16
|
+
},
|
|
17
|
+
"records": [
|
|
18
|
+
{
|
|
19
|
+
"name": "S 4.1",
|
|
20
|
+
"case": "An approximate size (ca.) is still the item's size.",
|
|
21
|
+
"values": [
|
|
22
|
+
{ "property": "width_cm", "value": 60, "quote": "ca. 60x40 cm" },
|
|
23
|
+
{ "property": "depth_cm", "value": 40, "quote": "ca. 60x40 cm" },
|
|
24
|
+
{
|
|
25
|
+
"property": "material",
|
|
26
|
+
"value": "Sanitärkeramik",
|
|
27
|
+
"accept": ["sanitärkeramik", "keramik", "sanitärkeramik, weiß", "sanitärkeramik weiß"],
|
|
28
|
+
"quote": "aus Sanitärkeramik, weiß"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"property": "water_supply",
|
|
32
|
+
"value": "kalt und warm",
|
|
33
|
+
"accept": ["kalt und warm", "warm und kalt", "kalt- und warmwasser", "kaltwasser und warmwasser"],
|
|
34
|
+
"quote": "Anschluss an Kalt- und Warmwasser"
|
|
35
|
+
}
|
|
36
|
+
],
|
|
37
|
+
"leave_out": []
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"name": "S 4.2",
|
|
41
|
+
"case": "An accessory of another material: the Klapprost.",
|
|
42
|
+
"values": [
|
|
43
|
+
{ "property": "width_cm", "value": 50, "quote": "Stahl emailliert, 50x35 cm" },
|
|
44
|
+
{ "property": "depth_cm", "value": 35, "quote": "Stahl emailliert, 50x35 cm" },
|
|
45
|
+
{
|
|
46
|
+
"property": "material",
|
|
47
|
+
"value": "Stahl emailliert",
|
|
48
|
+
"accept": ["stahl emailliert", "stahl, emailliert", "emaillierter stahl"],
|
|
49
|
+
"quote": "Ausgussbecken für Putzräume, Stahl emailliert"
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"property": "water_supply",
|
|
53
|
+
"value": "kalt",
|
|
54
|
+
"accept": ["kalt", "kaltwasser", "nur kaltwasser", "nur kalt"],
|
|
55
|
+
"quote": "Anschluss nur an Kaltwasser"
|
|
56
|
+
}
|
|
57
|
+
],
|
|
58
|
+
"leave_out": [
|
|
59
|
+
{ "property": "material", "value": "Edelstahl", "reason": "Edelstahl is the material of the Klapprost, an accessory." }
|
|
60
|
+
]
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"name": "S 4.3",
|
|
64
|
+
"case": "The only size in the text belongs to an accessory, the Glasablage.",
|
|
65
|
+
"values": [
|
|
66
|
+
{
|
|
67
|
+
"property": "material",
|
|
68
|
+
"value": "Mineralguss",
|
|
69
|
+
"accept": ["mineralguss"],
|
|
70
|
+
"quote": "Waschtisch, unterfahrbar, aus Mineralguss"
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"property": "water_supply",
|
|
74
|
+
"value": "kalt und warm",
|
|
75
|
+
"accept": ["kalt und warm", "warm und kalt", "kalt- und warmwasser", "kaltwasser und warmwasser"],
|
|
76
|
+
"quote": "Anschluss an Kalt- und Warmwasser"
|
|
77
|
+
}
|
|
78
|
+
],
|
|
79
|
+
"leave_out": [
|
|
80
|
+
{ "property": "width_cm", "value": null, "reason": "50x12 cm is the size of the Glasablage. The basin's size is left to the maker (Maße nach Hersteller)." },
|
|
81
|
+
{ "property": "depth_cm", "value": null, "reason": "50x12 cm is the size of the Glasablage. The basin's size is left to the maker (Maße nach Hersteller)." },
|
|
82
|
+
{ "property": "material", "value": "Glas", "reason": "Glas is the material of the Glasablage, an accessory." },
|
|
83
|
+
{ "property": "material", "value": "Edelstahl", "reason": "Edelstahl is the material of the Haltegriff, an accessory." }
|
|
84
|
+
]
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"name": "S 4.4",
|
|
88
|
+
"case": "Every value stated once, plainly.",
|
|
89
|
+
"values": [
|
|
90
|
+
{ "property": "width_cm", "value": 40, "quote": "Edelstahl, 40x30 cm" },
|
|
91
|
+
{ "property": "depth_cm", "value": 30, "quote": "Edelstahl, 40x30 cm" },
|
|
92
|
+
{
|
|
93
|
+
"property": "material",
|
|
94
|
+
"value": "Edelstahl",
|
|
95
|
+
"accept": ["edelstahl"],
|
|
96
|
+
"quote": "Trinkbrunnen für den Flur, Edelstahl"
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"property": "water_supply",
|
|
100
|
+
"value": "kalt",
|
|
101
|
+
"accept": ["kalt", "kaltwasser", "nur kaltwasser", "nur kalt"],
|
|
102
|
+
"quote": "Anschluss an Kaltwasser in Trinkwasserqualität"
|
|
103
|
+
}
|
|
104
|
+
],
|
|
105
|
+
"leave_out": []
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"name": "S 4.5",
|
|
109
|
+
"case": "A variant that holds only under a condition (Bei Einsatz von Geschirrspülern).",
|
|
110
|
+
"values": [
|
|
111
|
+
{ "property": "width_cm", "value": 100, "quote": "Edelstahl, 100x50 cm" },
|
|
112
|
+
{ "property": "depth_cm", "value": 50, "quote": "Edelstahl, 100x50 cm" },
|
|
113
|
+
{
|
|
114
|
+
"property": "material",
|
|
115
|
+
"value": "Edelstahl",
|
|
116
|
+
"accept": ["edelstahl"],
|
|
117
|
+
"quote": "Spüle für die Lehrerküche, Edelstahl"
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
"property": "water_supply",
|
|
121
|
+
"value": "kalt und warm",
|
|
122
|
+
"accept": ["kalt und warm", "warm und kalt", "kalt- und warmwasser", "kaltwasser und warmwasser"],
|
|
123
|
+
"quote": "Anschluss an Kalt- und Warmwasser"
|
|
124
|
+
}
|
|
125
|
+
],
|
|
126
|
+
"leave_out": [
|
|
127
|
+
{ "property": "width_cm", "value": 120, "reason": "The width of the variant with two basins, which holds only bei Einsatz von Geschirrspülern." }
|
|
128
|
+
]
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"name": "S 4.6",
|
|
132
|
+
"case": "A second size that is a height, and no water supply.",
|
|
133
|
+
"values": [
|
|
134
|
+
{ "property": "width_cm", "value": 60, "quote": "Breite 60 cm" },
|
|
135
|
+
{
|
|
136
|
+
"property": "material",
|
|
137
|
+
"value": "Kristallglas",
|
|
138
|
+
"accept": ["kristallglas", "glas"],
|
|
139
|
+
"quote": "Spiegel für Waschplätze, Kristallglas"
|
|
140
|
+
}
|
|
141
|
+
],
|
|
142
|
+
"leave_out": [
|
|
143
|
+
{ "property": "depth_cm", "value": null, "reason": "45 cm is the height (Höhe). The text gives no depth." },
|
|
144
|
+
{ "property": "water_supply", "value": null, "reason": "The text names no water supply." }
|
|
145
|
+
]
|
|
146
|
+
}
|
|
147
|
+
]
|
|
148
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"name": "S 4.1",
|
|
4
|
+
"text": "S 4.1 Waschtisch für Klassenräume, aus Sanitärkeramik, weiß, ca. 60x40 cm, mit einem Hahnloch. Anschluss an Kalt- und Warmwasser über Einhebelmischer."
|
|
5
|
+
},
|
|
6
|
+
{
|
|
7
|
+
"name": "S 4.2",
|
|
8
|
+
"text": "S 4.2 Ausgussbecken für Putzräume, Stahl emailliert, 50x35 cm. Zubehör: Klapprost aus Edelstahl. Anschluss nur an Kaltwasser."
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"name": "S 4.3",
|
|
12
|
+
"text": "S 4.3 Waschtisch, unterfahrbar, aus Mineralguss, Maße nach Hersteller. Zubehör: Glasablage 50x12 cm und Haltegriff aus Edelstahl. Anschluss an Kalt- und Warmwasser."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "S 4.4",
|
|
16
|
+
"text": "S 4.4 Trinkbrunnen für den Flur, Edelstahl, 40x30 cm, mit Druckknopfventil. Anschluss an Kaltwasser in Trinkwasserqualität."
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"name": "S 4.5",
|
|
20
|
+
"text": "S 4.5 Spüle für die Lehrerküche, Edelstahl, 100x50 cm, mit einem Becken und Abtropffläche. Anschluss an Kalt- und Warmwasser. Bei Einsatz von Geschirrspülern: Ausführung mit zwei Becken, 120x50 cm."
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"name": "S 4.6",
|
|
24
|
+
"text": "S 4.6 Spiegel für Waschplätze, Kristallglas, Kanten poliert, Breite 60 cm, Höhe 45 cm."
|
|
25
|
+
}
|
|
26
|
+
]
|