@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.
Files changed (52) hide show
  1. package/LICENSE +55 -0
  2. package/README.md +73 -2
  3. package/bin/create-littlebigbrain.mjs +9 -0
  4. package/lib/create.mjs +244 -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 +521 -0
  10. package/templates/documents-link/README.md +146 -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 +497 -0
  21. package/templates/documents-link/workflow.ts +467 -0
  22. package/templates/documents-map/README.md +212 -0
  23. package/templates/documents-map/map.ts +1135 -0
  24. package/templates/documents-map/samples/ausstattungsliste.pdf +76 -0
  25. package/templates/documents-map/samples/baubeschreibung.pdf +54 -0
  26. package/templates/documents-map/samples/expected.json +50 -0
  27. package/templates/documents-map/samples/pages/raumblaetter.json +1580 -0
  28. package/templates/documents-map/samples/raumblaetter.pdf +109 -0
  29. package/templates/documents-map/samples/trace.json +613 -0
  30. package/templates/documents-map/samples.ts +182 -0
  31. package/templates/documents-map/workflow.test.ts +770 -0
  32. package/templates/documents-map/workflow.ts +1199 -0
  33. package/templates/ontology-fit/README.md +81 -0
  34. package/templates/ontology-fit/samples/expected.md +88 -0
  35. package/templates/ontology-fit/samples/texts.json +22 -0
  36. package/templates/ontology-fit/samples.ts +40 -0
  37. package/templates/ontology-fit/workflow.test.ts +270 -0
  38. package/templates/ontology-fit/workflow.ts +416 -0
  39. package/templates/review/README.md +158 -0
  40. package/templates/review/decision.ts +483 -0
  41. package/templates/review/samples/expected.json +37 -0
  42. package/templates/review/samples/proposals.json +35 -0
  43. package/templates/review/samples.ts +154 -0
  44. package/templates/review/workflow.test.ts +438 -0
  45. package/templates/review/workflow.ts +459 -0
  46. package/templates/values-extract/README.md +81 -0
  47. package/templates/values-extract/samples/expected.json +148 -0
  48. package/templates/values-extract/samples/records.json +26 -0
  49. package/templates/values-extract/samples.ts +27 -0
  50. package/templates/values-extract/workflow.test.ts +279 -0
  51. package/templates/values-extract/workflow.ts +419 -0
  52. 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.