@littlebigbrain/create 0.4.0 → 0.6.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 (33) hide show
  1. package/README.md +42 -6
  2. package/lib/create.mjs +228 -14
  3. package/package.json +2 -1
  4. package/project/python/values_extract/tests/fake_lbb.py +226 -0
  5. package/project/python/values_extract/tests/test_workflow.py +150 -0
  6. package/python/values_extract/README.md +52 -0
  7. package/python/values_extract/__init__.py +3 -0
  8. package/python/values_extract/__main__.py +97 -0
  9. package/python/values_extract/samples/expected.json +149 -0
  10. package/python/values_extract/samples/records.json +26 -0
  11. package/python/values_extract/samples.py +37 -0
  12. package/python/values_extract/workflow.py +1044 -0
  13. package/python/versions.json +3 -0
  14. package/templates/_shared/testing.ts +259 -5
  15. package/templates/ontology-fit/README.md +41 -32
  16. package/templates/ontology-fit/workflow.test.ts +56 -5
  17. package/templates/ontology-fit/workflow.ts +708 -207
  18. package/templates/review/README.md +56 -29
  19. package/templates/review/decision.ts +118 -37
  20. package/templates/review/workflow.test.ts +30 -10
  21. package/templates/review/workflow.ts +123 -76
  22. package/templates/rows/README.md +8 -1
  23. package/templates/rows/workflow.test.ts +56 -0
  24. package/templates/rows/workflow.ts +182 -148
  25. package/templates/sections/README.md +8 -1
  26. package/templates/sections/workflow.test.ts +51 -1
  27. package/templates/sections/workflow.ts +137 -98
  28. package/templates/values-extract/README.md +92 -23
  29. package/templates/values-extract/samples/expected.json +1 -0
  30. package/templates/values-extract/test-examples.ts +155 -0
  31. package/templates/values-extract/workflow.test.ts +532 -27
  32. package/templates/values-extract/workflow.ts +719 -179
  33. package/templates/versions.json +1 -1
@@ -19,13 +19,19 @@
19
19
  // to 44);
20
20
  // 4. with `represents`, writes `Document REPRESENTS <instance>` when the
21
21
  // file's name names exactly one instance of that class;
22
- // 5. records the derivation.
22
+ // 5. records the derivation, for the upload its check named. When the file
23
+ // was deleted (or deleted and uploaded again) since, nothing is recorded,
24
+ // and the run of the new upload reads the file again. A file deleted and
25
+ // not uploaded again answers 404 to the next read of its bytes: the run
26
+ // ends there and counts the file as skipped.
23
27
  //
24
28
  // A turn writes at most `sectionsPerTurn` sections; the rest goes to a new
25
29
  // turn (`ctx.continue`).
26
30
  import { createHash } from "node:crypto";
27
31
  import {
32
+ LbbError,
28
33
  entityRef,
34
+ isDerivationUploadChanged,
29
35
  triggeredWorkflow,
30
36
  type LbbActivities,
31
37
  type Schemas,
@@ -121,7 +127,7 @@ export interface ContentPorts {
121
127
  export interface SectionsState {
122
128
  /** Files read to their end. */
123
129
  contents: number;
124
- /** Files skipped: read before with these settings, another name or type, or no file. */
130
+ /** Files skipped: read before with these settings, another name or type, no file, or deleted while read. */
125
131
  skipped: number;
126
132
  sections: number;
127
133
  represents: number;
@@ -135,9 +141,11 @@ interface FileInfo {
135
141
  mediaType: string;
136
142
  }
137
143
 
138
- /** What the check of a file found: the file to read, or why it is skipped. */
144
+ /** What the check of a file found: the file to read and its upload, or why it is skipped. */
139
145
  interface Checked {
140
146
  file?: FileInfo;
147
+ /** The `content_uploaded_at` of the check: absent without a file record. */
148
+ uploadedAt?: string;
141
149
  skip?: string;
142
150
  }
143
151
 
@@ -148,6 +156,8 @@ interface Plan {
148
156
  name: string;
149
157
  count: number;
150
158
  represents: string | null;
159
+ /** The upload of the file the check named: the record names it. */
160
+ uploadedAt?: string;
151
161
  }
152
162
 
153
163
  /** Where a file's next turn starts, and the commits of its writes so far. */
@@ -174,6 +184,10 @@ const LABEL = "http://www.w3.org/2000/01/rdf-schema#label";
174
184
  /** The media type without parameters, in lower case. */
175
185
  const baseType = (mediaType: string) => mediaType.split(";")[0]!.trim().toLowerCase();
176
186
 
187
+ /** Whether an error is the 404 of a file's bytes: the file was deleted and not uploaded again. */
188
+ export const fileGone = (error: unknown) =>
189
+ error instanceof LbbError && error.status === 404 && error.code === "file_not_found";
190
+
177
191
  /** A file name without its last extension. */
178
192
  export const stem = (name: string) => name.replace(/\.[^./\\]*$/, "");
179
193
 
@@ -244,108 +258,133 @@ export function createWorkflow(
244
258
  delete plain.cursor;
245
259
  return ctx.continue(next, plain);
246
260
  };
247
- const document = message.instances[0];
248
- if (!document) return { state };
249
- let split: Promise<Section[]> | undefined;
250
- const sectionsOf = (sha256: string, name: string) =>
251
- (split ??= ports.files
252
- .content(sha256)
253
- .then((bytes) => markdownSections(new TextDecoder().decode(bytes), stem(name))));
254
-
255
- let cursor = message.cursor;
256
- if (!cursor) {
257
- const checked = await ctx.step("check", async (s): Promise<Checked> => {
258
- s.progress("checking the file", { done: 0, total: 1, unit: "documents" });
259
- const file = await readDocument(message, document);
260
- if (!file) return { skip: "no file" };
261
- if (!isMarkdown(file.mediaType, file.name)) return { skip: "media type" };
262
- if (match && !match.test(file.name)) return { skip: "name" };
263
- const found = await ports.workflows.derivations.check(
264
- derivationKey(file.sha256, settings),
265
- );
266
- return found.exists ? { skip: "derived" } : { file };
267
- });
268
- if (!checked.file) return rest({ ...state, skipped: state.skipped + 1 });
269
- const file = checked.file;
270
- const planned = await ctx.step("plan", async (s) => {
271
- s.progress("reading sections", { done: 0, unit: "sections" });
272
- const count = (await sectionsOf(file.sha256, file.name)).length;
273
- s.progress("reading sections", { done: count, total: count, unit: "sections" });
274
- const found = settings.represents ? await representsOf(file.name) : null;
275
- const plan: Plan = { ...file, count, represents: found?.iri ?? null };
276
- return { plan, label: found?.label ?? null };
277
- });
278
- const commits: number[] = [];
279
- const p = planned.plan;
280
- if (p.represents) {
281
- const written = await lbb.writeOwned(ctx, "represents", (s) => {
282
- s.progress("writing facts", { done: 0, total: 1, unit: "documents" });
283
- return {
284
- subjects: [entityRef(p.document, "Document", p.name)],
285
- relations: [REPRESENTS],
286
- triplets: [
287
- {
288
- source: entityRef(p.document, "Document", p.name),
289
- relation: REPRESENTS,
290
- target: entityRef(
291
- p.represents!,
292
- settings.represents!.class,
293
- planned.label ?? undefined,
294
- ),
295
- confidence: 1,
296
- evidence: { text: p.name, source_id: p.document },
297
- },
298
- ],
261
+ try {
262
+ const document = message.instances[0];
263
+ if (!document) return { state };
264
+ let split: Promise<Section[]> | undefined;
265
+ const sectionsOf = (sha256: string, name: string) =>
266
+ (split ??= ports.files
267
+ .content(sha256)
268
+ .then((bytes) => markdownSections(new TextDecoder().decode(bytes), stem(name))));
269
+
270
+ let cursor = message.cursor;
271
+ if (!cursor) {
272
+ const checked = await ctx.step("check", async (s): Promise<Checked> => {
273
+ s.progress("checking the file", { done: 0, total: 1, unit: "documents" });
274
+ const file = await readDocument(message, document);
275
+ if (!file) return { skip: "no file" };
276
+ if (!isMarkdown(file.mediaType, file.name)) return { skip: "media type" };
277
+ if (match && !match.test(file.name)) return { skip: "name" };
278
+ const found = await ports.workflows.derivations.check(
279
+ derivationKey(file.sha256, settings),
280
+ );
281
+ if (found.exists) return { skip: "derived" };
282
+ const uploadedAt = found.content_uploaded_at ?? undefined;
283
+ return uploadedAt ? { file, uploadedAt } : { file };
284
+ });
285
+ if (!checked.file) return rest({ ...state, skipped: state.skipped + 1 });
286
+ const file = checked.file;
287
+ const planned = await ctx.step("plan", async (s) => {
288
+ s.progress("reading sections", { done: 0, unit: "sections" });
289
+ const count = (await sectionsOf(file.sha256, file.name)).length;
290
+ s.progress("reading sections", { done: count, total: count, unit: "sections" });
291
+ const found = settings.represents ? await representsOf(file.name) : null;
292
+ const plan: Plan = {
293
+ ...file,
294
+ count,
295
+ represents: found?.iri ?? null,
296
+ ...(checked.uploadedAt ? { uploadedAt: checked.uploadedAt } : {}),
299
297
  };
298
+ return { plan, label: found?.label ?? null };
299
+ });
300
+ const commits: number[] = [];
301
+ const p = planned.plan;
302
+ if (p.represents) {
303
+ const written = await lbb.writeOwned(ctx, "represents", (s) => {
304
+ s.progress("writing facts", { done: 0, total: 1, unit: "documents" });
305
+ return {
306
+ subjects: [entityRef(p.document, "Document", p.name)],
307
+ relations: [REPRESENTS],
308
+ triplets: [
309
+ {
310
+ source: entityRef(p.document, "Document", p.name),
311
+ relation: REPRESENTS,
312
+ target: entityRef(
313
+ p.represents!,
314
+ settings.represents!.class,
315
+ planned.label ?? undefined,
316
+ ),
317
+ confidence: 1,
318
+ evidence: { text: p.name, source_id: p.document },
319
+ },
320
+ ],
321
+ };
322
+ });
323
+ if (written.commit_seq !== null) commits.push(written.commit_seq);
324
+ }
325
+ cursor = { next: 0, plan: p, commits };
326
+ }
327
+
328
+ // The sections of this turn, written in parts.
329
+ const { plan, next: from } = cursor;
330
+ const to = Math.min(plan.count, from + settings.sectionsPerTurn);
331
+ const commits = [...cursor.commits];
332
+ for (let n = 0; from + n * PART < to; n++) {
333
+ const written = await lbb.writeOwned(ctx, `write-${n}`, async (s) => {
334
+ s.progress("writing facts", { done: n * PART, total: to - from, unit: "sections" });
335
+ const all = await sectionsOf(plan.sha256, plan.name);
336
+ const start = from + n * PART;
337
+ return partWrite(plan, all, all.slice(start, Math.min(to, start + PART)));
300
338
  });
301
339
  if (written.commit_seq !== null) commits.push(written.commit_seq);
302
340
  }
303
- cursor = { next: 0, plan: p, commits };
304
- }
341
+ const counted = { ...state, sections: state.sections + (to - from) };
342
+ if (to < plan.count)
343
+ return ctx.continue(counted, {
344
+ ...message,
345
+ removed: [],
346
+ cursor: { next: to, plan, commits: commits.slice(0, 256) },
347
+ });
305
348
 
306
- // The sections of this turn, written in parts.
307
- const { plan, next: from } = cursor;
308
- const to = Math.min(plan.count, from + settings.sectionsPerTurn);
309
- const commits = [...cursor.commits];
310
- for (let n = 0; from + n * PART < to; n++) {
311
- const written = await lbb.writeOwned(ctx, `write-${n}`, async (s) => {
312
- s.progress("writing facts", { done: n * PART, total: to - from, unit: "sections" });
313
- const all = await sectionsOf(plan.sha256, plan.name);
314
- const start = from + n * PART;
315
- return partWrite(plan, all, all.slice(start, Math.min(to, start + PART)));
349
+ const recorded = await ctx.step("record", async (s) => {
350
+ s.progress("recording the derivation", { done: 0, total: 1, unit: "documents" });
351
+ const all = plan.count ? await sectionsOf(plan.sha256, plan.name) : [];
352
+ const levels: Record<string, number> = {};
353
+ for (const section of all) levels[section.level] = (levels[section.level] ?? 0) + 1;
354
+ try {
355
+ const answer = await ports.workflows.derivations.record({
356
+ ...derivationKey(plan.sha256, settings),
357
+ run_id: ctx.messageId,
358
+ outputs: {
359
+ sections: plan.count,
360
+ levels,
361
+ represents: plan.represents,
362
+ commits: commits.slice(0, 256),
363
+ },
364
+ ...(plan.uploadedAt ? { content_uploaded_at: plan.uploadedAt } : {}),
365
+ });
366
+ return answer.recorded;
367
+ } catch (error) {
368
+ // The file was deleted while this run read it: nothing is
369
+ // recorded, and the run of a new upload reads it again.
370
+ if (isDerivationUploadChanged(error)) return "upload changed";
371
+ throw error;
372
+ }
316
373
  });
317
- if (written.commit_seq !== null) commits.push(written.commit_seq);
318
- }
319
- const counted = { ...state, sections: state.sections + (to - from) };
320
- if (to < plan.count)
321
- return ctx.continue(counted, {
322
- ...message,
323
- removed: [],
324
- cursor: { next: to, plan, commits: commits.slice(0, 256) },
374
+ if (recorded === "upload changed")
375
+ return rest({ ...counted, skipped: counted.skipped + 1 });
376
+ return rest({
377
+ ...counted,
378
+ contents: counted.contents + 1,
379
+ represents: counted.represents + (plan.represents ? 1 : 0),
325
380
  });
326
-
327
- await ctx.step("record", async (s) => {
328
- s.progress("recording the derivation", { done: 0, total: 1, unit: "documents" });
329
- const all = plan.count ? await sectionsOf(plan.sha256, plan.name) : [];
330
- const levels: Record<string, number> = {};
331
- for (const section of all) levels[section.level] = (levels[section.level] ?? 0) + 1;
332
- const recorded = await ports.workflows.derivations.record({
333
- ...derivationKey(plan.sha256, settings),
334
- run_id: ctx.messageId,
335
- outputs: {
336
- sections: plan.count,
337
- levels,
338
- represents: plan.represents,
339
- commits: commits.slice(0, 256),
340
- },
341
- });
342
- return recorded.recorded;
343
- });
344
- return rest({
345
- ...counted,
346
- contents: counted.contents + 1,
347
- represents: counted.represents + (plan.represents ? 1 : 0),
348
- });
381
+ } catch (error) {
382
+ // The file was deleted while this run read it, and not uploaded
383
+ // again: its bytes answer 404. Nothing is recorded, and the file
384
+ // counts as skipped.
385
+ if (!fileGone(error)) throw error;
386
+ return rest({ ...state, skipped: state.skipped + 1 });
387
+ }
349
388
  },
350
389
  });
351
390
 
@@ -11,31 +11,73 @@ The LBB-run version is the starter workflow `values.extract@v1`
11
11
 
12
12
  ## How it runs
13
13
 
14
+ The template follows the rules of LBB's version
15
+ (`lbb-graph-query/src/infer_llm.rs`). Its eval runs both on the same
16
+ recorded model answers and finds the same calls and outputs
17
+ ([`../_evals/README.md`](../_evals/README.md)).
18
+
14
19
  The triggered workflow watches the class and its text field. For each
15
20
  instance of a trigger message, two steps:
16
21
 
17
- 1. **Values.** The reasoning model reads the text and returns
18
- `{values: [{property, value, quote}]}`; the schema allows only the
19
- settings' property names. The prompt is the LBB version's: the values of
20
- the instance's own item in its base version, not those of a part, an
21
- accessory, an alternative or a variant under a condition; an approximate
22
- value is stated. Then the checks without a model: a value takes its
23
- property's type (`typed`), its quote is in the text with at least three
24
- words or the value in it (`quoteHolds`), and one value per property.
22
+ 1. **Values.** The text is the field's values as LBB composes it
23
+ (`text: <values>`, each value's whitespace as single spaces). The
24
+ reasoning model reads it and returns `{values: [{property, value, unit,
25
+ quote}]}`; the schema allows only the settings' property names, in name
26
+ order, and a number property names its unit. A number comes as the text
27
+ states it, with its unit, and the model never converts it. The prompt is
28
+ the LBB version's: the values of the instance's own item in its base
29
+ version, not those of a part, an accessory, an alternative or a variant
30
+ under a condition; an approximate value is stated. Then the checks
31
+ without a model, in LBB's order (`check`): the property is one the
32
+ workflow fills (else `unknown_property`), the value is the first of its
33
+ property (else `duplicate`), it takes its property's type (else
34
+ `invalid_value`), and its quote is in the text with at least three words
35
+ or the value in it (else `quote_not_found`). A number of a property with
36
+ a unit must come in that unit, with the unit in its quote
37
+ (`unitProblem`); otherwise it goes to review at most, with LBB's reason.
25
38
  2. **Support.** The decision model scores how well each quote supports its
26
39
  value: `supported`, `weak` or `not_supported`. The support is the
27
40
  probability of `supported`.
28
41
 
29
- Then the gate: a value at or above `gate` is written, one from 0.5 to the
30
- gate becomes a review item with the value as its facts, one below 0.5 is
31
- dropped. One owned write per turn replaces the values of the turn's
32
- instances: a property the workflow does not write again loses its value and
33
- its source. An accepted review item's value is written with the others the
34
- next time its instance is read. An instance that leaves the class loses the
35
- values this workflow wrote.
36
-
37
- A turn reads at most 24 instances; the rest of a message goes to a new turn
38
- (`ctx.continue`).
42
+ Then the gate: a value at or above `gate` is `written`, one from 0.5 to the
43
+ gate goes to `review`, one below 0.5 or without an answer is
44
+ `weak_support` and is dropped. A value in review becomes a `value` review
45
+ item (`lbb.review({ kind: "value" })`) with the property's unit and the
46
+ quote, one per property of an instance, under LBB's key
47
+ `starter:<workflow>:value:<hash of instance and property>` (`valueItemKey`,
48
+ with `stableHashHex`). One owned write per turn replaces the values of the
49
+ turn's instances: a property the workflow does not write again loses its
50
+ value and its source. Each value's evidence is its quote, with the
51
+ instance's page box as its region when the instance has one (`page`, `x0`,
52
+ `y0`, `x1`, `y1`, as a `DocEntry` has).
53
+
54
+ Before it writes, the workflow reads the items (`lbb.reviewItems(keys)`): a
55
+ person's answer wins over the model, for the evidence it was given on
56
+ (`evidence_hash`: a hash of the value's quote and of the unit the text
57
+ states, `evidenceOf`). With the same evidence, the value in force, or the
58
+ value the person decided on, a value a person accepted, or corrected with
59
+ a number in the property's unit, is written as answered. New evidence that
60
+ reads another value asks the person again, and the answered value and its
61
+ source link stay (the instance is written on its own, without that
62
+ property) until a new answer is written. A value a person rejected is
63
+ not written again, nor is one the graph refused (`effects.commit_error`; the
64
+ item is open again for the person). An earlier version filed a value in
65
+ review as a suggestion, one per value (`legacyValueKey`): an accepted one is
66
+ written again, an open one stays the question, and a dismissed one is a
67
+ reject of its value. An instance that leaves the class keeps its values:
68
+ they go with its fields when it is retracted.
69
+
70
+ The write's request id is its step's effect id, which no other turn
71
+ repeats, so a value that changes back (A, then B, then A) is written again.
72
+ When a model call is refused, the instance goes back to the watch
73
+ (`lbb.requeue`, after 1, 4 and 16 minutes, at most 3 times) and keeps its
74
+ values; the turn completes and the shard goes on. A spent budget fails the
75
+ turn with the time to retry, and the worker waits without an attempt.
76
+
77
+ The state counts what LBB's version counts: `instances`,
78
+ `values_proposed`, one counter per outcome, `failed`, `commits` and
79
+ `removed`. A turn reads at most 24 instances; the rest of a message goes to
80
+ a new turn (`ctx.continue`).
39
81
 
40
82
  ## Settings
41
83
 
@@ -48,6 +90,7 @@ A turn reads at most 24 instances; the rest of a message goes to a new turn
48
90
  | `properties` | `width_cm`, `depth_cm` (`f64`), `material` (`text`), `water_supply` (`keyword`) | The properties to fill, with their types (`text`, `keyword`, `i64`, `f64`, `bool`, `date_time`) |
49
91
  | `context` | the samples' | One sentence about what the text is |
50
92
  | `gate` | 0.8 | The support a value needs to be written |
93
+ | `units` | `width_cm`, `depth_cm`: `cm` | The unit of each number property; `ontology()` declares it on the property |
51
94
 
52
95
  `trigger(settings)` names the triggered workflow `values-extract` and
53
96
  owns the class `ValueSource`, the relation `HAS_VALUE_SOURCE` and the
@@ -57,14 +100,22 @@ relation.
57
100
  ## What the LBB-run version adds
58
101
 
59
102
  - `params.where`: a SPARQL pattern that limits the instances it reads.
60
- - Texts built from several watched fields, as fit sources build them, and
61
- the fields chosen for you when the watch names none.
62
- - The instance's page box as the region of each value's evidence, for an
63
- instance that has one (a `DocEntry`).
103
+ - Texts built from several watched fields, and the fields chosen for you
104
+ when the watch names none.
64
105
  - A check at creation that each property is in the property registry with
65
106
  a type it can write.
66
107
  - Its own monthly budget, a preview with the values of up to 3 instances,
67
- and counters per outcome in the workflow's status.
108
+ and its counters in the workflow's status.
109
+
110
+ It differs in three places. It commits once per instance, the template once
111
+ per turn, so its `commits` counter is larger. When the reasoning model gives
112
+ no usable answer, it counts the instance `failed` and clears its values; the
113
+ template sends the instance back to the watch and keeps them. And it
114
+ converts a number the text states in another unit of the same kind (`600 mm`
115
+ for a property in `cm`) to the unit the property declares, and refuses a
116
+ unit it does not know or of another kind; the template sends such a number
117
+ to review. So the question of such a number differs: LBB's item asks the
118
+ converted value, the template's the value as stated.
68
119
 
69
120
  ## Samples and test
70
121
 
@@ -79,3 +130,21 @@ or a conditional variant (`Bei Einsatz`) does not support its value. The run
79
130
  writes the values of the answer key and none it leaves out. The test also
80
131
  checks the review path, the checks of type and quote, an instance that
81
132
  leaves the class, and a message larger than a turn.
133
+
134
+ ## Test on examples
135
+
136
+ `npm run test:examples` runs the workflow on instances of your graph with
137
+ the real models, and writes nothing (`test-examples.ts`). The activities go
138
+ through `captureActivities`: the server checks each owned write with the
139
+ commit's dry run, and keeps it. Each review item is kept too. The script
140
+ prints, for each input, the values the workflow would write and the values
141
+ it would give a person to review.
142
+
143
+ ```sh
144
+ LBB_URL=... LBB_API_KEY=... LBB_GRAPH=tender npm run test:examples # the first 6 instances
145
+ LBB_URL=... LBB_API_KEY=... LBB_GRAPH=tender npm run test:examples -- <iri> # chosen instances
146
+ ```
147
+
148
+ The model calls count in your environment's budget. For the LBB-run
149
+ version, test the workflow in the console (the workflow's Test tab) or with
150
+ `client.workflows.tests`.
@@ -12,6 +12,7 @@
12
12
  "material": "text",
13
13
  "water_supply": "keyword"
14
14
  },
15
+ "units": { "width_cm": "cm", "depth_cm": "cm" },
15
16
  "context": "Entries of the sanitary equipment list of a school building tender, in German."
16
17
  },
17
18
  "records": [
@@ -0,0 +1,155 @@
1
+ // Test the workflow on examples before it writes. `npm run test:examples`
2
+ // runs it with the real models on chosen instances of your graph, through
3
+ // `captureActivities`, which writes nothing: each owned write is checked
4
+ // with the commit's dry run and kept, and each review item is kept. Then it
5
+ // prints, per input, the values the workflow would write and the values it
6
+ // would give a person to review.
7
+ //
8
+ // LBB_URL=... LBB_API_KEY=... LBB_GRAPH=... npm run test:examples -- [<iri> …]
9
+ //
10
+ // Without IRIs it tests the first 6 instances of the class. The model calls
11
+ // count in your environment's budget.
12
+ import { pathToFileURL } from "node:url";
13
+ import {
14
+ LbbClient,
15
+ captureActivities,
16
+ entityIdFromIri,
17
+ lbbActivities,
18
+ type ActivityCaptureResult,
19
+ type LbbActivities,
20
+ type WorkflowTriggerMessage,
21
+ } from "@littlebigbrain/client";
22
+ import { runMessage } from "../_shared/testing.js";
23
+ import { createWorkflow, defaults, type Settings } from "./workflow.js";
24
+
25
+ /** A value the workflow would write. */
26
+ export interface ExampleValue {
27
+ property: string;
28
+ value: string;
29
+ quote?: string;
30
+ support?: number;
31
+ }
32
+
33
+ /** What the workflow would do with one input. */
34
+ export interface ExampleResult {
35
+ iri: string;
36
+ /** Values at or above the gate, which it writes. */
37
+ written: ExampleValue[];
38
+ /** Values it gives a person to review: each item's title. */
39
+ review: string[];
40
+ /** Why the server's dry run (or the local check) refused a write. */
41
+ errors: string[];
42
+ }
43
+
44
+ /** The first `limit` instances of the class, and the commit they were read at. */
45
+ export async function firstInstances(
46
+ client: LbbClient,
47
+ settings: Settings = defaults,
48
+ limit = 6,
49
+ ): Promise<{ iris: string[]; seq: number }> {
50
+ const read = await client.sparqlRows({
51
+ query: `SELECT ?s WHERE { ?s a <https://littlebigbrain.com/class/${encodeURIComponent(settings.className.toLowerCase())}> } LIMIT ${limit}`,
52
+ });
53
+ return {
54
+ iris: read.rows.map((row) => row.s!).filter(Boolean),
55
+ seq: read.snapshot?.served_at_seq ?? 0,
56
+ };
57
+ }
58
+
59
+ /** Per input, what the captured writes and review items say. */
60
+ export function resultsOf(iris: string[], captured: ActivityCaptureResult): ExampleResult[] {
61
+ return iris.map((iri) => {
62
+ const id = entityIdFromIri(iri);
63
+ const mine = (key: string | null | undefined) => key?.startsWith(`value:${id}:`) ?? false;
64
+ const written: ExampleValue[] = [];
65
+ const errors: string[] = [];
66
+ for (const write of captured.writes) {
67
+ const about = (write.body.triplets ?? []).filter(
68
+ (edge) => edge.relation === "HAS_VALUE_SOURCE" && mine(edge.target.key),
69
+ );
70
+ if (about.length) errors.push(...write.errors);
71
+ for (const edge of about) {
72
+ const source = (write.body.entity_properties ?? []).find(
73
+ (entity) => entity.type === "ValueSource" && entity.key === edge.target.key,
74
+ );
75
+ const properties = (source?.properties ?? {}) as Record<string, unknown>;
76
+ const quote = typeof edge.evidence === "object" ? edge.evidence?.text : undefined;
77
+ written.push({
78
+ property: String(properties.property ?? edge.target.key?.split(":")[2] ?? ""),
79
+ value: String(properties.value ?? ""),
80
+ ...(quote ? { quote } : {}),
81
+ ...(edge.confidence != null ? { support: edge.confidence } : {}),
82
+ });
83
+ }
84
+ }
85
+ const review = captured.reviews
86
+ .filter((item) => item.request.key?.includes(`:value:${id}:`))
87
+ .map((item) => item.request.title);
88
+ return { iri, written, review, errors };
89
+ });
90
+ }
91
+
92
+ /**
93
+ * Run the workflow on `iris` with `activities` captured
94
+ * (`captureActivities`): the models run, and nothing is written. `seq` is
95
+ * the commit the workflow reads the texts at.
96
+ */
97
+ export async function testExamples(
98
+ activities: LbbActivities,
99
+ iris: string[],
100
+ options: { settings?: Settings; seq?: number; graph?: string } = {},
101
+ ): Promise<ExampleResult[]> {
102
+ const settings = options.settings ?? defaults;
103
+ const capture = captureActivities(activities, { workflow: "values-extract" });
104
+ const message: WorkflowTriggerMessage = {
105
+ type: "instances",
106
+ workflow: "values-extract-test",
107
+ graph: options.graph ?? "main",
108
+ as_of_commit_seq: options.seq ?? 0,
109
+ reason: "refresh",
110
+ instances: iris,
111
+ removed: [],
112
+ };
113
+ await runMessage(createWorkflow(capture.activities, settings), message);
114
+ return resultsOf(iris, capture.result());
115
+ }
116
+
117
+ /** The results as lines of text. */
118
+ export function report(results: ExampleResult[]): string[] {
119
+ const lines: string[] = [];
120
+ for (const result of results) {
121
+ lines.push(result.iri);
122
+ for (const value of result.written)
123
+ lines.push(
124
+ ` write ${value.property} = ${value.value}${value.support != null ? ` (support ${value.support.toFixed(2)})` : ""}${value.quote ? ` "${value.quote}"` : ""}`,
125
+ );
126
+ for (const title of result.review) lines.push(` review ${title}`);
127
+ if (!result.written.length && !result.review.length) lines.push(" no value");
128
+ for (const error of result.errors) lines.push(` refused ${error}`);
129
+ }
130
+ return lines;
131
+ }
132
+
133
+ async function main() {
134
+ const graph = process.env.LBB_GRAPH;
135
+ if (!graph) throw new Error("set LBB_GRAPH to the graph to test on");
136
+ const client = new LbbClient({
137
+ baseUrl: process.env.LBB_URL ?? "http://127.0.0.1:7400",
138
+ apiKey: process.env.LBB_API_KEY,
139
+ graph,
140
+ });
141
+ const named = process.argv.slice(2).filter((arg) => !arg.startsWith("-"));
142
+ const first = await firstInstances(client);
143
+ const iris = named.length ? named : first.iris;
144
+ if (!iris.length) throw new Error(`the graph ${graph} has no ${defaults.className} to test`);
145
+ const results = await testExamples(lbbActivities(client), iris, { seq: first.seq, graph });
146
+ for (const line of report(results)) console.log(line);
147
+ console.log("Nothing was written.");
148
+ }
149
+
150
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
151
+ main().catch((error: unknown) => {
152
+ console.error(error instanceof Error ? error.message : error);
153
+ process.exitCode = 1;
154
+ });
155
+ }