@littlebigbrain/create 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -2
- package/lib/create.mjs +28 -4
- package/package.json +1 -1
- package/templates/_shared/testing.ts +185 -5
- package/templates/link/README.md +203 -0
- package/templates/link/candidates.ts +432 -0
- package/templates/link/decide.test.ts +368 -0
- package/templates/link/decide.ts +340 -0
- package/templates/link/fixture.ts +727 -0
- package/templates/link/grade-run.ts +53 -0
- package/templates/link/grade.test.ts +113 -0
- package/templates/link/grade.ts +335 -0
- package/templates/link/items.ts +333 -0
- package/templates/link/names.test.ts +260 -0
- package/templates/link/names.ts +590 -0
- package/templates/link/reads.ts +302 -0
- package/templates/link/samples/gold.json +108 -0
- package/templates/link/samples/records.json +48 -0
- package/templates/link/samples.ts +64 -0
- package/templates/link/settings.ts +162 -0
- package/templates/link/setup.ts +18 -0
- package/templates/link/source-turn.ts +894 -0
- package/templates/link/sources/records.ts +429 -0
- package/templates/link/things-turn.ts +984 -0
- package/templates/link/things.test.ts +204 -0
- package/templates/link/things.ts +426 -0
- package/templates/link/try.ts +28 -0
- package/templates/link/worker.ts +24 -0
- package/templates/link/workflow.test.ts +484 -0
- package/templates/link/workflow.ts +202 -0
- package/templates/rows/README.md +150 -0
- package/templates/rows/csv.ts +260 -0
- package/templates/rows/samples/expected.json +150 -0
- package/templates/rows/samples/properties.json +11 -0
- package/templates/rows/samples/q3-customers.csv +10 -0
- package/templates/rows/samples/q3-orders.tsv +5 -0
- package/templates/rows/samples/reports.json +5 -0
- package/templates/rows/samples.ts +56 -0
- package/templates/rows/setup.ts +18 -0
- package/templates/rows/try.ts +32 -0
- package/templates/rows/worker.ts +24 -0
- package/templates/rows/workflow.test.ts +541 -0
- package/templates/rows/workflow.ts +657 -0
- package/templates/sections/README.md +118 -0
- package/templates/sections/markdown.ts +121 -0
- package/templates/sections/samples/expected.json +82 -0
- package/templates/sections/samples/guides.json +4 -0
- package/templates/sections/samples/support-handbook.md +34 -0
- package/templates/sections/samples.ts +41 -0
- package/templates/sections/setup.ts +18 -0
- package/templates/sections/try.ts +32 -0
- package/templates/sections/worker.ts +24 -0
- package/templates/sections/workflow.test.ts +265 -0
- package/templates/sections/workflow.ts +433 -0
- package/templates/versions.json +1 -1
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
// rows: one instance per row of a CSV or TSV file, each with an edge to the
|
|
2
|
+
// file whose evidence is the row.
|
|
3
|
+
//
|
|
4
|
+
// A named workflow with a content trigger sends each upload of `text/csv` or
|
|
5
|
+
// `text/tab-separated-values` here within seconds, one file per message,
|
|
6
|
+
// without a wait for a publication. For each file the workflow:
|
|
7
|
+
//
|
|
8
|
+
// 1. reads the file's name and checks its derivation: a file this version
|
|
9
|
+
// already read with these settings is skipped, so identical bytes under
|
|
10
|
+
// two names are read once;
|
|
11
|
+
// 2. reads the header row and maps its columns to properties of the graph
|
|
12
|
+
// (the settings' `columns`, else each column whose header matches a
|
|
13
|
+
// property by its normalized name);
|
|
14
|
+
// 3. writes one instance of the settings' class per data row: the mapped
|
|
15
|
+
// values, its key (the key column, else `<sha256>:<row>`) and an edge
|
|
16
|
+
// `READ_FROM` to the file's `Document` whose evidence is the row's text,
|
|
17
|
+
// with the source `<Document IRI>#row=<n>` (RFC 7111: rows from 1, the
|
|
18
|
+
// header is row 1);
|
|
19
|
+
// 4. with `represents`, writes `Document REPRESENTS <instance>` when the
|
|
20
|
+
// file's name names exactly one instance of that class;
|
|
21
|
+
// 5. records the derivation.
|
|
22
|
+
//
|
|
23
|
+
// A turn reads at most `rowsPerTurn` rows; the rest goes to a new turn
|
|
24
|
+
// (`ctx.continue`) that starts where the last one stopped. The file is read
|
|
25
|
+
// by byte range, a window of at most 4 MiB per read: the first turn reads
|
|
26
|
+
// the header, and each turn reads on from the byte offset where the last
|
|
27
|
+
// one stopped, so a large file is read about once in all.
|
|
28
|
+
import { createHash } from "node:crypto";
|
|
29
|
+
import {
|
|
30
|
+
LbbError,
|
|
31
|
+
entityRef,
|
|
32
|
+
triggeredWorkflow,
|
|
33
|
+
type LbbActivities,
|
|
34
|
+
type Schemas,
|
|
35
|
+
type WorkflowTriggerMessage,
|
|
36
|
+
} from "@littlebigbrain/client";
|
|
37
|
+
import {
|
|
38
|
+
MAX_RECORD_BYTES,
|
|
39
|
+
WINDOW_BYTES,
|
|
40
|
+
delimiterOf,
|
|
41
|
+
isBlank,
|
|
42
|
+
normalizeName,
|
|
43
|
+
readRecords,
|
|
44
|
+
type CsvRecord,
|
|
45
|
+
type HeldBytes,
|
|
46
|
+
type ReadRange,
|
|
47
|
+
type RecordsRead,
|
|
48
|
+
type WindowOptions,
|
|
49
|
+
} from "./csv.js";
|
|
50
|
+
|
|
51
|
+
export type ValueType = "text" | "keyword" | "i64" | "f64" | "bool" | "date_time";
|
|
52
|
+
|
|
53
|
+
export interface Settings {
|
|
54
|
+
/** The class of the instances: one per data row. */
|
|
55
|
+
className: string;
|
|
56
|
+
/** The column whose value keys each instance. Without it (or without a value), the key is `<sha256>:<row>`. */
|
|
57
|
+
key?: string;
|
|
58
|
+
/** Column → property. Without it: each column whose header matches a property of the graph by its normalized name. */
|
|
59
|
+
columns?: Record<string, string>;
|
|
60
|
+
/** Also write `Document REPRESENTS <instance>` when the file's name, without its extension, is the label of exactly one instance of this class. */
|
|
61
|
+
represents?: { class: string };
|
|
62
|
+
/** Only files whose name matches this regular expression. Default: every file. */
|
|
63
|
+
match?: string;
|
|
64
|
+
/** Rows one turn reads, 1 to 20,000. */
|
|
65
|
+
rowsPerTurn: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The settings: edit them for your files. These fit the samples. */
|
|
69
|
+
export const defaults: Settings = {
|
|
70
|
+
className: "Customer",
|
|
71
|
+
key: "Customer ID",
|
|
72
|
+
represents: { class: "Report" },
|
|
73
|
+
match: "customers",
|
|
74
|
+
rowsPerTurn: 5_000,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** The workflow type and version the named workflow names, and the derivations record. */
|
|
78
|
+
export const workflowType = { workflow_type: "rows", version: "v1" };
|
|
79
|
+
|
|
80
|
+
/** The media types the content trigger sends. */
|
|
81
|
+
export const MEDIA_TYPES = ["text/csv", "text/tab-separated-values"];
|
|
82
|
+
|
|
83
|
+
/** The relation from each instance to the file it was read from. */
|
|
84
|
+
export const READ_FROM = "READ_FROM";
|
|
85
|
+
const REPRESENTS = "REPRESENTS";
|
|
86
|
+
|
|
87
|
+
/** The named workflow with its content trigger: `client.workflows.named.put(trigger())`. */
|
|
88
|
+
export function trigger(settings: Settings = defaults): Schemas["NamedWorkflowRequest"] {
|
|
89
|
+
return {
|
|
90
|
+
name: "rows",
|
|
91
|
+
workflow: workflowType,
|
|
92
|
+
triggers: [{ id: "rows", content: { media_types: MEDIA_TYPES } }],
|
|
93
|
+
owns: {
|
|
94
|
+
classes: [settings.className],
|
|
95
|
+
relations: [READ_FROM, ...(settings.represents ? [REPRESENTS] : [])],
|
|
96
|
+
properties: [...new Set(Object.values(settings.columns ?? {}))],
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The class and the relations the workflow writes, for `lbb.evolve`. The
|
|
103
|
+
* properties are the graph's own. `REPRESENTS` is the relation an upload
|
|
104
|
+
* adds (from `Document` to any class); an evolve that adds it again
|
|
105
|
+
* changes nothing.
|
|
106
|
+
*/
|
|
107
|
+
export function ontology(settings: Settings = defaults): Schemas["OntologyEvolveOp"][] {
|
|
108
|
+
return [
|
|
109
|
+
{ op: "add_entity_type", name: settings.className },
|
|
110
|
+
{ op: "add_entity_type", name: "Document" },
|
|
111
|
+
{ op: "add_relation", name: READ_FROM, domain: [settings.className], range: ["Document"] },
|
|
112
|
+
...(settings.represents
|
|
113
|
+
? [{ op: "add_relation" as const, name: REPRESENTS, domain: ["Document"], range: [] }]
|
|
114
|
+
: []),
|
|
115
|
+
];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** What a client reads for the workflow beside the activities. An `LbbClient` has both. */
|
|
119
|
+
export interface ContentPorts {
|
|
120
|
+
files: {
|
|
121
|
+
/** The file's bytes; with `range`, bytes `start` to `end` (both included), answered `206`. */
|
|
122
|
+
content(
|
|
123
|
+
sha256: string,
|
|
124
|
+
options?: { range?: { start: number; end?: number } },
|
|
125
|
+
): Promise<Uint8Array>;
|
|
126
|
+
};
|
|
127
|
+
workflows: {
|
|
128
|
+
derivations: {
|
|
129
|
+
check(key: Schemas["DerivationKey"]): Promise<Schemas["DerivationCheckResponse"]>;
|
|
130
|
+
record(
|
|
131
|
+
request: Schemas["DerivationRecordRequest"],
|
|
132
|
+
): Promise<Schemas["DerivationRecordResponse"]>;
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** What the workflow has done, over all its turns. */
|
|
138
|
+
export interface RowsState {
|
|
139
|
+
/** Files read to their end. */
|
|
140
|
+
contents: number;
|
|
141
|
+
/** Files skipped: read before with these settings, another name, or no file. */
|
|
142
|
+
skipped: number;
|
|
143
|
+
rows: number;
|
|
144
|
+
instances: number;
|
|
145
|
+
represents: number;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** A mapped column: its index in the header, its header, and the property it fills with its type. */
|
|
149
|
+
interface Column {
|
|
150
|
+
index: number;
|
|
151
|
+
header: string;
|
|
152
|
+
property: string;
|
|
153
|
+
type: ValueType;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** What the first turn of a file decides, carried by its later turns. */
|
|
157
|
+
interface Plan {
|
|
158
|
+
sha256: string;
|
|
159
|
+
document: string;
|
|
160
|
+
name: string;
|
|
161
|
+
delimiter: string;
|
|
162
|
+
columns: Column[];
|
|
163
|
+
/** The headers that fill no property, at most 100. */
|
|
164
|
+
left: string[];
|
|
165
|
+
key: number | null;
|
|
166
|
+
label: number | null;
|
|
167
|
+
represents: string | null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Where a file's next turn starts, and what its turns did so far. */
|
|
171
|
+
interface Cursor {
|
|
172
|
+
/** The byte offset of the next row. */
|
|
173
|
+
byte: number;
|
|
174
|
+
/** The row number of the next row. */
|
|
175
|
+
row: number;
|
|
176
|
+
plan: Plan;
|
|
177
|
+
done: Done;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
interface Done {
|
|
181
|
+
rows: number;
|
|
182
|
+
instances: number;
|
|
183
|
+
/** Rows without a key value. */
|
|
184
|
+
keyless: number;
|
|
185
|
+
/** Values that did not take their property's type. */
|
|
186
|
+
rejected: number;
|
|
187
|
+
/** The commits of the writes, at most 256. */
|
|
188
|
+
commits: number[];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** A file of the graph: its content's SHA-256, its `Document`, its name and media type. */
|
|
192
|
+
interface FileInfo {
|
|
193
|
+
sha256: string;
|
|
194
|
+
document: string;
|
|
195
|
+
name: string;
|
|
196
|
+
mediaType: string;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** What the check of a file found: the file to read, or why it is skipped. */
|
|
200
|
+
interface Checked {
|
|
201
|
+
file?: FileInfo;
|
|
202
|
+
skip?: string;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** A trigger message; a file's later turns carry the cursor. */
|
|
206
|
+
type RowsMessage = WorkflowTriggerMessage & { cursor?: Cursor };
|
|
207
|
+
|
|
208
|
+
/** Rows one owned write holds: a commit names at most 1,000 entities. */
|
|
209
|
+
const PART = 400;
|
|
210
|
+
/** Characters of a row's text its evidence keeps. */
|
|
211
|
+
const EVIDENCE_CHARS = 1_000;
|
|
212
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
213
|
+
const PLAIN_IRI = /^[^\s<>"{}|\\^`]+$/;
|
|
214
|
+
const P = "PREFIX p: <https://littlebigbrain.com/p/>\n";
|
|
215
|
+
const LABEL = "http://www.w3.org/2000/01/rdf-schema#label";
|
|
216
|
+
const LABEL_HEADERS = new Set(["name", "title", "label"]);
|
|
217
|
+
const SCALAR: Record<string, ValueType> = {
|
|
218
|
+
text: "text",
|
|
219
|
+
keyword: "keyword",
|
|
220
|
+
i64: "i64",
|
|
221
|
+
f64: "f64",
|
|
222
|
+
bool: "bool",
|
|
223
|
+
datetime: "date_time",
|
|
224
|
+
date_time: "date_time",
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/** The media type without parameters, in lower case. */
|
|
228
|
+
const baseType = (mediaType: string) => mediaType.split(";")[0]!.trim().toLowerCase();
|
|
229
|
+
|
|
230
|
+
/** A file name without its last extension. */
|
|
231
|
+
export const stem = (name: string) => name.replace(/\.[^./\\]*$/, "");
|
|
232
|
+
|
|
233
|
+
/** A label for comparing a file name with labels: lower case, and runs of `-`, `_` and spaces as one space. */
|
|
234
|
+
export const labelKey = (text: string) =>
|
|
235
|
+
text
|
|
236
|
+
.normalize("NFC")
|
|
237
|
+
.toLowerCase()
|
|
238
|
+
.replace(/[-_\s]+/g, " ")
|
|
239
|
+
.trim();
|
|
240
|
+
|
|
241
|
+
/** JSON with its object keys sorted, so equal settings give one digest. */
|
|
242
|
+
function canonical(value: unknown): string {
|
|
243
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
244
|
+
if (value && typeof value === "object")
|
|
245
|
+
return `{${Object.entries(value)
|
|
246
|
+
.filter(([, v]) => v !== undefined)
|
|
247
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
248
|
+
.map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`)
|
|
249
|
+
.join(",")}}`;
|
|
250
|
+
return JSON.stringify(value);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The settings digest of the derivations: the hex SHA-256 of the settings' canonical JSON. */
|
|
254
|
+
export function settingsDigest(settings: Settings): string {
|
|
255
|
+
return createHash("sha256").update(canonical(settings)).digest("hex");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The derivation key of one content with these settings. */
|
|
259
|
+
export function derivationKey(sha256: string, settings: Settings): Schemas["DerivationKey"] {
|
|
260
|
+
return { sha256, ...workflowType, settings_digest: settingsDigest(settings) };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The value of a cell in its property's type, or null when it is empty or does not take the type. */
|
|
264
|
+
export function typed(raw: string, type: ValueType): string | number | boolean | null {
|
|
265
|
+
const text = raw.trim();
|
|
266
|
+
if (!text) return null;
|
|
267
|
+
switch (type) {
|
|
268
|
+
case "i64": {
|
|
269
|
+
const n = Number(text.replace(/[\s_]/g, ""));
|
|
270
|
+
return Number.isSafeInteger(n) ? n : null;
|
|
271
|
+
}
|
|
272
|
+
case "f64": {
|
|
273
|
+
const n = Number(text.replace(/\s/g, "").replace(/^([-+]?\d+),(\d+)$/, "$1.$2"));
|
|
274
|
+
return Number.isFinite(n) ? n : null;
|
|
275
|
+
}
|
|
276
|
+
case "bool":
|
|
277
|
+
return /^(true|yes|1)$/i.test(text) ? true : /^(false|no|0)$/i.test(text) ? false : null;
|
|
278
|
+
case "date_time": {
|
|
279
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(text))
|
|
280
|
+
return Number.isNaN(Date.parse(text)) ? null : `${text}T00:00:00Z`;
|
|
281
|
+
if (!/^\d{4}-\d{2}-\d{2}T/.test(text) || Number.isNaN(Date.parse(text))) return null;
|
|
282
|
+
return new Date(Date.parse(text)).toISOString().replace(/\.000Z$/, "Z");
|
|
283
|
+
}
|
|
284
|
+
default:
|
|
285
|
+
return text;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** The index of the header a setting names: the same text, else the same normalized name. */
|
|
290
|
+
function headerIndex(header: string[], name: string): number {
|
|
291
|
+
const exact = header.indexOf(name);
|
|
292
|
+
if (exact >= 0) return exact;
|
|
293
|
+
return header.findIndex((h) => normalizeName(h) === normalizeName(name));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The columns that fill properties: the settings' `columns`, else each
|
|
298
|
+
* header whose normalized name is a property of the graph's registry. A
|
|
299
|
+
* property must be in the registry with a scalar type.
|
|
300
|
+
*/
|
|
301
|
+
export function mapColumns(
|
|
302
|
+
header: string[],
|
|
303
|
+
registry: { name: string; value_type: string }[],
|
|
304
|
+
columns?: Record<string, string>,
|
|
305
|
+
): { columns: Column[]; left: string[] } {
|
|
306
|
+
const byName = new Map<string, { name: string; type: ValueType }>();
|
|
307
|
+
for (const p of registry) {
|
|
308
|
+
const type = SCALAR[p.value_type.toLowerCase()];
|
|
309
|
+
if (type && !byName.has(normalizeName(p.name)))
|
|
310
|
+
byName.set(normalizeName(p.name), { name: p.name, type });
|
|
311
|
+
}
|
|
312
|
+
const mapped: Column[] = [];
|
|
313
|
+
if (columns)
|
|
314
|
+
for (const [column, property] of Object.entries(columns)) {
|
|
315
|
+
const index = headerIndex(header, column);
|
|
316
|
+
const found = byName.get(normalizeName(property));
|
|
317
|
+
if (index >= 0 && found)
|
|
318
|
+
mapped.push({ index, header: header[index]!, property: found.name, type: found.type });
|
|
319
|
+
}
|
|
320
|
+
else
|
|
321
|
+
header.forEach((h, index) => {
|
|
322
|
+
const found = byName.get(normalizeName(h));
|
|
323
|
+
if (found) mapped.push({ index, header: h, property: found.name, type: found.type });
|
|
324
|
+
});
|
|
325
|
+
const used = new Set(mapped.map((c) => c.index));
|
|
326
|
+
return { columns: mapped, left: header.filter((_, i) => !used.has(i)).slice(0, 100) };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* How the workflow reads a file: bytes per read (default 4 MiB, at least
|
|
331
|
+
* 64) and the longest row (default 16 MiB). They change no output, so they
|
|
332
|
+
* are no settings and enter no derivation key.
|
|
333
|
+
*/
|
|
334
|
+
export type ReadOptions = Partial<WindowOptions>;
|
|
335
|
+
|
|
336
|
+
/** The workflow, for a `WorkflowWorker`. `lbb` is `lbbActivities(client)` and `ports` the client, or mocks in a test. */
|
|
337
|
+
export function createWorkflow(
|
|
338
|
+
lbb: LbbActivities,
|
|
339
|
+
ports: ContentPorts,
|
|
340
|
+
settings: Settings = defaults,
|
|
341
|
+
reading: ReadOptions = {},
|
|
342
|
+
) {
|
|
343
|
+
if (!(Number.isSafeInteger(settings.rowsPerTurn) && settings.rowsPerTurn >= 1))
|
|
344
|
+
throw new RangeError("rowsPerTurn is a whole number from 1");
|
|
345
|
+
if (settings.rowsPerTurn > 20_000)
|
|
346
|
+
throw new RangeError("rowsPerTurn is at most 20,000: a turn holds 64 steps");
|
|
347
|
+
const windows: WindowOptions = {
|
|
348
|
+
window: reading.window ?? WINDOW_BYTES,
|
|
349
|
+
maxRecord: reading.maxRecord ?? MAX_RECORD_BYTES,
|
|
350
|
+
};
|
|
351
|
+
if (!(Number.isSafeInteger(windows.window) && windows.window >= 64))
|
|
352
|
+
throw new RangeError("window is a whole number of bytes from 64");
|
|
353
|
+
if (!(Number.isSafeInteger(windows.maxRecord) && windows.maxRecord >= 1))
|
|
354
|
+
throw new RangeError("maxRecord is a whole number of bytes from 1");
|
|
355
|
+
const match = settings.match ? new RegExp(settings.match, "u") : null;
|
|
356
|
+
|
|
357
|
+
/** Bytes `start` to `end` of a content: none from its end on (the server answers 416 there). */
|
|
358
|
+
const rangeOf =
|
|
359
|
+
(sha256: string): ReadRange =>
|
|
360
|
+
async (start, end) => {
|
|
361
|
+
try {
|
|
362
|
+
const bytes = await ports.files.content(sha256, { range: { start, end } });
|
|
363
|
+
// A server or proxy that ignores Range answers the whole file.
|
|
364
|
+
return bytes.length > end - start + 1 ? bytes.subarray(start, end + 1) : bytes;
|
|
365
|
+
} catch (error) {
|
|
366
|
+
if (error instanceof LbbError && error.status === 416) return new Uint8Array(0);
|
|
367
|
+
throw error;
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
return triggeredWorkflow<RowsState>({
|
|
372
|
+
name: workflowType.workflow_type,
|
|
373
|
+
version: workflowType.version,
|
|
374
|
+
initialState: { contents: 0, skipped: 0, rows: 0, instances: 0, represents: 0 },
|
|
375
|
+
async onInstances(ctx, message: RowsMessage, state) {
|
|
376
|
+
/** The state after this file: the message's next instance goes to a new turn. */
|
|
377
|
+
const rest = (next: RowsState) => {
|
|
378
|
+
const others = message.instances.slice(1);
|
|
379
|
+
if (!others.length) return { state: next };
|
|
380
|
+
const plain: RowsMessage = { ...message, instances: others, removed: [], origin: null };
|
|
381
|
+
delete plain.cursor;
|
|
382
|
+
return ctx.continue(next, plain);
|
|
383
|
+
};
|
|
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);
|
|
409
|
+
}
|
|
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);
|
|
417
|
+
});
|
|
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
|
+
};
|
|
440
|
+
});
|
|
441
|
+
if (written.commit_seq !== null) first.push(written.commit_seq);
|
|
442
|
+
}
|
|
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 },
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
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;
|
|
481
|
+
}
|
|
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
|
+
});
|
|
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
|
+
},
|
|
528
|
+
});
|
|
529
|
+
return recorded.recorded;
|
|
530
|
+
});
|
|
531
|
+
return rest({
|
|
532
|
+
...counted,
|
|
533
|
+
contents: counted.contents + 1,
|
|
534
|
+
represents: counted.represents + (filePlan.represents ? 1 : 0),
|
|
535
|
+
});
|
|
536
|
+
},
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
/** The file of a `Document`: its SHA-256, name and media type, read with a strong read. */
|
|
540
|
+
async function readDocument(message: RowsMessage, document: string): Promise<FileInfo | null> {
|
|
541
|
+
if (!PLAIN_IRI.test(document)) return null;
|
|
542
|
+
const query = `# rows: document\n${P}SELECT ?name ?sha ?type WHERE {
|
|
543
|
+
VALUES ?doc { <${document}> }
|
|
544
|
+
OPTIONAL { ?doc p:name ?name } OPTIONAL { ?doc p:file_sha256 ?sha } OPTIONAL { ?doc p:content_type ?type }
|
|
545
|
+
}`;
|
|
546
|
+
const { rows } = await lbb.sparql(query, { consistency: "strong" });
|
|
547
|
+
const row = rows[0] ?? {};
|
|
548
|
+
const origin =
|
|
549
|
+
message.instances.length === 1 && message.origin?.kind === "content" ? message.origin : null;
|
|
550
|
+
const sha256 = (origin?.sha256 ?? row.sha ?? "").toLowerCase();
|
|
551
|
+
if (!SHA256.test(sha256)) return null;
|
|
552
|
+
return {
|
|
553
|
+
sha256,
|
|
554
|
+
document,
|
|
555
|
+
name: row.name ?? sha256.slice(0, 12),
|
|
556
|
+
mediaType: baseType(origin?.media_type ?? row.type ?? ""),
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
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) {
|
|
562
|
+
const first = read.records[0];
|
|
563
|
+
const header = first ? first.fields.map((h) => h.trim()) : [];
|
|
564
|
+
const registry = header.length ? ((await lbb.ontology()).property_defs ?? []) : [];
|
|
565
|
+
const mapped = mapColumns(header, registry, settings.columns);
|
|
566
|
+
const key = settings.key ? headerIndex(header, settings.key) : -1;
|
|
567
|
+
const labelAt = header.findIndex((h) => LABEL_HEADERS.has(normalizeName(h)));
|
|
568
|
+
const label = labelAt >= 0 ? labelAt : key >= 0 ? key : null;
|
|
569
|
+
const found = settings.represents ? await representsOf(file.name) : null;
|
|
570
|
+
return {
|
|
571
|
+
at: read.next,
|
|
572
|
+
label: found?.label ?? null,
|
|
573
|
+
plan: {
|
|
574
|
+
sha256: file.sha256,
|
|
575
|
+
document: file.document,
|
|
576
|
+
name: file.name,
|
|
577
|
+
delimiter,
|
|
578
|
+
columns: mapped.columns,
|
|
579
|
+
left: mapped.left,
|
|
580
|
+
key: key >= 0 ? key : null,
|
|
581
|
+
label,
|
|
582
|
+
represents: found?.iri ?? null,
|
|
583
|
+
} satisfies Plan,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** The one instance of the `represents` class whose label is the file's name without its extension; null for none or several. */
|
|
588
|
+
async function representsOf(name: string) {
|
|
589
|
+
const wanted = labelKey(stem(name));
|
|
590
|
+
if (!wanted) return null;
|
|
591
|
+
const cls = `https://littlebigbrain.com/class/${encodeURIComponent(settings.represents!.class.toLowerCase())}`;
|
|
592
|
+
const query = `# rows: represents
|
|
593
|
+
SELECT DISTINCT ?s ?label WHERE {
|
|
594
|
+
?s a <${cls}> ; <${LABEL}> ?label .
|
|
595
|
+
FILTER(REPLACE(REPLACE(LCASE(STR(?label)), "[-_\\\\s]+", " "), "^ | $", "") = ${JSON.stringify(wanted)})
|
|
596
|
+
}
|
|
597
|
+
LIMIT 2`;
|
|
598
|
+
const { rows } = await lbb.sparql(query, { consistency: "strong" });
|
|
599
|
+
const iris = [...new Set(rows.map((r) => r.s).filter(Boolean))];
|
|
600
|
+
if (iris.length !== 1) return null;
|
|
601
|
+
return { iri: iris[0]!, label: rows[0]!.label ?? stem(name) };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** The instance, values and evidence of one row; `blank` for a blank row, `keyless` for a row without its key. */
|
|
605
|
+
function rowFacts(p: Plan, record: CsvRecord) {
|
|
606
|
+
if (isBlank(record)) return "blank" as const;
|
|
607
|
+
const cell = (index: number | null) =>
|
|
608
|
+
index === null ? "" : (record.fields[index] ?? "").trim();
|
|
609
|
+
const key = p.key === null ? `${p.sha256}:${record.row}` : cell(p.key);
|
|
610
|
+
if (!key) return "keyless" as const;
|
|
611
|
+
const name = (cell(p.label) || `${stem(p.name)} row ${record.row}`).slice(0, 200);
|
|
612
|
+
const ref = { type: settings.className, name, key };
|
|
613
|
+
const values: Record<string, string | number | boolean> = {};
|
|
614
|
+
let rejected = 0;
|
|
615
|
+
for (const c of p.columns) {
|
|
616
|
+
const raw = cell(c.index);
|
|
617
|
+
const value = typed(raw, c.type);
|
|
618
|
+
if (value !== null) values[c.property] = value;
|
|
619
|
+
else if (raw) rejected += 1;
|
|
620
|
+
}
|
|
621
|
+
return {
|
|
622
|
+
ref,
|
|
623
|
+
values,
|
|
624
|
+
rejected,
|
|
625
|
+
edge: {
|
|
626
|
+
source: ref,
|
|
627
|
+
relation: READ_FROM,
|
|
628
|
+
target: entityRef(p.document, "Document", p.name),
|
|
629
|
+
confidence: 1,
|
|
630
|
+
evidence: {
|
|
631
|
+
text: record.text.slice(0, EVIDENCE_CHARS),
|
|
632
|
+
source_id: `${p.document}#row=${record.row}`,
|
|
633
|
+
},
|
|
634
|
+
},
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** The owned write of a part of a turn's rows: each instance's mapped values replaced, and its edge to the file. */
|
|
639
|
+
function partWrite(p: Plan, part: CsvRecord[]) {
|
|
640
|
+
const subjects: Schemas["NamedEntityInput"][] = [];
|
|
641
|
+
const entity_properties: Schemas["EntityPropertiesInput"][] = [];
|
|
642
|
+
const triplets: Schemas["TripletInput"][] = [];
|
|
643
|
+
for (const record of part) {
|
|
644
|
+
const facts = rowFacts(p, record);
|
|
645
|
+
if (typeof facts === "string") continue;
|
|
646
|
+
subjects.push(facts.ref);
|
|
647
|
+
entity_properties.push({ ...facts.ref, properties: facts.values });
|
|
648
|
+
triplets.push(facts.edge);
|
|
649
|
+
}
|
|
650
|
+
return {
|
|
651
|
+
subjects,
|
|
652
|
+
properties: [...new Set(p.columns.map((c) => c.property))],
|
|
653
|
+
entity_properties,
|
|
654
|
+
triplets,
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
}
|