@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,26 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"name": "S 4.1",
|
|
4
|
+
"text": "S 4.1 Waschtisch für Klassenräume, aus Sanitärkeramik, weiß, ca. 60x40 cm, mit einem Hahnloch. Anschluss an Kalt- und Warmwasser über Einhebelmischer."
|
|
5
|
+
},
|
|
6
|
+
{
|
|
7
|
+
"name": "S 4.2",
|
|
8
|
+
"text": "S 4.2 Ausgussbecken für Putzräume, Stahl emailliert, 50x35 cm. Zubehör: Klapprost aus Edelstahl. Anschluss nur an Kaltwasser."
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"name": "S 4.3",
|
|
12
|
+
"text": "S 4.3 Waschtisch, unterfahrbar, aus Mineralguss, Maße nach Hersteller. Zubehör: Glasablage 50x12 cm und Haltegriff aus Edelstahl. Anschluss an Kalt- und Warmwasser."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "S 4.4",
|
|
16
|
+
"text": "S 4.4 Trinkbrunnen für den Flur, Edelstahl, 40x30 cm, mit Druckknopfventil. Anschluss an Kaltwasser in Trinkwasserqualität."
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"name": "S 4.5",
|
|
20
|
+
"text": "S 4.5 Spüle für die Lehrerküche, Edelstahl, 100x50 cm, mit einem Becken und Abtropffläche. Anschluss an Kalt- und Warmwasser. Bei Einsatz von Geschirrspülern: Ausführung mit zwei Becken, 120x50 cm."
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"name": "S 4.6",
|
|
24
|
+
"text": "S 4.6 Spiegel für Waschplätze, Kristallglas, Kanten poliert, Breite 60 cm, Höhe 45 cm."
|
|
25
|
+
}
|
|
26
|
+
]
|
|
@@ -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,279 @@
|
|
|
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
|
+
phases,
|
|
15
|
+
readJson,
|
|
16
|
+
runMessage,
|
|
17
|
+
samplesDir,
|
|
18
|
+
triggerMessage,
|
|
19
|
+
type MockRules,
|
|
20
|
+
} from "../_shared/testing.js";
|
|
21
|
+
import { createWorkflow, defaults, quoteHolds, typed } from "./workflow.js";
|
|
22
|
+
|
|
23
|
+
const SAMPLES = samplesDir(import.meta.url, "values-extract");
|
|
24
|
+
const RECORDS = readJson<{ name: string; text: string }[]>(join(SAMPLES, "records.json"));
|
|
25
|
+
interface ExpectedValue {
|
|
26
|
+
property: string;
|
|
27
|
+
value: number | string | null;
|
|
28
|
+
accept?: string[];
|
|
29
|
+
quote?: string;
|
|
30
|
+
}
|
|
31
|
+
const EXPECTED = readJson<{
|
|
32
|
+
records: { name: string; values: ExpectedValue[]; leave_out: ExpectedValue[] }[];
|
|
33
|
+
}>(join(SAMPLES, "expected.json"));
|
|
34
|
+
|
|
35
|
+
const iriOf = (name: string) => ENTITY_IRI_NAMESPACE + mockId(`EquipmentItem:${name}`);
|
|
36
|
+
|
|
37
|
+
const MATERIALS = [
|
|
38
|
+
"Sanitärkeramik",
|
|
39
|
+
"Stahl emailliert",
|
|
40
|
+
"Mineralguss",
|
|
41
|
+
"Edelstahl",
|
|
42
|
+
"Kristallglas",
|
|
43
|
+
"Keramik",
|
|
44
|
+
"Glas",
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/** The mock reasoning model: the first size, material and water supply of a text. */
|
|
48
|
+
function readValues(text: string) {
|
|
49
|
+
const values: { property: string; value: string; quote: string }[] = [];
|
|
50
|
+
const size = text.match(/(?:ca\. )?(\d+)x(\d+) cm/);
|
|
51
|
+
const width = text.match(/Breite (\d+) cm/);
|
|
52
|
+
if (size)
|
|
53
|
+
values.push(
|
|
54
|
+
{ property: "width_cm", value: size[1]!, quote: size[0] },
|
|
55
|
+
{ property: "depth_cm", value: size[2]!, quote: size[0] },
|
|
56
|
+
);
|
|
57
|
+
else if (width) values.push({ property: "width_cm", value: width[1]!, quote: width[0] });
|
|
58
|
+
const material = MATERIALS.map((m) => ({
|
|
59
|
+
m,
|
|
60
|
+
at: text.search(new RegExp(`(?<![\\p{L}])${m}(?![\\p{L}])`, "u")),
|
|
61
|
+
}))
|
|
62
|
+
.filter((found) => found.at >= 0)
|
|
63
|
+
.sort((a, b) => a.at - b.at)[0];
|
|
64
|
+
if (material) values.push({ property: "material", value: material.m, quote: material.m });
|
|
65
|
+
const water = text.match(/Anschluss[^.]*?(Kalt- und Warmwasser|Kaltwasser)/);
|
|
66
|
+
if (water)
|
|
67
|
+
values.push({
|
|
68
|
+
property: "water_supply",
|
|
69
|
+
value: water[1] === "Kaltwasser" ? "kalt" : "kalt und warm",
|
|
70
|
+
quote: water[0],
|
|
71
|
+
});
|
|
72
|
+
return values;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The mock decision model: the support of each quote, from the sentence that holds it. */
|
|
76
|
+
function support(text: string, instructions: string, options: string[]): DecideAnswer {
|
|
77
|
+
const quote = instructions.match(/Quote: "(.*)"$/s)![1]!;
|
|
78
|
+
const sentence = text.split(/(?<=\.)\s+/).find((s) => s.includes(quote)) ?? "";
|
|
79
|
+
const p = /Zubehör|Bei Einsatz/.test(sentence) ? 0.05 : /etwa/.test(sentence) ? 0.6 : 0.92;
|
|
80
|
+
const choice = p >= 0.5 ? "supported" : "not_supported";
|
|
81
|
+
return {
|
|
82
|
+
choice,
|
|
83
|
+
confidence: Math.max(p, 1 - p),
|
|
84
|
+
probabilities: Object.fromEntries(options.map((o) => [o, o === "supported" ? p : (1 - p) / 2])),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function rules(records: { name: string; text: string }[], more: MockRules = {}): MockRules {
|
|
89
|
+
return {
|
|
90
|
+
reason: (request) => ({
|
|
91
|
+
values: readValues((JSON.parse(request.prompt) as { text: string }).text),
|
|
92
|
+
}),
|
|
93
|
+
decide: (request) =>
|
|
94
|
+
Object.fromEntries(
|
|
95
|
+
Object.entries(request.questions).map(([id, q]) => [
|
|
96
|
+
id,
|
|
97
|
+
support(
|
|
98
|
+
(request.state as { text: string }).text,
|
|
99
|
+
q.instructions,
|
|
100
|
+
Object.keys(q.criteria),
|
|
101
|
+
),
|
|
102
|
+
]),
|
|
103
|
+
),
|
|
104
|
+
sparql: (query) => {
|
|
105
|
+
assert.ok(query.startsWith("# values-extract: texts"));
|
|
106
|
+
return records
|
|
107
|
+
.filter((r) => query.includes(iriOf(r.name)))
|
|
108
|
+
.map((r) => ({ s: iriOf(r.name), label: r.name, text: r.text }));
|
|
109
|
+
},
|
|
110
|
+
...more,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The values written on an instance: its owned properties. */
|
|
115
|
+
const written = (lbb: MockActivities, name: string) =>
|
|
116
|
+
lbb.graph.entities.get(iriOf(name))?.properties ?? {};
|
|
117
|
+
|
|
118
|
+
const matches = (got: unknown, want: ExpectedValue) =>
|
|
119
|
+
typeof want.value === "number"
|
|
120
|
+
? got === want.value
|
|
121
|
+
: (want.accept ?? [String(want.value).toLowerCase()]).includes(
|
|
122
|
+
String(got).trim().toLowerCase(),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
test("the samples give the values of the answer key and none it leaves out", async () => {
|
|
126
|
+
const lbb = new MockActivities(rules(RECORDS));
|
|
127
|
+
const { state, turns } = await runMessage(
|
|
128
|
+
createWorkflow(lbb),
|
|
129
|
+
triggerMessage(
|
|
130
|
+
"values-extract",
|
|
131
|
+
RECORDS.map((r) => iriOf(r.name)),
|
|
132
|
+
),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
for (const record of EXPECTED.records) {
|
|
136
|
+
const values = written(lbb, record.name);
|
|
137
|
+
for (const want of record.values)
|
|
138
|
+
assert.ok(
|
|
139
|
+
matches(values[want.property], want),
|
|
140
|
+
`${record.name} ${want.property}: ${values[want.property]}`,
|
|
141
|
+
);
|
|
142
|
+
for (const out of record.leave_out)
|
|
143
|
+
assert.ok(
|
|
144
|
+
out.value === null ? !(out.property in values) : !matches(values[out.property], out),
|
|
145
|
+
`${record.name} leaves out ${out.property}`,
|
|
146
|
+
);
|
|
147
|
+
assert.deepEqual(
|
|
148
|
+
Object.keys(values).sort(),
|
|
149
|
+
record.values.map((v) => v.property).sort(),
|
|
150
|
+
record.name,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
assert.deepEqual(state, { instances: 6, written: 20, review: 0, dropped: 2 });
|
|
154
|
+
assert.equal(lbb.reviews.size, 0);
|
|
155
|
+
assert.equal(turns.length, 1);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("each written value has a source whose evidence is a quote of the text", async () => {
|
|
159
|
+
const lbb = new MockActivities(rules(RECORDS));
|
|
160
|
+
await runMessage(
|
|
161
|
+
createWorkflow(lbb),
|
|
162
|
+
triggerMessage(
|
|
163
|
+
"values-extract",
|
|
164
|
+
RECORDS.map((r) => iriOf(r.name)),
|
|
165
|
+
),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const edges = lbb.graph.edgesOf("HAS_VALUE_SOURCE");
|
|
169
|
+
assert.equal(edges.length, 20);
|
|
170
|
+
for (const edge of edges) {
|
|
171
|
+
const record = RECORDS.find((r) => iriOf(r.name) === edge.source)!;
|
|
172
|
+
const evidence = edge.evidence as { text: string };
|
|
173
|
+
assert.ok(record.text.includes(evidence.text), evidence.text);
|
|
174
|
+
const source = lbb.graph.entities.get(edge.target)!;
|
|
175
|
+
assert.equal(source.type, "ValueSource");
|
|
176
|
+
assert.ok((source.properties.support as number) >= defaults.gate);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("each step reports its phase as the LBB-run values.extract words it", async () => {
|
|
181
|
+
const lbb = new MockActivities(rules(RECORDS));
|
|
182
|
+
const { turns } = await runMessage(
|
|
183
|
+
createWorkflow(lbb),
|
|
184
|
+
triggerMessage(
|
|
185
|
+
"values-extract",
|
|
186
|
+
RECORDS.map((r) => iriOf(r.name)),
|
|
187
|
+
),
|
|
188
|
+
);
|
|
189
|
+
assert.deepEqual(phases(turns), ["reading values", "asking the decision model", "writing facts"]);
|
|
190
|
+
assert.deepEqual(
|
|
191
|
+
turns[0]!.progress
|
|
192
|
+
.filter((p) => p.phase === "reading values")
|
|
193
|
+
.map((p) => [p.step, p.done, p.total, p.unit]),
|
|
194
|
+
RECORDS.map((_, i) => [`values-${i}`, i, RECORDS.length, "instances"]),
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("a value below the gate goes to review, and the checks drop wrong types and quotes", async () => {
|
|
199
|
+
const record = {
|
|
200
|
+
name: "S 9.1",
|
|
201
|
+
text: "S 9.1 Becken aus Kunststoff. Breite etwa 70 cm, Tiefe 40 cm.",
|
|
202
|
+
};
|
|
203
|
+
const proposals = [
|
|
204
|
+
{ property: "width_cm", value: "70", quote: "Breite etwa 70 cm" },
|
|
205
|
+
{ property: "depth_cm", value: "vierzig", quote: "Tiefe 40 cm" },
|
|
206
|
+
{ property: "material", value: "Holz", quote: "aus Holz gefertigt" },
|
|
207
|
+
{ property: "material", value: "Kunststoff", quote: "Becken aus Kunststoff" },
|
|
208
|
+
];
|
|
209
|
+
const lbb = new MockActivities(rules([record], { reason: () => ({ values: proposals }) }));
|
|
210
|
+
const { state, turns } = await runMessage(
|
|
211
|
+
createWorkflow(lbb),
|
|
212
|
+
triggerMessage("values-extract", [iriOf(record.name)]),
|
|
213
|
+
);
|
|
214
|
+
assert.deepEqual(
|
|
215
|
+
turns[0]!.progress.find((p) => p.phase === "filing review items"),
|
|
216
|
+
{ step: "review", phase: "filing review items", done: 0, total: 1, unit: "items" },
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
assert.deepEqual(written(lbb, record.name), { material: "Kunststoff" });
|
|
220
|
+
assert.deepEqual(state, { instances: 1, written: 1, review: 1, dropped: 0 });
|
|
221
|
+
const [item] = [...lbb.reviews.values()];
|
|
222
|
+
assert.equal(item!.title, "Set width_cm of S 9.1 to 70");
|
|
223
|
+
assert.equal(item!.workflow, "values-extract");
|
|
224
|
+
assert.deepEqual(item!.facts!.entity_properties![0]!.properties, { width_cm: 70 });
|
|
225
|
+
|
|
226
|
+
// Accepted, the value is written with the others the next time the instance is read.
|
|
227
|
+
const accepting = new MockActivities(
|
|
228
|
+
rules([record], { reason: () => ({ values: proposals }), reviewStatus: () => "accepted" }),
|
|
229
|
+
);
|
|
230
|
+
await runMessage(
|
|
231
|
+
createWorkflow(accepting),
|
|
232
|
+
triggerMessage("values-extract", [iriOf(record.name)]),
|
|
233
|
+
);
|
|
234
|
+
assert.deepEqual(written(accepting, record.name), { width_cm: 70, material: "Kunststoff" });
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("an instance that leaves the class loses its values and their sources", async () => {
|
|
238
|
+
const lbb = new MockActivities(rules(RECORDS));
|
|
239
|
+
const workflow = createWorkflow(lbb);
|
|
240
|
+
await runMessage(
|
|
241
|
+
workflow,
|
|
242
|
+
triggerMessage(
|
|
243
|
+
"values-extract",
|
|
244
|
+
RECORDS.map((r) => iriOf(r.name)),
|
|
245
|
+
),
|
|
246
|
+
);
|
|
247
|
+
await runMessage(workflow, triggerMessage("values-extract", [], [iriOf("S 4.1")]));
|
|
248
|
+
|
|
249
|
+
assert.deepEqual(written(lbb, "S 4.1"), {});
|
|
250
|
+
assert.ok(!lbb.graph.edgesOf("HAS_VALUE_SOURCE").some((e) => e.source === iriOf("S 4.1")));
|
|
251
|
+
assert.equal(lbb.graph.ofType("ValueSource").length, 20 - 4);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("a message of more instances than a turn reads continues in a new turn", async () => {
|
|
255
|
+
const many = Array.from({ length: 30 }, (_, n) => ({ ...RECORDS[n % 6]!, name: `S 5.${n + 1}` }));
|
|
256
|
+
const lbb = new MockActivities(rules(many));
|
|
257
|
+
const { turns, state } = await runMessage(
|
|
258
|
+
createWorkflow(lbb),
|
|
259
|
+
triggerMessage(
|
|
260
|
+
"values-extract",
|
|
261
|
+
many.map((r) => iriOf(r.name)),
|
|
262
|
+
),
|
|
263
|
+
);
|
|
264
|
+
assert.equal(turns.length, 2);
|
|
265
|
+
assert.ok(turns.every((t) => t.keys.length <= 64));
|
|
266
|
+
assert.equal(state.instances, 30);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("values take their property's type, and a quote is in the text", () => {
|
|
270
|
+
assert.equal(typed("60", "f64"), 60);
|
|
271
|
+
assert.equal(typed("0,5", "f64"), 0.5);
|
|
272
|
+
assert.equal(typed("2.5", "i64"), null);
|
|
273
|
+
assert.equal(typed("ja", "bool"), true);
|
|
274
|
+
assert.equal(typed("2026-09-16", "date_time"), "2026-09-16T00:00:00Z");
|
|
275
|
+
assert.equal(typed("16.09.2026", "date_time"), null);
|
|
276
|
+
assert.ok(quoteHolds("ca. 60x40 cm", "60", "Waschtisch, ca. 60x40 cm, weiß"));
|
|
277
|
+
assert.ok(!quoteHolds("weiß", "Keramik", "Waschtisch, weiß"));
|
|
278
|
+
assert.ok(!quoteHolds("aus Holz gefertigt", "Holz", "Waschtisch aus Keramik"));
|
|
279
|
+
});
|