@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,484 @@
1
+ // link on its samples: an invented CRM (samples/records.json) with names
2
+ // that vary between records, deals and notes, and its gold groups
3
+ // (samples/gold.json). The worker runs with mock models and a graph in
4
+ // memory; a mock reviewer decides the review items as the gold says.
5
+ import { test } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { join } from "node:path";
8
+ import {
9
+ phases,
10
+ readJson,
11
+ runTurn,
12
+ Crash,
13
+ samplesDir,
14
+ triggerMessage,
15
+ } from "../_shared/testing.js";
16
+ import {
17
+ drain,
18
+ gradeRun,
19
+ measure,
20
+ newRun,
21
+ reviewAsGold,
22
+ runAll,
23
+ sendRecords,
24
+ type FixtureRecord,
25
+ type LinkRun,
26
+ } from "./fixture.js";
27
+ import { formatReport, type Gold } from "./grade.js";
28
+ import { defaults, sourceWorkflowName, type Settings } from "./settings.js";
29
+ import type { ShardState } from "./source-turn.js";
30
+ import type { ThingsState } from "./things-turn.js";
31
+ import { createWorkflow, ontology, workflows } from "./workflow.js";
32
+
33
+ const SAMPLES = samplesDir(import.meta.url, "link");
34
+ const RECORDS = readJson<FixtureRecord[]>(join(SAMPLES, "records.json"));
35
+ const GOLD = readJson<Gold>(join(SAMPLES, "gold.json"));
36
+ const ORDER = ["Organization", "Person", "Lead", "Deal", "Note"];
37
+ const models: Settings = { ...defaults, modelsReadRecords: true };
38
+
39
+ /** The members of each thing, by span, for the records named. */
40
+ function thingOf(run: LinkRun, span: string, record?: string): string[] {
41
+ const graph = run.lbb.graph;
42
+ const iri = record ? run.ids.get(record) : undefined;
43
+ const mention = graph
44
+ .ofType("Mention")
45
+ .find(
46
+ (m) =>
47
+ m.properties.span === span &&
48
+ (!iri ||
49
+ graph.edges.some(
50
+ (e) => e.source === m.iri && e.relation === "MENTIONED_IN" && e.target === iri,
51
+ )),
52
+ );
53
+ assert.ok(mention, `a mention "${span}"`);
54
+ const thing = graph.edges.find(
55
+ (e) => e.source === mention.iri && e.relation === "REFERS_TO",
56
+ )?.target;
57
+ if (!thing) return [span];
58
+ return graph.edges
59
+ .filter((e) => e.relation === "REFERS_TO" && e.target === thing)
60
+ .map((e) => String(graph.entities.get(e.source)!.properties.span))
61
+ .sort();
62
+ }
63
+
64
+ const thingsWithRecords = (run: LinkRun) =>
65
+ run.lbb.graph
66
+ .ofType("LinkedThing")
67
+ .map(
68
+ (t) =>
69
+ run.lbb.graph.edges.filter((e) => e.source === t.iri && e.relation === "HAS_RECORD").length,
70
+ );
71
+
72
+ test("without model reads, strong keys link, names go to a person, and no two records become one thing", async () => {
73
+ const run = newRun(RECORDS, defaults);
74
+ await runAll(run, RECORDS, ORDER);
75
+
76
+ assert.equal(run.lbb.calls.length, 0, "no model reads a record value");
77
+ assert.ok(
78
+ thingsWithRecords(run).every((n) => n <= 1),
79
+ "a thing holds at most one record before a person accepts an identity",
80
+ );
81
+ assert.deepEqual(
82
+ thingOf(run, "M. Tannenholt"),
83
+ ["M. Tannenholt"],
84
+ "a name below the gate 1 is not written",
85
+ );
86
+ const items = [...run.lbb.reviews.values()].filter((r) => r.status === "open");
87
+ const identities = items
88
+ .filter((r) => r.key!.includes(":identity:"))
89
+ .map((r) =>
90
+ r
91
+ .identities![0]!.members.map((m) => m.name)
92
+ .sort()
93
+ .join(" = "),
94
+ );
95
+ assert.deepEqual(
96
+ identities.sort(),
97
+ [
98
+ "Kit Marsh = k.marsh@fernway.example",
99
+ "Mira Ruiz = Mira Tannenholt-Ruiz",
100
+ "Soren Vestergaard = Søren Vestergaard",
101
+ "Zoe Lindqvist = Zoë Lindqvist",
102
+ ],
103
+ "the two Alex Brandt at two organizations are not proposed",
104
+ );
105
+ // One link item per thing: Robert Haldane's holds both notes.
106
+ const robert = items.find((r) => r.title === "2 mentions may name Robert Haldane (Person)");
107
+ assert.ok(robert, items.map((r) => r.title).join("\n"));
108
+ assert.deepEqual(robert.facts!.triplets!.map((t) => t.source.name).sort(), [
109
+ "Bob Haldane",
110
+ "Robert Haldane",
111
+ ]);
112
+ assert.ok(items.every((r) => r.origin?.id === "starter:link" && r.origin.label === "link"));
113
+
114
+ const report = await gradeRun(run, GOLD);
115
+ console.log(formatReport(report, "keys and rules, before review"));
116
+ assert.equal(report.mentions.matched, report.mentions.gold, "every gold mention is found");
117
+ assert.equal(report.mentions.unjudged, 0, "no mention the gold does not list");
118
+ assert.equal(report.pairs.precision.value, 1);
119
+ assert.equal(report.identities.precision.value, 1);
120
+ assert.equal(report.identities.recall.value, 1);
121
+ });
122
+
123
+ test("a person's review as the gold says makes one thing of the dogfood case: contact, lead, deal and note", async () => {
124
+ const run = newRun(RECORDS, defaults);
125
+ await runAll(run, RECORDS, ORDER);
126
+ assert.ok((await reviewAsGold(run, GOLD)) > 10);
127
+ assert.equal(await reviewAsGold(run, GOLD), 0, "no item is filed again");
128
+
129
+ assert.deepEqual(thingOf(run, "Mira Tannenholt-Ruiz"), [
130
+ "M. Tannenholt",
131
+ "Mimi T.",
132
+ "Mira Ruiz",
133
+ "Mira Tannenholt-Ruiz",
134
+ "mira.tannenholt@northbay.example",
135
+ "mira.tannenholt@northbay.example",
136
+ ]);
137
+ const report = await gradeRun(run, GOLD);
138
+ console.log(formatReport(report, "keys and rules, after the gold review"));
139
+ assert.equal(report.links.value, 1, "every written link is right");
140
+ assert.equal(report.pairs.precision.value, 1);
141
+ assert.ok(report.pairs.recall.value! >= 0.95, `recall ${report.pairs.recall.value}`);
142
+ assert.equal(report.purity, 1);
143
+ });
144
+
145
+ test("with model reads, the links at or above the gate meet the CRM bar of 9.3, and identities wait for a person", async () => {
146
+ const run = newRun(RECORDS, models);
147
+ await runAll(run, RECORDS, ORDER);
148
+ const report = await gradeRun(run, GOLD);
149
+ console.log(formatReport(report, "with model reads (mock models), at the gate 0.8"));
150
+ // The bar's Wilson interval needs more links than the samples hold: on 15 right links it starts at 0.7961.
151
+ assert.ok(report.links.value! >= 0.95, `precision at the gate ${report.links.value}`);
152
+ assert.ok(report.identities.precision.value! >= 0.99);
153
+ assert.ok(
154
+ thingsWithRecords(run).every((n) => n <= 1),
155
+ "a model links no two records",
156
+ );
157
+ assert.deepEqual(
158
+ thingOf(run, "Alex Brandt", "note-10"),
159
+ ["Alex Brandt", "Alex Brandt", "a.brandt@ridgeway.example"],
160
+ "the Alex Brandt of the note's deal",
161
+ );
162
+ assert.deepEqual(
163
+ thingOf(run, "Mimi T."),
164
+ ["M. Tannenholt", "Mimi T.", "Mira Tannenholt-Ruiz", "mira.tannenholt@northbay.example"],
165
+ "found through the note's deal",
166
+ );
167
+ const kinds = new Set(run.lbb.calls.map((c) => c.kind));
168
+ assert.deepEqual([...kinds].sort(), ["decide", "reason"]);
169
+
170
+ await reviewAsGold(run, GOLD);
171
+ const after = await gradeRun(run, GOLD);
172
+ console.log(formatReport(after, "with model reads (mock models), after the gold review"));
173
+ assert.equal(after.pairs.recall.value, 1);
174
+ assert.equal(after.completeness, 1);
175
+ });
176
+
177
+ test("the order of a backfill does not change the things: notes first", async () => {
178
+ const partition = async (order: string[]) => {
179
+ const run = newRun(RECORDS, models);
180
+ await runAll(run, RECORDS, order);
181
+ await reviewAsGold(run, GOLD);
182
+ await reviewAsGold(run, GOLD);
183
+ return (await gradeRun(run, GOLD)).pairs;
184
+ };
185
+ const forward = await partition(ORDER);
186
+ const backward = await partition([...ORDER].reverse());
187
+ assert.deepEqual(backward, forward);
188
+ });
189
+
190
+ test("a decision is asked once per input: a rerun asks only what new candidates bring, then nothing", async () => {
191
+ const run = newRun(RECORDS, models);
192
+ const snapshot = () =>
193
+ JSON.stringify([
194
+ [...run.lbb.graph.entities.values()].map((e) => [e.iri, e.properties]).sort(),
195
+ run.lbb.graph.edges.map((e) => [e.source, e.relation, e.target]).sort(),
196
+ ]);
197
+ const pass = async () => {
198
+ run.counters.clear();
199
+ run.ports.sent.clear();
200
+ const calls = run.lbb.calls.length;
201
+ await runAll(run, RECORDS, ORDER);
202
+ return run.lbb.calls.slice(calls);
203
+ };
204
+ const first = await pass();
205
+ // The second pass sees the records that came after each one in the first: a lead after its contact.
206
+ const second = await pass();
207
+ const graph = snapshot();
208
+ const third = await pass();
209
+ console.log(
210
+ `model calls of three passes: ${first.length}, then ${second.filter((c) => !c.cached).length} new and ${second.filter((c) => c.cached).length} kept, then ${third.filter((c) => !c.cached).length} new and ${third.filter((c) => c.cached).length} kept`,
211
+ );
212
+ assert.ok(second.filter((c) => !c.cached).length < first.length / 2);
213
+ assert.ok(
214
+ third.every((c) => c.cached),
215
+ "every request is the same, so each gets its kept answer",
216
+ );
217
+ assert.equal(snapshot(), graph, "the third pass writes the same graph");
218
+ });
219
+
220
+ test("a source turn stopped part way resumes from its saved steps, and its decided message has one id", async () => {
221
+ const run = newRun(RECORDS, models);
222
+ await runAll(run, RECORDS, ["Organization", "Person", "Lead", "Deal"]);
223
+ const notes = RECORDS.filter((r) => r.class === "Note").map((r) => run.ids.get(r.id)!);
224
+ const message = triggerMessage(sourceWorkflowName(models, "Note"), notes);
225
+ const options = { prefix: "t", workflowId: "starter.link-note.2", turn: 3 };
226
+ const stopped = await runTurn(run.definition, {}, message, { ...options, crashAfter: 5 }).then(
227
+ () => assert.fail("the turn should stop"),
228
+ (error: unknown) => error,
229
+ );
230
+ assert.ok(stopped instanceof Crash);
231
+ const calls = run.lbb.calls.length;
232
+ const { turn } = await runTurn(run.definition, {}, message, { ...options, saved: stopped.turn });
233
+ assert.deepEqual(turn.keys.slice(0, 6), [
234
+ "sources",
235
+ "mentions-0",
236
+ "mentions-1",
237
+ "mentions-2",
238
+ "mentions-3",
239
+ "mentions-4",
240
+ ]);
241
+ assert.equal(turn.keys.at(-1), "send");
242
+ assert.ok(
243
+ run.lbb.calls
244
+ .slice(calls)
245
+ .every((c) => !stopped.turn.keys.some((k) => c.effectId === `t/${k}`)),
246
+ "a saved step does not run again",
247
+ );
248
+ assert.deepEqual(
249
+ run.ports.inbox.map((m) => m.id),
250
+ ["decided-starter.link-note.2-3"],
251
+ );
252
+ // The same turn again sends the same id: the engine admits it once.
253
+ await runTurn(run.definition, {}, message, { ...options, prefix: "u" });
254
+ assert.equal(run.ports.inbox.length, 1);
255
+ await drain(run);
256
+ assert.ok(thingOf(run, "Bob Haldane").includes("Robert Haldane"));
257
+ });
258
+
259
+ test("each step reports its phase in the progress convention", async () => {
260
+ const run = newRun(RECORDS, models);
261
+ await runAll(run, RECORDS, ORDER);
262
+ await reviewAsGold(run, GOLD);
263
+ assert.deepEqual(phases(run.turns), [
264
+ "reading sources",
265
+ "finding mentions",
266
+ "finding candidates",
267
+ "linking mentions",
268
+ "writing mentions",
269
+ "sending decisions",
270
+ "grouping things",
271
+ "writing things",
272
+ "filing review items",
273
+ "reading review decisions",
274
+ ]);
275
+ });
276
+
277
+ test("a row a person leaves out keeps its mention apart from the thing, also when it is decided again", async () => {
278
+ // A gate no answer reaches: every link goes to a person.
279
+ const strict: Settings = { ...models, gate: 0.99 };
280
+ const run = newRun(RECORDS, strict);
281
+ await runAll(run, RECORDS, ORDER);
282
+ const felix = [...run.lbb.reviews.entries()].find(([, r]) => r.title.includes("Felix Okafor"))!;
283
+ // The person dismisses "F. Okafor"; the other items as the gold says.
284
+ run.ports.decided.set(felix[0], { status: "dismissed" });
285
+ felix[1].status = "dismissed";
286
+ await reviewAsGold(run, GOLD);
287
+ assert.deepEqual(thingOf(run, "F. Okafor"), ["F. Okafor"]);
288
+ // The note is read again under a lower gate: the cannot-link holds.
289
+ run.settings = models;
290
+ run.definition = createWorkflow(run.lbb, run.ports, models) as unknown as LinkRun["definition"];
291
+ await sendRecords(run, "Note", [run.ids.get("note-4")!]);
292
+ assert.deepEqual(thingOf(run, "F. Okafor"), ["F. Okafor"]);
293
+ const mention = run.lbb.graph.ofType("Mention").find((m) => m.properties.span === "F. Okafor")!;
294
+ assert.equal((mention.properties.cannot_link as string[]).length, 1);
295
+ });
296
+
297
+ test("two turns that run at once each leave a new name alone; the second look links them", async () => {
298
+ const lena: FixtureRecord[] = [
299
+ {
300
+ id: "note-a",
301
+ class: "Note",
302
+ name: "Note A",
303
+ fields: { body: "Lena Fairweather asked for a demo." },
304
+ },
305
+ {
306
+ id: "note-b",
307
+ class: "Note",
308
+ name: "Note B",
309
+ fields: { body: "Sent the slides to Lena Fairweather." },
310
+ },
311
+ ];
312
+ const run = newRun(lena, models);
313
+ const name = sourceWorkflowName(models, "Note");
314
+ // Two shards, no things turn between them: neither sees the other's mention.
315
+ for (const [k, id] of ["note-a", "note-b"].entries()) {
316
+ const out = await runTurn(run.definition, {}, triggerMessage(name, [run.ids.get(id)!]), {
317
+ workflowId: `starter.${name}.${k}`,
318
+ turn: 1,
319
+ });
320
+ run.turns.push(out.turn);
321
+ }
322
+ assert.equal(run.ports.inbox.length, 2);
323
+ await drain(run);
324
+ assert.deepEqual(thingOf(run, "Lena Fairweather", "note-a"), [
325
+ "Lena Fairweather",
326
+ "Lena Fairweather",
327
+ ]);
328
+ assert.equal((run.things as ThingsState).secondLooks, 1);
329
+ });
330
+
331
+ test("a record that comes after the notes anchors their thing; the old thing points to it", async () => {
332
+ const records: FixtureRecord[] = [
333
+ {
334
+ id: "note-a",
335
+ class: "Note",
336
+ name: "Note A",
337
+ fields: { body: "Bob Haldane asked for the price list." },
338
+ },
339
+ { id: "note-b", class: "Note", name: "Note B", fields: { body: "Called Bob Haldane back." } },
340
+ {
341
+ id: "person-robert",
342
+ class: "Person",
343
+ name: "Robert Haldane",
344
+ fields: { email: "r.haldane@quillfield.example" },
345
+ },
346
+ ];
347
+ const run = newRun(records, models);
348
+ await sendRecords(run, "Note", [run.ids.get("note-a")!]);
349
+ await sendRecords(run, "Note", [run.ids.get("note-b")!]);
350
+ const [before] = run.lbb.graph.ofType("LinkedThing");
351
+ assert.ok(before);
352
+ assert.equal(String(before.properties.anchor_key).slice(0, 8), "mention:");
353
+ await sendRecords(run, "Person", [run.ids.get("person-robert")!]);
354
+ const live = run.lbb.graph.ofType("LinkedThing").filter((t) => t.properties.anchor_key);
355
+ assert.equal(live.length, 1);
356
+ assert.equal(
357
+ String(live[0]!.properties.anchor_key),
358
+ `record:${run.ids.get("person-robert")!.slice(-32)}`,
359
+ );
360
+ assert.deepEqual(thingOf(run, "Robert Haldane"), [
361
+ "Bob Haldane",
362
+ "Bob Haldane",
363
+ "Robert Haldane",
364
+ "r.haldane@quillfield.example",
365
+ ]);
366
+ const pointer = run.lbb.graph.edges.find(
367
+ (e) => e.source === before.iri && e.relation === "MERGED_INTO",
368
+ );
369
+ assert.equal(pointer?.target, live[0]!.iri);
370
+ assert.deepEqual(
371
+ Object.keys(run.lbb.graph.entities.get(before.iri)!.properties),
372
+ [],
373
+ "the old thing keeps only its pointer",
374
+ );
375
+ });
376
+
377
+ test("a record that leaves the graph loses its mentions; its thing goes", async () => {
378
+ const run = newRun(RECORDS, models);
379
+ await runAll(run, RECORDS, ORDER);
380
+ const casey = run.ids.get("lead-casey")!;
381
+ assert.equal(thingOf(run, "Casey Moreau").length, 2);
382
+ run.lbb.graph.apply({
383
+ subjects: [],
384
+ retract_entities: [{ type: "Lead", name: "Casey Moreau", key: "lead-casey" }],
385
+ });
386
+ await sendRecords(run, "Lead", [], [casey]);
387
+ assert.ok(!run.lbb.graph.ofType("Mention").some((m) => m.properties.span === "Casey Moreau"));
388
+ assert.ok(!run.lbb.graph.ofType("LinkedThing").some((t) => t.name === "Casey Moreau"));
389
+ });
390
+
391
+ /** A CRM of `n` invented contacts that share a team address, and many share a given name. */
392
+ function crowd(n: number): FixtureRecord[] {
393
+ const family = [
394
+ "Ash",
395
+ "Birch",
396
+ "Cedar",
397
+ "Dale",
398
+ "Elm",
399
+ "Fern",
400
+ "Glen",
401
+ "Heath",
402
+ "Ivy",
403
+ "Juniper",
404
+ "Kerr",
405
+ "Lark",
406
+ ];
407
+ return Array.from({ length: n }, (_, i) => ({
408
+ id: `person-${i}`,
409
+ class: "Person",
410
+ name: `Alex ${family[i % family.length]}${Math.floor(i / family.length)}`,
411
+ fields: { email: i % 2 ? "team@northbay.example" : `alex.${i}@northbay.example` },
412
+ }));
413
+ }
414
+
415
+ test("a key that more than 200 mentions hold is a stop key: it finds nothing, and the status names it", async () => {
416
+ const records = crowd(420);
417
+ const run = newRun(records, defaults);
418
+ await runAll(run, records, ["Person"]);
419
+ const shard = run.shards.get(`starter.${sourceWorkflowName(defaults, "Person")}`) as ShardState;
420
+ assert.ok(shard.stopKeys["email:team@northbay.example"]! > 200, JSON.stringify(shard.stopKeys));
421
+ assert.ok(shard.stopKeys["name:part:alex"]! > 200);
422
+ assert.ok(shard.stopKeys["domain:northbay.example"]! > 200);
423
+ const identities = [...run.lbb.reviews.values()].filter((r) => r.key!.includes(":identity:"));
424
+ assert.ok(
425
+ identities.every((r) => r.identities![0]!.members.length <= 10),
426
+ "no identity of more than 10 records",
427
+ );
428
+ // The first message proposed the 8 contacts of the team address; the group grew past 10, and the item went.
429
+ assert.deepEqual(
430
+ identities.map((r) => r.status),
431
+ ["superseded"],
432
+ );
433
+ const things = run.things as ThingsState;
434
+ console.log(
435
+ `stop keys of ${records.length} contacts: ${Object.entries(shard.stopKeys)
436
+ .map(([k, n]) => `${k} (${n})`)
437
+ .join(
438
+ ", ",
439
+ )}; bounds: ${JSON.stringify(shard.bounds)}; identities proposed: ${identities.length}; groups too large: ${things.tooLarge}`,
440
+ );
441
+ console.log(`storage of ${records.length} contacts: ${JSON.stringify(measure(run.lbb.graph))}`);
442
+ });
443
+
444
+ test("block keys as graph facts: keys, facts and bytes per mention on the samples (decision 6)", async () => {
445
+ const run = newRun(RECORDS, models);
446
+ await runAll(run, RECORDS, ORDER);
447
+ const storage = measure(run.lbb.graph);
448
+ console.log(`storage of the samples: ${JSON.stringify(storage)}`);
449
+ assert.equal(storage.mentions, 61);
450
+ assert.ok(storage.keysPerMention <= 16);
451
+ });
452
+
453
+ test("the named workflows: one per class, the reviews watch, and the ontology of 2.5", () => {
454
+ const named = workflows(defaults);
455
+ assert.deepEqual(
456
+ named.map((w) => w.name),
457
+ ["link-person", "link-lead", "link-organization", "link-deal", "link-note", "link-reviews"],
458
+ );
459
+ assert.deepEqual(
460
+ named.map((w) => w.workflow.workflow_type),
461
+ Array(6).fill("link"),
462
+ );
463
+ assert.ok(
464
+ named[0]!.owns && named.slice(1).every((w) => !w.owns),
465
+ "the first declares what the workflow owns",
466
+ );
467
+ assert.deepEqual(named.find((w) => w.name === "link-note")!.triggers[0]!.watch!.fields, ["body"]);
468
+ assert.deepEqual(
469
+ named.find((w) => w.name === "link-person")!.triggers[0]!.watch!.fields,
470
+ ["label", "email"],
471
+ "the display name is watched as `label` (rdfs:label)",
472
+ );
473
+ const ops = ontology(defaults);
474
+ const relations = ops.flatMap((op) => (op.op === "add_relation" ? [op.name] : []));
475
+ assert.deepEqual(relations, [
476
+ "MENTIONED_IN",
477
+ "SAME_THING_AS",
478
+ "REFERS_TO",
479
+ "HAS_RECORD",
480
+ "MERGED_INTO",
481
+ "SAME_AS",
482
+ ]);
483
+ assert.ok(ops.some((op) => op.op === "add_entity_type" && op.name === "LinkedThing"));
484
+ });
@@ -0,0 +1,202 @@
1
+ // link: connect the mentions of the same thing across the records of a
2
+ // graph (general linking, phase L1: identities on records).
3
+ //
4
+ // The workflow reads records of the classes its settings name (contacts,
5
+ // leads, organizations, deals, notes). Each record's names and keys are its
6
+ // own mentions; its text fields give the mentions of other things. A mention
7
+ // gets block keys (normal forms of its name or key) as graph facts, the
8
+ // mentions that share a key are its candidates, and a decision links it to
9
+ // the thing it names. The things identity groups the links into things:
10
+ // `LinkedThing` entities that every member `REFERS_TO`. Two records are
11
+ // never one thing without a person: the workflow proposes them as an
12
+ // identity, and a person's accept writes `SAME_AS`.
13
+ //
14
+ // Without model reads of record values (the default) a strong key (an
15
+ // email, a web address, a phone number, a handle, a code) links at
16
+ // confidence 1, and every other candidate goes to a person, grouped by
17
+ // thing. With `modelsReadRecords: true` the decision model decides, and
18
+ // the links at or above the gate are written.
19
+ //
20
+ // Run it as every template runs: `npm run setup` creates the named
21
+ // workflows and the things identity, and `npm start` serves them.
22
+ import {
23
+ LbbError,
24
+ parseTriggerMessage,
25
+ workflow,
26
+ type LbbActivities,
27
+ type LbbClient,
28
+ type Schemas,
29
+ type WorkflowContext,
30
+ } from "@littlebigbrain/client";
31
+ import { emptyShard, sourceTurn, type ShardState } from "./source-turn.js";
32
+ import {
33
+ defaults,
34
+ reviewsWorkflowName,
35
+ sourceWorkflowName,
36
+ thingsId,
37
+ workflowType,
38
+ type DecidedMessage,
39
+ type LinkMessage,
40
+ type LinkPorts,
41
+ type Settings,
42
+ type SourceMessage,
43
+ } from "./settings.js";
44
+ import { roleOf } from "./sources/records.js";
45
+ import { emptyThings, thingsTurn, type ThingsState } from "./things-turn.js";
46
+ import { LINK_FIELDS, MENTION_FIELDS, THING_FIELDS, isDisplayName } from "./reads.js";
47
+
48
+ export { defaults, workflowType, thingsId, type Settings, type LinkPorts };
49
+
50
+ /** The state of an identity of the workflow: a source shard's or the things identity's. */
51
+ export type LinkState = Partial<ShardState> & Partial<ThingsState>;
52
+
53
+ /** The fields a named workflow watches: the fields that give mentions; the display name is `label`. */
54
+ const watchedFields = (fields: Settings["fields"][string]) =>
55
+ Object.entries(fields)
56
+ .filter(([, spec]) => roleOf(spec) !== "context")
57
+ .map(([field]) => (isDisplayName(field) ? "label" : field));
58
+
59
+ /**
60
+ * The named workflows that send the records here: one per class, and one
61
+ * that watches the accepted review items. A named workflow takes one watch
62
+ * trigger in this release, so the classes of the design's one workflow
63
+ * (general linking 3.1) are one named workflow each. The first declares
64
+ * what the workflow owns.
65
+ */
66
+ export function workflows(settings: Settings = defaults): Schemas["NamedWorkflowRequest"][] {
67
+ const owns: Schemas["WorkflowOwns"] = {
68
+ classes: ["Mention", "LinkedThing"],
69
+ relations: ["MENTIONED_IN", "SAME_THING_AS", "REFERS_TO", "HAS_RECORD", "MERGED_INTO"],
70
+ properties: [...new Set([...MENTION_FIELDS, ...LINK_FIELDS, ...THING_FIELDS])],
71
+ };
72
+ const sources = Object.entries(settings.fields).map(([cls, fields], i) => ({
73
+ name: sourceWorkflowName(settings, cls),
74
+ workflow: workflowType,
75
+ shards: settings.shards,
76
+ window: settings.window,
77
+ batch: settings.sourcesPerTurn,
78
+ triggers: [{ id: "records", watch: { class: cls, fields: watchedFields(fields) } }],
79
+ ...(i === 0 ? { owns } : {}),
80
+ }));
81
+ return [
82
+ ...sources,
83
+ {
84
+ name: reviewsWorkflowName(settings),
85
+ workflow: workflowType,
86
+ shards: 1,
87
+ window: settings.window,
88
+ batch: 64,
89
+ triggers: [{ id: "accepts", watch: { class: "Mention", fields: ["same_thing_as"] } }],
90
+ },
91
+ ];
92
+ }
93
+
94
+ const property = (name: string, value_type: string) => ({
95
+ op: "add_property" as const,
96
+ name,
97
+ value_type,
98
+ });
99
+
100
+ /** The classes, properties and relations the workflow writes (general linking 2.5): `lbb.evolve(ontology())`. */
101
+ export function ontology(settings: Settings = defaults): Schemas["OntologyEvolveOp"][] {
102
+ const classes = Object.keys(settings.fields);
103
+ return [
104
+ { op: "add_entity_type", name: "Mention" },
105
+ { op: "add_entity_type", name: "LinkedThing" },
106
+ property("span", "text"),
107
+ property("role", "keyword"),
108
+ property("kind", "keyword"),
109
+ property("field", "keyword"),
110
+ property("offset", "i64"),
111
+ property("length", "i64"),
112
+ property("block_key", "keyword_set"),
113
+ property("window", "text"),
114
+ property("linked_to", "keyword_set"),
115
+ property("must_link", "keyword_set"),
116
+ property("cannot_link", "keyword_set"),
117
+ property("anchor_key", "keyword"),
118
+ property("alias", "keyword_set"),
119
+ { op: "add_relation", name: "MENTIONED_IN", domain: ["Mention"], range: classes },
120
+ { op: "add_relation", name: "SAME_THING_AS", domain: ["Mention"], range: ["Mention"] },
121
+ { op: "add_relation", name: "REFERS_TO", domain: ["Mention"], range: ["LinkedThing"] },
122
+ { op: "add_relation", name: "HAS_RECORD", domain: ["LinkedThing"], range: classes },
123
+ { op: "add_relation", name: "MERGED_INTO", domain: ["LinkedThing"], range: ["LinkedThing"] },
124
+ { op: "add_relation", name: "SAME_AS", symmetric: true },
125
+ ] as Schemas["OntologyEvolveOp"][];
126
+ }
127
+
128
+ /** The ports of a client: send messages, and read and supersede review items. */
129
+ export function linkPorts(client: LbbClient): LinkPorts {
130
+ return {
131
+ send: (workflowId, message, options) => client.workflows.send(workflowId, message, options),
132
+ suggestion: (id) => client.ontology.suggestions.get(id),
133
+ supersede: (id, reason) => client.ontology.suggestions.supersede(id, { reason }),
134
+ };
135
+ }
136
+
137
+ /**
138
+ * The workflow, for a `WorkflowWorker`: a trigger message goes to a source
139
+ * turn, a `decided` message to a things turn. `lbb` is `lbbActivities(client)`
140
+ * and `ports` is `linkPorts(client)`, or mocks in a test.
141
+ */
142
+ export function createWorkflow(
143
+ lbb: LbbActivities,
144
+ ports: LinkPorts,
145
+ settings: Settings = defaults,
146
+ ) {
147
+ if (!(settings.sourcesPerTurn >= 1 && settings.sourcesPerTurn <= 64))
148
+ throw new RangeError("sourcesPerTurn is from 1 to 64: a message holds at most 64 instances");
149
+ if (!(settings.gate > 0 && settings.gate <= 1))
150
+ throw new RangeError("gate is above 0 and at most 1");
151
+ return workflow<LinkState, LinkMessage>({
152
+ name: workflowType.workflow_type,
153
+ version: workflowType.version,
154
+ initialState: {},
155
+ async onMessage(ctx: WorkflowContext, saved: LinkState, raw: LinkMessage) {
156
+ const body = raw as unknown as Record<string, unknown>;
157
+ if (body?.type === "decided") {
158
+ const message = raw as DecidedMessage;
159
+ const state = { ...emptyThings(), ...(saved ?? {}) } as ThingsState;
160
+ const turn = await thingsTurn(ctx, lbb, ports, settings, message, state);
161
+ return turn.next ? ctx.continue(turn.state, turn.next) : { state: turn.state };
162
+ }
163
+ const message = { ...parseTriggerMessage(raw), cursor: body?.cursor } as SourceMessage;
164
+ if (message.cursor === undefined) delete message.cursor;
165
+ const state = { ...emptyShard(), ...(saved ?? {}) } as ShardState;
166
+ const turn = await sourceTurn(ctx, lbb, ports, settings, message, state);
167
+ return turn.next ? ctx.continue(turn.state, turn.next) : { state: turn.state };
168
+ },
169
+ });
170
+ }
171
+
172
+ /**
173
+ * Create the workflow on a graph: its ontology, its named workflows and its
174
+ * things identity. Each call is safe to repeat. Returns the names created.
175
+ */
176
+ export async function setup(
177
+ client: LbbClient,
178
+ lbb: LbbActivities,
179
+ settings: Settings = defaults,
180
+ ): Promise<string[]> {
181
+ await lbb.evolve(ontology(settings));
182
+ const names: string[] = [];
183
+ for (const request of workflows(settings)) {
184
+ const created = await client.workflows.named.put(request);
185
+ names.push(created.name);
186
+ }
187
+ try {
188
+ await client.workflows.create({
189
+ id: thingsId(settings),
190
+ workflow_type: workflowType.workflow_type,
191
+ version: workflowType.version,
192
+ state: {},
193
+ lease_ms: 120_000,
194
+ max_attempts: 3,
195
+ });
196
+ } catch (error) {
197
+ // 409: the identity exists already.
198
+ if (!(error instanceof LbbError && error.status === 409)) throw error;
199
+ }
200
+ names.push(thingsId(settings));
201
+ return names;
202
+ }