@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
@@ -18,7 +18,11 @@
18
18
  // header is row 1);
19
19
  // 4. with `represents`, writes `Document REPRESENTS <instance>` when the
20
20
  // file's name names exactly one instance of that class;
21
- // 5. records the derivation.
21
+ // 5. records the derivation, for the upload its check named. When the file
22
+ // was deleted (or deleted and uploaded again) since, nothing is recorded,
23
+ // and the run of the new upload reads the file again. A file deleted and
24
+ // not uploaded again answers 404 to the next read of its bytes: the run
25
+ // ends there and counts the file as skipped.
22
26
  //
23
27
  // A turn reads at most `rowsPerTurn` rows; the rest goes to a new turn
24
28
  // (`ctx.continue`) that starts where the last one stopped. The file is read
@@ -29,6 +33,7 @@ import { createHash } from "node:crypto";
29
33
  import {
30
34
  LbbError,
31
35
  entityRef,
36
+ isDerivationUploadChanged,
32
37
  triggeredWorkflow,
33
38
  type LbbActivities,
34
39
  type Schemas,
@@ -138,7 +143,7 @@ export interface ContentPorts {
138
143
  export interface RowsState {
139
144
  /** Files read to their end. */
140
145
  contents: number;
141
- /** Files skipped: read before with these settings, another name, or no file. */
146
+ /** Files skipped: read before with these settings, another name, no file, or deleted while read. */
142
147
  skipped: number;
143
148
  rows: number;
144
149
  instances: number;
@@ -165,6 +170,8 @@ interface Plan {
165
170
  key: number | null;
166
171
  label: number | null;
167
172
  represents: string | null;
173
+ /** The upload of the file the check named: the record names it. */
174
+ uploadedAt?: string;
168
175
  }
169
176
 
170
177
  /** Where a file's next turn starts, and what its turns did so far. */
@@ -196,9 +203,11 @@ interface FileInfo {
196
203
  mediaType: string;
197
204
  }
198
205
 
199
- /** What the check of a file found: the file to read, or why it is skipped. */
206
+ /** What the check of a file found: the file to read and its upload, or why it is skipped. */
200
207
  interface Checked {
201
208
  file?: FileInfo;
209
+ /** The `content_uploaded_at` of the check: absent without a file record. */
210
+ uploadedAt?: string;
202
211
  skip?: string;
203
212
  }
204
213
 
@@ -227,6 +236,10 @@ const SCALAR: Record<string, ValueType> = {
227
236
  /** The media type without parameters, in lower case. */
228
237
  const baseType = (mediaType: string) => mediaType.split(";")[0]!.trim().toLowerCase();
229
238
 
239
+ /** Whether an error is the 404 of a file's bytes: the file was deleted and not uploaded again. */
240
+ export const fileGone = (error: unknown) =>
241
+ error instanceof LbbError && error.status === 404 && error.code === "file_not_found";
242
+
230
243
  /** A file name without its last extension. */
231
244
  export const stem = (name: string) => name.replace(/\.[^./\\]*$/, "");
232
245
 
@@ -381,158 +394,178 @@ export function createWorkflow(
381
394
  delete plain.cursor;
382
395
  return ctx.continue(next, plain);
383
396
  };
384
- const document = message.instances[0];
385
- if (!document) return { state };
386
- // A cursor without a byte offset (a message of an earlier version of
387
- // this template) reads the file again from its start.
388
- let cursor = typeof message.cursor?.byte === "number" ? message.cursor : undefined;
389
- // The bytes the header read holds after the header, for the rows of
390
- // the same turn: they are not read again.
391
- let held: HeldBytes | undefined;
392
-
393
- if (!cursor) {
394
- const checked = await ctx.step("check", async (s): Promise<Checked> => {
395
- s.progress("checking the file", { done: 0, total: 1, unit: "documents" });
396
- const file = await readDocument(message, document);
397
- if (!file) return { skip: "no file" };
398
- const named = /\.(csv|tsv)$/i.test(file.name);
399
- if (!MEDIA_TYPES.includes(file.mediaType) && !named) return { skip: "media type" };
400
- if (match && !match.test(file.name)) return { skip: "name" };
401
- const found = await ports.workflows.derivations.check(
402
- derivationKey(file.sha256, settings),
403
- );
404
- return found.exists ? { skip: "derived" } : { file };
405
- });
406
- if (!checked.file) {
407
- const next = { ...state, skipped: state.skipped + 1 };
408
- return rest(next);
397
+ try {
398
+ const document = message.instances[0];
399
+ if (!document) return { state };
400
+ // A cursor without a byte offset (a message of an earlier version of
401
+ // this template) reads the file again from its start.
402
+ let cursor = typeof message.cursor?.byte === "number" ? message.cursor : undefined;
403
+ // The bytes the header read holds after the header, for the rows of
404
+ // the same turn: they are not read again.
405
+ let held: HeldBytes | undefined;
406
+
407
+ if (!cursor) {
408
+ const checked = await ctx.step("check", async (s): Promise<Checked> => {
409
+ s.progress("checking the file", { done: 0, total: 1, unit: "documents" });
410
+ const file = await readDocument(message, document);
411
+ if (!file) return { skip: "no file" };
412
+ const named = /\.(csv|tsv)$/i.test(file.name);
413
+ if (!MEDIA_TYPES.includes(file.mediaType) && !named) return { skip: "media type" };
414
+ if (match && !match.test(file.name)) return { skip: "name" };
415
+ const found = await ports.workflows.derivations.check(
416
+ derivationKey(file.sha256, settings),
417
+ );
418
+ if (found.exists) return { skip: "derived" };
419
+ const uploadedAt = found.content_uploaded_at ?? undefined;
420
+ return uploadedAt ? { file, uploadedAt } : { file };
421
+ });
422
+ if (!checked.file) {
423
+ const next = { ...state, skipped: state.skipped + 1 };
424
+ return rest(next);
425
+ }
426
+ const file = checked.file;
427
+ const planned = await ctx.step("plan", async (s) => {
428
+ s.progress("reading the header", { done: 0, total: 1, unit: "documents" });
429
+ const delimiter = delimiterOf(file.mediaType, file.name);
430
+ const header = await readRecords(rangeOf(file.sha256), delimiter, 0, 1, 1, windows);
431
+ held = header.held;
432
+ return plan(file, delimiter, header, checked.uploadedAt);
433
+ });
434
+ const first: number[] = [];
435
+ if (planned.plan.represents) {
436
+ const written = await lbb.writeOwned(ctx, "represents", (s) => {
437
+ s.progress("writing facts", { done: 0, total: 1, unit: "documents" });
438
+ const p = planned.plan;
439
+ return {
440
+ subjects: [entityRef(p.document, "Document", p.name)],
441
+ relations: [REPRESENTS],
442
+ triplets: [
443
+ {
444
+ source: entityRef(p.document, "Document", p.name),
445
+ relation: REPRESENTS,
446
+ target: entityRef(
447
+ p.represents!,
448
+ settings.represents!.class,
449
+ planned.label ?? undefined,
450
+ ),
451
+ confidence: 1,
452
+ evidence: { text: p.name, source_id: p.document },
453
+ },
454
+ ],
455
+ };
456
+ });
457
+ if (written.commit_seq !== null) first.push(written.commit_seq);
458
+ }
459
+ cursor = {
460
+ byte: planned.at,
461
+ row: 2,
462
+ plan: planned.plan,
463
+ done: { rows: 0, instances: 0, keyless: 0, rejected: 0, commits: first },
464
+ };
409
465
  }
410
- const file = checked.file;
411
- const planned = await ctx.step("plan", async (s) => {
412
- s.progress("reading the header", { done: 0, total: 1, unit: "documents" });
413
- const delimiter = delimiterOf(file.mediaType, file.name);
414
- const header = await readRecords(rangeOf(file.sha256), delimiter, 0, 1, 1, windows);
415
- held = header.held;
416
- return plan(file, delimiter, header);
466
+
467
+ // The rows of this turn, read from the byte offset where the last turn
468
+ // stopped, then their writes in parts. A turn run again after a crash
469
+ // reads its rows again from the same offset.
470
+ const from = cursor;
471
+ const filePlan = from.plan;
472
+ let rows: Promise<RecordsRead> | undefined;
473
+ const turnRows = () =>
474
+ (rows ??= readRecords(
475
+ rangeOf(filePlan.sha256),
476
+ filePlan.delimiter,
477
+ from.byte,
478
+ from.row,
479
+ settings.rowsPerTurn,
480
+ windows,
481
+ held,
482
+ ));
483
+ const windowRecords = async (): Promise<CsvRecord[]> => (await turnRows()).records;
484
+ const window = await ctx.step("rows", async (s) => {
485
+ s.progress("reading rows", { done: 0, unit: "rows" });
486
+ const read = await turnRows();
487
+ const taken = read.records;
488
+ s.progress("reading rows", { done: taken.length, unit: "rows" });
489
+ const counts = { instances: 0, keyless: 0, rejected: 0 };
490
+ for (const record of taken) {
491
+ const built = rowFacts(filePlan, record);
492
+ if (built === "blank") continue;
493
+ if (built === "keyless") counts.keyless += 1;
494
+ else {
495
+ counts.instances += 1;
496
+ counts.rejected += built.rejected;
497
+ }
498
+ }
499
+ return { count: taken.length, byte: read.next, row: read.row, end: read.end, ...counts };
417
500
  });
418
- const first: number[] = [];
419
- if (planned.plan.represents) {
420
- const written = await lbb.writeOwned(ctx, "represents", (s) => {
421
- s.progress("writing facts", { done: 0, total: 1, unit: "documents" });
422
- const p = planned.plan;
423
- return {
424
- subjects: [entityRef(p.document, "Document", p.name)],
425
- relations: [REPRESENTS],
426
- triplets: [
427
- {
428
- source: entityRef(p.document, "Document", p.name),
429
- relation: REPRESENTS,
430
- target: entityRef(
431
- p.represents!,
432
- settings.represents!.class,
433
- planned.label ?? undefined,
434
- ),
435
- confidence: 1,
436
- evidence: { text: p.name, source_id: p.document },
437
- },
438
- ],
439
- };
501
+ const commits: number[] = [];
502
+ for (let n = 0; n * PART < window.count; n++) {
503
+ const written = await lbb.writeOwned(ctx, `write-${n}`, async (s) => {
504
+ s.progress("writing facts", { done: n * PART, total: window.count, unit: "rows" });
505
+ const part = (await windowRecords()).slice(n * PART, (n + 1) * PART);
506
+ return partWrite(filePlan, part);
440
507
  });
441
- if (written.commit_seq !== null) first.push(written.commit_seq);
508
+ if (written.commit_seq !== null) commits.push(written.commit_seq);
442
509
  }
443
- cursor = {
444
- byte: planned.at,
445
- row: 2,
446
- plan: planned.plan,
447
- done: { rows: 0, instances: 0, keyless: 0, rejected: 0, commits: first },
510
+ const done: Done = {
511
+ rows: from.done.rows + window.count,
512
+ instances: from.done.instances + window.instances,
513
+ keyless: from.done.keyless + window.keyless,
514
+ rejected: from.done.rejected + window.rejected,
515
+ commits: [...from.done.commits, ...commits].slice(0, 256),
448
516
  };
449
- }
517
+ const counted = {
518
+ ...state,
519
+ rows: state.rows + window.count,
520
+ instances: state.instances + window.instances,
521
+ };
522
+ if (!window.end)
523
+ return ctx.continue(counted, {
524
+ ...message,
525
+ removed: [],
526
+ cursor: { byte: window.byte, row: window.row, plan: filePlan, done },
527
+ });
450
528
 
451
- // The rows of this turn, read from the byte offset where the last turn
452
- // stopped, then their writes in parts. A turn run again after a crash
453
- // reads its rows again from the same offset.
454
- const from = cursor;
455
- const filePlan = from.plan;
456
- let rows: Promise<RecordsRead> | undefined;
457
- const turnRows = () =>
458
- (rows ??= readRecords(
459
- rangeOf(filePlan.sha256),
460
- filePlan.delimiter,
461
- from.byte,
462
- from.row,
463
- settings.rowsPerTurn,
464
- windows,
465
- held,
466
- ));
467
- const windowRecords = async (): Promise<CsvRecord[]> => (await turnRows()).records;
468
- const window = await ctx.step("rows", async (s) => {
469
- s.progress("reading rows", { done: 0, unit: "rows" });
470
- const read = await turnRows();
471
- const taken = read.records;
472
- s.progress("reading rows", { done: taken.length, unit: "rows" });
473
- const counts = { instances: 0, keyless: 0, rejected: 0 };
474
- for (const record of taken) {
475
- const built = rowFacts(filePlan, record);
476
- if (built === "blank") continue;
477
- if (built === "keyless") counts.keyless += 1;
478
- else {
479
- counts.instances += 1;
480
- counts.rejected += built.rejected;
529
+ const recorded = await ctx.step("record", async (s) => {
530
+ s.progress("recording the derivation", { done: 0, total: 1, unit: "documents" });
531
+ try {
532
+ const answer = await ports.workflows.derivations.record({
533
+ ...derivationKey(filePlan.sha256, settings),
534
+ run_id: ctx.messageId,
535
+ outputs: {
536
+ rows: done.rows,
537
+ instances: done.instances,
538
+ rows_without_key: done.keyless,
539
+ values_left_out: done.rejected,
540
+ columns: filePlan.columns.slice(0, 100).map((c) => `${c.header} → ${c.property}`),
541
+ columns_left_out: filePlan.left,
542
+ represents: filePlan.represents,
543
+ commits: done.commits,
544
+ },
545
+ ...(filePlan.uploadedAt ? { content_uploaded_at: filePlan.uploadedAt } : {}),
546
+ });
547
+ return answer.recorded;
548
+ } catch (error) {
549
+ // The file was deleted while this run read it: nothing is
550
+ // recorded, and the run of a new upload reads it again.
551
+ if (isDerivationUploadChanged(error)) return "upload changed";
552
+ throw error;
481
553
  }
482
- }
483
- return { count: taken.length, byte: read.next, row: read.row, end: read.end, ...counts };
484
- });
485
- const commits: number[] = [];
486
- for (let n = 0; n * PART < window.count; n++) {
487
- const written = await lbb.writeOwned(ctx, `write-${n}`, async (s) => {
488
- s.progress("writing facts", { done: n * PART, total: window.count, unit: "rows" });
489
- const part = (await windowRecords()).slice(n * PART, (n + 1) * PART);
490
- return partWrite(filePlan, part);
491
- });
492
- if (written.commit_seq !== null) commits.push(written.commit_seq);
493
- }
494
- const done: Done = {
495
- rows: from.done.rows + window.count,
496
- instances: from.done.instances + window.instances,
497
- keyless: from.done.keyless + window.keyless,
498
- rejected: from.done.rejected + window.rejected,
499
- commits: [...from.done.commits, ...commits].slice(0, 256),
500
- };
501
- const counted = {
502
- ...state,
503
- rows: state.rows + window.count,
504
- instances: state.instances + window.instances,
505
- };
506
- if (!window.end)
507
- return ctx.continue(counted, {
508
- ...message,
509
- removed: [],
510
- cursor: { byte: window.byte, row: window.row, plan: filePlan, done },
511
554
  });
512
-
513
- await ctx.step("record", async (s) => {
514
- s.progress("recording the derivation", { done: 0, total: 1, unit: "documents" });
515
- const recorded = await ports.workflows.derivations.record({
516
- ...derivationKey(filePlan.sha256, settings),
517
- run_id: ctx.messageId,
518
- outputs: {
519
- rows: done.rows,
520
- instances: done.instances,
521
- rows_without_key: done.keyless,
522
- values_left_out: done.rejected,
523
- columns: filePlan.columns.slice(0, 100).map((c) => `${c.header} → ${c.property}`),
524
- columns_left_out: filePlan.left,
525
- represents: filePlan.represents,
526
- commits: done.commits,
527
- },
555
+ if (recorded === "upload changed")
556
+ return rest({ ...counted, skipped: counted.skipped + 1 });
557
+ return rest({
558
+ ...counted,
559
+ contents: counted.contents + 1,
560
+ represents: counted.represents + (filePlan.represents ? 1 : 0),
528
561
  });
529
- return recorded.recorded;
530
- });
531
- return rest({
532
- ...counted,
533
- contents: counted.contents + 1,
534
- represents: counted.represents + (filePlan.represents ? 1 : 0),
535
- });
562
+ } catch (error) {
563
+ // The file was deleted while this run read it, and not uploaded
564
+ // again: its bytes answer 404. Nothing is recorded, and the file
565
+ // counts as skipped.
566
+ if (!fileGone(error)) throw error;
567
+ return rest({ ...state, skipped: state.skipped + 1 });
568
+ }
536
569
  },
537
570
  });
538
571
 
@@ -557,8 +590,8 @@ export function createWorkflow(
557
590
  };
558
591
  }
559
592
 
560
- /** The plan of a file: its delimiter, the mapped columns, the key and label columns, and what it represents. */
561
- async function plan(file: FileInfo, delimiter: string, read: RecordsRead) {
593
+ /** The plan of a file: its delimiter, the mapped columns, the key and label columns, what it represents, and its upload. */
594
+ async function plan(file: FileInfo, delimiter: string, read: RecordsRead, uploadedAt?: string) {
562
595
  const first = read.records[0];
563
596
  const header = first ? first.fields.map((h) => h.trim()) : [];
564
597
  const registry = header.length ? ((await lbb.ontology()).property_defs ?? []) : [];
@@ -580,6 +613,7 @@ export function createWorkflow(
580
613
  key: key >= 0 ? key : null,
581
614
  label,
582
615
  represents: found?.iri ?? null,
616
+ ...(uploadedAt ? { uploadedAt } : {}),
583
617
  } satisfies Plan,
584
618
  };
585
619
  }
@@ -54,7 +54,14 @@ The text before the first heading, when it is not blank, is the section at
54
54
  position 0, of level 0, titled with the file's name without its extension.
55
55
  A section's lines run from its heading to its last line that is not
56
56
  blank. The last turn records the derivation: the sections, their count per
57
- level, the instance the file represents, and the commits of the writes.
57
+ level, the instance the file represents, and the commits of the writes. The record names the upload the check
58
+ answered (`content_uploaded_at`). The cursor carries it from turn to turn.
59
+ When the file was deleted while the run read it, the record writes nothing
60
+ (`409 derivation_upload_changed`). The run ends and counts the file as
61
+ skipped. The run of a new upload reads the file again. Say the file was
62
+ deleted and not uploaded again. The next read of its bytes answers
63
+ `404 file_not_found`. The run ends there too, and counts the file as
64
+ skipped.
58
65
 
59
66
  ## Settings
60
67
 
@@ -12,6 +12,7 @@ import {
12
12
  phases,
13
13
  readJson,
14
14
  runMessage,
15
+ runTurn,
15
16
  samplesDir,
16
17
  type MemoryGraph,
17
18
  type MockFile,
@@ -74,7 +75,7 @@ function setup(settings: Settings = WITH_GUIDES, guides = GUIDES.map((g) => g.na
74
75
  const definition = createWorkflow(lbb, content, settings);
75
76
  const run = (file: MockFile, state: unknown = {}) =>
76
77
  runMessage<SectionsState>(definition, contentMessage("sections", file), state);
77
- return { lbb, content, run };
78
+ return { lbb, content, run, definition };
78
79
  }
79
80
 
80
81
  /** The written sections by position. */
@@ -207,6 +208,55 @@ test("a content with a derivation for these settings is skipped, under any name"
207
208
  assert.deepEqual(again.turns[0]!.keys, ["check"]);
208
209
  });
209
210
 
211
+ test("a file deleted and uploaded again while it is read records nothing; the new upload's run reads it", async () => {
212
+ const { lbb, content, definition } = setup({ ...WITH_GUIDES, sectionsPerTurn: 3 });
213
+ const file = content.upload("support-handbook.md", HANDBOOK, "text/markdown");
214
+ const first = content.uploadedAt(file.sha256);
215
+
216
+ // Turn 1 checks the file; the turns after it carry the upload the check named.
217
+ const one = await runTurn<SectionsState>(definition, {}, contentMessage("sections", file));
218
+ type Continued = { cursor: { plan: { uploadedAt?: string } } };
219
+ const carried = one.result.continuation!.message as unknown as Continued;
220
+ assert.equal(carried.cursor.plan.uploadedAt, first);
221
+
222
+ // The file is deleted and uploaded again; the old run writes on to its end.
223
+ content.remove(file.sha256);
224
+ const again = content.upload("support-handbook.md", HANDBOOK, "text/markdown");
225
+ const old = await runMessage<SectionsState>(
226
+ definition,
227
+ one.result.continuation!.message,
228
+ one.result.state,
229
+ );
230
+ assert.deepEqual(old.state, { contents: 0, skipped: 1, sections: 8, represents: 0 });
231
+ assert.equal(content.records.at(-1)?.content_uploaded_at, first);
232
+ assert.equal(content.recorded.size, 0, "nothing is recorded for the deleted upload");
233
+
234
+ // The run of the new upload checks, writes the sections and records.
235
+ const fresh = await runMessage<SectionsState>(definition, contentMessage("sections", again));
236
+ assert.deepEqual(fresh.state, { contents: 1, skipped: 0, sections: 8, represents: 1 });
237
+ assert.equal(content.records.at(-1)?.content_uploaded_at, content.uploadedAt(again.sha256));
238
+ assert.equal(content.recorded.size, 1);
239
+ assert.equal(written(lbb.graph).length, EXPECTED.sections.length);
240
+ });
241
+
242
+ test("a file deleted while it is read, and not uploaded again, ends the run as skipped", async () => {
243
+ const { content, definition } = setup({ ...WITH_GUIDES, sectionsPerTurn: 3 });
244
+ const file = content.upload("support-handbook.md", HANDBOOK, "text/markdown");
245
+ const one = await runTurn<SectionsState>(definition, {}, contentMessage("sections", file));
246
+ assert.equal(one.result.state.sections, 3);
247
+
248
+ // The next read of the bytes answers 404: the run ends without an error.
249
+ content.remove(file.sha256);
250
+ const old = await runMessage<SectionsState>(
251
+ definition,
252
+ one.result.continuation!.message,
253
+ one.result.state,
254
+ );
255
+ assert.equal(old.turns.length, 1, "the run ends at the turn that read nothing");
256
+ assert.deepEqual(old.state, { contents: 0, skipped: 1, sections: 3, represents: 0 });
257
+ assert.equal(content.records.length, 0, "no derivation is recorded");
258
+ });
259
+
210
260
  test("REPRESENTS needs exactly one instance whose label is the file's name", async () => {
211
261
  const counts: number[] = [];
212
262
  for (const guides of [