@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.
Files changed (55) hide show
  1. package/README.md +7 -2
  2. package/lib/create.mjs +28 -4
  3. package/package.json +1 -1
  4. package/templates/_shared/testing.ts +185 -5
  5. package/templates/link/README.md +203 -0
  6. package/templates/link/candidates.ts +432 -0
  7. package/templates/link/decide.test.ts +368 -0
  8. package/templates/link/decide.ts +340 -0
  9. package/templates/link/fixture.ts +727 -0
  10. package/templates/link/grade-run.ts +53 -0
  11. package/templates/link/grade.test.ts +113 -0
  12. package/templates/link/grade.ts +335 -0
  13. package/templates/link/items.ts +333 -0
  14. package/templates/link/names.test.ts +260 -0
  15. package/templates/link/names.ts +590 -0
  16. package/templates/link/reads.ts +302 -0
  17. package/templates/link/samples/gold.json +108 -0
  18. package/templates/link/samples/records.json +48 -0
  19. package/templates/link/samples.ts +64 -0
  20. package/templates/link/settings.ts +162 -0
  21. package/templates/link/setup.ts +18 -0
  22. package/templates/link/source-turn.ts +894 -0
  23. package/templates/link/sources/records.ts +429 -0
  24. package/templates/link/things-turn.ts +984 -0
  25. package/templates/link/things.test.ts +204 -0
  26. package/templates/link/things.ts +426 -0
  27. package/templates/link/try.ts +28 -0
  28. package/templates/link/worker.ts +24 -0
  29. package/templates/link/workflow.test.ts +484 -0
  30. package/templates/link/workflow.ts +202 -0
  31. package/templates/rows/README.md +150 -0
  32. package/templates/rows/csv.ts +260 -0
  33. package/templates/rows/samples/expected.json +150 -0
  34. package/templates/rows/samples/properties.json +11 -0
  35. package/templates/rows/samples/q3-customers.csv +10 -0
  36. package/templates/rows/samples/q3-orders.tsv +5 -0
  37. package/templates/rows/samples/reports.json +5 -0
  38. package/templates/rows/samples.ts +56 -0
  39. package/templates/rows/setup.ts +18 -0
  40. package/templates/rows/try.ts +32 -0
  41. package/templates/rows/worker.ts +24 -0
  42. package/templates/rows/workflow.test.ts +541 -0
  43. package/templates/rows/workflow.ts +657 -0
  44. package/templates/sections/README.md +118 -0
  45. package/templates/sections/markdown.ts +121 -0
  46. package/templates/sections/samples/expected.json +82 -0
  47. package/templates/sections/samples/guides.json +4 -0
  48. package/templates/sections/samples/support-handbook.md +34 -0
  49. package/templates/sections/samples.ts +41 -0
  50. package/templates/sections/setup.ts +18 -0
  51. package/templates/sections/try.ts +32 -0
  52. package/templates/sections/worker.ts +24 -0
  53. package/templates/sections/workflow.test.ts +265 -0
  54. package/templates/sections/workflow.ts +433 -0
  55. package/templates/versions.json +1 -1
@@ -0,0 +1,433 @@
1
+ // sections: the sections of each Markdown file, split at its headings, as
2
+ // instances with an edge to their parent whose evidence is their lines.
3
+ //
4
+ // A named workflow with a content trigger sends each upload of
5
+ // `text/markdown` (and `text/plain` with a `.md` name) here within seconds,
6
+ // one file per message, without a wait for a publication. For each file the
7
+ // workflow:
8
+ //
9
+ // 1. reads the file's name and checks its derivation: a file this version
10
+ // already read with these settings is skipped, so identical bytes under
11
+ // two names are read once;
12
+ // 2. splits the file at its ATX headings (`#` to `######`; a line in a
13
+ // fenced code block is no heading);
14
+ // 3. writes one instance of the settings' class per section, with `title`,
15
+ // `level`, `text`, `heading_path` and `position`, and an edge `PART_OF`
16
+ // to its parent section or to the file's `Document`. The edge's evidence
17
+ // is the heading line, with the source
18
+ // `<Document IRI>#line=<from>,<to>` (RFC 5147: `#line=39,44` is lines 40
19
+ // to 44);
20
+ // 4. with `represents`, writes `Document REPRESENTS <instance>` when the
21
+ // file's name names exactly one instance of that class;
22
+ // 5. records the derivation.
23
+ //
24
+ // A turn writes at most `sectionsPerTurn` sections; the rest goes to a new
25
+ // turn (`ctx.continue`).
26
+ import { createHash } from "node:crypto";
27
+ import {
28
+ entityRef,
29
+ triggeredWorkflow,
30
+ type LbbActivities,
31
+ type Schemas,
32
+ type WorkflowTriggerMessage,
33
+ } from "@littlebigbrain/client";
34
+ import { lineFragment, markdownSections, type Section } from "./markdown.js";
35
+
36
+ export interface Settings {
37
+ /** The class of the sections. */
38
+ className: string;
39
+ /** Also write `Document REPRESENTS <instance>` when the file's name, without its extension, is the label of exactly one instance of this class. */
40
+ represents?: { class: string };
41
+ /** Only files whose name matches this regular expression. Default: every file. */
42
+ match?: string;
43
+ /** Sections one turn writes, 1 to 5,000. */
44
+ sectionsPerTurn: number;
45
+ }
46
+
47
+ /** The settings: edit them for your files. These fit the samples. */
48
+ export const defaults: Settings = {
49
+ className: "Section",
50
+ sectionsPerTurn: 1_000,
51
+ };
52
+
53
+ /** The workflow type and version the named workflow names, and the derivations record. */
54
+ export const workflowType = { workflow_type: "sections", version: "v1" };
55
+
56
+ /** The media types the content trigger sends. `text/plain` counts with a `.md` or `.markdown` name. */
57
+ export const MEDIA_TYPES = ["text/markdown", "text/x-markdown", "text/plain"];
58
+
59
+ /** The relation from each section to its parent section or its file. */
60
+ export const PART_OF = "PART_OF";
61
+ const REPRESENTS = "REPRESENTS";
62
+ /** The properties of a section. */
63
+ export const PROPERTIES = ["title", "level", "text", "heading_path", "position"];
64
+
65
+ /** The named workflow with its content trigger: `client.workflows.named.put(trigger())`. */
66
+ export function trigger(settings: Settings = defaults): Schemas["NamedWorkflowRequest"] {
67
+ return {
68
+ name: "sections",
69
+ workflow: workflowType,
70
+ triggers: [{ id: "sections", content: { media_types: MEDIA_TYPES } }],
71
+ owns: {
72
+ classes: [settings.className],
73
+ relations: [PART_OF, ...(settings.represents ? [REPRESENTS] : [])],
74
+ properties: PROPERTIES,
75
+ },
76
+ };
77
+ }
78
+
79
+ /** The class, properties and relations the workflow writes: `lbb.evolve(ontology())`. */
80
+ export function ontology(settings: Settings = defaults): Schemas["OntologyEvolveOp"][] {
81
+ const property = (name: string, value_type: string) => ({
82
+ op: "add_property" as const,
83
+ name,
84
+ value_type,
85
+ });
86
+ return [
87
+ { op: "add_entity_type", name: settings.className },
88
+ { op: "add_entity_type", name: "Document" },
89
+ property("title", "text"),
90
+ property("level", "i64"),
91
+ property("text", "text"),
92
+ property("heading_path", "text"),
93
+ property("position", "i64"),
94
+ {
95
+ op: "add_relation",
96
+ name: PART_OF,
97
+ domain: [settings.className],
98
+ range: [settings.className, "Document"],
99
+ },
100
+ // The relation an upload adds (from `Document` to any class); adding it again changes nothing.
101
+ ...(settings.represents
102
+ ? [{ op: "add_relation" as const, name: REPRESENTS, domain: ["Document"], range: [] }]
103
+ : []),
104
+ ];
105
+ }
106
+
107
+ /** What a client reads for the workflow beside the activities. An `LbbClient` has both. */
108
+ export interface ContentPorts {
109
+ files: { content(sha256: string): Promise<Uint8Array> };
110
+ workflows: {
111
+ derivations: {
112
+ check(key: Schemas["DerivationKey"]): Promise<Schemas["DerivationCheckResponse"]>;
113
+ record(
114
+ request: Schemas["DerivationRecordRequest"],
115
+ ): Promise<Schemas["DerivationRecordResponse"]>;
116
+ };
117
+ };
118
+ }
119
+
120
+ /** What the workflow has done, over all its turns. */
121
+ export interface SectionsState {
122
+ /** Files read to their end. */
123
+ contents: number;
124
+ /** Files skipped: read before with these settings, another name or type, or no file. */
125
+ skipped: number;
126
+ sections: number;
127
+ represents: number;
128
+ }
129
+
130
+ /** A file of the graph: its content's SHA-256, its `Document`, its name and media type. */
131
+ interface FileInfo {
132
+ sha256: string;
133
+ document: string;
134
+ name: string;
135
+ mediaType: string;
136
+ }
137
+
138
+ /** What the check of a file found: the file to read, or why it is skipped. */
139
+ interface Checked {
140
+ file?: FileInfo;
141
+ skip?: string;
142
+ }
143
+
144
+ /** What the first turn of a file decides, carried by its later turns. */
145
+ interface Plan {
146
+ sha256: string;
147
+ document: string;
148
+ name: string;
149
+ count: number;
150
+ represents: string | null;
151
+ }
152
+
153
+ /** Where a file's next turn starts, and the commits of its writes so far. */
154
+ interface Cursor {
155
+ /** The index of the next section. */
156
+ next: number;
157
+ plan: Plan;
158
+ commits: number[];
159
+ }
160
+
161
+ /** A trigger message; a file's later turns carry the cursor. */
162
+ type SectionsMessage = WorkflowTriggerMessage & { cursor?: Cursor };
163
+
164
+ /** Sections one owned write holds. */
165
+ const PART = 100;
166
+ /** Characters of a section's text the instance keeps. */
167
+ export const TEXT_CHARS = 8_000;
168
+ const SHA256 = /^[0-9a-f]{64}$/;
169
+ const PLAIN_IRI = /^[^\s<>"{}|\\^`]+$/;
170
+ const MARKDOWN_NAME = /\.(md|markdown)$/i;
171
+ const P = "PREFIX p: <https://littlebigbrain.com/p/>\n";
172
+ const LABEL = "http://www.w3.org/2000/01/rdf-schema#label";
173
+
174
+ /** The media type without parameters, in lower case. */
175
+ const baseType = (mediaType: string) => mediaType.split(";")[0]!.trim().toLowerCase();
176
+
177
+ /** A file name without its last extension. */
178
+ export const stem = (name: string) => name.replace(/\.[^./\\]*$/, "");
179
+
180
+ /** A label for comparing a file name with labels: lower case, and runs of `-`, `_` and spaces as one space. */
181
+ export const labelKey = (text: string) =>
182
+ text
183
+ .normalize("NFC")
184
+ .toLowerCase()
185
+ .replace(/[-_\s]+/g, " ")
186
+ .trim();
187
+
188
+ /** JSON with its object keys sorted, so equal settings give one digest. */
189
+ function canonical(value: unknown): string {
190
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
191
+ if (value && typeof value === "object")
192
+ return `{${Object.entries(value)
193
+ .filter(([, v]) => v !== undefined)
194
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
195
+ .map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`)
196
+ .join(",")}}`;
197
+ return JSON.stringify(value);
198
+ }
199
+
200
+ /** The settings digest of the derivations: the hex SHA-256 of the settings' canonical JSON. */
201
+ export function settingsDigest(settings: Settings): string {
202
+ return createHash("sha256").update(canonical(settings)).digest("hex");
203
+ }
204
+
205
+ /** The derivation key of one content with these settings. */
206
+ export function derivationKey(sha256: string, settings: Settings): Schemas["DerivationKey"] {
207
+ return { sha256, ...workflowType, settings_digest: settingsDigest(settings) };
208
+ }
209
+
210
+ /** Whether a file is Markdown: by its media type, or `text/plain` and other types with a `.md` name. */
211
+ export function isMarkdown(mediaType: string, name: string): boolean {
212
+ return (
213
+ mediaType === "text/markdown" || mediaType === "text/x-markdown" || MARKDOWN_NAME.test(name)
214
+ );
215
+ }
216
+
217
+ /** The workflow, for a `WorkflowWorker`. `lbb` is `lbbActivities(client)` and `ports` the client, or mocks in a test. */
218
+ export function createWorkflow(
219
+ lbb: LbbActivities,
220
+ ports: ContentPorts,
221
+ settings: Settings = defaults,
222
+ ) {
223
+ if (!(Number.isSafeInteger(settings.sectionsPerTurn) && settings.sectionsPerTurn >= 1))
224
+ throw new RangeError("sectionsPerTurn is a whole number from 1");
225
+ if (settings.sectionsPerTurn > 5_000)
226
+ throw new RangeError("sectionsPerTurn is at most 5,000: a turn holds 64 steps");
227
+ const match = settings.match ? new RegExp(settings.match, "u") : null;
228
+
229
+ return triggeredWorkflow<SectionsState>({
230
+ name: workflowType.workflow_type,
231
+ version: workflowType.version,
232
+ initialState: { contents: 0, skipped: 0, sections: 0, represents: 0 },
233
+ async onInstances(ctx, message: SectionsMessage, state) {
234
+ /** The state after this file: the message's next instance goes to a new turn. */
235
+ const rest = (next: SectionsState) => {
236
+ const others = message.instances.slice(1);
237
+ if (!others.length) return { state: next };
238
+ const plain: SectionsMessage = {
239
+ ...message,
240
+ instances: others,
241
+ removed: [],
242
+ origin: null,
243
+ };
244
+ delete plain.cursor;
245
+ return ctx.continue(next, plain);
246
+ };
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
+ ],
299
+ };
300
+ });
301
+ if (written.commit_seq !== null) commits.push(written.commit_seq);
302
+ }
303
+ cursor = { next: 0, plan: p, commits };
304
+ }
305
+
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)));
316
+ });
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) },
325
+ });
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
+ });
349
+ },
350
+ });
351
+
352
+ /** The file of a `Document`: its SHA-256, name and media type, read with a strong read. */
353
+ async function readDocument(
354
+ message: SectionsMessage,
355
+ document: string,
356
+ ): Promise<FileInfo | null> {
357
+ if (!PLAIN_IRI.test(document)) return null;
358
+ const query = `# sections: document\n${P}SELECT ?name ?sha ?type WHERE {
359
+ VALUES ?doc { <${document}> }
360
+ OPTIONAL { ?doc p:name ?name } OPTIONAL { ?doc p:file_sha256 ?sha } OPTIONAL { ?doc p:content_type ?type }
361
+ }`;
362
+ const { rows } = await lbb.sparql(query, { consistency: "strong" });
363
+ const row = rows[0] ?? {};
364
+ const origin =
365
+ message.instances.length === 1 && message.origin?.kind === "content" ? message.origin : null;
366
+ const sha256 = (origin?.sha256 ?? row.sha ?? "").toLowerCase();
367
+ if (!SHA256.test(sha256)) return null;
368
+ return {
369
+ sha256,
370
+ document,
371
+ name: row.name ?? sha256.slice(0, 12),
372
+ mediaType: baseType(origin?.media_type ?? row.type ?? ""),
373
+ };
374
+ }
375
+
376
+ /** The one instance of the `represents` class whose label is the file's name without its extension; null for none or several. */
377
+ async function representsOf(name: string) {
378
+ const wanted = labelKey(stem(name));
379
+ if (!wanted) return null;
380
+ const cls = `https://littlebigbrain.com/class/${encodeURIComponent(settings.represents!.class.toLowerCase())}`;
381
+ const query = `# sections: represents
382
+ SELECT DISTINCT ?s ?label WHERE {
383
+ ?s a <${cls}> ; <${LABEL}> ?label .
384
+ FILTER(REPLACE(REPLACE(LCASE(STR(?label)), "[-_\\\\s]+", " "), "^ | $", "") = ${JSON.stringify(wanted)})
385
+ }
386
+ LIMIT 2`;
387
+ const { rows } = await lbb.sparql(query, { consistency: "strong" });
388
+ const iris = [...new Set(rows.map((r) => r.s).filter(Boolean))];
389
+ if (iris.length !== 1) return null;
390
+ return { iri: iris[0]!, label: rows[0]!.label ?? stem(name) };
391
+ }
392
+
393
+ /** The entity of a section: keyed by the content and its position. */
394
+ function sectionRef(plan: Plan, section: Section) {
395
+ const name = (section.title || `${stem(plan.name)} section ${section.position}`).slice(0, 200);
396
+ return { type: settings.className, name, key: `${plan.sha256}:${section.position}` };
397
+ }
398
+
399
+ /** The owned write of a part of the sections: their properties replaced, and each one's edge to its parent. */
400
+ function partWrite(plan: Plan, all: Section[], part: Section[]) {
401
+ const document = entityRef(plan.document, "Document", plan.name);
402
+ const byPosition = new Map(all.map((section) => [section.position, section]));
403
+ const subjects: Schemas["NamedEntityInput"][] = [];
404
+ const entity_properties: Schemas["EntityPropertiesInput"][] = [];
405
+ const triplets: Schemas["TripletInput"][] = [];
406
+ for (const section of part) {
407
+ const ref = sectionRef(plan, section);
408
+ const parent = section.parent === null ? null : byPosition.get(section.parent);
409
+ subjects.push(ref);
410
+ entity_properties.push({
411
+ ...ref,
412
+ properties: {
413
+ ...(section.title ? { title: section.title } : {}),
414
+ level: section.level,
415
+ ...(section.text ? { text: section.text.slice(0, TEXT_CHARS) } : {}),
416
+ ...(section.headingPath.length ? { heading_path: section.headingPath.join(" > ") } : {}),
417
+ position: section.position,
418
+ },
419
+ });
420
+ triplets.push({
421
+ source: ref,
422
+ relation: PART_OF,
423
+ target: parent ? sectionRef(plan, parent) : document,
424
+ confidence: 1,
425
+ evidence: {
426
+ text: section.quote.slice(0, 1_000),
427
+ source_id: `${plan.document}#${lineFragment(section)}`,
428
+ },
429
+ });
430
+ }
431
+ return { subjects, relations: [PART_OF], properties: PROPERTIES, entity_properties, triplets };
432
+ }
433
+ }
@@ -1,3 +1,3 @@
1
1
  {
2
- "client": "0.25.0"
2
+ "client": "0.27.0"
3
3
  }