@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.
Files changed (52) hide show
  1. package/LICENSE +55 -0
  2. package/README.md +73 -2
  3. package/bin/create-littlebigbrain.mjs +9 -0
  4. package/lib/create.mjs +244 -0
  5. package/package.json +46 -4
  6. package/project/setup.ts +18 -0
  7. package/project/try.ts +30 -0
  8. package/project/worker.ts +23 -0
  9. package/templates/_shared/testing.ts +521 -0
  10. package/templates/documents-link/README.md +146 -0
  11. package/templates/documents-link/link.ts +580 -0
  12. package/templates/documents-link/samples/ausstattungsliste.pdf +76 -0
  13. package/templates/documents-link/samples/baubeschreibung.pdf +54 -0
  14. package/templates/documents-link/samples/expected.json +404 -0
  15. package/templates/documents-link/samples/pages/ausstattungsliste.json +1150 -0
  16. package/templates/documents-link/samples/pages/baubeschreibung.json +802 -0
  17. package/templates/documents-link/samples/pages/raumblaetter.json +1580 -0
  18. package/templates/documents-link/samples/raumblaetter.pdf +109 -0
  19. package/templates/documents-link/samples.ts +31 -0
  20. package/templates/documents-link/workflow.test.ts +497 -0
  21. package/templates/documents-link/workflow.ts +467 -0
  22. package/templates/documents-map/README.md +212 -0
  23. package/templates/documents-map/map.ts +1135 -0
  24. package/templates/documents-map/samples/ausstattungsliste.pdf +76 -0
  25. package/templates/documents-map/samples/baubeschreibung.pdf +54 -0
  26. package/templates/documents-map/samples/expected.json +50 -0
  27. package/templates/documents-map/samples/pages/raumblaetter.json +1580 -0
  28. package/templates/documents-map/samples/raumblaetter.pdf +109 -0
  29. package/templates/documents-map/samples/trace.json +613 -0
  30. package/templates/documents-map/samples.ts +182 -0
  31. package/templates/documents-map/workflow.test.ts +770 -0
  32. package/templates/documents-map/workflow.ts +1199 -0
  33. package/templates/ontology-fit/README.md +81 -0
  34. package/templates/ontology-fit/samples/expected.md +88 -0
  35. package/templates/ontology-fit/samples/texts.json +22 -0
  36. package/templates/ontology-fit/samples.ts +40 -0
  37. package/templates/ontology-fit/workflow.test.ts +270 -0
  38. package/templates/ontology-fit/workflow.ts +416 -0
  39. package/templates/review/README.md +158 -0
  40. package/templates/review/decision.ts +483 -0
  41. package/templates/review/samples/expected.json +37 -0
  42. package/templates/review/samples/proposals.json +35 -0
  43. package/templates/review/samples.ts +154 -0
  44. package/templates/review/workflow.test.ts +438 -0
  45. package/templates/review/workflow.ts +459 -0
  46. package/templates/values-extract/README.md +81 -0
  47. package/templates/values-extract/samples/expected.json +148 -0
  48. package/templates/values-extract/samples/records.json +26 -0
  49. package/templates/values-extract/samples.ts +27 -0
  50. package/templates/values-extract/workflow.test.ts +279 -0
  51. package/templates/values-extract/workflow.ts +419 -0
  52. package/templates/versions.json +3 -0
@@ -0,0 +1,109 @@
1
+ %PDF-1.4
2
+ 1 0 obj
3
+ << /Type /Catalog /Pages 2 0 R >>
4
+ endobj
5
+ 2 0 obj
6
+ << /Type /Pages /Kids [7 0 R 9 0 R] /Count 2 >>
7
+ endobj
8
+ 3 0 obj
9
+ << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>
10
+ endobj
11
+ 4 0 obj
12
+ << /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>
13
+ endobj
14
+ 5 0 obj
15
+ << /Title (Raumbl\344tter) /Producer (make-samples.mjs) >>
16
+ endobj
17
+ 6 0 obj
18
+ << /Length 1701 >>
19
+ stream
20
+ 0.35 g BT /F1 8 Tf 1 0 0 1 72 806 Tm (Neubau Grundschule Musterstadt, Raumbl\344tter) Tj ET 0 g
21
+ 0.6 G 0.5 w 72 798 m 523 798 l S 0 G
22
+ BT /F2 16 Tf 1 0 0 1 72 760 Tm (Raum 0.12 Klassenraum) Tj ET
23
+ BT /F1 10 Tf 1 0 0 1 105.91 734 Tm (Geschoss) Tj ET
24
+ BT /F1 10 Tf 1 0 0 1 156.37 734 Tm (Erdgeschoss) Tj ET
25
+ BT /F1 10 Tf 1 0 0 1 120.36 719 Tm (Fl\344che) Tj ET
26
+ BT /F1 10 Tf 1 0 0 1 156.37 719 Tm (62,40 m\262) Tj ET
27
+ BT /F1 10 Tf 1 0 0 1 72 704 Tm (Lichte Raumh\366he) Tj ET
28
+ BT /F1 10 Tf 1 0 0 1 156.37 704 Tm (3,00 m) Tj ET
29
+ BT /F1 10 Tf 1 0 0 1 97 689 Tm (Bodenbelag) Tj ET
30
+ BT /F1 10 Tf 1 0 0 1 156.37 689 Tm (Linoleum, siehe Baubeschreibung Kap. 3.1) Tj ET
31
+ BT /F2 10 Tf 1 0 0 1 72 656 Tm (Pos.) Tj ET
32
+ BT /F2 10 Tf 1 0 0 1 99.12 656 Tm (Ausstattung) Tj ET
33
+ 0.6 G 0.5 w 72 651 m 523 651 l S 0 G
34
+ BT /F1 10 Tf 1 0 0 1 81.45 634 Tm (T1) Tj ET
35
+ BT /F1 10 Tf 1 0 0 1 99.12 634 Tm (Einzeltisch, 28 St\374ck) Tj ET
36
+ BT /F1 10 Tf 1 0 0 1 78.67 619 Tm (T 3) Tj ET
37
+ BT /F1 10 Tf 1 0 0 1 99.12 619 Tm (Sch\374lerstuhl, 28 St\374ck) Tj ET
38
+ BT /F1 10 Tf 1 0 0 1 78.67 604 Tm (T 2) Tj ET
39
+ BT /F1 10 Tf 1 0 0 1 99.12 604 Tm (Lehrertisch, 1 St\374ck) Tj ET
40
+ BT /F1 10 Tf 1 0 0 1 75.34 589 Tm (W 1) Tj ET
41
+ BT /F1 10 Tf 1 0 0 1 99.12 589 Tm (Wandtafel, 1 St\374ck) Tj ET
42
+ BT /F1 10 Tf 1 0 0 1 79.22 574 Tm (L 2) Tj ET
43
+ BT /F1 10 Tf 1 0 0 1 99.12 574 Tm (Deckenleuchte, 6 St\374ck) Tj ET
44
+ BT /F1 10 Tf 1 0 0 1 72.55 559 Tm (S1.1) Tj ET
45
+ BT /F1 10 Tf 1 0 0 1 99.12 559 Tm (Waschtisch, 1 St\374ck, dar\374ber Spiegel S 2.1) Tj ET
46
+ BT /F1 10 Tf 1 0 0 1 76.45 544 Tm (M 7) Tj ET
47
+ BT /F1 10 Tf 1 0 0 1 99.12 544 Tm (Medienwagen, 1 St\374ck) Tj ET
48
+ 0.6 G 0.5 w 72 52 m 523 52 l S 0 G
49
+ 0.35 g BT /F1 8 Tf 1 0 0 1 476.3 40 Tm (Seite 1 von 2) Tj ET 0 g
50
+ endstream
51
+ endobj
52
+ 7 0 obj
53
+ << /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents 6 0 R >>
54
+ endobj
55
+ 8 0 obj
56
+ << /Length 1745 >>
57
+ stream
58
+ 0.35 g BT /F1 8 Tf 1 0 0 1 72 806 Tm (Neubau Grundschule Musterstadt, Raumbl\344tter) Tj ET 0 g
59
+ 0.6 G 0.5 w 72 798 m 523 798 l S 0 G
60
+ BT /F2 16 Tf 1 0 0 1 72 760 Tm (Raum 0.14 WC M\344dchen) Tj ET
61
+ BT /F1 10 Tf 1 0 0 1 105.91 734 Tm (Geschoss) Tj ET
62
+ BT /F1 10 Tf 1 0 0 1 156.37 734 Tm (Erdgeschoss) Tj ET
63
+ BT /F1 10 Tf 1 0 0 1 120.36 719 Tm (Fl\344che) Tj ET
64
+ BT /F1 10 Tf 1 0 0 1 156.37 719 Tm (14,20 m\262) Tj ET
65
+ BT /F1 10 Tf 1 0 0 1 72 704 Tm (Lichte Raumh\366he) Tj ET
66
+ BT /F1 10 Tf 1 0 0 1 156.37 704 Tm (2,50 m) Tj ET
67
+ BT /F1 10 Tf 1 0 0 1 84.8 689 Tm (Barrierefreiheit) Tj ET
68
+ BT /F1 10 Tf 1 0 0 1 156.37 689 Tm (eine Kabine nach DIN 18040) Tj ET
69
+ BT /F1 10 Tf 1 0 0 1 86.45 674 Tm (Sanit\344robjekte) Tj ET
70
+ BT /F1 10 Tf 1 0 0 1 156.37 674 Tm (siehe Baubeschreibung Kap. 3.2) Tj ET
71
+ BT /F2 10 Tf 1 0 0 1 74.23 641 Tm (Pos.) Tj ET
72
+ BT /F2 10 Tf 1 0 0 1 101.35 641 Tm (Ausstattung) Tj ET
73
+ 0.6 G 0.5 w 72 636 m 523 636 l S 0 G
74
+ BT /F1 10 Tf 1 0 0 1 72 619 Tm (S 1.2) Tj ET
75
+ BT /F1 10 Tf 1 0 0 1 101.35 619 Tm (WC, wandh\344ngend, 3 St\374ck) Tj ET
76
+ BT /F1 10 Tf 1 0 0 1 74.78 604 Tm (S1.1) Tj ET
77
+ BT /F1 10 Tf 1 0 0 1 101.35 604 Tm (Waschtisch, 2 St\374ck) Tj ET
78
+ BT /F1 10 Tf 1 0 0 1 72 589 Tm (S 2.1) Tj ET
79
+ BT /F1 10 Tf 1 0 0 1 101.35 589 Tm (Spiegel, 2 St\374ck) Tj ET
80
+ BT /F1 10 Tf 1 0 0 1 72 574 Tm (S.2.2) Tj ET
81
+ BT /F1 10 Tf 1 0 0 1 101.35 574 Tm (Seifenspender, 2 St\374ck) Tj ET
82
+ BT /F1 10 Tf 1 0 0 1 84.23 559 Tm (L1) Tj ET
83
+ BT /F1 10 Tf 1 0 0 1 101.35 559 Tm (Wandleuchte \374ber dem Spiegel, 2 St\374ck) Tj ET
84
+ BT /F1 10 Tf 1 0 0 1 81.45 544 Tm (L 3) Tj ET
85
+ BT /F1 10 Tf 1 0 0 1 101.35 544 Tm (Sicherheitsleuchte \374ber der T\374r, 1 St\374ck) Tj ET
86
+ 0.6 G 0.5 w 72 52 m 523 52 l S 0 G
87
+ 0.35 g BT /F1 8 Tf 1 0 0 1 476.3 40 Tm (Seite 2 von 2) Tj ET 0 g
88
+ endstream
89
+ endobj
90
+ 9 0 obj
91
+ << /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents 8 0 R >>
92
+ endobj
93
+ xref
94
+ 0 10
95
+ 0000000000 65535 f
96
+ 0000000009 00000 n
97
+ 0000000058 00000 n
98
+ 0000000121 00000 n
99
+ 0000000218 00000 n
100
+ 0000000320 00000 n
101
+ 0000000394 00000 n
102
+ 0000002146 00000 n
103
+ 0000002282 00000 n
104
+ 0000004078 00000 n
105
+ trailer
106
+ << /Size 10 /Root 1 0 R /Info 5 0 R /ID [<f97bc1f8b28b6fdc2c69a3073d3ec167> <f97bc1f8b28b6fdc2c69a3073d3ec167>] >>
107
+ startxref
108
+ 4214
109
+ %%EOF
@@ -0,0 +1,31 @@
1
+ // The samples of documents-link, for `npm run try`: three PDFs of an
2
+ // invented school building tender (samples/README.md in the repository
3
+ // describes them). LBB's own documents.parse workflow reads their text
4
+ // layer; this file uploads them and waits for the parse.
5
+ import { readdir, readFile } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+ import type { LbbClient } from "@littlebigbrain/client";
8
+
9
+ /** Upload the sample PDFs to the client's graph and wait until LBB has parsed them. */
10
+ export async function loadSamples(client: LbbClient, dir: string): Promise<void> {
11
+ await client.workflows.starters.use({ starter: "documents.parse" });
12
+ const names = (await readdir(dir)).filter((name) => name.endsWith(".pdf")).sort();
13
+ for (const name of names) {
14
+ const bytes = new Uint8Array(await readFile(join(dir, name)));
15
+ await client.files.upload(bytes, { name, contentType: "application/pdf" });
16
+ console.log(`Uploaded ${name}`);
17
+ }
18
+ // Link the documents once all are parsed, so the first turn sees every entry.
19
+ for (let waited = 0; ; waited += 2) {
20
+ const { files } = await client.files.list();
21
+ const parsed = files.filter(
22
+ (file) => names.includes(file.name) && file.parse?.status === "parsed",
23
+ );
24
+ if (parsed.length === names.length) return;
25
+ if (waited >= 180)
26
+ throw new Error(
27
+ "The samples were not parsed within 3 minutes: is documents.parse available?",
28
+ );
29
+ await new Promise((resolve) => setTimeout(resolve, 2_000));
30
+ }
31
+ }
@@ -0,0 +1,497 @@
1
+ // documents-link on its samples, with mock models whose rules are fixed: a
2
+ // row that starts with a code and a capitalized word defines the code, every
3
+ // code of a line is a mention, a span with a digit is an identifier, and the
4
+ // names question picks the candidate with the same identifier key. The
5
+ // expected links come from samples/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 WorkflowTriggerMessage,
15
+ } from "@littlebigbrain/client";
16
+ import {
17
+ Crash,
18
+ MockActivities,
19
+ mockId,
20
+ phases,
21
+ readJson,
22
+ runMessage,
23
+ runTurn,
24
+ samplesDir,
25
+ triggerMessage,
26
+ type MemoryGraph,
27
+ type MockRules,
28
+ } from "../_shared/testing.js";
29
+ import { createWorkflow, defaults, type Settings } from "./workflow.js";
30
+ import * as link from "./link.js";
31
+
32
+ const SAMPLES = samplesDir(import.meta.url, "documents-link");
33
+ const FILES = ["ausstattungsliste", "baubeschreibung", "raumblaetter"];
34
+ const PAGES = FILES.map((name) => readJson<FilePages>(join(SAMPLES, "pages", `${name}.json`)));
35
+
36
+ interface Expected {
37
+ roles: Record<string, string>;
38
+ entries: { file: string; identifier: string; optional?: boolean }[];
39
+ mentions: {
40
+ file: string;
41
+ page: number;
42
+ line_text: string;
43
+ span: string;
44
+ refers_to: { file: string; identifier: string } | null;
45
+ }[];
46
+ }
47
+ const EXPECTED = readJson<Expected>(join(SAMPLES, "expected.json"));
48
+
49
+ /** The Document facts of the samples, as the parse writes them. */
50
+ interface Doc {
51
+ iri: string;
52
+ name: string;
53
+ sha: string;
54
+ pages: number;
55
+ }
56
+ const sampleDocs = (pages: FilePages[] = PAGES, names = FILES.map((f) => `${f}.pdf`)): Doc[] =>
57
+ pages.map((p, i) => ({
58
+ iri: ENTITY_IRI_NAMESPACE + mockId(names[i]!),
59
+ name: names[i]!,
60
+ sha: p.sha256,
61
+ pages: p.page_count,
62
+ }));
63
+
64
+ // ---- the mock models -------------------------------------------------------------
65
+
66
+ const CODE = /\b[A-Z][A-Za-z]{0,5}[ .]?\d+(?:\.\d+)*\b|\b\d+(?:\.\d+)+\b/g;
67
+ const DEFINES = /^(\b[A-Z][A-Za-z]{0,5}[ .]?\d+(?:\.\d+)*|\b\d+(?:\.\d+)+)\s+\p{Lu}/u;
68
+
69
+ /** The lines of the listing a prompt ends with: `[id] text | [id] text` per row. */
70
+ const linesOf = (prompt: string) =>
71
+ prompt
72
+ .slice(prompt.indexOf("\n\n") + 2)
73
+ .split("\n")
74
+ .flatMap((row) => row.split(" | "))
75
+ .map((cell) => cell.match(/^\[([^\]]+)\] (.*)$/)!)
76
+ .map(([, id, text]) => ({ id: id!, text: text! }));
77
+
78
+ const answer = (choice: string, p: number, options: string[]): DecideAnswer => ({
79
+ choice,
80
+ confidence: p,
81
+ probabilities: Object.fromEntries(
82
+ options.map((o) => [o, o === choice ? p : (1 - p) / (options.length - 1)]),
83
+ ),
84
+ });
85
+
86
+ function decide(request: DecideRequest): Record<string, DecideAnswer> {
87
+ const out: Record<string, DecideAnswer> = {};
88
+ for (const [id, question] of Object.entries(request.questions)) {
89
+ const span = question.instructions.match(/span "([^"]*)"/)![1]!;
90
+ const options = Object.keys(question.criteria);
91
+ if (id.endsWith("-kind"))
92
+ out[id] = /\d/.test(span)
93
+ ? answer("identifier", 0.9, options)
94
+ : answer("abbreviation", 0.85, options);
95
+ else {
96
+ const identifier = (option: string) =>
97
+ question.criteria[option]!.split("] ")[1]!.split(": ")[0]!;
98
+ const named = (same: (a: string, b: string) => boolean) =>
99
+ options.find((o) => o.startsWith("e") && same(identifier(o), span));
100
+ const exact = named((a, b) => link.identifierKey(a) === link.identifierKey(b));
101
+ const dotless = named(
102
+ (a, b) =>
103
+ link.identifierKey(a).replace(/\./g, "") === link.identifierKey(b).replace(/\./g, ""),
104
+ );
105
+ out[id] = exact
106
+ ? answer(exact, 0.95, options)
107
+ : dotless
108
+ ? answer(dotless, 0.7, options)
109
+ : answer("none_of_these", 0.9, options);
110
+ }
111
+ }
112
+ return out;
113
+ }
114
+
115
+ const VOCABULARY = "abcdefghijklmnopqrstuvwxyz0123456789";
116
+ const rules = (docs: Doc[], pages: FilePages[] = PAGES): MockRules => ({
117
+ pages,
118
+ reason: (request) =>
119
+ "definitions" in (request.schema.properties as object)
120
+ ? {
121
+ definitions: linesOf(request.prompt).flatMap(({ id, text }) => {
122
+ const code = text.match(DEFINES)?.[1];
123
+ return code
124
+ ? [{ row_id: id, identifier: code, description: text.slice(code.length).trim() }]
125
+ : [];
126
+ }),
127
+ }
128
+ : {
129
+ mentions: linesOf(request.prompt).flatMap(({ id, text }) =>
130
+ [...text.matchAll(CODE)].map((m) => ({ line_id: id, span: m[0] })),
131
+ ),
132
+ },
133
+ decide,
134
+ embed: (texts) =>
135
+ texts.map((text) =>
136
+ [...VOCABULARY].map((c) => [...text.toLowerCase()].filter((x) => x === c).length + 0.01),
137
+ ),
138
+ sparql: (query, graph) => sparql(query, graph, docs),
139
+ });
140
+
141
+ /** The three queries of the workflow, answered from the fixtures and the graph in memory. */
142
+ function sparql(query: string, graph: MemoryGraph, docs: Doc[]): Record<string, string>[] {
143
+ const named = [...query.matchAll(/<(https:\/\/littlebigbrain\.com\/e\/[0-9a-f]+)>/g)].map(
144
+ (m) => m[1]!,
145
+ );
146
+ const byIri = new Map(docs.map((d) => [d.iri, d]));
147
+ if (query.startsWith("# documents-link: documents"))
148
+ return named.flatMap((iri) => {
149
+ const d = byIri.get(iri);
150
+ return d
151
+ ? [{ doc: iri, name: d.name, sha: d.sha, status: "parsed", pages: String(d.pages) }]
152
+ : [];
153
+ });
154
+ const docOf = (iri: string, relation: string) =>
155
+ graph.edges.find((e) => e.source === iri && e.relation === relation)?.target;
156
+ if (query.startsWith("# documents-link: catalogue"))
157
+ return graph.ofType("DocEntry").flatMap((e) => {
158
+ const d = byIri.get(docOf(e.iri, "DEFINED_IN") ?? "");
159
+ if (!d) return [];
160
+ const p = e.properties as Record<string, string>;
161
+ return [
162
+ {
163
+ identifier: p.identifier!,
164
+ context: p.context!,
165
+ line: p.line_id!,
166
+ doc: d.iri,
167
+ name: d.name,
168
+ sha: d.sha,
169
+ },
170
+ ];
171
+ });
172
+ if (query.startsWith("# documents-link: outputs"))
173
+ return [...graph.ofType("DocEntry"), ...graph.ofType("DocMention")].flatMap((e) => {
174
+ const doc = docOf(e.iri, e.type === "DocEntry" ? "DEFINED_IN" : "MENTIONED_IN");
175
+ if (doc && !named.includes(doc)) return [];
176
+ return [{ entity: e.iri, class: `https://littlebigbrain.com/class/${e.type.toLowerCase()}` }];
177
+ });
178
+ throw new Error(`a query the mock does not know: ${query.slice(0, 40)}`);
179
+ }
180
+
181
+ // ---- reading the outcome -----------------------------------------------------------
182
+
183
+ /** The links of the graph as `file|page|line text|span -> file|identifier`. */
184
+ function links(graph: MemoryGraph, docs: Doc[]): string[] {
185
+ const name = (iri: string) => docs.find((d) => d.iri === iri)?.name;
186
+ const target = (iri: string, relation: string) =>
187
+ graph.edges.find((e) => e.source === iri && e.relation === relation)?.target ?? "";
188
+ return graph.edgesOf("REFERS_TO").map((edge) => {
189
+ const mention = graph.entities.get(edge.source)!;
190
+ const entry = graph.entities.get(edge.target)!;
191
+ const m = mention.properties as { page: number; line_text: string; span: string };
192
+ return `${name(target(mention.iri, "MENTIONED_IN"))}|${m.page + 1}|${m.line_text}|${m.span} -> ${name(target(entry.iri, "DEFINED_IN"))}|${entry.properties.identifier}`;
193
+ });
194
+ }
195
+
196
+ const expectedLinks = EXPECTED.mentions
197
+ .filter((m) => m.refers_to)
198
+ .map(
199
+ (m) =>
200
+ `${m.file}|${m.page}|${m.line_text}|${m.span} -> ${m.refers_to!.file}|${m.refers_to!.identifier}`,
201
+ );
202
+
203
+ const settings = (change: Partial<Settings> = {}): Settings => ({
204
+ ...defaults,
205
+ roles: EXPECTED.roles as Settings["roles"],
206
+ ...change,
207
+ });
208
+
209
+ async function run(change: Partial<Settings> = {}, more: MockRules = {}) {
210
+ const docs = sampleDocs();
211
+ const lbb = new MockActivities({ ...rules(docs), ...more });
212
+ const workflow = createWorkflow(lbb, settings(change));
213
+ const out = await runMessage(
214
+ workflow,
215
+ triggerMessage(
216
+ "documents-link",
217
+ docs.map((d) => d.iri),
218
+ ),
219
+ );
220
+ return { ...out, lbb, docs, workflow };
221
+ }
222
+
223
+ // ---- the tests -------------------------------------------------------------------
224
+
225
+ test("the samples give the 15 links of the answer key and no other", async () => {
226
+ const { lbb, docs, state, turns } = await run();
227
+
228
+ assert.deepEqual(links(lbb.graph, docs).sort(), [...expectedLinks].sort());
229
+ assert.equal(expectedLinks.length, 15);
230
+ assert.equal(lbb.reviews.size, 0, "every link is at or above the gate");
231
+ assert.deepEqual(state, { entries: 15, mentions: 21, links: 15, review: 0 });
232
+ // One turn per window: the two defining files, then the room sheets.
233
+ assert.deepEqual(
234
+ turns.map((t) => (t.message as { cursor?: number }).cursor),
235
+ [undefined, 1, 2],
236
+ );
237
+ });
238
+
239
+ test("the entries are those the answer key lists, each defined in its file", async () => {
240
+ const { lbb, docs } = await run();
241
+ const defined = lbb.graph.ofType("DocEntry").map((e) => {
242
+ const doc = lbb.graph.edges.find(
243
+ (x) => x.source === e.iri && x.relation === "DEFINED_IN",
244
+ )!.target;
245
+ return `${docs.find((d) => d.iri === doc)!.name}|${e.properties.identifier}`;
246
+ });
247
+ const required = EXPECTED.entries
248
+ .filter((e) => !e.optional)
249
+ .map((e) => `${e.file}|${e.identifier}`);
250
+ const optional = EXPECTED.entries
251
+ .filter((e) => e.optional)
252
+ .map((e) => `${e.file}|${e.identifier}`);
253
+ assert.deepEqual(defined.filter((d) => !optional.includes(d)).sort(), required.sort());
254
+ // Every mention is written, with its document, kind and box.
255
+ const mentions = lbb.graph.ofType("DocMention");
256
+ assert.equal(mentions.length, 21);
257
+ for (const m of mentions) {
258
+ assert.equal(m.properties.kind, "identifier");
259
+ assert.ok((m.properties.x0 as number) < (m.properties.x1 as number));
260
+ }
261
+ });
262
+
263
+ test("every model call runs in a step with that step's effect id", async () => {
264
+ const { lbb, turns } = await run();
265
+ const steps = new Set(turns.flatMap((t) => t.keys));
266
+ for (const call of lbb.calls) {
267
+ const [, key] = call.effectId!.split("/");
268
+ assert.ok(steps.has(key!.replace(/\.\d+$/, "")), `${call.effectId} names a step`);
269
+ }
270
+ assert.deepEqual([...new Set(lbb.calls.map((c) => c.kind))].sort(), [
271
+ "decide",
272
+ "embed",
273
+ "reason",
274
+ ]);
275
+ });
276
+
277
+ test("a turn that runs again sends the same requests and gets the kept answers", async () => {
278
+ const docs = sampleDocs();
279
+ const lbb = new MockActivities(rules(docs));
280
+ const workflow = createWorkflow(lbb, settings());
281
+ const message = triggerMessage(
282
+ "documents-link",
283
+ docs.map((d) => d.iri),
284
+ );
285
+ await runMessage(workflow, message);
286
+ const first = lbb.calls.length;
287
+ // The same turns again, their steps lost: every call repeats its request under its effect id.
288
+ await runMessage(workflow, message);
289
+ const again = lbb.calls.slice(first);
290
+ assert.equal(again.length, first);
291
+ assert.ok(
292
+ again.every((c) => c.cached),
293
+ "no request changed, so none conflicts",
294
+ );
295
+ assert.deepEqual(links(lbb.graph, docs).sort(), [...expectedLinks].sort());
296
+ });
297
+
298
+ test("a turn stopped part way resumes from its saved steps", async () => {
299
+ const docs = sampleDocs();
300
+ const lbb = new MockActivities(rules(docs));
301
+ const workflow = createWorkflow(lbb, settings());
302
+ let message: WorkflowTriggerMessage = triggerMessage(
303
+ "documents-link",
304
+ docs.map((d) => d.iri),
305
+ );
306
+ // The two defining windows, then the link window of the room sheets, stopped after 4 steps.
307
+ for (let n = 0; n < 2; n++)
308
+ message = (await runTurn(workflow, {}, message)).result.continuation!.message;
309
+ const stopped = await runTurn(workflow, {}, message, { prefix: "t", crashAfter: 4 }).then(
310
+ () => assert.fail("the turn should stop"),
311
+ (error: unknown) => error,
312
+ );
313
+ assert.ok(stopped instanceof Crash);
314
+ assert.deepEqual(stopped.turn.keys, ["documents", "mention-p1", "mention-p2", "meaning"]);
315
+ const calls = lbb.calls.length;
316
+ const { turn } = await runTurn(workflow, {}, message, { prefix: "t", saved: stopped.turn });
317
+
318
+ assert.deepEqual(turn.keys, [
319
+ ...stopped.turn.keys,
320
+ "decide-p1",
321
+ "decide-p2",
322
+ "review",
323
+ "mentions-0",
324
+ ]);
325
+ const again = lbb.calls
326
+ .slice(calls)
327
+ .filter((c) => stopped.turn.keys.some((k) => c.effectId === `t/${k}`));
328
+ assert.deepEqual(again, [], "a saved step does not run again");
329
+ assert.deepEqual(links(lbb.graph, docs).sort(), [...expectedLinks].sort());
330
+ });
331
+
332
+ test("a long document is read in windows over several turns", async () => {
333
+ // 30 pages of room sheets: the two sample pages, alternately.
334
+ const sheets = PAGES[2]!;
335
+ const pages = Array.from({ length: 30 }, (_, n) => {
336
+ const source = sheets.pages[n % 2]!;
337
+ const renumber = (id: string) => id.replace(/^p\d+-/, `p${n + 1}-`);
338
+ return { ...source, page: n, lines: source.lines.map((l) => ({ ...l, id: renumber(l.id) })) };
339
+ });
340
+ const book: FilePages = { ...sheets, sha256: "cd".repeat(32), page_count: 30, to: 30, pages };
341
+ const all = [PAGES[0]!, PAGES[1]!, book];
342
+ const docs = sampleDocs(all, ["ausstattungsliste.pdf", "baubeschreibung.pdf", "raumbuch.pdf"]);
343
+ const lbb = new MockActivities(rules(docs, all));
344
+ const workflow = createWorkflow(
345
+ lbb,
346
+ settings({ roles: { ...EXPECTED.roles, "raumbuch.pdf": "refers" } as Settings["roles"] }),
347
+ );
348
+ const { turns, state } = await runMessage(
349
+ workflow,
350
+ triggerMessage(
351
+ "documents-link",
352
+ docs.map((d) => d.iri),
353
+ ),
354
+ );
355
+
356
+ // Two defining windows, then three windows of the book: 12, 12 and 6 pages.
357
+ assert.equal(turns.length, 5);
358
+ assert.deepEqual(
359
+ turns.map((t) => t.keys.length),
360
+ [3, 3, 28, 28, 16],
361
+ );
362
+ assert.equal(state.links, 15 * 8 + 15 * 7);
363
+ assert.equal(lbb.graph.edgesOf("REFERS_TO").length, 225);
364
+ });
365
+
366
+ test("each step reports its phase as the LBB-run documents.link words it", async () => {
367
+ const { turns } = await run();
368
+ assert.deepEqual(phases(turns), [
369
+ "reading pages",
370
+ "writing facts",
371
+ "embedding the catalogue",
372
+ "linking mentions",
373
+ ]);
374
+ const first = turns[0]!.progress[0]!;
375
+ assert.deepEqual(
376
+ [first.step, first.phase, first.done, first.unit],
377
+ ["define-p1", "reading pages", 0, "pages"],
378
+ );
379
+ const linking = turns.at(-1)!.progress.filter((p) => p.phase === "linking mentions");
380
+ assert.deepEqual(
381
+ linking.map((p) => p.done),
382
+ linking.map((_, n) => n),
383
+ );
384
+ assert.ok(linking.every((p) => p.total === linking.length && p.unit === "pages"));
385
+ });
386
+
387
+ test("links below the gate go to review instead of the graph", async () => {
388
+ const { lbb, docs, state, turns } = await run({ gate: 0.99 });
389
+ const filing = turns.flatMap((t) => t.progress).filter((p) => p.phase === "filing review items");
390
+ assert.deepEqual(
391
+ filing.map((p) => [p.done, p.total, p.unit]),
392
+ filing.map((_, k) => [k, 13, "items"]),
393
+ );
394
+ const reviewed = [...lbb.reviews.values()];
395
+ const linked = reviewed.filter((r) => r.key!.includes(":link:"));
396
+ // One item per document, span and entry: S1.1 and S 2.1 are on both room sheets.
397
+ assert.equal(linked.length, 13);
398
+ const facts = linked.flatMap((r) => r.facts!.triplets!);
399
+ assert.equal(facts.length, 15);
400
+ assert.ok(facts.every((t) => t.relation === "REFERS_TO" && (t.confidence ?? 1) < 0.99));
401
+ assert.ok(reviewed.every((r) => r.origin === undefined && r.workflow === "documents-link"));
402
+ assert.equal(reviewed.length, linked.length);
403
+ assert.deepEqual(links(lbb.graph, docs), []);
404
+ assert.deepEqual(state, { entries: 15, mentions: 21, links: 0, review: 15 });
405
+ });
406
+
407
+ test("a refused decide request is split in halves until it is answered", async () => {
408
+ const { lbb, docs } = await run(
409
+ {},
410
+ {
411
+ refuses: (kind, request) =>
412
+ kind === "decide" && Object.keys((request as DecideRequest).questions).length > 4,
413
+ },
414
+ );
415
+ const decides = lbb.calls.filter((c) => c.kind === "decide");
416
+ assert.ok(decides.some((c) => c.refused));
417
+ assert.ok(
418
+ decides.some((c) => /\.0(\.\d)*$/.test(c.effectId!)),
419
+ "each half has its own effect id",
420
+ );
421
+ assert.deepEqual(links(lbb.graph, docs).sort(), [...expectedLinks].sort());
422
+ });
423
+
424
+ test("a document that leaves the graph loses its entries, and the links to them", async () => {
425
+ const { lbb, docs, workflow } = await run();
426
+ const list = docs[0]!;
427
+ await runMessage(workflow, triggerMessage("documents-link", [], [list.iri]));
428
+
429
+ const left = links(lbb.graph, docs);
430
+ assert.deepEqual(left.map((l) => l.split(" -> ")[1]).sort(), [
431
+ "baubeschreibung.pdf|3.1",
432
+ "baubeschreibung.pdf|3.2",
433
+ ]);
434
+ assert.equal(lbb.graph.ofType("DocEntry").length, 3);
435
+ });
436
+
437
+ test("a write of more than 900 entities goes in parts", () => {
438
+ const subjects = Array.from({ length: 2000 }, (_, n) => ({
439
+ type: "DocMention",
440
+ name: `m${n}`,
441
+ key: `k${n}`,
442
+ }));
443
+ const write = {
444
+ subjects,
445
+ entity_properties: subjects.map((s) => ({ ...s, properties: { span: s.name } })),
446
+ triplets: subjects.map((s) => ({
447
+ source: s,
448
+ relation: "MENTIONED_IN",
449
+ target: { type: "Document", name: "d" },
450
+ })),
451
+ };
452
+ const parts = [0, 1, 2].map((n) => link.part(write, n));
453
+ assert.deepEqual(
454
+ parts.map((p) => p.subjects.length),
455
+ [900, 900, 200],
456
+ );
457
+ assert.deepEqual(
458
+ parts.map((p) => p.triplets!.length),
459
+ [900, 900, 200],
460
+ );
461
+ assert.equal(parts[2]!.entity_properties![0]!.key, "k1800");
462
+ });
463
+
464
+ test("the link helpers match identifiers, words and confidences as the LBB link stage does", () => {
465
+ assert.equal(link.identifierKey("S.4.1"), "s4.1");
466
+ assert.equal(link.identifierKey("s 4.1"), "s4.1");
467
+ assert.equal(link.identifierKey("Pos.-Nr. 2.4"), "pos.-nr.2.4");
468
+ assert.ok(link.holdsAsWord("RB-Pos. 4.4.2) liegt", "4.4.2"));
469
+ assert.ok(!link.holdsAsWord("RB-Pos. 4.4.2", "4.4"));
470
+ assert.ok(!link.holdsAsWord("S11.1", "1.1"));
471
+ const identifier = {
472
+ choice: "identifier",
473
+ confidence: 0.9,
474
+ probabilities: { identifier: 0.9, value: 0.1 },
475
+ };
476
+ assert.deepEqual(
477
+ link.linkRule(
478
+ link.resolve(identifier, { choice: "e3", confidence: 0.95, probabilities: { e3: 0.95 } }),
479
+ ),
480
+ {
481
+ target: 3,
482
+ confidence: 0.9,
483
+ },
484
+ );
485
+ const line = PAGES[2]!.pages[0]!.lines.find((l) => l.text.endsWith("Kap. 3.1"))!;
486
+ assert.deepEqual(
487
+ link.spanBox(line, "3.1"),
488
+ line.words.at(-1)!.box,
489
+ "the box of the span's own word",
490
+ );
491
+ const value = {
492
+ choice: "value",
493
+ confidence: 0.8,
494
+ probabilities: { value: 0.8, identifier: 0.15 },
495
+ };
496
+ assert.equal(link.linkRule(link.resolve(value, { choice: "e3", confidence: 0.95 })).target, null);
497
+ });