@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.
- package/README.md +6 -2
- package/lib/create.mjs +33 -5
- package/package.json +1 -1
- package/templates/_shared/testing.ts +53 -9
- package/templates/documents-link/README.md +9 -7
- package/templates/documents-link/workflow.test.ts +28 -1
- package/templates/documents-link/workflow.ts +24 -14
- 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/link/README.md +203 -0
- package/templates/link/candidates.ts +432 -0
- package/templates/link/decide.test.ts +368 -0
- package/templates/link/decide.ts +340 -0
- package/templates/link/fixture.ts +727 -0
- package/templates/link/grade-run.ts +53 -0
- package/templates/link/grade.test.ts +113 -0
- package/templates/link/grade.ts +335 -0
- package/templates/link/items.ts +333 -0
- package/templates/link/names.test.ts +260 -0
- package/templates/link/names.ts +590 -0
- package/templates/link/reads.ts +302 -0
- package/templates/link/samples/gold.json +108 -0
- package/templates/link/samples/records.json +48 -0
- package/templates/link/samples.ts +64 -0
- package/templates/link/settings.ts +162 -0
- package/templates/link/setup.ts +18 -0
- package/templates/link/source-turn.ts +894 -0
- package/templates/link/sources/records.ts +429 -0
- package/templates/link/things-turn.ts +984 -0
- package/templates/link/things.test.ts +204 -0
- package/templates/link/things.ts +426 -0
- package/templates/link/try.ts +28 -0
- package/templates/link/worker.ts +24 -0
- package/templates/link/workflow.test.ts +484 -0
- package/templates/link/workflow.ts +202 -0
- package/templates/ontology-fit/workflow.test.ts +7 -1
- package/templates/ontology-fit/workflow.ts +5 -2
- 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/workflow.test.ts +24 -1
- package/templates/values-extract/workflow.ts +11 -3
- package/templates/versions.json +1 -1
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
// documents-map on its samples, with mock models whose rules are fixed: the grounding question
|
|
2
|
+
// picks the candidate whose label holds the entry's identifier, else a new instance; the class
|
|
3
|
+
// question picks Room for the room sheets, SpecSection for a section of the specification and
|
|
4
|
+
// Equipment for the rest; the typing question picks the relation whose range is the object's
|
|
5
|
+
// class. The trace and the expected proposals come from samples/trace.json and expected.json.
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import {
|
|
10
|
+
ENTITY_IRI_NAMESPACE,
|
|
11
|
+
type DecideAnswer,
|
|
12
|
+
type DecideRequest,
|
|
13
|
+
type FilePages,
|
|
14
|
+
type Schemas,
|
|
15
|
+
} from "@littlebigbrain/client";
|
|
16
|
+
import {
|
|
17
|
+
MockActivities,
|
|
18
|
+
mockId,
|
|
19
|
+
phases,
|
|
20
|
+
readJson,
|
|
21
|
+
runMessage,
|
|
22
|
+
runTurn,
|
|
23
|
+
samplesDir,
|
|
24
|
+
triggerMessage,
|
|
25
|
+
Crash,
|
|
26
|
+
type MemoryGraph,
|
|
27
|
+
type MockRules,
|
|
28
|
+
} from "../_shared/testing.js";
|
|
29
|
+
import * as map from "./map.js";
|
|
30
|
+
import { sampleOntology, traceOntology, type SampleTrace } from "./samples.js";
|
|
31
|
+
import { createWorkflow, ontology, type Settings } from "./workflow.js";
|
|
32
|
+
|
|
33
|
+
const SAMPLES = samplesDir(import.meta.url, "documents-map");
|
|
34
|
+
const TRACE = readJson<SampleTrace>(join(SAMPLES, "trace.json"));
|
|
35
|
+
const PAGES = readJson<FilePages>(join(SAMPLES, "pages", "raumblaetter.json"));
|
|
36
|
+
interface Expected {
|
|
37
|
+
instances: { document: string; identifier: string; class: string }[];
|
|
38
|
+
facts: { subject: string; predicate: string; object: string }[];
|
|
39
|
+
leave_out: { identifier: string }[];
|
|
40
|
+
}
|
|
41
|
+
const EXPECTED = readJson<Expected>(join(SAMPLES, "expected.json"));
|
|
42
|
+
|
|
43
|
+
// ---- the graph the trace is in ------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
const iri = (key: string) => ENTITY_IRI_NAMESPACE + mockId(key);
|
|
46
|
+
const sha = (document: string) => TRACE.documents.find((d) => d.name === document)!.sha256;
|
|
47
|
+
const docIri = (name: string) => iri(`doc:${name}`);
|
|
48
|
+
const entryIri = (document: string, identifier: string) => iri(`entry:${document}:${identifier}`);
|
|
49
|
+
const mentionIri = (m: SampleTrace["mentions"][number]) =>
|
|
50
|
+
iri(`mention:${m.document}:${m.line_id}:${m.span}`);
|
|
51
|
+
const WORKFLOW = "documents-map";
|
|
52
|
+
const PROPOSER = `workflow/${WORKFLOW}`;
|
|
53
|
+
|
|
54
|
+
/** An instance the graph holds before the run. */
|
|
55
|
+
interface Instance {
|
|
56
|
+
iri: string;
|
|
57
|
+
class: string;
|
|
58
|
+
label: string;
|
|
59
|
+
identifier?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The ontology view `GET /v1/ontology` answers for evolve operations. */
|
|
63
|
+
function viewOf(ops: Schemas["OntologyEvolveOp"][]): Schemas["OntologyView"] {
|
|
64
|
+
const types: string[] = [];
|
|
65
|
+
const relations: { name: string; domain: string[]; range: string[] }[] = [];
|
|
66
|
+
for (const op of ops as { op: string; name: string; domain?: string[]; range?: string[] }[]) {
|
|
67
|
+
if (op.op === "add_entity_type" && !types.includes(op.name)) types.push(op.name);
|
|
68
|
+
if (op.op === "add_relation")
|
|
69
|
+
relations.push({ name: op.name, domain: op.domain ?? [], range: op.range ?? [] });
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
graph: { tenant_id: "t", graph_id: "samples", branch_id: "main" },
|
|
73
|
+
ontology_version: 3,
|
|
74
|
+
entity_types: [],
|
|
75
|
+
relations: [],
|
|
76
|
+
entity_type_defs: types.map((name) => ({
|
|
77
|
+
name,
|
|
78
|
+
iri: map.classIri(name),
|
|
79
|
+
stable_id: name.toLowerCase(),
|
|
80
|
+
super_types: [],
|
|
81
|
+
properties: [],
|
|
82
|
+
resolvable: true,
|
|
83
|
+
since_version: 1,
|
|
84
|
+
})),
|
|
85
|
+
relation_defs: relations.map((r) => ({
|
|
86
|
+
name: r.name,
|
|
87
|
+
src_types: r.domain,
|
|
88
|
+
dst_types: r.range,
|
|
89
|
+
inverse_name: null,
|
|
90
|
+
cardinality: "many_to_many",
|
|
91
|
+
temporal_semantics: "atemporal",
|
|
92
|
+
transitive: false,
|
|
93
|
+
symmetric: false,
|
|
94
|
+
since_version: 1,
|
|
95
|
+
})),
|
|
96
|
+
} as unknown as Schemas["OntologyView"];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The queries of the workflow, answered from the trace, the instances and the graph in memory. */
|
|
100
|
+
function sparql(
|
|
101
|
+
query: string,
|
|
102
|
+
graph: MemoryGraph,
|
|
103
|
+
trace: SampleTrace,
|
|
104
|
+
instances: Instance[],
|
|
105
|
+
): Record<string, string>[] {
|
|
106
|
+
const named = new Set(
|
|
107
|
+
[...query.matchAll(/<(https:\/\/littlebigbrain\.com\/e\/[0-9a-f]+)>/g)].map((m) => m[1]!),
|
|
108
|
+
);
|
|
109
|
+
const box = (b: number[]) => ({ x0: `${b[0]}`, y0: `${b[1]}`, x1: `${b[2]}`, y1: `${b[3]}` });
|
|
110
|
+
const linked = trace.mentions.filter((m) => m.link);
|
|
111
|
+
if (query.startsWith("# documents-map: links"))
|
|
112
|
+
return linked.map((m) => ({
|
|
113
|
+
mention: mentionIri(m),
|
|
114
|
+
relation: map.relationIri(m.link!.relation),
|
|
115
|
+
entry: entryIri(m.link!.document, m.link!.identifier),
|
|
116
|
+
doc: docIri(m.document),
|
|
117
|
+
confidence: `${m.link!.confidence}`,
|
|
118
|
+
}));
|
|
119
|
+
if (query.startsWith("# documents-map: mentions"))
|
|
120
|
+
return trace.mentions
|
|
121
|
+
.filter((m) => named.has(docIri(m.document)))
|
|
122
|
+
.map((m) => ({
|
|
123
|
+
mention: mentionIri(m),
|
|
124
|
+
doc: docIri(m.document),
|
|
125
|
+
span: m.span,
|
|
126
|
+
line: m.line_id,
|
|
127
|
+
text: m.line_text,
|
|
128
|
+
page: `${m.page}`,
|
|
129
|
+
...box(m.box),
|
|
130
|
+
}));
|
|
131
|
+
if (query.startsWith("# documents-map: entries"))
|
|
132
|
+
return trace.entries
|
|
133
|
+
.filter((e) => named.has(docIri(e.document)) || named.has(entryIri(e.document, e.identifier)))
|
|
134
|
+
.map((e) => ({
|
|
135
|
+
entry: entryIri(e.document, e.identifier),
|
|
136
|
+
doc: docIri(e.document),
|
|
137
|
+
identifier: e.identifier,
|
|
138
|
+
context: e.context,
|
|
139
|
+
line: e.line_id,
|
|
140
|
+
page: `${e.page}`,
|
|
141
|
+
...box(e.box),
|
|
142
|
+
}));
|
|
143
|
+
if (query.startsWith("# documents-map: documents"))
|
|
144
|
+
return trace.documents
|
|
145
|
+
.filter((d) => named.has(docIri(d.name)))
|
|
146
|
+
.map((d) => ({ doc: docIri(d.name), name: d.name, sha: d.sha256 }));
|
|
147
|
+
if (query.startsWith("# documents-map: candidates")) {
|
|
148
|
+
const literals = [...query.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map(
|
|
149
|
+
(m) => JSON.parse(`"${m[1]}"`) as string,
|
|
150
|
+
);
|
|
151
|
+
return instances.flatMap((i) =>
|
|
152
|
+
literals.filter((v) => v === i.identifier || v === i.label).map((v) => ({ s: i.iri, v })),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
if (query.startsWith("# documents-map: details"))
|
|
156
|
+
return instances
|
|
157
|
+
.filter((i) => named.has(i.iri))
|
|
158
|
+
.flatMap((i) => [
|
|
159
|
+
{ s: i.iri, p: map.RDF_TYPE, o: map.classIri(i.class) },
|
|
160
|
+
{ s: i.iri, p: map.RDFS_LABEL, o: i.label },
|
|
161
|
+
...(i.identifier ? [{ s: i.iri, p: map.propertyIri("identifier"), o: i.identifier }] : []),
|
|
162
|
+
]);
|
|
163
|
+
if (query.startsWith("# documents-map: proposals"))
|
|
164
|
+
return graph
|
|
165
|
+
.ofType(map.FACT_PROPOSAL)
|
|
166
|
+
.filter((e) => e.properties.proposed_by === PROPOSER)
|
|
167
|
+
.map((e) => {
|
|
168
|
+
const p = e.properties as Record<string, string | number>;
|
|
169
|
+
return {
|
|
170
|
+
x: e.iri,
|
|
171
|
+
s: `${p.fact_subject}`,
|
|
172
|
+
p: `${p.fact_predicate}`,
|
|
173
|
+
o: `${p.fact_object}`,
|
|
174
|
+
...(p.fact_datatype ? { dt: `${p.fact_datatype}` } : {}),
|
|
175
|
+
c: `${p.fact_confidence}`,
|
|
176
|
+
ev: `${p.fact_evidence}`,
|
|
177
|
+
};
|
|
178
|
+
});
|
|
179
|
+
throw new Error(`a query the mock does not know: ${query.slice(0, 40)}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---- the mock decision model -----------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
const answer = (choice: string, p: number, options: string[]): DecideAnswer => ({
|
|
185
|
+
choice,
|
|
186
|
+
confidence: p,
|
|
187
|
+
probabilities: Object.fromEntries(
|
|
188
|
+
options.map((o) => [o, o === choice ? p : (1 - p) / (options.length - 1)]),
|
|
189
|
+
),
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
/** The relation each object class takes in the samples' ontology. */
|
|
193
|
+
const RELATION_TO: Record<string, string> = {
|
|
194
|
+
Equipment: "HAS_EQUIPMENT",
|
|
195
|
+
"Spec section": "SPECIFIED_IN",
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
function decide(request: DecideRequest): Record<string, DecideAnswer> {
|
|
199
|
+
const out: Record<string, DecideAnswer> = {};
|
|
200
|
+
const state = request.state as Record<string, Record<string, Record<string, string>>>;
|
|
201
|
+
for (const [id, q] of Object.entries(request.questions)) {
|
|
202
|
+
const options = Object.keys(q.criteria);
|
|
203
|
+
if (id.startsWith("g")) {
|
|
204
|
+
const identifier = q.instructions.match(/entry "([^"]*)"/)![1]!;
|
|
205
|
+
const named = options.find(
|
|
206
|
+
(o) => /^c\d+$/.test(o) && q.criteria[o]!.split(" (")[0]!.includes(identifier),
|
|
207
|
+
);
|
|
208
|
+
out[id] = named ? answer(named, 0.93, options) : answer(map.NEW_INSTANCE, 0.9, options);
|
|
209
|
+
} else if (id.startsWith("k")) {
|
|
210
|
+
const [, identifier, document] = q.instructions.match(/entry "([^"]*)" of ([^,]+),/)!;
|
|
211
|
+
const wanted = document!.startsWith("raumblaetter")
|
|
212
|
+
? "Room"
|
|
213
|
+
: /^\d+\.\d+$/.test(identifier!)
|
|
214
|
+
? "SpecSection"
|
|
215
|
+
: "Equipment";
|
|
216
|
+
out[id] = options.includes(wanted)
|
|
217
|
+
? answer(wanted, 0.88, options)
|
|
218
|
+
: answer(map.NO_CLASS, 0.8, options);
|
|
219
|
+
} else {
|
|
220
|
+
const object = state.instances![`o${id.slice(1)}`]!;
|
|
221
|
+
const wanted = RELATION_TO[object.class!] ?? map.NO_FACT;
|
|
222
|
+
out[id] = options.includes(wanted)
|
|
223
|
+
? answer(wanted, 0.95, options)
|
|
224
|
+
: answer(map.MISSING_PROPERTY, 0.8, options);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const SAMPLE_VIEW = viewOf([...traceOntology, ...ontology(), ...sampleOntology]);
|
|
231
|
+
const rules = (
|
|
232
|
+
options: { trace?: SampleTrace; instances?: Instance[]; view?: Schemas["OntologyView"] } = {},
|
|
233
|
+
more: MockRules = {},
|
|
234
|
+
): MockRules => ({
|
|
235
|
+
pages: [PAGES],
|
|
236
|
+
ontology: options.view ?? SAMPLE_VIEW,
|
|
237
|
+
decide,
|
|
238
|
+
sparql: (query, graph) => sparql(query, graph, options.trace ?? TRACE, options.instances ?? []),
|
|
239
|
+
...more,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const message = (trace: SampleTrace = TRACE) =>
|
|
243
|
+
triggerMessage(WORKFLOW, trace.mentions.filter((m) => m.link).map(mentionIri));
|
|
244
|
+
|
|
245
|
+
// ---- reading the outcome -----------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
/** The new instance of an entry of the answer key, as the workflow mints it. */
|
|
248
|
+
const minted = (document: string, identifier: string) => map.mintedIri(sha(document), identifier);
|
|
249
|
+
const byIri = new Map(
|
|
250
|
+
EXPECTED.instances.map((i) => [minted(i.document, i.identifier), i.identifier]),
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
/** The proposals of the graph, as `subject predicate object` with identifiers for instances. */
|
|
254
|
+
function proposals(graph: MemoryGraph): { text: string; confidence: number; kind: string }[] {
|
|
255
|
+
const name = (value: string) =>
|
|
256
|
+
byIri.get(value) ??
|
|
257
|
+
(value.startsWith(map.RELATION_NS) || value.startsWith(map.CLASS_NS)
|
|
258
|
+
? map.localName(value)
|
|
259
|
+
: value === map.RDF_TYPE
|
|
260
|
+
? "a"
|
|
261
|
+
: value === map.RDFS_LABEL
|
|
262
|
+
? "label"
|
|
263
|
+
: value === map.propertyIri("identifier")
|
|
264
|
+
? "identifier"
|
|
265
|
+
: value);
|
|
266
|
+
return graph.ofType(map.FACT_PROPOSAL).map((e) => {
|
|
267
|
+
const p = e.properties as Record<string, string | number>;
|
|
268
|
+
return {
|
|
269
|
+
text: `${name(`${p.fact_subject}`)} ${name(`${p.fact_predicate}`)} ${name(`${p.fact_object}`)}`,
|
|
270
|
+
confidence: Number(p.fact_confidence),
|
|
271
|
+
kind: (JSON.parse(`${p.fact_evidence}`) as { kind: string }).kind,
|
|
272
|
+
};
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const factsOf = (graph: MemoryGraph) =>
|
|
277
|
+
proposals(graph)
|
|
278
|
+
.filter((p) => p.kind === "fact")
|
|
279
|
+
.map((p) => p.text)
|
|
280
|
+
.sort();
|
|
281
|
+
const expectedFacts = EXPECTED.facts
|
|
282
|
+
.map((f) => `${f.subject} ${f.predicate.toLowerCase()} ${f.object}`)
|
|
283
|
+
.sort();
|
|
284
|
+
|
|
285
|
+
// ---- the tests ---------------------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
test("the samples give the proposals of the answer key and none other", async () => {
|
|
288
|
+
const lbb = new MockActivities(rules());
|
|
289
|
+
const { state, turns } = await runMessage(createWorkflow(lbb), message());
|
|
290
|
+
|
|
291
|
+
assert.deepEqual(factsOf(lbb.graph), expectedFacts);
|
|
292
|
+
const all = proposals(lbb.graph);
|
|
293
|
+
for (const i of EXPECTED.instances) {
|
|
294
|
+
const types = all.filter((p) => p.text.startsWith(`${i.identifier} a `)).map((p) => p.text);
|
|
295
|
+
assert.deepEqual(types, [`${i.identifier} a ${i.class.toLowerCase()}`], i.identifier);
|
|
296
|
+
assert.ok(
|
|
297
|
+
all.some((p) => p.text === `${i.identifier} identifier ${i.identifier}`),
|
|
298
|
+
i.identifier,
|
|
299
|
+
);
|
|
300
|
+
assert.ok(all.some((p) => p.kind === "denotes" && p.text.endsWith(` denotes ${i.identifier}`)));
|
|
301
|
+
}
|
|
302
|
+
for (const out of EXPECTED.leave_out)
|
|
303
|
+
assert.ok(!all.some((p) => p.text.includes(` ${out.identifier}`)), out.identifier);
|
|
304
|
+
assert.equal(all.length, EXPECTED.facts.length + 4 * EXPECTED.instances.length);
|
|
305
|
+
assert.deepEqual(state, {
|
|
306
|
+
runs: 1,
|
|
307
|
+
links: 15,
|
|
308
|
+
entries: 15,
|
|
309
|
+
proposals: 75,
|
|
310
|
+
suggestions: 0,
|
|
311
|
+
unmapped: 0,
|
|
312
|
+
unmappedLinks: [],
|
|
313
|
+
});
|
|
314
|
+
assert.equal(lbb.reviews.size, 0);
|
|
315
|
+
assert.equal(turns.length, 1);
|
|
316
|
+
assert.ok(turns[0]!.keys.length <= 64);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("each step reports its phase as the LBB-run documents.map words it", async () => {
|
|
320
|
+
const lbb = new MockActivities(rules());
|
|
321
|
+
const { turns } = await runMessage(createWorkflow(lbb), message());
|
|
322
|
+
assert.deepEqual(phases(turns), [
|
|
323
|
+
"grounding entries",
|
|
324
|
+
"choosing classes",
|
|
325
|
+
"typing links",
|
|
326
|
+
"writing proposals",
|
|
327
|
+
]);
|
|
328
|
+
for (const call of turns[0]!.progress.filter((p) => p.phase !== "writing proposals"))
|
|
329
|
+
assert.equal(call.unit, "questions", call.step);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("a fact's confidence is the link's times the typing answer's, a room's DENOTES starts at its mention", async () => {
|
|
333
|
+
const lbb = new MockActivities(rules());
|
|
334
|
+
await runMessage(createWorkflow(lbb), message());
|
|
335
|
+
const all = proposals(lbb.graph);
|
|
336
|
+
// T1 is linked to T 1 with 0.9 (another spelling), typed with 0.95.
|
|
337
|
+
assert.equal(all.find((p) => p.text === "0.12 has_equipment T 1")!.confidence, 0.855);
|
|
338
|
+
// A new instance's type: the grounding answer's 0.9 times the class answer's 0.88.
|
|
339
|
+
assert.equal(all.find((p) => p.text === "0.12 a room")!.confidence, 0.792);
|
|
340
|
+
const room = lbb.graph
|
|
341
|
+
.ofType(map.FACT_PROPOSAL)
|
|
342
|
+
.find(
|
|
343
|
+
(e) =>
|
|
344
|
+
e.properties.fact_object === minted("raumblaetter.pdf", "0.12") &&
|
|
345
|
+
e.properties.fact_predicate === map.relationIri(map.DENOTES),
|
|
346
|
+
)!;
|
|
347
|
+
const head = TRACE.mentions.find((m) => m.span === "0.12")!;
|
|
348
|
+
assert.equal(room.properties.fact_subject, mentionIri(head));
|
|
349
|
+
const label = lbb.graph
|
|
350
|
+
.ofType(map.FACT_PROPOSAL)
|
|
351
|
+
.find(
|
|
352
|
+
(e) =>
|
|
353
|
+
e.properties.fact_subject === minted("ausstattungsliste.pdf", "S 1.1") &&
|
|
354
|
+
e.properties.fact_predicate === map.RDFS_LABEL,
|
|
355
|
+
)!;
|
|
356
|
+
assert.equal(
|
|
357
|
+
label.properties.fact_object,
|
|
358
|
+
"S 1.1 Waschtisch, Keramik, ca. 55x45 cm, mit Einhebelmischer",
|
|
359
|
+
);
|
|
360
|
+
assert.equal(label.properties.fact_datatype, map.XSD_STRING);
|
|
361
|
+
// A page entry's description is its line without the span: the room is "0.12 Raum Klassenraum".
|
|
362
|
+
const roomLabel = lbb.graph
|
|
363
|
+
.ofType(map.FACT_PROPOSAL)
|
|
364
|
+
.find(
|
|
365
|
+
(e) =>
|
|
366
|
+
e.properties.fact_subject === minted("raumblaetter.pdf", "0.12") &&
|
|
367
|
+
e.properties.fact_predicate === map.RDFS_LABEL,
|
|
368
|
+
)!;
|
|
369
|
+
assert.equal(roomLabel.properties.fact_object, "0.12 Raum Klassenraum");
|
|
370
|
+
// DENOTES is open on both sides: an entry or a page's head mention, any instance.
|
|
371
|
+
assert.deepEqual(
|
|
372
|
+
ontology().find((op) => op.op === "add_relation" && op.name === map.DENOTES),
|
|
373
|
+
{ op: "add_relation", name: "DENOTES", domain: [], range: [] },
|
|
374
|
+
);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("a second message asks every question from the effect cache and changes no proposal", async () => {
|
|
378
|
+
const lbb = new MockActivities(rules());
|
|
379
|
+
const workflow = createWorkflow(lbb);
|
|
380
|
+
await runMessage(workflow, message());
|
|
381
|
+
const asked = lbb.calls.length;
|
|
382
|
+
const before = proposals(lbb.graph);
|
|
383
|
+
const { state } = await runMessage(workflow, message(), { runs: 1 });
|
|
384
|
+
const again = lbb.calls.slice(asked);
|
|
385
|
+
assert.ok(again.length > 0);
|
|
386
|
+
assert.ok(again.every((c) => c.cached));
|
|
387
|
+
assert.deepEqual(proposals(lbb.graph), before);
|
|
388
|
+
assert.equal(state.runs, 2);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("a turn stopped part way runs again and pays for nothing twice", async () => {
|
|
392
|
+
const lbb = new MockActivities(rules());
|
|
393
|
+
const workflow = createWorkflow(lbb);
|
|
394
|
+
const crashed = await runTurn(workflow, {}, message(), { crashAfter: 2 }).catch(
|
|
395
|
+
(error: unknown) => error,
|
|
396
|
+
);
|
|
397
|
+
assert.ok(crashed instanceof Crash);
|
|
398
|
+
await runTurn(workflow, {}, message(), { saved: crashed.turn });
|
|
399
|
+
assert.equal(
|
|
400
|
+
lbb.calls.filter((c) => !c.cached).length,
|
|
401
|
+
new Set(lbb.calls.map((c) => c.effectId)).size,
|
|
402
|
+
);
|
|
403
|
+
assert.deepEqual(factsOf(lbb.graph), expectedFacts);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test("an entry the graph holds is grounded to its instance: DENOTES, and no new instance", async () => {
|
|
407
|
+
const s11: Instance = {
|
|
408
|
+
iri: iri("instance:S 1.1"),
|
|
409
|
+
class: "Equipment",
|
|
410
|
+
label: "S 1.1 Waschtisch",
|
|
411
|
+
identifier: "S1.1",
|
|
412
|
+
};
|
|
413
|
+
const lbb = new MockActivities(rules({ instances: [s11] }));
|
|
414
|
+
await runMessage(createWorkflow(lbb), message());
|
|
415
|
+
const rows = lbb.graph.ofType(map.FACT_PROPOSAL).map((e) => e.properties);
|
|
416
|
+
assert.ok(
|
|
417
|
+
rows.some(
|
|
418
|
+
(p) =>
|
|
419
|
+
p.fact_subject === entryIri("ausstattungsliste.pdf", "S 1.1") &&
|
|
420
|
+
p.fact_predicate === map.relationIri(map.DENOTES) &&
|
|
421
|
+
p.fact_object === s11.iri,
|
|
422
|
+
),
|
|
423
|
+
);
|
|
424
|
+
assert.ok(!rows.some((p) => p.fact_subject === minted("ausstattungsliste.pdf", "S 1.1")));
|
|
425
|
+
const facts = rows.filter(
|
|
426
|
+
(p) => p.fact_object === s11.iri && p.fact_predicate === map.relationIri("HAS_EQUIPMENT"),
|
|
427
|
+
);
|
|
428
|
+
assert.deepEqual(
|
|
429
|
+
facts.map((p) => p.fact_subject).sort(),
|
|
430
|
+
[minted("raumblaetter.pdf", "0.12"), minted("raumblaetter.pdf", "0.14")].sort(),
|
|
431
|
+
);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("a class or a property the ontology lacks becomes one ontology suggestion per pattern", async () => {
|
|
435
|
+
// No SpecSection: the sections of the specification fit no class.
|
|
436
|
+
const noClass = sampleOntology.filter(
|
|
437
|
+
(op) => !("name" in op) || (op.name !== "SpecSection" && op.name !== "SPECIFIED_IN"),
|
|
438
|
+
);
|
|
439
|
+
const first = new MockActivities(
|
|
440
|
+
rules({ view: viewOf([...traceOntology, ...ontology(), ...noClass]) }),
|
|
441
|
+
);
|
|
442
|
+
const { state, turns } = await runMessage(createWorkflow(first), message());
|
|
443
|
+
assert.equal(state.suggestions, 1);
|
|
444
|
+
assert.deepEqual(
|
|
445
|
+
turns[0]!.progress.filter((p) => p.phase === "filing review items"),
|
|
446
|
+
[{ step: "lacks", phase: "filing review items", done: 0, total: 1, unit: "items" }],
|
|
447
|
+
);
|
|
448
|
+
const [item] = [...first.reviews.values()];
|
|
449
|
+
// The title and the rationale name the entries, with what the model answered.
|
|
450
|
+
assert.ok(
|
|
451
|
+
item!.title.startsWith("Add a class for 2 entries of baubeschreibung.pdf: 3.1 Bodenbeläge"),
|
|
452
|
+
item!.title,
|
|
453
|
+
);
|
|
454
|
+
assert.ok(item!.title.includes(", 3.2 Sanitärobjekte"), item!.title);
|
|
455
|
+
assert.ok(
|
|
456
|
+
item!.rationale!.startsWith(
|
|
457
|
+
"The decision model found that no class of the ontology fits 2 entries of baubeschreibung.pdf (no class fits, up to 0.80): 3.1 Bodenbeläge",
|
|
458
|
+
),
|
|
459
|
+
item!.rationale,
|
|
460
|
+
);
|
|
461
|
+
assert.ok(item!.rationale!.includes("The classes the model weighed got at most:"));
|
|
462
|
+
assert.deepEqual(item!.change, [{ op: "add_entity_type", name: "BaubeschreibungItem" }]);
|
|
463
|
+
assert.equal(item!.evidence!.records, 2);
|
|
464
|
+
assert.equal(item!.workflow, WORKFLOW);
|
|
465
|
+
// Each sample is where its entry is, with the answer.
|
|
466
|
+
const [sample] = item!.evidence!.samples as Record<string, unknown>[];
|
|
467
|
+
assert.equal(sample!.kind, "entry");
|
|
468
|
+
assert.equal(sample!.document, "baubeschreibung.pdf");
|
|
469
|
+
assert.equal(sample!.page, 1);
|
|
470
|
+
assert.equal(sample!.answer, "no class fits");
|
|
471
|
+
assert.equal(sample!.probability, 0.8);
|
|
472
|
+
assert.equal((sample!.box as number[]).length, 4);
|
|
473
|
+
// An entry no class fits proposes nothing: no type, label, identifier or trace. Its links
|
|
474
|
+
// stay in the state as unmapped, with the reason.
|
|
475
|
+
assert.ok(
|
|
476
|
+
!first.graph
|
|
477
|
+
.ofType(map.FACT_PROPOSAL)
|
|
478
|
+
.some(
|
|
479
|
+
(e) =>
|
|
480
|
+
e.properties.fact_subject === minted("baubeschreibung.pdf", "3.1") ||
|
|
481
|
+
e.properties.fact_object === minted("baubeschreibung.pdf", "3.1"),
|
|
482
|
+
),
|
|
483
|
+
);
|
|
484
|
+
const waiting = state.unmappedLinks.filter((l) => l.outcome === "unclassed");
|
|
485
|
+
assert.deepEqual(waiting.map((l) => l.object).sort(), ["3.1", "3.2"]);
|
|
486
|
+
assert.ok(String(waiting[0]!.reason).includes("waits for the class suggestion"));
|
|
487
|
+
assert.equal(waiting[0]!.document, "raumblaetter.pdf");
|
|
488
|
+
// SpecSection without SPECIFIED_IN: no property relates a room to a section.
|
|
489
|
+
const noRelation = sampleOntology.filter((op) => !("name" in op) || op.name !== "SPECIFIED_IN");
|
|
490
|
+
const second = new MockActivities(
|
|
491
|
+
rules({ view: viewOf([...traceOntology, ...ontology(), ...noRelation]) }),
|
|
492
|
+
);
|
|
493
|
+
await runMessage(createWorkflow(second), message());
|
|
494
|
+
const [relation] = [...second.reviews.values()];
|
|
495
|
+
// Named from the words next to the span: "… siehe Baubeschreibung Kap. 3.1".
|
|
496
|
+
assert.equal(relation!.title, "Add a relation from Room to SpecSection: SIEHE_KAP");
|
|
497
|
+
assert.deepEqual(relation!.change, [
|
|
498
|
+
{ op: "add_relation", name: "SIEHE_KAP", domain: ["Room"], range: ["SpecSection"] },
|
|
499
|
+
]);
|
|
500
|
+
assert.ok(
|
|
501
|
+
relation!.rationale!.includes(
|
|
502
|
+
'p. 1, "Bodenbelag Linoleum, siehe Baubeschreibung Kap. 3.1" (0.12 → 3.1, 0.80)',
|
|
503
|
+
),
|
|
504
|
+
relation!.rationale,
|
|
505
|
+
);
|
|
506
|
+
assert.ok(!relation!.rationale!.includes("The ontology has"), relation!.rationale);
|
|
507
|
+
const [line] = relation!.evidence!.samples as Record<string, unknown>[];
|
|
508
|
+
assert.equal(line!.kind, "link");
|
|
509
|
+
assert.equal(line!.answer, "a property the ontology lacks");
|
|
510
|
+
assert.equal(line!.document, "raumblaetter.pdf");
|
|
511
|
+
assert.ok(!factsOf(second.graph).some((f) => f.includes("3.1") || f.includes("3.2")));
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test("a relation an existing relation fits needs 2 lines or one at 0.8, and says why it does not fit", () => {
|
|
515
|
+
const ontology = map.MapOntology.fromView(SAMPLE_VIEW);
|
|
516
|
+
const [room, equipment] = [map.classIri("Room"), map.classIri("Equipment")];
|
|
517
|
+
const has = map.relationIri("HAS_EQUIPMENT");
|
|
518
|
+
const line = "S1.1 Waschtisch, 1 Stück, darüber Spiegel S 2.1";
|
|
519
|
+
const lack = (p: number, offered = true): map.Lack => ({
|
|
520
|
+
kind: "property",
|
|
521
|
+
subjectClass: room,
|
|
522
|
+
objectClass: equipment,
|
|
523
|
+
document: "raumblaetter.pdf",
|
|
524
|
+
sample: { line, probability: p },
|
|
525
|
+
p,
|
|
526
|
+
options: { HAS_EQUIPMENT: (1 - p) * 0.8, missing_property: p, no_fact: (1 - p) * 0.2 },
|
|
527
|
+
offered: offered ? { HAS_EQUIPMENT: has } : {},
|
|
528
|
+
phrase: map.spanPhrase(line, "S 2.1", map.nameWords("S 2.1 Spiegel")),
|
|
529
|
+
quote: `p. 1, "${line}" (0.12 → S 2.1, ${p.toFixed(2)})`,
|
|
530
|
+
});
|
|
531
|
+
const draft = (members: map.Lack[]) =>
|
|
532
|
+
map.relationDraft(ontology, room, equipment, "raumblaetter.pdf", members);
|
|
533
|
+
// One line at 0.59: nothing is filed; the line is unmapped, with the reason.
|
|
534
|
+
const one = draft([lack(0.59)]);
|
|
535
|
+
assert.ok("unmapped" in one);
|
|
536
|
+
assert.equal(one.unmapped[0]!.outcome, "lacks");
|
|
537
|
+
assert.ok(String(one.unmapped[0]!.reason).includes("HAS_EQUIPMENT fits Room → Equipment"));
|
|
538
|
+
// One line at 0.85, or two lines: filed, named from the line, with the reason from the answer.
|
|
539
|
+
const sure = draft([lack(0.85)]);
|
|
540
|
+
assert.ok("draft" in sure);
|
|
541
|
+
assert.equal(sure.draft.title, "Add a relation from Room to Equipment: STUECK_DARUEBER");
|
|
542
|
+
assert.ok(
|
|
543
|
+
sure.draft.rationale.includes(
|
|
544
|
+
"The ontology has HAS_EQUIPMENT (has equipment), which fits Room → Equipment. On these lines the model gave it 0.12 at most",
|
|
545
|
+
),
|
|
546
|
+
sure.draft.rationale,
|
|
547
|
+
);
|
|
548
|
+
assert.ok("draft" in draft([lack(0.6), lack(0.6)]));
|
|
549
|
+
// A line whose question did not offer HAS_EQUIPMENT does not back it.
|
|
550
|
+
const unweighed = draft([lack(0.6), lack(0.95, false)]);
|
|
551
|
+
assert.ok("unmapped" in unweighed);
|
|
552
|
+
assert.ok(String(unweighed.unmapped[1]!.reason).includes("did not offer HAS_EQUIPMENT"));
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
test("a class suggestion names the entries of its document written alike that got a class", () => {
|
|
556
|
+
const ontology = map.MapOntology.fromView(SAMPLE_VIEW);
|
|
557
|
+
const entry = (id: string, description: string, p: number): map.Lack => ({
|
|
558
|
+
kind: "class",
|
|
559
|
+
document: "baubeschreibung.pdf",
|
|
560
|
+
sample: { identifier: id },
|
|
561
|
+
p,
|
|
562
|
+
options: { Equipment: 0.2, Room: 0.01, no_class: p },
|
|
563
|
+
quote: `${id} ${description} (p. 1, ${p.toFixed(2)})`,
|
|
564
|
+
entry: { name: map.newInstanceLabel(id, description), shape: map.codeShape(id) },
|
|
565
|
+
});
|
|
566
|
+
const classed = (document: string, id: string, description: string, p: number) => ({
|
|
567
|
+
document,
|
|
568
|
+
shape: map.codeShape(id),
|
|
569
|
+
name: map.newInstanceLabel(id, description),
|
|
570
|
+
classLabel: "Equipment",
|
|
571
|
+
p,
|
|
572
|
+
});
|
|
573
|
+
const draft = map.classDraft(
|
|
574
|
+
ontology,
|
|
575
|
+
"baubeschreibung.pdf",
|
|
576
|
+
[entry("3.1", "Bodenbeläge", 0.69)],
|
|
577
|
+
[
|
|
578
|
+
classed("baubeschreibung.pdf", "3.2", "Sanitärobjekte", 0.89),
|
|
579
|
+
classed("baubeschreibung.pdf", "3.3", "Beleuchtung", 0.92),
|
|
580
|
+
classed("baubeschreibung.pdf", "S 1.1", "Waschtisch", 0.95),
|
|
581
|
+
classed("raumblaetter.pdf", "3.4", "Türen", 0.9),
|
|
582
|
+
],
|
|
583
|
+
);
|
|
584
|
+
assert.equal(draft.title, "Add a class for 3.1 Bodenbeläge");
|
|
585
|
+
assert.ok(
|
|
586
|
+
draft.rationale.includes(
|
|
587
|
+
"got a class of the ontology: 3.2 Sanitärobjekte (Equipment, 0.89), 3.3 Beleuchtung (Equipment, 0.92). When they belong to the new class too, dismiss their types in the review.",
|
|
588
|
+
),
|
|
589
|
+
draft.rationale,
|
|
590
|
+
);
|
|
591
|
+
assert.ok(
|
|
592
|
+
draft.rationale.includes(
|
|
593
|
+
"The classes the model weighed got at most: Equipment 0.20, Room 0.01.",
|
|
594
|
+
),
|
|
595
|
+
);
|
|
596
|
+
assert.ok(!draft.rationale.includes("Waschtisch") && !draft.rationale.includes("Türen"));
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
test("a withdrawn link withdraws the proposals that only it made", async () => {
|
|
600
|
+
const lbb = new MockActivities(rules());
|
|
601
|
+
const workflow = createWorkflow(lbb);
|
|
602
|
+
await runMessage(workflow, message());
|
|
603
|
+
// The room sheet no longer names W 1.
|
|
604
|
+
const without: SampleTrace = {
|
|
605
|
+
...TRACE,
|
|
606
|
+
mentions: TRACE.mentions.map((m) => (m.span === "W 1" ? { ...m, link: null } : m)),
|
|
607
|
+
};
|
|
608
|
+
lbb.rules.sparql = (query, graph) => sparql(query, graph, without, []);
|
|
609
|
+
await runMessage(workflow, message(without), { runs: 1 });
|
|
610
|
+
const all = proposals(lbb.graph).map((p) => p.text);
|
|
611
|
+
assert.ok(!all.includes("0.12 has_equipment W 1"));
|
|
612
|
+
assert.ok(!all.some((t) => t.startsWith("W 1 ")));
|
|
613
|
+
assert.equal(all.length, 75 - 5);
|
|
614
|
+
assert.deepEqual(
|
|
615
|
+
factsOf(lbb.graph),
|
|
616
|
+
expectedFacts.filter((f) => f !== "0.12 has_equipment W 1"),
|
|
617
|
+
);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
test("a refused request is asked in halves", async () => {
|
|
621
|
+
const lbb = new MockActivities(
|
|
622
|
+
rules(
|
|
623
|
+
{},
|
|
624
|
+
{
|
|
625
|
+
refuses: (kind, request) =>
|
|
626
|
+
kind === "decide" && Object.keys((request as DecideRequest).questions).length > 4,
|
|
627
|
+
},
|
|
628
|
+
),
|
|
629
|
+
);
|
|
630
|
+
await runMessage(createWorkflow(lbb), message());
|
|
631
|
+
assert.ok(lbb.calls.some((c) => c.refused));
|
|
632
|
+
assert.deepEqual(factsOf(lbb.graph), expectedFacts);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
test("a declared mapping types its links without a question", async () => {
|
|
636
|
+
const settings: Settings = {
|
|
637
|
+
mappings: [
|
|
638
|
+
{
|
|
639
|
+
from: { entry_class: "Room" },
|
|
640
|
+
to: { entry_class: "Equipment" },
|
|
641
|
+
property: "HAS_EQUIPMENT",
|
|
642
|
+
},
|
|
643
|
+
],
|
|
644
|
+
};
|
|
645
|
+
const lbb = new MockActivities(rules());
|
|
646
|
+
await runMessage(createWorkflow(lbb, settings), message());
|
|
647
|
+
assert.deepEqual(factsOf(lbb.graph), expectedFacts);
|
|
648
|
+
// The fact keeps the link's confidence: T1 to T 1 at 0.9.
|
|
649
|
+
assert.equal(
|
|
650
|
+
proposals(lbb.graph).find((p) => p.text === "0.12 has_equipment T 1")!.confidence,
|
|
651
|
+
0.9,
|
|
652
|
+
);
|
|
653
|
+
const typing = lbb.calls.filter((c) => c.label?.endsWith("links"));
|
|
654
|
+
assert.equal(typing.length, 2, "one request per page, for the two sections");
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
test("the subject of a mention: the anchor above it, or the page's head", () => {
|
|
658
|
+
const own = (page: number, y: number, x: number, identifier: string): map.OwnEntry => ({
|
|
659
|
+
entry: identifier,
|
|
660
|
+
identifier,
|
|
661
|
+
page,
|
|
662
|
+
box: [x, y, x + 0.1, y + 0.01],
|
|
663
|
+
});
|
|
664
|
+
const mention = (
|
|
665
|
+
page: number,
|
|
666
|
+
y: number,
|
|
667
|
+
x: number,
|
|
668
|
+
span: string,
|
|
669
|
+
target: string | null = null,
|
|
670
|
+
) => ({
|
|
671
|
+
span,
|
|
672
|
+
target,
|
|
673
|
+
page,
|
|
674
|
+
box: [x, y, x + 0.05, y + 0.01],
|
|
675
|
+
});
|
|
676
|
+
// A room sheet's number top right with a code the sheet defines lower down; a bill whose
|
|
677
|
+
// positions run down the page, the last one onto the next page; a page with neither.
|
|
678
|
+
const bill = map.subjects(
|
|
679
|
+
[
|
|
680
|
+
own(0, 0.1, 0.7, "Pos.-Nr. 1.1"),
|
|
681
|
+
own(0, 0.5, 0.1, "IK 2.3"),
|
|
682
|
+
own(1, 0.4, 0.1, "01.110"),
|
|
683
|
+
own(1, 0.6, 0.1, "01.120"),
|
|
684
|
+
own(2, 0.5, 0.1, "01.130"),
|
|
685
|
+
],
|
|
686
|
+
[
|
|
687
|
+
mention(0, 0.1, 0.2, "Lernatelier"),
|
|
688
|
+
mention(0, 0.6, 0.3, "IK2.4"),
|
|
689
|
+
mention(1, 0.45, 0.3, "S 38"),
|
|
690
|
+
mention(1, 0.65, 0.3, "S 39"),
|
|
691
|
+
mention(2, 0.2, 0.3, "S 40"),
|
|
692
|
+
mention(3, 0.2, 0.3, "S 41"),
|
|
693
|
+
],
|
|
694
|
+
);
|
|
695
|
+
assert.deepEqual(bill, [
|
|
696
|
+
{ entry: "Pos.-Nr. 1.1" },
|
|
697
|
+
{ entry: "Pos.-Nr. 1.1" },
|
|
698
|
+
{ entry: "01.110" },
|
|
699
|
+
{ entry: "01.120" },
|
|
700
|
+
{ entry: "01.120" },
|
|
701
|
+
null,
|
|
702
|
+
]);
|
|
703
|
+
// Room sheets: the number in the head, linked to nothing on the first sheet and to a room
|
|
704
|
+
// list's entry on the second, the codes below it (one of them an entry the sheet defines). A
|
|
705
|
+
// third page continues the second. A page whose first mention is below an entry has no head.
|
|
706
|
+
const sheets = map.subjects(
|
|
707
|
+
[own(0, 0.35, 0.1, "M 7"), own(3, 0.1, 0.1, "S 1.1")],
|
|
708
|
+
[
|
|
709
|
+
mention(0, 0.08, 0.1, "0.12"),
|
|
710
|
+
mention(0, 0.24, 0.1, "T1", "T 1"),
|
|
711
|
+
mention(0, 0.26, 0.1, "S1.1"),
|
|
712
|
+
mention(0, 0.35, 0.1, "M 7"),
|
|
713
|
+
mention(1, 0.08, 0.1, "0.14", "0.14"),
|
|
714
|
+
mention(1, 0.25, 0.1, "S 1.2"),
|
|
715
|
+
mention(1, 0.27, 0.1, "L 3"),
|
|
716
|
+
mention(2, 0.1, 0.1, "S 2.1"),
|
|
717
|
+
mention(3, 0.12, 0.3, "S 2.1"),
|
|
718
|
+
mention(3, 0.14, 0.1, "S 2.2"),
|
|
719
|
+
],
|
|
720
|
+
);
|
|
721
|
+
const room = { entry: "0.14" };
|
|
722
|
+
assert.deepEqual(sheets, [
|
|
723
|
+
null,
|
|
724
|
+
{ mention: 0 },
|
|
725
|
+
{ mention: 0 },
|
|
726
|
+
{ mention: 0 },
|
|
727
|
+
null,
|
|
728
|
+
room,
|
|
729
|
+
room,
|
|
730
|
+
room,
|
|
731
|
+
{ entry: "S 1.1" },
|
|
732
|
+
{ entry: "S 1.1" },
|
|
733
|
+
]);
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
test("names, keys and shapes", () => {
|
|
737
|
+
assert.equal(map.humanize("HAS_EQUIPMENT"), "has equipment");
|
|
738
|
+
assert.equal(map.humanize("hasEquipment"), "has equipment");
|
|
739
|
+
assert.equal(map.classLabel("SpecSection"), "Spec section");
|
|
740
|
+
assert.equal(map.identifierKey("S.2.2"), map.identifierKey("S 2.2"));
|
|
741
|
+
assert.equal(map.codeShape("S 1.1"), "a9.9");
|
|
742
|
+
assert.equal(map.codeShape("0.12"), "9.9");
|
|
743
|
+
assert.equal(map.codeShape("440 DPM 008"), "9a9");
|
|
744
|
+
assert.deepEqual(map.identifierVariants("S 38"), ["S 38", "S38"]);
|
|
745
|
+
assert.deepEqual(map.identifierVariants("S38"), ["S38", "S 38"]);
|
|
746
|
+
assert.equal(map.newInstanceLabel("F1", "Linoleum"), "F1 Linoleum");
|
|
747
|
+
assert.equal(map.newInstanceLabel("F1", ""), "F1");
|
|
748
|
+
assert.equal(map.pageEntryDescription("0.12", "Raum 0.12 Klassenraum"), "Raum Klassenraum");
|
|
749
|
+
assert.equal(map.pageEntryDescription("0.12", "0.12"), "0.12");
|
|
750
|
+
assert.equal(map.lackingRelationName("EquipmentType", new Set()), "HAS_EQUIPMENT_TYPE");
|
|
751
|
+
// A taken name gets a number, whatever its case and separators.
|
|
752
|
+
assert.equal(
|
|
753
|
+
map.lackingRelationName("Equipment", new Set([map.nameKey("hasEquipment")])),
|
|
754
|
+
"HAS_EQUIPMENT_2",
|
|
755
|
+
);
|
|
756
|
+
assert.equal(map.lackingClassName("D18_Equipment-schedule_main-site.pdf"), "EquipmentItem");
|
|
757
|
+
assert.equal(
|
|
758
|
+
map.lackingClassName("D18_Equipment.pdf", new Set([map.nameKey("Equipment item")])),
|
|
759
|
+
"EquipmentItem2",
|
|
760
|
+
);
|
|
761
|
+
// A lacking relation is named from the words next to its span.
|
|
762
|
+
const skip = map.nameWords("3.2 Sanitärobjekte baubeschreibung.pdf");
|
|
763
|
+
const kap = map.spanPhrase("Sanitärobjekte siehe Baubeschreibung Kap. 3.2", "3.2", skip);
|
|
764
|
+
assert.deepEqual(kap, ["siehe", "Kap"]);
|
|
765
|
+
assert.deepEqual(map.spanPhrase("3.2 gilt für alle Räume", "3.2", new Set()), ["gilt", "für"]);
|
|
766
|
+
assert.deepEqual(map.spanPhrase("S 2.1", "S 2.1", new Set()), []);
|
|
767
|
+
assert.equal(map.relationNameFromLines([kap, kap, ["darüber"]], new Set()), "SIEHE_KAP");
|
|
768
|
+
assert.equal(map.relationNameFromLines([["darüber"]], new Set()), "DARUEBER");
|
|
769
|
+
assert.equal(map.relationNameFromLines([[]], new Set()), undefined);
|
|
770
|
+
});
|