@littlebigbrain/create 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +55 -0
- package/README.md +71 -2
- package/bin/create-littlebigbrain.mjs +9 -0
- package/lib/create.mjs +238 -0
- package/package.json +46 -4
- package/project/setup.ts +18 -0
- package/project/try.ts +30 -0
- package/project/worker.ts +23 -0
- package/templates/_shared/testing.ts +490 -0
- package/templates/documents-link/README.md +144 -0
- package/templates/documents-link/link.ts +580 -0
- package/templates/documents-link/samples/ausstattungsliste.pdf +76 -0
- package/templates/documents-link/samples/baubeschreibung.pdf +54 -0
- package/templates/documents-link/samples/expected.json +404 -0
- package/templates/documents-link/samples/pages/ausstattungsliste.json +1150 -0
- package/templates/documents-link/samples/pages/baubeschreibung.json +802 -0
- package/templates/documents-link/samples/pages/raumblaetter.json +1580 -0
- package/templates/documents-link/samples/raumblaetter.pdf +109 -0
- package/templates/documents-link/samples.ts +31 -0
- package/templates/documents-link/workflow.test.ts +470 -0
- package/templates/documents-link/workflow.ts +457 -0
- package/templates/ontology-fit/README.md +81 -0
- package/templates/ontology-fit/samples/expected.md +88 -0
- package/templates/ontology-fit/samples/texts.json +22 -0
- package/templates/ontology-fit/samples.ts +40 -0
- package/templates/ontology-fit/workflow.test.ts +264 -0
- package/templates/ontology-fit/workflow.ts +413 -0
- package/templates/values-extract/README.md +81 -0
- package/templates/values-extract/samples/expected.json +148 -0
- package/templates/values-extract/samples/records.json +26 -0
- package/templates/values-extract/samples.ts +27 -0
- package/templates/values-extract/workflow.test.ts +256 -0
- package/templates/values-extract/workflow.ts +411 -0
- package/templates/versions.json +3 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// The samples of ontology-fit, for `npm run try`: the ontology of
|
|
2
|
+
// samples/expected.md and five invented site reports (samples/texts.json),
|
|
3
|
+
// committed as instances of the class the settings name.
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import type { LbbClient, Schemas } from "@littlebigbrain/client";
|
|
7
|
+
import { defaults, ontology } from "./workflow.js";
|
|
8
|
+
|
|
9
|
+
/** The graph before the run: the classes, properties and relations of expected.md. */
|
|
10
|
+
export const sampleOntology: Schemas["OntologyEvolveOp"][] = [
|
|
11
|
+
...ontology(),
|
|
12
|
+
{ op: "add_entity_type", name: "Raum" },
|
|
13
|
+
{ op: "add_entity_type", name: "Firma" },
|
|
14
|
+
{ op: "add_property", name: "datum", value_type: "date_time" },
|
|
15
|
+
{ op: "add_property", name: "nummer", value_type: "keyword" },
|
|
16
|
+
{ op: "add_property", name: "bezeichnung", value_type: "text" },
|
|
17
|
+
{ op: "add_property", name: "gewerk", value_type: "keyword" },
|
|
18
|
+
{ op: "add_relation", name: "betrifft_raum", domain: [defaults.className], range: ["Raum"] },
|
|
19
|
+
{ op: "add_relation", name: "anwesend", domain: [defaults.className], range: ["Firma"] },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
/** Set up the sample ontology and commit the sample texts to the client's graph. */
|
|
23
|
+
export async function loadSamples(client: LbbClient, dir: string): Promise<void> {
|
|
24
|
+
const texts = JSON.parse(await readFile(join(dir, "texts.json"), "utf8")) as {
|
|
25
|
+
name: string;
|
|
26
|
+
text: string;
|
|
27
|
+
}[];
|
|
28
|
+
await client.ontology.evolve({ ops: sampleOntology });
|
|
29
|
+
await client.commit(
|
|
30
|
+
{
|
|
31
|
+
entity_properties: texts.map((t) => ({
|
|
32
|
+
type: defaults.className,
|
|
33
|
+
name: t.name,
|
|
34
|
+
properties: { [defaults.textField]: t.text },
|
|
35
|
+
})),
|
|
36
|
+
},
|
|
37
|
+
{ idempotencyKey: "ontology-fit-samples" },
|
|
38
|
+
);
|
|
39
|
+
console.log(`Committed ${texts.length} ${defaults.className} texts`);
|
|
40
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// ontology-fit on its samples, with a mock reasoning model whose rules are
|
|
2
|
+
// fixed: a sentence about a defect (`Mangel`) proposes the class `Mangel`,
|
|
3
|
+
// `Frist`, `Status` and `Zuständig` propose its facts, a room in the same
|
|
4
|
+
// text widens `betrifft_raum`, a list of who was present proposes the
|
|
5
|
+
// relation `anwesend` the ontology has, and a text about the acceptance
|
|
6
|
+
// (`Abnahme`) gets a quote that is not in the text. The expected keys come
|
|
7
|
+
// from samples/expected.md.
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { ENTITY_IRI_NAMESPACE, type Schemas } from "@littlebigbrain/client";
|
|
13
|
+
import {
|
|
14
|
+
MockActivities,
|
|
15
|
+
mockId,
|
|
16
|
+
readJson,
|
|
17
|
+
runMessage,
|
|
18
|
+
samplesDir,
|
|
19
|
+
triggerMessage,
|
|
20
|
+
type MockRules,
|
|
21
|
+
} from "../_shared/testing.js";
|
|
22
|
+
import { createWorkflow, keyPart, quoteInText } from "./workflow.js";
|
|
23
|
+
import { sampleOntology } from "./samples.js";
|
|
24
|
+
|
|
25
|
+
const SAMPLES = samplesDir(import.meta.url, "ontology-fit");
|
|
26
|
+
const TEXTS = readJson<{ name: string; text: string }[]>(join(SAMPLES, "texts.json"));
|
|
27
|
+
const EXPECTED_MD = readFileSync(join(SAMPLES, "expected.md"), "utf8");
|
|
28
|
+
/** The keys of the table "Proposals a good run makes". */
|
|
29
|
+
const EXPECTED_KEYS = [
|
|
30
|
+
...EXPECTED_MD.split("## Proposals a good run makes")[1]!
|
|
31
|
+
.split("## Proposals a good run may make")[0]!
|
|
32
|
+
.matchAll(/`(fit:[^`]+)`/g),
|
|
33
|
+
].map((m) => m[1]!);
|
|
34
|
+
|
|
35
|
+
const iriOf = (name: string) => ENTITY_IRI_NAMESPACE + mockId(`Baustellenbericht:${name}`);
|
|
36
|
+
|
|
37
|
+
/** The ontology view of the graph before the run, built from the sample ontology. */
|
|
38
|
+
function view(): Schemas["OntologyView"] {
|
|
39
|
+
const ops = sampleOntology;
|
|
40
|
+
const types = ops.flatMap((op) => (op.op === "add_entity_type" ? [op.name] : []));
|
|
41
|
+
return {
|
|
42
|
+
graph: { tenant_id: "t", graph_id: "samples", branch_id: "main" },
|
|
43
|
+
ontology_version: 3,
|
|
44
|
+
entity_types: [],
|
|
45
|
+
relations: [],
|
|
46
|
+
entity_type_defs: types.map((name) => ({
|
|
47
|
+
name,
|
|
48
|
+
stable_id: `type:${name}`,
|
|
49
|
+
properties: [],
|
|
50
|
+
resolvable: true,
|
|
51
|
+
since_version: 1,
|
|
52
|
+
})),
|
|
53
|
+
relation_defs: ops.flatMap((op) =>
|
|
54
|
+
op.op === "add_relation"
|
|
55
|
+
? [
|
|
56
|
+
{
|
|
57
|
+
name: op.name,
|
|
58
|
+
src_types: op.domain ?? [],
|
|
59
|
+
dst_types: op.range ?? [],
|
|
60
|
+
cardinality: "many_to_many",
|
|
61
|
+
since_version: 1,
|
|
62
|
+
symmetric: false,
|
|
63
|
+
temporal_semantics: "bitemporal",
|
|
64
|
+
transitive: false,
|
|
65
|
+
},
|
|
66
|
+
]
|
|
67
|
+
: [],
|
|
68
|
+
),
|
|
69
|
+
property_defs: ops.flatMap((op) =>
|
|
70
|
+
op.op === "add_property"
|
|
71
|
+
? [{ name: op.name, value_type: op.value_type ?? "text", required: false }]
|
|
72
|
+
: [],
|
|
73
|
+
),
|
|
74
|
+
} as Schemas["OntologyView"];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Whether the ontology has a change already: the dry run's no-op. */
|
|
78
|
+
function covered(op: Schemas["OntologyEvolveOp"], v: Schemas["OntologyView"]): boolean {
|
|
79
|
+
const has = (names: { name: string }[] | undefined, name: string) =>
|
|
80
|
+
(names ?? []).some((n) => n.name.toLowerCase() === name.toLowerCase());
|
|
81
|
+
if (op.op === "add_entity_type") return has(v.entity_type_defs, op.name);
|
|
82
|
+
if (op.op === "add_property") return has(v.property_defs, op.name);
|
|
83
|
+
if (op.op === "add_relation") return has(v.relation_defs, op.name);
|
|
84
|
+
if (op.op === "widen_relation") {
|
|
85
|
+
const r = v.relation_defs?.find((d) => d.name.toLowerCase() === op.relation.toLowerCase());
|
|
86
|
+
return Boolean(
|
|
87
|
+
r &&
|
|
88
|
+
(op.add_domain ?? []).every((d) => r.src_types.includes(d)) &&
|
|
89
|
+
(op.add_range ?? []).every((d) => r.dst_types.includes(d)),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const proposal = (fields: Partial<Record<string, string>>) => ({
|
|
96
|
+
kind: "",
|
|
97
|
+
name: "",
|
|
98
|
+
parent: "",
|
|
99
|
+
class: "",
|
|
100
|
+
value_type: "",
|
|
101
|
+
domain: "",
|
|
102
|
+
range: "",
|
|
103
|
+
quote: "",
|
|
104
|
+
reason: "",
|
|
105
|
+
...fields,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
function propose(text: string) {
|
|
109
|
+
const sentences = text.split(/(?<=\.)\s+/);
|
|
110
|
+
const find = (pattern: RegExp) => sentences.find((s) => pattern.test(s));
|
|
111
|
+
const out = [];
|
|
112
|
+
const defect = find(/Mangel|Mängel/);
|
|
113
|
+
if (defect)
|
|
114
|
+
out.push(
|
|
115
|
+
proposal({
|
|
116
|
+
kind: "add_class",
|
|
117
|
+
name: "Mangel",
|
|
118
|
+
quote: defect,
|
|
119
|
+
reason: "The texts follow defects.",
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
const deadline = find(/Frist/);
|
|
123
|
+
if (defect && deadline)
|
|
124
|
+
out.push(
|
|
125
|
+
proposal({
|
|
126
|
+
kind: "add_property",
|
|
127
|
+
name: "frist",
|
|
128
|
+
class: "Mangel",
|
|
129
|
+
value_type: "date_time",
|
|
130
|
+
quote: deadline,
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
const status = find(/Status/);
|
|
134
|
+
if (defect && status)
|
|
135
|
+
out.push(
|
|
136
|
+
proposal({
|
|
137
|
+
kind: "add_property",
|
|
138
|
+
name: "status",
|
|
139
|
+
class: "Mangel",
|
|
140
|
+
value_type: "keyword",
|
|
141
|
+
quote: status,
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
const owner = find(/Zuständig/);
|
|
145
|
+
if (defect && owner)
|
|
146
|
+
out.push(
|
|
147
|
+
proposal({
|
|
148
|
+
kind: "add_relation",
|
|
149
|
+
name: "zustaendig",
|
|
150
|
+
domain: "Mangel",
|
|
151
|
+
range: "Firma",
|
|
152
|
+
quote: owner,
|
|
153
|
+
}),
|
|
154
|
+
);
|
|
155
|
+
const room = find(/Raum 0\.\d+/);
|
|
156
|
+
if (defect && room)
|
|
157
|
+
out.push(
|
|
158
|
+
proposal({ kind: "widen_relation", name: "betrifft_raum", domain: "Mangel", quote: room }),
|
|
159
|
+
);
|
|
160
|
+
const present = find(/Anwesend:|Teilnehmer:/);
|
|
161
|
+
if (present)
|
|
162
|
+
out.push(
|
|
163
|
+
proposal({
|
|
164
|
+
kind: "add_relation",
|
|
165
|
+
name: "anwesend",
|
|
166
|
+
domain: "Baustellenbericht",
|
|
167
|
+
range: "Firma",
|
|
168
|
+
quote: present,
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
if (find(/Abnahme/))
|
|
172
|
+
out.push(
|
|
173
|
+
proposal({ kind: "add_class", name: "Abnahme", quote: "Die Abnahme findet morgen statt." }),
|
|
174
|
+
);
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function rules(more: MockRules = {}): MockRules {
|
|
179
|
+
const v = view();
|
|
180
|
+
return {
|
|
181
|
+
ontology: v,
|
|
182
|
+
reason: (request) => ({
|
|
183
|
+
proposals: propose((JSON.parse(request.prompt) as { text: string }).text),
|
|
184
|
+
}),
|
|
185
|
+
evolve: (ops) => ({ no_op: ops.every((op) => covered(op, v)) }),
|
|
186
|
+
sparql: (query) =>
|
|
187
|
+
TEXTS.filter((t) => query.includes(iriOf(t.name))).map((t) => ({
|
|
188
|
+
s: iriOf(t.name),
|
|
189
|
+
label: t.name,
|
|
190
|
+
text: t.text,
|
|
191
|
+
})),
|
|
192
|
+
...more,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
test("the samples give the changes of expected.md, one review item per key", async () => {
|
|
197
|
+
const lbb = new MockActivities(rules());
|
|
198
|
+
const { state } = await runMessage(
|
|
199
|
+
createWorkflow(lbb),
|
|
200
|
+
triggerMessage(
|
|
201
|
+
"berichte",
|
|
202
|
+
TEXTS.map((t) => iriOf(t.name)),
|
|
203
|
+
),
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
assert.equal(EXPECTED_KEYS.length, 5);
|
|
207
|
+
assert.deepEqual([...lbb.reviews.keys()].sort(), [...EXPECTED_KEYS].sort());
|
|
208
|
+
assert.deepEqual(state, { texts: 5, proposals: 3 + 4 + 5 + 5 + 2, filed: 5 });
|
|
209
|
+
const mangel = lbb.reviews.get("fit:berichte/add_class/mangel")!;
|
|
210
|
+
assert.equal(mangel.workflow, "berichte");
|
|
211
|
+
assert.deepEqual(mangel.change, [{ op: "add_entity_type", name: "Mangel" }]);
|
|
212
|
+
assert.equal(mangel.evidence!.records, 5);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("a change names the classes it needs, and a widening keeps the relation's name", async () => {
|
|
216
|
+
const lbb = new MockActivities(rules());
|
|
217
|
+
await runMessage(
|
|
218
|
+
createWorkflow(lbb),
|
|
219
|
+
triggerMessage(
|
|
220
|
+
"berichte",
|
|
221
|
+
TEXTS.map((t) => iriOf(t.name)),
|
|
222
|
+
),
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
assert.deepEqual(lbb.reviews.get("fit:berichte/add_relation/mangel/zustaendig/firma")!.change, [
|
|
226
|
+
{ op: "add_entity_type", name: "Mangel" },
|
|
227
|
+
{ op: "add_relation", name: "zustaendig", domain: ["Mangel"], range: ["Firma"] },
|
|
228
|
+
]);
|
|
229
|
+
assert.deepEqual(lbb.reviews.get("fit:berichte/widen_relation/betrifft-raum/mangel/x")!.change, [
|
|
230
|
+
{ op: "add_entity_type", name: "Mangel" },
|
|
231
|
+
{ op: "widen_relation", relation: "betrifft_raum", add_domain: ["Mangel"], add_range: [] },
|
|
232
|
+
]);
|
|
233
|
+
assert.equal(
|
|
234
|
+
lbb.reviews.get("fit:berichte/add_property/mangel/frist")!.title,
|
|
235
|
+
"Add property frist to Mangel",
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("a change the ontology has, and a quote that is not in the text, are not filed", async () => {
|
|
240
|
+
const lbb = new MockActivities(rules());
|
|
241
|
+
await runMessage(
|
|
242
|
+
createWorkflow(lbb),
|
|
243
|
+
triggerMessage(
|
|
244
|
+
"berichte",
|
|
245
|
+
TEXTS.map((t) => iriOf(t.name)),
|
|
246
|
+
),
|
|
247
|
+
);
|
|
248
|
+
const keys = [...lbb.reviews.keys()];
|
|
249
|
+
|
|
250
|
+
assert.ok(!keys.some((k) => k.includes("anwesend")), "anwesend is already in the ontology");
|
|
251
|
+
assert.ok(!keys.some((k) => k.includes("abnahme")), "its quote is not in the text");
|
|
252
|
+
for (const item of lbb.reviews.values())
|
|
253
|
+
for (const sample of item.evidence!.samples! as { entity: string; quote: string }[]) {
|
|
254
|
+
const text = TEXTS.find((t) => iriOf(t.name) === sample.entity)!.text;
|
|
255
|
+
assert.ok(quoteInText(sample.quote, text), sample.quote);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("keys keep lowercase ASCII letters and digits, other runs as one dash", () => {
|
|
260
|
+
assert.equal(keyPart("Mangel"), "mangel");
|
|
261
|
+
assert.equal(keyPart("betrifft_raum"), "betrifft-raum");
|
|
262
|
+
assert.equal(keyPart("-"), "x");
|
|
263
|
+
assert.equal(keyPart("zuständig"), "zust-ndig");
|
|
264
|
+
});
|