@littlebigbrain/create 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +55 -0
- package/README.md +73 -2
- package/bin/create-littlebigbrain.mjs +9 -0
- package/lib/create.mjs +244 -0
- package/package.json +46 -4
- package/project/setup.ts +18 -0
- package/project/try.ts +30 -0
- package/project/worker.ts +23 -0
- package/templates/_shared/testing.ts +521 -0
- package/templates/documents-link/README.md +146 -0
- package/templates/documents-link/link.ts +580 -0
- package/templates/documents-link/samples/ausstattungsliste.pdf +76 -0
- package/templates/documents-link/samples/baubeschreibung.pdf +54 -0
- package/templates/documents-link/samples/expected.json +404 -0
- package/templates/documents-link/samples/pages/ausstattungsliste.json +1150 -0
- package/templates/documents-link/samples/pages/baubeschreibung.json +802 -0
- package/templates/documents-link/samples/pages/raumblaetter.json +1580 -0
- package/templates/documents-link/samples/raumblaetter.pdf +109 -0
- package/templates/documents-link/samples.ts +31 -0
- package/templates/documents-link/workflow.test.ts +497 -0
- package/templates/documents-link/workflow.ts +467 -0
- package/templates/documents-map/README.md +212 -0
- package/templates/documents-map/map.ts +1135 -0
- package/templates/documents-map/samples/ausstattungsliste.pdf +76 -0
- package/templates/documents-map/samples/baubeschreibung.pdf +54 -0
- package/templates/documents-map/samples/expected.json +50 -0
- package/templates/documents-map/samples/pages/raumblaetter.json +1580 -0
- package/templates/documents-map/samples/raumblaetter.pdf +109 -0
- package/templates/documents-map/samples/trace.json +613 -0
- package/templates/documents-map/samples.ts +182 -0
- package/templates/documents-map/workflow.test.ts +770 -0
- package/templates/documents-map/workflow.ts +1199 -0
- package/templates/ontology-fit/README.md +81 -0
- package/templates/ontology-fit/samples/expected.md +88 -0
- package/templates/ontology-fit/samples/texts.json +22 -0
- package/templates/ontology-fit/samples.ts +40 -0
- package/templates/ontology-fit/workflow.test.ts +270 -0
- package/templates/ontology-fit/workflow.ts +416 -0
- package/templates/review/README.md +158 -0
- package/templates/review/decision.ts +483 -0
- package/templates/review/samples/expected.json +37 -0
- package/templates/review/samples/proposals.json +35 -0
- package/templates/review/samples.ts +154 -0
- package/templates/review/workflow.test.ts +438 -0
- package/templates/review/workflow.ts +459 -0
- package/templates/values-extract/README.md +81 -0
- package/templates/values-extract/samples/expected.json +148 -0
- package/templates/values-extract/samples/records.json +26 -0
- package/templates/values-extract/samples.ts +27 -0
- package/templates/values-extract/workflow.test.ts +279 -0
- package/templates/values-extract/workflow.ts +419 -0
- package/templates/versions.json +3 -0
|
@@ -0,0 +1,416 @@
|
|
|
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
|
+
s.progress("reading texts", { done: i, total: batch.length, unit: "instances" });
|
|
304
|
+
const record = (await texts())[iri];
|
|
305
|
+
if (!record?.text.trim()) return [];
|
|
306
|
+
const ontology = await (onto ??= lbb.ontology().then(describe));
|
|
307
|
+
// The changes the earlier texts of this turn proposed: the model proposes them again by the same names.
|
|
308
|
+
const earlier = [...new Set(kept.flat().map((c) => c.title))];
|
|
309
|
+
const system = [
|
|
310
|
+
SYSTEM,
|
|
311
|
+
`The ontology now:\n${ontology.text}`,
|
|
312
|
+
...(earlier.length
|
|
313
|
+
? [
|
|
314
|
+
`Changes already proposed and waiting for review:\n${earlier.map((t) => `- ${t}`).join("\n")}`,
|
|
315
|
+
]
|
|
316
|
+
: []),
|
|
317
|
+
].join("\n\n");
|
|
318
|
+
const prompt = JSON.stringify({
|
|
319
|
+
class: settings.className,
|
|
320
|
+
context: settings.context,
|
|
321
|
+
instance: record.label,
|
|
322
|
+
text: record.text,
|
|
323
|
+
});
|
|
324
|
+
const answer = await lbb.reason<{ proposals: Raw[] }>(
|
|
325
|
+
{ system, prompt, schema: SCHEMA, max_tokens: 16_000 },
|
|
326
|
+
{ effectId: s.effectId, label: `${record.label.slice(0, 50)} fit` },
|
|
327
|
+
);
|
|
328
|
+
const out: Change[] = [];
|
|
329
|
+
for (const raw of (answer.value.proposals ?? []).slice(0, 12)) {
|
|
330
|
+
const change = shape(message.workflow, raw, ontology);
|
|
331
|
+
if (
|
|
332
|
+
!change ||
|
|
333
|
+
out.some((c) => c.key === change.key) ||
|
|
334
|
+
!quoteInText(change.quote, record.text)
|
|
335
|
+
)
|
|
336
|
+
continue;
|
|
337
|
+
// A dry run: a change the ontology has already is a no-op.
|
|
338
|
+
const preview = await lbb
|
|
339
|
+
.evolve(change.change, { dryRun: true })
|
|
340
|
+
.catch((error: unknown) => {
|
|
341
|
+
if (error instanceof LbbError && error.status === 400) return null;
|
|
342
|
+
throw error;
|
|
343
|
+
});
|
|
344
|
+
if (preview && !preview.no_op && preview.publishable) out.push(change);
|
|
345
|
+
}
|
|
346
|
+
return out;
|
|
347
|
+
}),
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
const filed = await ctx.step("review", async (s) => {
|
|
351
|
+
const records = await texts();
|
|
352
|
+
const byKey = new Map<
|
|
353
|
+
string,
|
|
354
|
+
{ change: Change; samples: { entity: string; label: string; quote: string }[] }
|
|
355
|
+
>();
|
|
356
|
+
for (const [i, changes] of kept.entries())
|
|
357
|
+
for (const change of changes) {
|
|
358
|
+
const group = byKey.get(change.key) ?? { change, samples: [] };
|
|
359
|
+
group.samples.push({
|
|
360
|
+
entity: batch[i]!,
|
|
361
|
+
label: records[batch[i]!]?.label ?? "",
|
|
362
|
+
quote: change.quote.slice(0, 600),
|
|
363
|
+
});
|
|
364
|
+
byKey.set(change.key, group);
|
|
365
|
+
}
|
|
366
|
+
for (const [n, { change, samples }] of [...byKey.values()].entries()) {
|
|
367
|
+
s.progress("filing review items", { done: n, total: byKey.size, unit: "items" });
|
|
368
|
+
await lbb.review({
|
|
369
|
+
workflow: message.workflow,
|
|
370
|
+
key: change.key,
|
|
371
|
+
title: change.title.slice(0, 200),
|
|
372
|
+
rationale: `${change.reason} "${change.quote.slice(0, 600)}"`.trim().slice(0, 4_000),
|
|
373
|
+
anchor: change.anchor,
|
|
374
|
+
change: change.change,
|
|
375
|
+
evidence: { records: samples.length, samples: samples.slice(0, 20) },
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
return byKey.size;
|
|
379
|
+
});
|
|
380
|
+
const next = {
|
|
381
|
+
texts: state.texts + batch.length,
|
|
382
|
+
proposals: state.proposals + kept.flat().length,
|
|
383
|
+
filed: state.filed + filed,
|
|
384
|
+
};
|
|
385
|
+
const rest = message.instances.slice(TURN_INSTANCES);
|
|
386
|
+
return rest.length
|
|
387
|
+
? ctx.continue(next, { ...message, instances: rest, removed: [] })
|
|
388
|
+
: { state: next };
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
/** The label and text of each instance of the turn, read once per attempt at the message's commit. */
|
|
393
|
+
function reader(message: WorkflowTriggerMessage, iris: string[]) {
|
|
394
|
+
let read: Promise<Record<string, { label: string; text: string }>> | undefined;
|
|
395
|
+
return () =>
|
|
396
|
+
(read ??= (async () => {
|
|
397
|
+
const query = `# ontology-fit: texts
|
|
398
|
+
SELECT ?s ?label ?text WHERE {
|
|
399
|
+
VALUES ?s { ${iris
|
|
400
|
+
.filter((iri) => PLAIN_IRI.test(iri))
|
|
401
|
+
.map((iri) => `<${iri}>`)
|
|
402
|
+
.join(" ")} }
|
|
403
|
+
OPTIONAL { ?s <http://www.w3.org/2000/01/rdf-schema#label> ?label }
|
|
404
|
+
OPTIONAL { ?s <https://littlebigbrain.com/p/${encodeURIComponent(settings.textField.toLowerCase())}> ?text }
|
|
405
|
+
}`;
|
|
406
|
+
const { rows } = await lbb.sparql({ query, as_of_commit_seq: message.as_of_commit_seq });
|
|
407
|
+
const out: Record<string, { label: string; text: string }> = {};
|
|
408
|
+
for (const row of rows) {
|
|
409
|
+
const record = (out[row.s!] ??= { label: row.label ?? row.s!, text: "" });
|
|
410
|
+
if (row.text && !record.text.includes(row.text))
|
|
411
|
+
record.text = `${record.text}\n${row.text}`.trim().slice(0, TEXT_CHARS);
|
|
412
|
+
}
|
|
413
|
+
return out;
|
|
414
|
+
})());
|
|
415
|
+
}
|
|
416
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# review
|
|
2
|
+
|
|
3
|
+
Decides which fact proposals become facts. A proposer (the
|
|
4
|
+
[`documents-map`](../documents-map/README.md) template, LBB's
|
|
5
|
+
`documents.map`, or a workflow of yours) does not write the facts of the
|
|
6
|
+
graph's own ontology itself: it writes one `FactProposal` per statement,
|
|
7
|
+
with its confidence and its evidence. This workflow writes a proposal at or
|
|
8
|
+
above the gate as a fact, with the proposal as its evidence, and gives the
|
|
9
|
+
others to a person: one review item per instance.
|
|
10
|
+
|
|
11
|
+
The LBB-run version is the starter workflow `review@v1`
|
|
12
|
+
(`docs/reference/starter-workflows.md`, "Review"; design
|
|
13
|
+
`docs/architecture/document-output-mapping.md`). This template is the same
|
|
14
|
+
decision in TypeScript: the workflow in `workflow.ts`, the rules and the
|
|
15
|
+
facts in `decision.ts`. It calls no model.
|
|
16
|
+
|
|
17
|
+
## How it runs
|
|
18
|
+
|
|
19
|
+
The triggered workflow watches the class `FactProposal` and the fields of
|
|
20
|
+
its statement (`fact_subject`, `fact_predicate`, `fact_object`), so a new
|
|
21
|
+
proposal, or one whose statement changed, sends a message. A turn decides at
|
|
22
|
+
most 20 proposals, two steps each, and files at most one review item per
|
|
23
|
+
instance; the rest of a message goes to a new turn (`ctx.continue`). Each
|
|
24
|
+
proposal is decided once: one that has a `fact_status` already is skipped.
|
|
25
|
+
|
|
26
|
+
1. **Read** the proposal (`fact_subject`, `fact_predicate`, `fact_object`,
|
|
27
|
+
`fact_datatype`, `fact_confidence`, `fact_evidence`, `proposed_by`), the
|
|
28
|
+
entities its statement names, and the ontology (`GET /v1/ontology`). An
|
|
29
|
+
entity the graph does not have is a new instance: its class comes from an
|
|
30
|
+
`rdf:type` proposal of it, its name from an `rdfs:label` proposal.
|
|
31
|
+
2. **What the statement is** (`classify`): an `rdf:type` of a class, an
|
|
32
|
+
`rdfs:label`, a relation between two instances (the entry's `DENOTES`
|
|
33
|
+
included), or a property value in its type. The ends of a relation must fit
|
|
34
|
+
its domain and range, superclasses included.
|
|
35
|
+
3. **The decision** (`decide`), in this order:
|
|
36
|
+
- **refused** when the graph cannot hold the statement: a predicate that
|
|
37
|
+
is no relation or property of the records ontology (nor `rdf:type` or
|
|
38
|
+
`rdfs:label`), a value of another type, an object that is no instance,
|
|
39
|
+
an instance without a class, an end its relation does not take, a second
|
|
40
|
+
class, or a statement about a proposal;
|
|
41
|
+
- **to a person** when a rule of `alwaysReview` names it (`new_instance`:
|
|
42
|
+
a statement whose subject or object the graph does not have yet; or a
|
|
43
|
+
predicate IRI), when it would replace another writer's value (a records
|
|
44
|
+
graph holds one value per property) or name, or when its confidence is
|
|
45
|
+
below the gate. The proposal goes into the **review item of its
|
|
46
|
+
instance** (`groupOf`): its subject; the object of a link whose subject
|
|
47
|
+
the graph has and whose object is new; and always the object of the
|
|
48
|
+
trace (`DENOTES`). One item holds every proposal of its instance that
|
|
49
|
+
the turn sends to a person, in order: the type, the label, values,
|
|
50
|
+
links, the trace (`fileItem`). Its title names the instance (`New
|
|
51
|
+
Equipment M 7 Medienwagen`, or `Room Raum 0.12 Klassenraum: 1 fact to
|
|
52
|
+
check`), its rationale says why, and it has one evidence sample per
|
|
53
|
+
fact (what it states, its confidence, its line). Its facts are each
|
|
54
|
+
statement and its proposal's `fact_status: accepted`, and each sample
|
|
55
|
+
names the rows and triplets of its fact (`row`, `rows`, `triplets`), so
|
|
56
|
+
a person can leave facts out of the accept. The type of a new instance
|
|
57
|
+
is a required row: a person keeps it or dismisses the item;
|
|
58
|
+
- **written** otherwise: a relation as an edge whose evidence names the
|
|
59
|
+
proposal (`source_id` is the proposal's IRI, the text its evidence); a
|
|
60
|
+
property as its value; a new instance's `rdf:type` as the instance of
|
|
61
|
+
that class with `proposed_in` (a records graph holds an instance through
|
|
62
|
+
its facts); a label as the entity's name. A new instance takes the name
|
|
63
|
+
of its label proposal (the first in order) from its first write, so its
|
|
64
|
+
name does not depend on which statement is written first. A label is a
|
|
65
|
+
text: an IRI or an empty label is refused. A statement the graph holds
|
|
66
|
+
already is not written again. Nothing is taken back, a name included: a
|
|
67
|
+
records instance keeps one.
|
|
68
|
+
4. **Write** (`writeOwned`): the proposal's `fact_status` (`written`,
|
|
69
|
+
`in_review` or `refused`) and `review_note`, owned by this workflow, and in
|
|
70
|
+
the same commit the facts of a written statement.
|
|
71
|
+
|
|
72
|
+
## Settings
|
|
73
|
+
|
|
74
|
+
`defaults` in `workflow.ts`, those of LBB's `review`:
|
|
75
|
+
|
|
76
|
+
| Setting | Default | Meaning |
|
|
77
|
+
| --- | --- | --- |
|
|
78
|
+
| `className` | `FactProposal` | The class of the proposals |
|
|
79
|
+
| `gate` | 0.8 | A proposal below this confidence goes to a person |
|
|
80
|
+
| `alwaysReview` | `[]` | What goes to a person whatever its confidence: `new_instance`, and predicate IRIs (`https://littlebigbrain.com/r/has_equipment`). Empty: the gate decides everything, a new instance too |
|
|
81
|
+
|
|
82
|
+
With `["new_instance"]` every statement about a new instance goes to a
|
|
83
|
+
person, whatever its confidence. That was the default of LBB's `review`
|
|
84
|
+
until 2026-10-12.
|
|
85
|
+
|
|
86
|
+
`trigger(settings)` names the triggered workflow `review` and owns
|
|
87
|
+
`fact_status` and `review_note`. `ontology(settings)` adds the class and the
|
|
88
|
+
properties of a proposal and of the review (`proposed_in` too). Do not run it
|
|
89
|
+
beside an LBB-run `review` on the same graph: they own the same properties.
|
|
90
|
+
|
|
91
|
+
## What the LBB-run version adds
|
|
92
|
+
|
|
93
|
+
- **It writes the accepted facts itself.** Its item carries the statuses
|
|
94
|
+
only; its sweep writes each kept fact, the type first, and dismisses the
|
|
95
|
+
facts a person left out. A link to another new instance waits until that
|
|
96
|
+
instance's item is accepted, and is dismissed with it. The template's item
|
|
97
|
+
commits its facts on accept, so such a link makes the other instance too.
|
|
98
|
+
- **One item per instance over time.** Its items follow the open proposals
|
|
99
|
+
of an instance across messages: when they change, it supersedes the open
|
|
100
|
+
item with a new one, and an accept of the old item fails (409). The
|
|
101
|
+
template files one item per instance and turn: the key carries the
|
|
102
|
+
proposals, so a later turn with other proposals of the instance files
|
|
103
|
+
another item.
|
|
104
|
+
- **Withdrawn proposals.** When a proposer withdraws a proposal, it takes
|
|
105
|
+
the proposal out of its item and retracts the fact it wrote, unless a
|
|
106
|
+
person accepted it or the fact held before; a record per proposal says
|
|
107
|
+
which fact. The template keeps both: dismiss the item, or retract the fact
|
|
108
|
+
yourself.
|
|
109
|
+
- **Dismissed items.** It checks the open review items and sets
|
|
110
|
+
`dismissed` on the proposals of a dismissed one. The template leaves
|
|
111
|
+
`in_review`.
|
|
112
|
+
- **Predicates another workflow owns.** It refuses a statement whose
|
|
113
|
+
predicate another workflow owns by predicate, since that workflow's owned
|
|
114
|
+
write would delete it.
|
|
115
|
+
- **RDF-native graphs by IRI.** On an RDF-native graph it writes each
|
|
116
|
+
statement as one owned triple: `lbb:denotes`, the asserted schema's
|
|
117
|
+
properties (the OWL object, symmetric, transitive, functional,
|
|
118
|
+
inverse-functional and asymmetric properties take an IRI) and classes
|
|
119
|
+
(the ends of `rdfs:subClassOf` count), the records ontology's terms and
|
|
120
|
+
`rdfs:label`. There a subject or object is new when it is the subject of
|
|
121
|
+
no statement. The template writes through `POST /v1/graph/commit`, on both
|
|
122
|
+
graph kinds: the records ontology's relations (the records `DENOTES`
|
|
123
|
+
included), properties and classes, and `rdfs:label`. An instance is new
|
|
124
|
+
when it has no class of the records ontology. It refuses any other
|
|
125
|
+
predicate with a note that says so (`owl:sameAs`, `lbb:denotes`).
|
|
126
|
+
- **Ownership by statement.** Its facts count as owned by statement, so it
|
|
127
|
+
conflicts with no other workflow on the predicates it writes. The template
|
|
128
|
+
owns `fact_status` and `review_note` only and writes the facts without
|
|
129
|
+
owning them.
|
|
130
|
+
- **The graph refuses.** It lets the commit refuse a statement (a domain, a
|
|
131
|
+
range) and marks the proposal `refused`. The template checks domain and
|
|
132
|
+
range before it writes; another refusal stops the turn.
|
|
133
|
+
- **Status and preview.** Counters per outcome, the open items it follows,
|
|
134
|
+
and a preview of what it would decide.
|
|
135
|
+
|
|
136
|
+
## Samples and test
|
|
137
|
+
|
|
138
|
+
`samples/proposals.json` holds a small invented graph (two rooms of the
|
|
139
|
+
school building and five pieces of equipment, one `HAS_EQUIPMENT` edge) and
|
|
140
|
+
14 proposals about it, one for each case; `samples/expected.json` gives the
|
|
141
|
+
status each one gets with `alwaysReview` `["new_instance"]`, the facts the
|
|
142
|
+
graph holds after the run, and the three review items with the proposals
|
|
143
|
+
each one holds
|
|
144
|
+
([`../_samples/README.md`](../_samples/README.md)).
|
|
145
|
+
|
|
146
|
+
`npm run try` adds the sample ontology (`Room`, `Equipment`,
|
|
147
|
+
`HAS_EQUIPMENT`, `identifier`, `area_m2`), commits the instances, reads
|
|
148
|
+
their IRIs back, and commits the proposals as a proposer would
|
|
149
|
+
(`proposed_by` is `samples/review`). This is the simplest input: the review
|
|
150
|
+
calls no model and needs no proposer to run first. To review the proposals
|
|
151
|
+
of `documents-map`, run it (or LBB's `documents.map`) on the same graph.
|
|
152
|
+
|
|
153
|
+
`workflow.test.ts` runs the workflow on a graph in memory with the samples:
|
|
154
|
+
the statuses and facts of the answer key, the evidence of a written fact,
|
|
155
|
+
the notes, one item per instance (the order of its facts and its required
|
|
156
|
+
type), the facts of an accepted item, the default settings (the gate
|
|
157
|
+
decides a new instance), a predicate rule, a second message that changes
|
|
158
|
+
nothing, and a message larger than a turn.
|