@littlebigbrain/create 0.1.0 → 0.3.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 (56) hide show
  1. package/README.md +6 -2
  2. package/lib/create.mjs +33 -5
  3. package/package.json +1 -1
  4. package/templates/_shared/testing.ts +53 -9
  5. package/templates/documents-link/README.md +9 -7
  6. package/templates/documents-link/workflow.test.ts +28 -1
  7. package/templates/documents-link/workflow.ts +24 -14
  8. package/templates/documents-map/README.md +212 -0
  9. package/templates/documents-map/map.ts +1135 -0
  10. package/templates/documents-map/samples/ausstattungsliste.pdf +76 -0
  11. package/templates/documents-map/samples/baubeschreibung.pdf +54 -0
  12. package/templates/documents-map/samples/expected.json +50 -0
  13. package/templates/documents-map/samples/pages/raumblaetter.json +1580 -0
  14. package/templates/documents-map/samples/raumblaetter.pdf +109 -0
  15. package/templates/documents-map/samples/trace.json +613 -0
  16. package/templates/documents-map/samples.ts +182 -0
  17. package/templates/documents-map/workflow.test.ts +770 -0
  18. package/templates/documents-map/workflow.ts +1199 -0
  19. package/templates/link/README.md +203 -0
  20. package/templates/link/candidates.ts +432 -0
  21. package/templates/link/decide.test.ts +368 -0
  22. package/templates/link/decide.ts +340 -0
  23. package/templates/link/fixture.ts +727 -0
  24. package/templates/link/grade-run.ts +53 -0
  25. package/templates/link/grade.test.ts +113 -0
  26. package/templates/link/grade.ts +335 -0
  27. package/templates/link/items.ts +333 -0
  28. package/templates/link/names.test.ts +260 -0
  29. package/templates/link/names.ts +590 -0
  30. package/templates/link/reads.ts +302 -0
  31. package/templates/link/samples/gold.json +108 -0
  32. package/templates/link/samples/records.json +48 -0
  33. package/templates/link/samples.ts +64 -0
  34. package/templates/link/settings.ts +162 -0
  35. package/templates/link/setup.ts +18 -0
  36. package/templates/link/source-turn.ts +894 -0
  37. package/templates/link/sources/records.ts +429 -0
  38. package/templates/link/things-turn.ts +984 -0
  39. package/templates/link/things.test.ts +204 -0
  40. package/templates/link/things.ts +426 -0
  41. package/templates/link/try.ts +28 -0
  42. package/templates/link/worker.ts +24 -0
  43. package/templates/link/workflow.test.ts +484 -0
  44. package/templates/link/workflow.ts +202 -0
  45. package/templates/ontology-fit/workflow.test.ts +7 -1
  46. package/templates/ontology-fit/workflow.ts +5 -2
  47. package/templates/review/README.md +158 -0
  48. package/templates/review/decision.ts +483 -0
  49. package/templates/review/samples/expected.json +37 -0
  50. package/templates/review/samples/proposals.json +35 -0
  51. package/templates/review/samples.ts +154 -0
  52. package/templates/review/workflow.test.ts +438 -0
  53. package/templates/review/workflow.ts +459 -0
  54. package/templates/values-extract/workflow.test.ts +24 -1
  55. package/templates/values-extract/workflow.ts +11 -3
  56. package/templates/versions.json +1 -1
@@ -0,0 +1,154 @@
1
+ // The samples of review, for `npm run try`: a small graph of the invented school building (two
2
+ // rooms and their equipment) and fourteen fact proposals about it (samples/proposals.json), one
3
+ // for each case the review decides. This file commits the instances, reads their IRIs back, and
4
+ // commits the proposals as a proposer would. samples/README.md in the repository describes them.
5
+ import { createHash } from "node:crypto";
6
+ import { readFile } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+ import { ENTITY_IRI_NAMESPACE, type LbbClient, type Schemas } from "@littlebigbrain/client";
9
+ import * as review from "./decision.js";
10
+ import { defaults } from "./workflow.js";
11
+
12
+ /** The graph's own ontology: rooms, their equipment, and a room's area. */
13
+ export const sampleOntology: Schemas["OntologyEvolveOp"][] = [
14
+ { op: "add_entity_type", name: "Room" },
15
+ { op: "add_entity_type", name: "Equipment" },
16
+ { op: "add_property", name: "identifier", value_type: "keyword" },
17
+ { op: "add_property", name: "area_m2", value_type: "f64" },
18
+ { op: "add_relation", name: "HAS_EQUIPMENT", domain: ["Room"], range: ["Equipment"] },
19
+ ];
20
+
21
+ /** The samples of proposals.json. */
22
+ export interface SampleProposals {
23
+ instances: { id: string; class: string; name: string; identifier: string }[];
24
+ edges: { source: string; relation: string; target: string }[];
25
+ new: string[];
26
+ proposals: {
27
+ id: string;
28
+ subject: string;
29
+ predicate: string;
30
+ object: string;
31
+ datatype?: string;
32
+ confidence: number;
33
+ line: string;
34
+ }[];
35
+ }
36
+
37
+ /** The proposer the samples name. */
38
+ export const PROPOSER = "samples/review";
39
+
40
+ const PREFIXES: Record<string, string> = {
41
+ "rdf:": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
42
+ "rdfs:": "http://www.w3.org/2000/01/rdf-schema#",
43
+ "owl:": "http://www.w3.org/2002/07/owl#",
44
+ };
45
+ const RELATIONS = new Set(
46
+ sampleOntology.flatMap((op) => (op.op === "add_relation" ? [op.name] : [])),
47
+ );
48
+ const sha256 = (text: string) => createHash("sha256").update(text).digest("hex");
49
+
50
+ /** The IRI of an instance the graph does not have yet: minted from its id. */
51
+ export const mintedIri = (id: string) =>
52
+ ENTITY_IRI_NAMESPACE + sha256(`samples:${id}`).slice(0, 32);
53
+
54
+ /** The commit of the sample instances and the edge the graph holds before the run. */
55
+ export function instancesCommit(samples: SampleProposals): Schemas["TripletCommitFile"] {
56
+ const ref = (id: string) => {
57
+ const i = samples.instances.find((x) => x.id === id)!;
58
+ return { type: i.class, name: i.name, key: `sample:${i.id}` };
59
+ };
60
+ return {
61
+ entity_properties: samples.instances.map((i) => ({
62
+ ...ref(i.id),
63
+ properties: { identifier: i.identifier },
64
+ })),
65
+ triplets: samples.edges.map((e) => ({
66
+ source: ref(e.source),
67
+ relation: e.relation,
68
+ target: ref(e.target),
69
+ })),
70
+ };
71
+ }
72
+
73
+ /** The statement of a sample proposal; `iris` gives each instance's IRI by its id. */
74
+ export function statementOf(
75
+ p: SampleProposals["proposals"][number],
76
+ iris: Map<string, string>,
77
+ ): review.Statement {
78
+ const iri = (id: string) => (id.startsWith("new:") ? mintedIri(id.slice(4)) : iris.get(id));
79
+ const prefix = Object.keys(PREFIXES).find((k) => p.predicate.startsWith(k));
80
+ const predicate = prefix
81
+ ? PREFIXES[prefix] + p.predicate.slice(prefix.length)
82
+ : /^https?:/.test(p.predicate)
83
+ ? p.predicate
84
+ : RELATIONS.has(p.predicate)
85
+ ? review.relationIri(p.predicate)
86
+ : review.propertyIri(p.predicate);
87
+ const subject = iri(p.subject)!;
88
+ if (predicate === review.RDF_TYPE)
89
+ return { subject, predicate, object: review.classIri(p.object) };
90
+ const object = iri(p.object);
91
+ return object
92
+ ? { subject, predicate, object }
93
+ : { subject, predicate, object: p.object, ...(p.datatype ? { datatype: p.datatype } : {}) };
94
+ }
95
+
96
+ /** The commit of the proposals, as a proposer writes them: one `FactProposal` per statement. */
97
+ export function proposalsCommit(
98
+ samples: SampleProposals,
99
+ iris: Map<string, string>,
100
+ ): Schemas["TripletCommitFile"] {
101
+ return {
102
+ entity_properties: samples.proposals.map((p) => {
103
+ const s = statementOf(p, iris);
104
+ const key = `proposal:${sha256(
105
+ ["fact_proposal_v1", s.subject, s.predicate, s.object, s.datatype ?? "", PROPOSER].join(
106
+ "\u0000",
107
+ ),
108
+ ).slice(0, 32)}`;
109
+ return {
110
+ type: defaults.className,
111
+ name: key,
112
+ key,
113
+ properties: {
114
+ fact_subject: s.subject,
115
+ fact_predicate: s.predicate,
116
+ fact_object: s.object,
117
+ ...(s.datatype ? { fact_datatype: s.datatype } : {}),
118
+ fact_confidence: p.confidence,
119
+ fact_evidence: JSON.stringify({ sample: p.id, line: p.line }),
120
+ proposed_by: PROPOSER,
121
+ },
122
+ };
123
+ }),
124
+ };
125
+ }
126
+
127
+ /** Commit the instances, read their IRIs, then commit the proposals to the client's graph. */
128
+ export async function loadSamples(client: LbbClient, dir: string): Promise<void> {
129
+ const samples = JSON.parse(
130
+ await readFile(join(dir, "proposals.json"), "utf8"),
131
+ ) as SampleProposals;
132
+ await client.ontology.evolve({ ops: sampleOntology });
133
+ await client.commit(instancesCommit(samples), { idempotencyKey: "review-samples-instances" });
134
+ const { rows } = await client.sparqlRows(
135
+ {
136
+ query: `SELECT ?s ?identifier WHERE { ?s <${review.propertyIri("identifier")}> ?identifier }`,
137
+ },
138
+ { consistency: "strong" },
139
+ );
140
+ const iris = new Map(
141
+ samples.instances.flatMap((i) => {
142
+ const found = rows.find((r) => r.identifier === i.identifier);
143
+ return found ? [[i.id, found.s!] as const] : [];
144
+ }),
145
+ );
146
+ if (iris.size !== samples.instances.length)
147
+ throw new Error("The sample instances are not readable yet: run npm run try again.");
148
+ await client.commit(proposalsCommit(samples, iris), {
149
+ idempotencyKey: "review-samples-proposals",
150
+ });
151
+ console.log(
152
+ `Committed ${samples.instances.length} instances and ${samples.proposals.length} proposals`,
153
+ );
154
+ }
@@ -0,0 +1,438 @@
1
+ // review on its samples: a graph in memory with the instances and the proposals of
2
+ // samples/proposals.json, the queries of the workflow answered from it, and no model (the review
3
+ // calls none). The expected statuses and facts come from samples/expected.json.
4
+ import { test } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { join } from "node:path";
7
+ import type { Schemas } from "@littlebigbrain/client";
8
+ import {
9
+ MockActivities,
10
+ phases,
11
+ readJson,
12
+ runMessage,
13
+ samplesDir,
14
+ triggerMessage,
15
+ type MemoryGraph,
16
+ } from "../_shared/testing.js";
17
+ import * as review from "./decision.js";
18
+ import {
19
+ instancesCommit,
20
+ proposalsCommit,
21
+ sampleOntology,
22
+ statementOf,
23
+ type SampleProposals,
24
+ } from "./samples.js";
25
+ import { createWorkflow, defaults, ontology, type Settings } from "./workflow.js";
26
+
27
+ const SAMPLES = samplesDir(import.meta.url, "review");
28
+ const DATA = readJson<SampleProposals>(join(SAMPLES, "proposals.json"));
29
+ interface Expected {
30
+ statuses: Record<string, { status: string; held?: boolean }>;
31
+ facts: { subject: string; predicate: string; object: string | number }[];
32
+ review_items: number;
33
+ items: { instance: string; title: string; facts: string[] }[];
34
+ }
35
+ const EXPECTED = readJson<Expected>(join(SAMPLES, "expected.json"));
36
+ /** The settings of the answer key: every statement about a new instance goes to a person. */
37
+ const SAMPLE_SETTINGS: Settings = { ...defaults, alwaysReview: [review.NEW_INSTANCE] };
38
+
39
+ /** The ontology view `GET /v1/ontology` answers for evolve operations. */
40
+ function viewOf(ops: Schemas["OntologyEvolveOp"][]): Schemas["OntologyView"] {
41
+ const list = ops as {
42
+ op: string;
43
+ name: string;
44
+ domain?: string[];
45
+ range?: string[];
46
+ value_type?: string;
47
+ }[];
48
+ return {
49
+ graph: { tenant_id: "t", graph_id: "samples", branch_id: "main" },
50
+ ontology_version: 2,
51
+ entity_types: [],
52
+ relations: [],
53
+ entity_type_defs: list
54
+ .filter((o) => o.op === "add_entity_type")
55
+ .map((o) => ({
56
+ name: o.name,
57
+ iri: review.classIri(o.name),
58
+ stable_id: o.name.toLowerCase(),
59
+ super_types: [],
60
+ properties: [],
61
+ resolvable: true,
62
+ since_version: 1,
63
+ })),
64
+ relation_defs: list
65
+ .filter((o) => o.op === "add_relation")
66
+ .map((o) => ({
67
+ name: o.name,
68
+ src_types: o.domain ?? [],
69
+ dst_types: o.range ?? [],
70
+ inverse_name: null,
71
+ cardinality: "many_to_many",
72
+ temporal_semantics: "atemporal",
73
+ transitive: false,
74
+ symmetric: false,
75
+ since_version: 1,
76
+ })),
77
+ property_defs: list
78
+ .filter((o) => o.op === "add_property")
79
+ .map((o) => ({ name: o.name, value_type: o.value_type!, required: false })),
80
+ } as unknown as Schemas["OntologyView"];
81
+ }
82
+ const VIEW = viewOf([...ontology(), ...sampleOntology]);
83
+
84
+ /** The queries of the workflow, answered from the graph in memory. */
85
+ function sparql(query: string, graph: MemoryGraph): Record<string, string>[] {
86
+ const named = [...query.matchAll(/<(https:\/\/littlebigbrain\.com\/e\/[0-9a-f]+)>/g)].map(
87
+ (m) => m[1]!,
88
+ );
89
+ const field = (name: string) => (e: { properties: Record<string, unknown> }) =>
90
+ e.properties[name] === undefined ? undefined : String(e.properties[name]);
91
+ if (query.startsWith("# review: proposals"))
92
+ return named.flatMap((iri) => {
93
+ const e = graph.entities.get(iri);
94
+ if (!e) return [];
95
+ const row: Record<string, string | undefined> = {
96
+ x: iri,
97
+ label: e.name,
98
+ s: field("fact_subject")(e),
99
+ p: field("fact_predicate")(e),
100
+ o: field("fact_object")(e),
101
+ dt: field("fact_datatype")(e),
102
+ c: field("fact_confidence")(e),
103
+ ev: field("fact_evidence")(e),
104
+ by: field("proposed_by")(e),
105
+ status: field("fact_status")(e),
106
+ };
107
+ return [
108
+ Object.fromEntries(Object.entries(row).filter(([, v]) => v !== undefined)) as Record<
109
+ string,
110
+ string
111
+ >,
112
+ ];
113
+ });
114
+ if (query.startsWith("# review: entities"))
115
+ return named.flatMap((iri) => {
116
+ const e = graph.entities.get(iri);
117
+ return e ? [{ s: iri, type: review.classIri(e.type), label: e.name }] : [];
118
+ });
119
+ if (query.startsWith("# review: new instances")) {
120
+ const literals = [...query.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => m[1]!);
121
+ return graph
122
+ .ofType(defaults.className)
123
+ .filter(
124
+ (e) =>
125
+ literals.includes(String(e.properties.fact_subject)) &&
126
+ literals.includes(String(e.properties.fact_predicate)),
127
+ )
128
+ .map((e) => ({
129
+ s: String(e.properties.fact_subject),
130
+ p: String(e.properties.fact_predicate),
131
+ o: String(e.properties.fact_object),
132
+ }));
133
+ }
134
+ if (query.startsWith("# review: values")) {
135
+ const [subject, predicate] = [...query.matchAll(/<([^>]+)>/g)].map((m) => m[1]!);
136
+ const e = graph.entities.get(subject!);
137
+ const value = e?.properties[predicate!.slice(review.PROPERTY_NS.length)];
138
+ return value === undefined ? [] : [{ v: String(value) }];
139
+ }
140
+ if (query.startsWith("# review: edge")) {
141
+ const [subject, predicate, object] = [...query.matchAll(/<([^>]+)>/g)].map((m) => m[1]!);
142
+ return graph.edges.some(
143
+ (e) =>
144
+ e.source === subject && review.relationIri(e.relation) === predicate && e.target === object,
145
+ )
146
+ ? [{ s: subject! }]
147
+ : [];
148
+ }
149
+ throw new Error(`a query the mock does not know: ${query.slice(0, 40)}`);
150
+ }
151
+
152
+ /** A graph in memory with the sample instances and proposals, and the IRIs of both. */
153
+ function sampleGraph(data: SampleProposals = DATA) {
154
+ const lbb = new MockActivities({ ontology: VIEW, sparql });
155
+ lbb.graph.apply({ subjects: [], ...instancesCommit(data) });
156
+ const iris = new Map(
157
+ data.instances.map((i) => [
158
+ i.id,
159
+ lbb.graph.iriOf({ type: i.class, name: i.name, key: `sample:${i.id}` }),
160
+ ]),
161
+ );
162
+ const rows = proposalsCommit(data, iris).entity_properties!;
163
+ lbb.graph.apply({ subjects: [], entity_properties: rows });
164
+ const proposals = new Map(data.proposals.map((p, n) => [p.id, lbb.graph.iriOf(rows[n]!)]));
165
+ return { lbb, iris, proposals };
166
+ }
167
+
168
+ const statusOf = (graph: MemoryGraph, iri: string) =>
169
+ graph.entities.get(iri)!.properties.fact_status;
170
+
171
+ test("the samples get the statuses and the facts of the answer key", async () => {
172
+ const { lbb, iris, proposals } = sampleGraph();
173
+ const { state, turns } = await runMessage(
174
+ createWorkflow(lbb, SAMPLE_SETTINGS),
175
+ triggerMessage("review", [...proposals.values()]),
176
+ );
177
+
178
+ for (const [id, want] of Object.entries(EXPECTED.statuses))
179
+ assert.equal(statusOf(lbb.graph, proposals.get(id)!), want.status, id);
180
+ const edges = lbb.graph
181
+ .edgesOf("HAS_EQUIPMENT")
182
+ .map((e) => `${e.source} ${e.target}`)
183
+ .sort();
184
+ const want = EXPECTED.facts
185
+ .filter((f) => f.predicate === "HAS_EQUIPMENT")
186
+ .map((f) => `${iris.get(f.subject)} ${iris.get(String(f.object))}`)
187
+ .sort();
188
+ assert.deepEqual(edges, want);
189
+ assert.equal(lbb.graph.entities.get(iris.get("room-012")!)!.properties.area_m2, 62.4);
190
+ assert.equal(lbb.reviews.size, EXPECTED.review_items);
191
+ assert.deepEqual(state, { proposals: 14, written: 4, held: 1, in_review: 5, refused: 4 });
192
+ assert.equal(turns.length, 1);
193
+ assert.ok(turns[0]!.keys.length <= 64);
194
+ // Each step reports its phase as the LBB-run review words it.
195
+ assert.deepEqual(phases(turns), ["deciding proposals", "writing facts", "filing review items"]);
196
+ const deciding = turns[0]!.progress.filter((p) => p.phase === "deciding proposals");
197
+ assert.deepEqual(
198
+ deciding.map((p) => [p.step, p.done, p.total, p.unit]),
199
+ deciding.map((_, i) => [`decide-${i}`, i, deciding.length, "proposals"]),
200
+ );
201
+ });
202
+
203
+ test("a written fact names its proposal as evidence, a refusal and a review say why", async () => {
204
+ const { lbb, iris, proposals } = sampleGraph();
205
+ await runMessage(
206
+ createWorkflow(lbb, SAMPLE_SETTINGS),
207
+ triggerMessage("review", [...proposals.values()]),
208
+ );
209
+ const edge = lbb.graph
210
+ .edgesOf("HAS_EQUIPMENT")
211
+ .find((e) => e.source === iris.get("room-012") && e.target === iris.get("t1"))!;
212
+ assert.deepEqual(edge.evidence, {
213
+ text: JSON.stringify({ sample: "p1", line: "T1 Einzeltisch, 28 Stück" }),
214
+ source_id: proposals.get("p1"),
215
+ });
216
+ assert.equal(edge.confidence, 0.95);
217
+ // The names of the instances stay as they were.
218
+ assert.equal(lbb.graph.entities.get(iris.get("t1")!)!.name, "T 1 Einzeltisch");
219
+ const note = (id: string) =>
220
+ lbb.graph.entities.get(proposals.get(id)!)!.properties.review_note as string;
221
+ assert.match(note("p6"), /vierzehn/);
222
+ assert.match(note("p11"), /points to Equipment/);
223
+ assert.match(note("p2"), /below the gate 0\.8/);
224
+ assert.match(note("p7"), /rule new_instance/);
225
+ assert.equal(note("p1"), undefined);
226
+ const item = [...lbb.reviews.values()].find((r) => r.title.includes("Raum 0.12"))!;
227
+ assert.equal(item.title, "Room Raum 0.12 Klassenraum: 1 fact to check");
228
+ assert.equal(item.workflow, "review");
229
+ });
230
+
231
+ test("one item per instance: a new instance's facts in order, its type required", async () => {
232
+ const { lbb, proposals } = sampleGraph();
233
+ await runMessage(
234
+ createWorkflow(lbb, SAMPLE_SETTINGS),
235
+ triggerMessage("review", [...proposals.values()]),
236
+ );
237
+ const items = [...lbb.reviews.values()];
238
+ const expected = EXPECTED.items.map((item) => item.title).sort();
239
+ assert.deepEqual(items.map((item) => item.title).sort(), expected);
240
+ const m7 = items.find((item) => item.title === "New Equipment M 7 Medienwagen")!;
241
+ const samples = m7.evidence!.samples as Record<string, unknown>[];
242
+ assert.deepEqual(
243
+ samples.map((sample) => [sample.kind, sample.fact]),
244
+ [
245
+ ["type", "is a Equipment"],
246
+ ["label", "label · M 7 Medienwagen"],
247
+ ["link", "HAS_EQUIPMENT ← Raum 0.12 Klassenraum"],
248
+ ],
249
+ );
250
+ assert.deepEqual(
251
+ samples.map((sample) => sample.proposal),
252
+ ["p7", "p8", "p9"].map((id) => proposals.get(id)),
253
+ );
254
+ assert.equal(samples[0]!.line, "M 7 Medienwagen, 1 Stück");
255
+ // The type's write and its status are required; the link is not.
256
+ const required = (m7.facts as { required?: { row: number }[] }).required!.map((r) => r.row);
257
+ assert.deepEqual(required, [samples[0]!.rows, samples[0]!.row].flat());
258
+ assert.equal(samples[2]!.required, undefined);
259
+ assert.deepEqual(samples[2]!.triplets, [0]);
260
+ assert.match(m7.key!, /^starter:review:instance:[0-9a-f]{32}:[0-9a-f]{12}$/);
261
+ assert.deepEqual((m7.mapping as { review: { new: boolean } }).review.new, true);
262
+ });
263
+
264
+ test("accepting a review item writes its statement and the proposal's accepted", async () => {
265
+ const { lbb, iris, proposals } = sampleGraph();
266
+ await runMessage(
267
+ createWorkflow(lbb, SAMPLE_SETTINGS),
268
+ triggerMessage("review", [...proposals.values()]),
269
+ );
270
+ // A person accepts every item: the server commits each item's facts.
271
+ for (const item of lbb.reviews.values())
272
+ lbb.graph.apply({
273
+ subjects: [],
274
+ triplets: item.facts!.triplets ?? [],
275
+ entity_properties: item.facts!.entity_properties ?? [],
276
+ });
277
+ const m7 = lbb.graph.entities.get(
278
+ statementOf(
279
+ DATA.proposals.find((p) => p.id === "p7")!,
280
+ iris,
281
+ ).subject,
282
+ )!;
283
+ assert.equal(m7.type, "Equipment");
284
+ assert.equal(m7.name, "M 7 Medienwagen");
285
+ assert.equal(m7.properties.proposed_in, proposals.get("p7"));
286
+ assert.ok(
287
+ lbb.graph
288
+ .edgesOf("HAS_EQUIPMENT")
289
+ .some((e) => e.source === iris.get("room-012") && e.target === m7.iri),
290
+ );
291
+ for (const id of ["p2", "p7", "p8", "p9", "p12"])
292
+ assert.equal(statusOf(lbb.graph, proposals.get(id)!), "accepted", id);
293
+ // The label's item names s21 again (the server renames it; the graph in memory keeps names).
294
+ const rename = [...lbb.reviews.values()].find((r) =>
295
+ r.title.startsWith("Equipment S 2.1 Spiegel"),
296
+ )!;
297
+ assert.deepEqual(rename.facts!.entity_properties![0]!.name, "S 2.1 Spiegel, Kristallglas");
298
+ assert.equal(
299
+ rename.facts!.entity_properties![0]!.key,
300
+ `\u0000lbb-eid:${iris.get("s21")!.slice(-32)}`,
301
+ );
302
+ });
303
+
304
+ test("by default the gate decides a new instance: written at the gate, an item below it", async () => {
305
+ assert.deepEqual(defaults.alwaysReview, []);
306
+ const { lbb, iris, proposals } = sampleGraph();
307
+ await runMessage(createWorkflow(lbb), triggerMessage("review", [...proposals.values()]));
308
+ const m7 = lbb.graph.entities.get(
309
+ statementOf(
310
+ DATA.proposals.find((p) => p.id === "p7")!,
311
+ iris,
312
+ ).subject,
313
+ )!;
314
+ assert.equal(m7.type, "Equipment");
315
+ assert.equal(m7.name, "M 7 Medienwagen");
316
+ for (const id of ["p7", "p8", "p9"])
317
+ assert.equal(statusOf(lbb.graph, proposals.get(id)!), "written", id);
318
+ const titles = [...lbb.reviews.values()].map((item) => item.title);
319
+ assert.ok(!titles.some((title) => title.includes("M 7")), titles.join(", "));
320
+ assert.equal(lbb.reviews.size, EXPECTED.review_items - 1);
321
+
322
+ // The same new instance below the gate goes to a person, in one item.
323
+ const unsure: SampleProposals = {
324
+ ...DATA,
325
+ proposals: DATA.proposals.map((p) =>
326
+ ["p7", "p8", "p9"].includes(p.id) ? { ...p, confidence: 0.5 } : p,
327
+ ),
328
+ };
329
+ const below = sampleGraph(unsure);
330
+ await runMessage(
331
+ createWorkflow(below.lbb),
332
+ triggerMessage("review", [...below.proposals.values()]),
333
+ );
334
+ for (const id of ["p7", "p8", "p9"])
335
+ assert.equal(statusOf(below.lbb.graph, below.proposals.get(id)!), "in_review", id);
336
+ const item = [...below.lbb.reviews.values()].find(
337
+ (r) => r.title === "New Equipment M 7 Medienwagen",
338
+ )!;
339
+ assert.equal((item.evidence!.samples as unknown[]).length, 3);
340
+ const note = below.lbb.graph.entities.get(below.proposals.get("p7")!)!.properties
341
+ .review_note as string;
342
+ assert.match(note, /below the gate 0\.8/);
343
+ });
344
+
345
+ test("a predicate rule sends its statements to a person whatever their confidence", async () => {
346
+ const { lbb, proposals } = sampleGraph();
347
+ const settings: Settings = { ...defaults, alwaysReview: [review.relationIri("HAS_EQUIPMENT")] };
348
+ await runMessage(
349
+ createWorkflow(lbb, settings),
350
+ triggerMessage("review", [...proposals.values()]),
351
+ );
352
+ assert.equal(lbb.graph.edgesOf("HAS_EQUIPMENT").length, 1, "only the edge the graph held");
353
+ assert.equal(statusOf(lbb.graph, proposals.get("p1")!), "in_review");
354
+ assert.equal(statusOf(lbb.graph, proposals.get("p5")!), "written");
355
+ });
356
+
357
+ test("a proposal is decided once: a second message changes nothing", async () => {
358
+ const { lbb, proposals } = sampleGraph();
359
+ const workflow = createWorkflow(lbb);
360
+ await runMessage(workflow, triggerMessage("review", [...proposals.values()]));
361
+ const before = JSON.stringify([...lbb.graph.entities.values()]);
362
+ const items = lbb.reviews.size;
363
+ const { state } = await runMessage(workflow, triggerMessage("review", [...proposals.values()]), {
364
+ proposals: 14,
365
+ });
366
+ assert.equal(JSON.stringify([...lbb.graph.entities.values()]), before);
367
+ assert.equal(lbb.reviews.size, items);
368
+ assert.equal(state.proposals, 14);
369
+ });
370
+
371
+ test("a message of more proposals than a turn decides continues in a new turn", async () => {
372
+ const many: SampleProposals = {
373
+ ...DATA,
374
+ proposals: Array.from({ length: 40 }, (_, n) => ({
375
+ id: `q${n}`,
376
+ subject: n % 2 ? "room-012" : "room-014",
377
+ predicate: "area_m2",
378
+ object: `${10 + n}`,
379
+ datatype: "http://www.w3.org/2001/XMLSchema#double",
380
+ confidence: 0.9,
381
+ line: `Fläche ${10 + n} m²`,
382
+ })),
383
+ };
384
+ const { lbb, proposals } = sampleGraph(many);
385
+ const { turns, state } = await runMessage(
386
+ createWorkflow(lbb),
387
+ triggerMessage("review", [...proposals.values()]),
388
+ );
389
+ assert.equal(turns.length, 2);
390
+ assert.ok(turns.every((t) => t.keys.length <= 64));
391
+ // The first value of each room is written; each later one would replace it, so it goes to a person.
392
+ assert.deepEqual(state, { proposals: 40, written: 2, held: 0, in_review: 38, refused: 0 });
393
+ });
394
+
395
+ test("the decision reads the statement's terms as the LBB review does", () => {
396
+ const ontology = review.readOntology(VIEW);
397
+ const room: review.Known = {
398
+ iri: `https://littlebigbrain.com/e/${"a".repeat(32)}`,
399
+ class: "Room",
400
+ label: "R",
401
+ exists: true,
402
+ };
403
+ const seen = { entities: new Map([[room.iri, room]]) };
404
+ const rules = { gate: 0.8, alwaysReview: [] };
405
+ const terms = review.classify(
406
+ { subject: room.iri, predicate: review.propertyIri("area_m2"), object: "14.2" },
407
+ ontology,
408
+ { ...seen, current: ["15"] },
409
+ "FactProposal",
410
+ );
411
+ assert.deepEqual(review.decide(terms, "", 0.99, rules), {
412
+ status: "in_review",
413
+ why: "The subject's area_m2 is 15 already: writing 14.2 would replace it.",
414
+ });
415
+ const typed = review.classify(
416
+ { subject: room.iri, predicate: review.RDF_TYPE, object: review.classIri("Equipment") },
417
+ ontology,
418
+ seen,
419
+ "FactProposal",
420
+ );
421
+ assert.equal(typed.kind, "refused");
422
+ // A label is a text: an IRI as a label is refused.
423
+ const label = review.classify(
424
+ { subject: room.iri, predicate: review.RDFS_LABEL, object: room.iri },
425
+ ontology,
426
+ seen,
427
+ "FactProposal",
428
+ );
429
+ assert.deepEqual(label, {
430
+ kind: "refused",
431
+ why: "A label is a text; the object is an IRI or empty.",
432
+ });
433
+ assert.equal(review.coerce("12", "i64"), 12);
434
+ assert.equal(review.coerce("1.5", "i64"), null);
435
+ assert.equal(review.coerce("true", "bool"), true);
436
+ assert.equal(review.isEntity(room.iri), true);
437
+ assert.equal(review.isEntity("https://example.org/x"), false);
438
+ });