@littlebigbrain/create 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/LICENSE +55 -0
  2. package/README.md +71 -2
  3. package/bin/create-littlebigbrain.mjs +9 -0
  4. package/lib/create.mjs +238 -0
  5. package/package.json +46 -4
  6. package/project/setup.ts +18 -0
  7. package/project/try.ts +30 -0
  8. package/project/worker.ts +23 -0
  9. package/templates/_shared/testing.ts +490 -0
  10. package/templates/documents-link/README.md +144 -0
  11. package/templates/documents-link/link.ts +580 -0
  12. package/templates/documents-link/samples/ausstattungsliste.pdf +76 -0
  13. package/templates/documents-link/samples/baubeschreibung.pdf +54 -0
  14. package/templates/documents-link/samples/expected.json +404 -0
  15. package/templates/documents-link/samples/pages/ausstattungsliste.json +1150 -0
  16. package/templates/documents-link/samples/pages/baubeschreibung.json +802 -0
  17. package/templates/documents-link/samples/pages/raumblaetter.json +1580 -0
  18. package/templates/documents-link/samples/raumblaetter.pdf +109 -0
  19. package/templates/documents-link/samples.ts +31 -0
  20. package/templates/documents-link/workflow.test.ts +470 -0
  21. package/templates/documents-link/workflow.ts +457 -0
  22. package/templates/ontology-fit/README.md +81 -0
  23. package/templates/ontology-fit/samples/expected.md +88 -0
  24. package/templates/ontology-fit/samples/texts.json +22 -0
  25. package/templates/ontology-fit/samples.ts +40 -0
  26. package/templates/ontology-fit/workflow.test.ts +264 -0
  27. package/templates/ontology-fit/workflow.ts +413 -0
  28. package/templates/values-extract/README.md +81 -0
  29. package/templates/values-extract/samples/expected.json +148 -0
  30. package/templates/values-extract/samples/records.json +26 -0
  31. package/templates/values-extract/samples.ts +27 -0
  32. package/templates/values-extract/workflow.test.ts +256 -0
  33. package/templates/values-extract/workflow.ts +411 -0
  34. package/templates/versions.json +3 -0
@@ -0,0 +1,27 @@
1
+ // The samples of values-extract, for `npm run try`: six invented entries
2
+ // of a sanitary equipment list (samples/records.json), committed as
3
+ // instances of the class the settings name, each with its text.
4
+ import { readFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import type { LbbClient } from "@littlebigbrain/client";
7
+ import { defaults, ontology } from "./workflow.js";
8
+
9
+ /** Add the class and its properties, then commit the sample records to the client's graph. */
10
+ export async function loadSamples(client: LbbClient, dir: string): Promise<void> {
11
+ const records = JSON.parse(await readFile(join(dir, "records.json"), "utf8")) as {
12
+ name: string;
13
+ text: string;
14
+ }[];
15
+ await client.ontology.evolve({ ops: ontology() });
16
+ await client.commit(
17
+ {
18
+ entity_properties: records.map((record) => ({
19
+ type: defaults.className,
20
+ name: record.name,
21
+ properties: { [defaults.textField]: record.text },
22
+ })),
23
+ },
24
+ { idempotencyKey: "values-extract-samples" },
25
+ );
26
+ console.log(`Committed ${records.length} ${defaults.className} records`);
27
+ }
@@ -0,0 +1,256 @@
1
+ // values-extract on its samples, with mock models whose rules are fixed:
2
+ // the reasoning model reads the first size (`60x40 cm`, or `Breite 60 cm`),
3
+ // the first material and the water supply of each text, and the decision
4
+ // model says a quote from a sentence about an accessory (`Zubehör`) or a
5
+ // conditional variant (`Bei Einsatz`) does not support its value. The
6
+ // expected values come from samples/expected.json.
7
+ import { test } from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { join } from "node:path";
10
+ import { ENTITY_IRI_NAMESPACE, type DecideAnswer } from "@littlebigbrain/client";
11
+ import {
12
+ MockActivities,
13
+ mockId,
14
+ readJson,
15
+ runMessage,
16
+ samplesDir,
17
+ triggerMessage,
18
+ type MockRules,
19
+ } from "../_shared/testing.js";
20
+ import { createWorkflow, defaults, quoteHolds, typed } from "./workflow.js";
21
+
22
+ const SAMPLES = samplesDir(import.meta.url, "values-extract");
23
+ const RECORDS = readJson<{ name: string; text: string }[]>(join(SAMPLES, "records.json"));
24
+ interface ExpectedValue {
25
+ property: string;
26
+ value: number | string | null;
27
+ accept?: string[];
28
+ quote?: string;
29
+ }
30
+ const EXPECTED = readJson<{
31
+ records: { name: string; values: ExpectedValue[]; leave_out: ExpectedValue[] }[];
32
+ }>(join(SAMPLES, "expected.json"));
33
+
34
+ const iriOf = (name: string) => ENTITY_IRI_NAMESPACE + mockId(`EquipmentItem:${name}`);
35
+
36
+ const MATERIALS = [
37
+ "Sanitärkeramik",
38
+ "Stahl emailliert",
39
+ "Mineralguss",
40
+ "Edelstahl",
41
+ "Kristallglas",
42
+ "Keramik",
43
+ "Glas",
44
+ ];
45
+
46
+ /** The mock reasoning model: the first size, material and water supply of a text. */
47
+ function readValues(text: string) {
48
+ const values: { property: string; value: string; quote: string }[] = [];
49
+ const size = text.match(/(?:ca\. )?(\d+)x(\d+) cm/);
50
+ const width = text.match(/Breite (\d+) cm/);
51
+ if (size)
52
+ values.push(
53
+ { property: "width_cm", value: size[1]!, quote: size[0] },
54
+ { property: "depth_cm", value: size[2]!, quote: size[0] },
55
+ );
56
+ else if (width) values.push({ property: "width_cm", value: width[1]!, quote: width[0] });
57
+ const material = MATERIALS.map((m) => ({
58
+ m,
59
+ at: text.search(new RegExp(`(?<![\\p{L}])${m}(?![\\p{L}])`, "u")),
60
+ }))
61
+ .filter((found) => found.at >= 0)
62
+ .sort((a, b) => a.at - b.at)[0];
63
+ if (material) values.push({ property: "material", value: material.m, quote: material.m });
64
+ const water = text.match(/Anschluss[^.]*?(Kalt- und Warmwasser|Kaltwasser)/);
65
+ if (water)
66
+ values.push({
67
+ property: "water_supply",
68
+ value: water[1] === "Kaltwasser" ? "kalt" : "kalt und warm",
69
+ quote: water[0],
70
+ });
71
+ return values;
72
+ }
73
+
74
+ /** The mock decision model: the support of each quote, from the sentence that holds it. */
75
+ function support(text: string, instructions: string, options: string[]): DecideAnswer {
76
+ const quote = instructions.match(/Quote: "(.*)"$/s)![1]!;
77
+ const sentence = text.split(/(?<=\.)\s+/).find((s) => s.includes(quote)) ?? "";
78
+ const p = /Zubehör|Bei Einsatz/.test(sentence) ? 0.05 : /etwa/.test(sentence) ? 0.6 : 0.92;
79
+ const choice = p >= 0.5 ? "supported" : "not_supported";
80
+ return {
81
+ choice,
82
+ confidence: Math.max(p, 1 - p),
83
+ probabilities: Object.fromEntries(options.map((o) => [o, o === "supported" ? p : (1 - p) / 2])),
84
+ };
85
+ }
86
+
87
+ function rules(records: { name: string; text: string }[], more: MockRules = {}): MockRules {
88
+ return {
89
+ reason: (request) => ({
90
+ values: readValues((JSON.parse(request.prompt) as { text: string }).text),
91
+ }),
92
+ decide: (request) =>
93
+ Object.fromEntries(
94
+ Object.entries(request.questions).map(([id, q]) => [
95
+ id,
96
+ support(
97
+ (request.state as { text: string }).text,
98
+ q.instructions,
99
+ Object.keys(q.criteria),
100
+ ),
101
+ ]),
102
+ ),
103
+ sparql: (query) => {
104
+ assert.ok(query.startsWith("# values-extract: texts"));
105
+ return records
106
+ .filter((r) => query.includes(iriOf(r.name)))
107
+ .map((r) => ({ s: iriOf(r.name), label: r.name, text: r.text }));
108
+ },
109
+ ...more,
110
+ };
111
+ }
112
+
113
+ /** The values written on an instance: its owned properties. */
114
+ const written = (lbb: MockActivities, name: string) =>
115
+ lbb.graph.entities.get(iriOf(name))?.properties ?? {};
116
+
117
+ const matches = (got: unknown, want: ExpectedValue) =>
118
+ typeof want.value === "number"
119
+ ? got === want.value
120
+ : (want.accept ?? [String(want.value).toLowerCase()]).includes(
121
+ String(got).trim().toLowerCase(),
122
+ );
123
+
124
+ test("the samples give the values of the answer key and none it leaves out", async () => {
125
+ const lbb = new MockActivities(rules(RECORDS));
126
+ const { state, turns } = await runMessage(
127
+ createWorkflow(lbb),
128
+ triggerMessage(
129
+ "values-extract",
130
+ RECORDS.map((r) => iriOf(r.name)),
131
+ ),
132
+ );
133
+
134
+ for (const record of EXPECTED.records) {
135
+ const values = written(lbb, record.name);
136
+ for (const want of record.values)
137
+ assert.ok(
138
+ matches(values[want.property], want),
139
+ `${record.name} ${want.property}: ${values[want.property]}`,
140
+ );
141
+ for (const out of record.leave_out)
142
+ assert.ok(
143
+ out.value === null ? !(out.property in values) : !matches(values[out.property], out),
144
+ `${record.name} leaves out ${out.property}`,
145
+ );
146
+ assert.deepEqual(
147
+ Object.keys(values).sort(),
148
+ record.values.map((v) => v.property).sort(),
149
+ record.name,
150
+ );
151
+ }
152
+ assert.deepEqual(state, { instances: 6, written: 20, review: 0, dropped: 2 });
153
+ assert.equal(lbb.reviews.size, 0);
154
+ assert.equal(turns.length, 1);
155
+ });
156
+
157
+ test("each written value has a source whose evidence is a quote of the text", async () => {
158
+ const lbb = new MockActivities(rules(RECORDS));
159
+ await runMessage(
160
+ createWorkflow(lbb),
161
+ triggerMessage(
162
+ "values-extract",
163
+ RECORDS.map((r) => iriOf(r.name)),
164
+ ),
165
+ );
166
+
167
+ const edges = lbb.graph.edgesOf("HAS_VALUE_SOURCE");
168
+ assert.equal(edges.length, 20);
169
+ for (const edge of edges) {
170
+ const record = RECORDS.find((r) => iriOf(r.name) === edge.source)!;
171
+ const evidence = edge.evidence as { text: string };
172
+ assert.ok(record.text.includes(evidence.text), evidence.text);
173
+ const source = lbb.graph.entities.get(edge.target)!;
174
+ assert.equal(source.type, "ValueSource");
175
+ assert.ok((source.properties.support as number) >= defaults.gate);
176
+ }
177
+ });
178
+
179
+ test("a value below the gate goes to review, and the checks drop wrong types and quotes", async () => {
180
+ const record = {
181
+ name: "S 9.1",
182
+ text: "S 9.1 Becken aus Kunststoff. Breite etwa 70 cm, Tiefe 40 cm.",
183
+ };
184
+ const proposals = [
185
+ { property: "width_cm", value: "70", quote: "Breite etwa 70 cm" },
186
+ { property: "depth_cm", value: "vierzig", quote: "Tiefe 40 cm" },
187
+ { property: "material", value: "Holz", quote: "aus Holz gefertigt" },
188
+ { property: "material", value: "Kunststoff", quote: "Becken aus Kunststoff" },
189
+ ];
190
+ const lbb = new MockActivities(rules([record], { reason: () => ({ values: proposals }) }));
191
+ const { state } = await runMessage(
192
+ createWorkflow(lbb),
193
+ triggerMessage("values-extract", [iriOf(record.name)]),
194
+ );
195
+
196
+ assert.deepEqual(written(lbb, record.name), { material: "Kunststoff" });
197
+ assert.deepEqual(state, { instances: 1, written: 1, review: 1, dropped: 0 });
198
+ const [item] = [...lbb.reviews.values()];
199
+ assert.equal(item!.title, "Set width_cm of S 9.1 to 70");
200
+ assert.equal(item!.workflow, "values-extract");
201
+ assert.deepEqual(item!.facts!.entity_properties![0]!.properties, { width_cm: 70 });
202
+
203
+ // Accepted, the value is written with the others the next time the instance is read.
204
+ const accepting = new MockActivities(
205
+ rules([record], { reason: () => ({ values: proposals }), reviewStatus: () => "accepted" }),
206
+ );
207
+ await runMessage(
208
+ createWorkflow(accepting),
209
+ triggerMessage("values-extract", [iriOf(record.name)]),
210
+ );
211
+ assert.deepEqual(written(accepting, record.name), { width_cm: 70, material: "Kunststoff" });
212
+ });
213
+
214
+ test("an instance that leaves the class loses its values and their sources", async () => {
215
+ const lbb = new MockActivities(rules(RECORDS));
216
+ const workflow = createWorkflow(lbb);
217
+ await runMessage(
218
+ workflow,
219
+ triggerMessage(
220
+ "values-extract",
221
+ RECORDS.map((r) => iriOf(r.name)),
222
+ ),
223
+ );
224
+ await runMessage(workflow, triggerMessage("values-extract", [], [iriOf("S 4.1")]));
225
+
226
+ assert.deepEqual(written(lbb, "S 4.1"), {});
227
+ assert.ok(!lbb.graph.edgesOf("HAS_VALUE_SOURCE").some((e) => e.source === iriOf("S 4.1")));
228
+ assert.equal(lbb.graph.ofType("ValueSource").length, 20 - 4);
229
+ });
230
+
231
+ test("a message of more instances than a turn reads continues in a new turn", async () => {
232
+ const many = Array.from({ length: 30 }, (_, n) => ({ ...RECORDS[n % 6]!, name: `S 5.${n + 1}` }));
233
+ const lbb = new MockActivities(rules(many));
234
+ const { turns, state } = await runMessage(
235
+ createWorkflow(lbb),
236
+ triggerMessage(
237
+ "values-extract",
238
+ many.map((r) => iriOf(r.name)),
239
+ ),
240
+ );
241
+ assert.equal(turns.length, 2);
242
+ assert.ok(turns.every((t) => t.keys.length <= 64));
243
+ assert.equal(state.instances, 30);
244
+ });
245
+
246
+ test("values take their property's type, and a quote is in the text", () => {
247
+ assert.equal(typed("60", "f64"), 60);
248
+ assert.equal(typed("0,5", "f64"), 0.5);
249
+ assert.equal(typed("2.5", "i64"), null);
250
+ assert.equal(typed("ja", "bool"), true);
251
+ assert.equal(typed("2026-09-16", "date_time"), "2026-09-16T00:00:00Z");
252
+ assert.equal(typed("16.09.2026", "date_time"), null);
253
+ assert.ok(quoteHolds("ca. 60x40 cm", "60", "Waschtisch, ca. 60x40 cm, weiß"));
254
+ assert.ok(!quoteHolds("weiß", "Keramik", "Waschtisch, weiß"));
255
+ assert.ok(!quoteHolds("aus Holz gefertigt", "Holz", "Waschtisch aus Keramik"));
256
+ });
@@ -0,0 +1,411 @@
1
+ // values-extract: fill properties of a class from the text of its
2
+ // instances, each value with a verbatim quote.
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:
7
+ //
8
+ // 1. asks the reasoning model (`reason`) for the values of the properties the
9
+ // text states, each with the words that state it;
10
+ // 2. checks each value without a model: it takes its property's type, and its
11
+ // quote is in the text;
12
+ // 3. asks the decision model (`decide`) how well each quote supports its
13
+ // value;
14
+ // 4. writes the values at or above the gate, each with a `ValueSource` whose
15
+ // evidence is the quote, and files a review item for each value between
16
+ // 0.5 and the gate. A value below 0.5 is dropped.
17
+ //
18
+ // Every model call runs in a step with the step's effect id, so a turn that
19
+ // runs again after a crash pays for nothing twice.
20
+ import {
21
+ entityIdFromIri,
22
+ entityRef,
23
+ triggeredWorkflow,
24
+ type LbbActivities,
25
+ type Schemas,
26
+ type WorkflowTriggerMessage,
27
+ } from "@littlebigbrain/client";
28
+
29
+ export type ValueType = "text" | "keyword" | "i64" | "f64" | "bool" | "date_time";
30
+
31
+ export interface Settings {
32
+ /** The class whose instances the workflow reads. */
33
+ className: string;
34
+ /** The property that holds each instance's text. */
35
+ textField: string;
36
+ /** The properties to fill, with their types. */
37
+ properties: Record<string, ValueType>;
38
+ /** One sentence about what the text is, for the models. */
39
+ context: string;
40
+ /** A value whose support is below this goes to review (from 0.5) or is dropped. */
41
+ gate: number;
42
+ }
43
+
44
+ /** The settings: edit them for your class. These fit the samples. */
45
+ export const defaults: Settings = {
46
+ className: "EquipmentItem",
47
+ textField: "text",
48
+ properties: { width_cm: "f64", depth_cm: "f64", material: "text", water_supply: "keyword" },
49
+ context: "Entries of the sanitary equipment list of a school building tender, in German.",
50
+ gate: 0.8,
51
+ };
52
+
53
+ /** The workflow type and version the triggered workflow names. */
54
+ export const workflowType = { workflow_type: "values-extract", version: "v1" };
55
+
56
+ /** The triggered workflow that sends the instances to this workflow: `client.workflows.starters.use(trigger())`. */
57
+ export function trigger(settings: Settings = defaults): Schemas["TriggeredWorkflowRequest"] {
58
+ return {
59
+ starter: "workflow",
60
+ name: "values-extract",
61
+ watch: { class: settings.className, fields: [settings.textField] },
62
+ workflow: workflowType,
63
+ owns: {
64
+ classes: ["ValueSource"],
65
+ relations: ["HAS_VALUE_SOURCE"],
66
+ properties: Object.keys(settings.properties),
67
+ },
68
+ batch: 8,
69
+ };
70
+ }
71
+
72
+ /** The classes, properties and relation the workflow reads and writes, for `lbb.evolve`. */
73
+ export function ontology(settings: Settings = defaults): Schemas["OntologyEvolveOp"][] {
74
+ const property = (name: string, value_type: string) => ({
75
+ op: "add_property" as const,
76
+ name,
77
+ value_type,
78
+ });
79
+ return [
80
+ { op: "add_entity_type", name: settings.className },
81
+ { op: "add_entity_type", name: "ValueSource" },
82
+ property(settings.textField, "text"),
83
+ ...Object.entries(settings.properties).map(([name, type]) => property(name, type)),
84
+ property("property", "keyword"),
85
+ property("value", "text"),
86
+ property("support", "f64"),
87
+ {
88
+ op: "add_relation",
89
+ name: "HAS_VALUE_SOURCE",
90
+ domain: [settings.className],
91
+ range: ["ValueSource"],
92
+ },
93
+ ];
94
+ }
95
+
96
+ export interface ValuesState {
97
+ instances: number;
98
+ written: number;
99
+ review: number;
100
+ dropped: number;
101
+ }
102
+
103
+ /** A value after the checks: its property, its typed value, its quote and its support. */
104
+ interface Value {
105
+ property: string;
106
+ value: string | number | boolean;
107
+ quote: string;
108
+ support?: number;
109
+ }
110
+
111
+ const SYSTEM = `You read the text of one record of a knowledge graph and fill in property values the text states. Rules:
112
+ - Give a value only when the text states it. Do not guess and do not compute.
113
+ - Give the values of the record's own item in its base version. Leave out the values of a part, an accessory or an alternative the text names (the battery of a drill, the shade of a lamp), and the values of a variant that holds only under a condition ("if …", "when used with …").
114
+ - A value the text gives as approximate ("about 40 kg", "approx. 2 m") is stated: give it without the approximation.
115
+ - Each value quotes the words of the text that state it in \`quote\`, copied exactly, character for character. A value without an exact quote is dropped.
116
+ - Write each value in its property's type: a number for i64 and f64 (digits, a dot for decimals, no unit), true or false for bool, YYYY-MM-DD for date_time, and the words of the text for text and keyword.
117
+ - Give at most one value per property. Leave a property out when the text does not state it.`;
118
+
119
+ const SUPPORT: Record<string, string> = {
120
+ supported:
121
+ "The quote states this value of this property for the record's own item. A value stated as approximate counts: 'approx. 40 kg' supports a weight of 40.",
122
+ weak: "The text implies the value but does not state it.",
123
+ not_supported:
124
+ "The quote does not state this value, or it states the value of a part, an accessory, an alternative or a variant under a condition, not of the record's own item.",
125
+ };
126
+
127
+ /** Instances one turn reads: two steps each, in a turn of 64 steps. */
128
+ const TURN_INSTANCES = 24;
129
+ /** Characters of a text the models read. */
130
+ const TEXT_CHARS = 40_000;
131
+ const PLAIN_IRI = /^[^\s<>"{}|\\^`]+$/;
132
+ const LABEL = "http://www.w3.org/2000/01/rdf-schema#label";
133
+ const normalize = (text: string) => text.split(/\s+/).filter(Boolean).join(" ");
134
+
135
+ /** The value of `raw` in its property's type, or null when it does not take the type. */
136
+ export function typed(raw: string, type: ValueType): string | number | boolean | null {
137
+ const text = raw.trim();
138
+ if (!text) return null;
139
+ const number = Number(text.replace(",", ".").replace(/\s+/g, ""));
140
+ switch (type) {
141
+ case "i64":
142
+ return Number.isInteger(number) ? number : null;
143
+ case "f64":
144
+ return Number.isFinite(number) ? number : null;
145
+ case "bool":
146
+ return /^(true|yes|ja)$/i.test(text) ? true : /^(false|no|nein)$/i.test(text) ? false : null;
147
+ case "date_time":
148
+ return /^\d{4}-\d{2}-\d{2}$/.test(text) && !Number.isNaN(Date.parse(text))
149
+ ? `${text}T00:00:00Z`
150
+ : null;
151
+ default:
152
+ return text;
153
+ }
154
+ }
155
+
156
+ /** A quote must be in the text, with at least three words or the value in it. */
157
+ export function quoteHolds(quote: string, value: string, text: string): boolean {
158
+ const q = normalize(quote);
159
+ if (!q || !normalize(text).includes(q)) return false;
160
+ return q.split(" ").length >= 3 || q.toLowerCase().includes(normalize(value).toLowerCase());
161
+ }
162
+
163
+ /** The workflow, for a `WorkflowWorker`. `lbb` is `lbbActivities(client)`, or a mock in a test. */
164
+ export function createWorkflow(lbb: LbbActivities, settings: Settings = defaults) {
165
+ const names = Object.keys(settings.properties);
166
+ const schema = {
167
+ type: "object",
168
+ additionalProperties: false,
169
+ required: ["values"],
170
+ properties: {
171
+ values: {
172
+ type: "array",
173
+ items: {
174
+ type: "object",
175
+ additionalProperties: false,
176
+ required: ["property", "value", "quote"],
177
+ properties: {
178
+ property: { type: "string", enum: names },
179
+ value: { type: "string", description: "The value in the property's type." },
180
+ quote: {
181
+ type: "string",
182
+ description: "The words of the text that state the value, copied exactly.",
183
+ },
184
+ },
185
+ },
186
+ },
187
+ },
188
+ };
189
+
190
+ return triggeredWorkflow<ValuesState>({
191
+ name: workflowType.workflow_type,
192
+ version: workflowType.version,
193
+ initialState: { instances: 0, written: 0, review: 0, dropped: 0 },
194
+ async onInstances(ctx, message, state) {
195
+ // Instances that left the class lose the values this workflow wrote.
196
+ if (message.removed.length)
197
+ await lbb.writeOwned(ctx, "drop", {
198
+ subjects: message.removed.map((iri) => entityRef(iri, settings.className)),
199
+ relations: ["HAS_VALUE_SOURCE"],
200
+ properties: names,
201
+ retract_entities: message.removed.flatMap((iri) => names.map((p) => source(iri, p))),
202
+ });
203
+ const batch = message.instances.slice(0, TURN_INSTANCES);
204
+ const texts = reader(message, batch);
205
+ const read: Value[][] = [];
206
+ for (const [i, iri] of batch.entries()) {
207
+ const proposed = await ctx.step(`values-${i}`, async (s) => {
208
+ const record = (await texts())[iri];
209
+ if (!record?.text.trim()) return [];
210
+ const prompt = JSON.stringify({
211
+ class: settings.className,
212
+ instance: record.label,
213
+ context: settings.context,
214
+ properties: names.map((name) => ({ name, type: settings.properties[name] })),
215
+ text: record.text,
216
+ });
217
+ const answer = await lbb.reason<{
218
+ values: { property: string; value: string; quote: string }[];
219
+ }>(
220
+ { system: SYSTEM, prompt, schema, max_tokens: 8_000 },
221
+ { effectId: s.effectId, label: `${record.label.slice(0, 50)} values` },
222
+ );
223
+ // The checks without a model: one value per property, of its type, quoted from the text.
224
+ const seen = new Set<string>();
225
+ return (answer.value.values ?? []).flatMap((v): Value[] => {
226
+ const type = settings.properties[v.property];
227
+ const value = type ? typed(v.value, type) : null;
228
+ if (
229
+ value === null ||
230
+ seen.has(v.property) ||
231
+ !quoteHolds(v.quote, v.value, record.text)
232
+ )
233
+ return [];
234
+ seen.add(v.property);
235
+ return [{ property: v.property, value, quote: v.quote }];
236
+ });
237
+ });
238
+ read.push(
239
+ await ctx.step(`support-${i}`, async (s) => {
240
+ if (!proposed.length) return [];
241
+ const record = (await texts())[iri]!;
242
+ const questions = Object.fromEntries(
243
+ proposed.map((v, n) => [
244
+ `v${n}`,
245
+ {
246
+ instructions: `The state holds the text of one record of the class ${settings.className}. A model read the value of a property from the text and quoted the words that state it. How well does the quote support the value?\nProperty: ${v.property} (${settings.properties[v.property]})\nValue: ${v.value}\nQuote: "${v.quote}"`,
247
+ criteria: SUPPORT,
248
+ },
249
+ ]),
250
+ );
251
+ const { answers } = await lbb.decide(
252
+ {
253
+ state: {
254
+ class: settings.className,
255
+ record: record.label,
256
+ context: settings.context,
257
+ text: record.text,
258
+ },
259
+ questions,
260
+ },
261
+ { effectId: s.effectId, label: `${record.label.slice(0, 50)} support` },
262
+ );
263
+ return proposed.map((v, n) => {
264
+ const a = answers[`v${n}`];
265
+ const support =
266
+ a?.probabilities?.supported ??
267
+ (a?.choice === "supported" ? (a.confidence ?? 1) : 0);
268
+ return { ...v, support };
269
+ });
270
+ }),
271
+ );
272
+ }
273
+
274
+ // The gate: written, review, or dropped.
275
+ const at = (i: number) => ({ iri: batch[i]!, values: read[i]! });
276
+ const below = batch.flatMap((_, i) =>
277
+ at(i)
278
+ .values.filter((v) => v.support! < settings.gate && v.support! >= 0.5)
279
+ .map((v) => ({ i, v })),
280
+ );
281
+ const accepted = await ctx.step("review", async () => {
282
+ const kept: { i: number; property: string }[] = [];
283
+ for (const { i, v } of below) {
284
+ const record = (await texts())[batch[i]!]!;
285
+ const facts = valueFacts(batch[i]!, record.label, v);
286
+ const filed = await lbb.review({
287
+ workflow: message.workflow,
288
+ key: `starter:${message.workflow}:value:${entityIdFromIri(batch[i]!)}:${v.property}`,
289
+ title: `Set ${v.property} of ${record.label} to ${v.value}`.slice(0, 200),
290
+ rationale: `The text supports the value with ${v.support!.toFixed(2)}, below the gate ${settings.gate}: "${v.quote.slice(0, 600)}"`,
291
+ facts: {
292
+ entity_properties: facts.properties,
293
+ triplets: [facts.edge],
294
+ summary: `${v.property} = ${v.value}`,
295
+ },
296
+ evidence: {
297
+ records: 1,
298
+ samples: [{ entity: batch[i], quote: v.quote.slice(0, 600), support: v.support }],
299
+ },
300
+ });
301
+ if (filed.status === "accepted") kept.push({ i, property: v.property });
302
+ }
303
+ return kept;
304
+ });
305
+ await lbb.writeOwned(ctx, "write", async () => {
306
+ const records = await texts();
307
+ const write = {
308
+ subjects: [] as Schemas["NamedEntityInput"][],
309
+ properties: [] as Schemas["EntityPropertiesInput"][],
310
+ triplets: [] as Schemas["TripletInput"][],
311
+ retract: [] as Schemas["NamedEntityInput"][],
312
+ };
313
+ for (const [i, iri] of batch.entries()) {
314
+ const label = records[iri]?.label ?? iri;
315
+ write.subjects.push(entityRef(iri, settings.className, label));
316
+ const kept = at(i).values.filter(
317
+ (v) =>
318
+ v.support! >= settings.gate ||
319
+ accepted.some((a) => a.i === i && a.property === v.property),
320
+ );
321
+ for (const v of kept) {
322
+ const facts = valueFacts(iri, label, v);
323
+ write.properties.push(...facts.properties);
324
+ write.triplets.push(facts.edge);
325
+ }
326
+ write.retract.push(
327
+ ...names.filter((p) => !kept.some((v) => v.property === p)).map((p) => source(iri, p)),
328
+ );
329
+ }
330
+ return {
331
+ subjects: write.subjects,
332
+ relations: ["HAS_VALUE_SOURCE"],
333
+ properties: names,
334
+ entity_properties: write.properties,
335
+ triplets: write.triplets,
336
+ retract_entities: write.retract,
337
+ };
338
+ });
339
+ const values = read.flat();
340
+ const next: ValuesState = {
341
+ instances: state.instances + batch.length,
342
+ written:
343
+ state.written +
344
+ values.filter((v) => v.support! >= settings.gate).length +
345
+ accepted.length,
346
+ review: state.review + below.length - accepted.length,
347
+ dropped: state.dropped + values.filter((v) => v.support! < 0.5).length,
348
+ };
349
+ // More instances than a turn reads: the rest goes to a new turn.
350
+ const rest = message.instances.slice(TURN_INSTANCES);
351
+ return rest.length
352
+ ? ctx.continue(next, { ...message, instances: rest, removed: [] })
353
+ : { state: next };
354
+ },
355
+ });
356
+
357
+ /** The label and text of each instance of the turn, read once per attempt at the message's commit. */
358
+ function reader(message: WorkflowTriggerMessage, iris: string[]) {
359
+ let read: Promise<Record<string, { label: string; text: string }>> | undefined;
360
+ return () =>
361
+ (read ??= (async () => {
362
+ const query = `# values-extract: texts
363
+ SELECT ?s ?label ?text WHERE {
364
+ VALUES ?s { ${iris
365
+ .filter((iri) => PLAIN_IRI.test(iri))
366
+ .map((iri) => `<${iri}>`)
367
+ .join(" ")} }
368
+ OPTIONAL { ?s <${LABEL}> ?label }
369
+ OPTIONAL { ?s <https://littlebigbrain.com/p/${encodeURIComponent(settings.textField.toLowerCase())}> ?text }
370
+ }`;
371
+ const { rows } = await lbb.sparql({ query, as_of_commit_seq: message.as_of_commit_seq });
372
+ const out: Record<string, { label: string; text: string }> = {};
373
+ for (const row of rows) {
374
+ const record = (out[row.s!] ??= { label: row.label ?? row.s!, text: "" });
375
+ if (row.text && !record.text.includes(row.text))
376
+ record.text = `${record.text}\n${row.text}`.trim().slice(0, TEXT_CHARS);
377
+ }
378
+ return out;
379
+ })());
380
+ }
381
+
382
+ /** The source entity of a property's value on an instance. */
383
+ function source(iri: string, property: string): Schemas["NamedEntityInput"] {
384
+ return {
385
+ type: "ValueSource",
386
+ name: property,
387
+ key: `value:${entityIdFromIri(iri)}:${property}`,
388
+ };
389
+ }
390
+
391
+ /** The facts of one value: the property on the instance, its source, and the edge with the quote. */
392
+ function valueFacts(iri: string, label: string, v: Value) {
393
+ const target = { ...source(iri, v.property), name: `${v.property} = ${v.value}`.slice(0, 200) };
394
+ return {
395
+ properties: [
396
+ { ...entityRef(iri, settings.className, label), properties: { [v.property]: v.value } },
397
+ {
398
+ ...target,
399
+ properties: { property: v.property, value: String(v.value), support: v.support! },
400
+ },
401
+ ],
402
+ edge: {
403
+ source: entityRef(iri, settings.className, label),
404
+ relation: "HAS_VALUE_SOURCE",
405
+ target,
406
+ confidence: v.support!,
407
+ evidence: { text: v.quote, source_id: label.slice(0, 200) },
408
+ },
409
+ };
410
+ }
411
+ }