@littlebigbrain/create 0.3.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.
@@ -0,0 +1,118 @@
1
+ # sections
2
+
3
+ Reads each Markdown file of a graph into its sections. A handbook gives one
4
+ `Section` per heading, with its title, its level, its own text, the path of
5
+ headings above it and its position. Each section has an edge `PART_OF` to
6
+ its parent section, or to the file's `Document` when it has none. The
7
+ edge's evidence is the heading line, and its source is the file's IRI with
8
+ the section's lines: `https://littlebigbrain.com/e/41ab…#line=39,44` is
9
+ lines 40 to 44 (RFC 5147,
10
+ [evidence.md](../../../docs/reference/evidence.md#evidence-from-a-file)).
11
+
12
+ The template has no LBB-run version. It is a named workflow with a
13
+ **content trigger**: LBB sends it each upload of `text/markdown`,
14
+ `text/x-markdown` and `text/plain` within seconds, without a wait for a
15
+ publication ([workflows.md](../../../docs/reference/workflows.md#content-triggers)).
16
+ A `text/plain` file is read when its name ends with `.md` or `.markdown`.
17
+
18
+ ## How it runs
19
+
20
+ Each message holds one file. The first turn of a file:
21
+
22
+ 1. **Check.** Reads the file's name with a strong read, and the
23
+ derivation of the content for `sections@v1` and the settings
24
+ ([Derivations](../../../docs/reference/workflows.md#derivations)). A
25
+ content with a derivation is skipped, so the same bytes under a second
26
+ name are read once. So is a file whose name the settings' `match` does
27
+ not match, and a `text/plain` file without a Markdown name.
28
+ 2. **Plan.** Reads the bytes (`client.files.content`) and splits them at
29
+ their ATX headings (`#` to `######`, up to 3 spaces before them, a
30
+ space after them). A line in a fenced code block (three or more
31
+ backticks or tildes) is text. Closing hashes (`## Escalation ##`) are
32
+ dropped.
33
+ 3. **Represents.** With `represents`, when the file's name without its
34
+ extension is the label of exactly one instance of that class, writes
35
+ `Document REPRESENTS <instance>`. `support-handbook.md` represents the
36
+ guide `Support handbook`: the labels compare in lower case, with `-`,
37
+ `_` and spaces as one space. No match, or more than one, writes nothing
38
+ and proposes nothing.
39
+
40
+ Then each turn writes at most `sectionsPerTurn` sections in owned writes of
41
+ 100. A section is one instance of the settings' class, keyed
42
+ `<sha256>:<position>`:
43
+
44
+ | Property | Holds |
45
+ | --- | --- |
46
+ | `title` | The heading's text |
47
+ | `level` | 1 to 6 |
48
+ | `text` | The lines after the heading up to the next heading, at most 8,000 characters |
49
+ | `heading_path` | The titles from the top heading to its own, joined by ` > ` |
50
+ | `position` | Its place in the file: 1, 2, … |
51
+
52
+ The parent of a section is the nearest heading above it of a lower level.
53
+ The text before the first heading, when it is not blank, is the section at
54
+ position 0, of level 0, titled with the file's name without its extension.
55
+ A section's lines run from its heading to its last line that is not
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.
58
+
59
+ ## Settings
60
+
61
+ `defaults` in `workflow.ts`:
62
+
63
+ | Setting | Default | Meaning |
64
+ | --- | --- | --- |
65
+ | `className` | `Section` | The class of the sections |
66
+ | `represents` | none | Write `REPRESENTS` to the one instance of this class that the file's name names. The samples' guides are of the class `Guide` |
67
+ | `match` | none | A regular expression on the file's name. Without it, every Markdown file |
68
+ | `sectionsPerTurn` | 1,000 | Sections one turn writes, 1 to 5,000 |
69
+
70
+ `trigger(settings)` names the workflow `sections`, with the content trigger
71
+ `sections`. It owns the class, `PART_OF`, `REPRESENTS` when `represents`
72
+ is set, and the five properties. `ontology(settings)` adds the class, the
73
+ properties and `PART_OF`.
74
+
75
+ The settings digest is the SHA-256 of the settings' canonical JSON: a
76
+ change of any setting reads every file again.
77
+
78
+ Two content workflows own one relation when no upload can reach both:
79
+ `rows` and `sections`, both with `represents`, go on one graph, because no
80
+ media type of one meets a media type of the other. A second workflow that
81
+ writes `REPRESENTS` for a type that meets one of these, such as `text/*`, is
82
+ refused with `409 workflow_owns_conflict`
83
+ ([starter-workflows.md](../../../docs/reference/starter-workflows.md#cycles-and-conflicts)).
84
+
85
+ ## Commands
86
+
87
+ `setup.ts`, `worker.ts` and `try.ts` are the template's own:
88
+ `npm run setup` creates the named workflow with
89
+ `client.workflows.named.put(trigger())`, and `npm start` serves
90
+ `createWorkflow(lbbActivities(client), client)`: the client reads the
91
+ files' bytes and the derivations. `npm run try` creates the graph
92
+ `try-sections`, commits the two sample guides, uploads the sample handbook
93
+ and creates the workflow.
94
+
95
+ ## Limits
96
+
97
+ - Setext headings (a line of `===` or `---` under the text) start no
98
+ section.
99
+ - Another `className` in the settings writes the sections again as new
100
+ instances; the instances of the earlier class stay.
101
+ - A file deleted later keeps its sections; their `PART_OF` edges to the
102
+ file go with its `Document`.
103
+
104
+ ## Samples and test
105
+
106
+ `samples/support-handbook.md` is an invented support handbook: text before
107
+ the first heading, headings of levels 1 to 3, a heading without text of
108
+ its own, a fenced code block with a line that starts with `#`, closing
109
+ hashes, and a `#` without a space. `samples/guides.json` holds two guides
110
+ the file's name can name. `samples/expected.json` gives the 8 sections a
111
+ correct run writes, each with its parent and its lines.
112
+
113
+ `workflow.test.ts` runs the workflow without a server (`MockContent` holds
114
+ the files and the derivations). It checks the answer key, the evidence of
115
+ each section, `text/plain` with and without a Markdown name, a file
116
+ without a heading, an empty file, a file of more sections than a turn
117
+ writes, the skip by derivation, `represents` with zero, one and two
118
+ matching instances, the phases, and the heading rules.
@@ -0,0 +1,121 @@
1
+ // The sections of a Markdown file, split at its ATX headings (`#` to
2
+ // `######`, as CommonMark reads them). Each section holds its heading, its
3
+ // own text up to the next heading, its parent (the nearest heading above it
4
+ // of a lower level, else the document) and its lines, which its evidence
5
+ // names as RFC 5147 line positions: `#line=39,44` is lines 40 to 44.
6
+
7
+ /** One section of a file. */
8
+ export interface Section {
9
+ /** Its place in the file: 0 for the text before the first heading, then 1, 2, … */
10
+ position: number;
11
+ /** 1 to 6; 0 for the text before the first heading. */
12
+ level: number;
13
+ title: string;
14
+ /** Its own text: the lines after its heading, up to the next heading. */
15
+ text: string;
16
+ /** The titles of its parents and its own, from the top. */
17
+ headingPath: string[];
18
+ /** The position of its parent section; null when its parent is the document. */
19
+ parent: number | null;
20
+ /** Its first and last line, from 1. */
21
+ from: number;
22
+ to: number;
23
+ /** The heading line, or the first line of the text before the first heading. */
24
+ quote: string;
25
+ }
26
+
27
+ /** The lines of a text: split at CRLF, LF and CR, without a byte order mark. A line break at the end adds no line. */
28
+ export function splitLines(text: string): string[] {
29
+ const body = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
30
+ if (!body) return [];
31
+ const lines = body.split(/\r\n|\n|\r/);
32
+ if (lines[lines.length - 1] === "") lines.pop();
33
+ return lines;
34
+ }
35
+
36
+ /** An ATX heading: up to 3 spaces, 1 to 6 `#`, then a space or the end. A closing run of `#` is dropped. */
37
+ export function atxHeading(line: string): { level: number; title: string } | null {
38
+ const m = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?[ \t]*$/.exec(line);
39
+ if (!m) return null;
40
+ const title = (m[2] ?? "").replace(/(?:^|[ \t]+)#+$/, "").trim();
41
+ return { level: m[1]!.length, title };
42
+ }
43
+
44
+ /** The opening of a fenced code block: up to 3 spaces and 3 or more backticks or tildes. */
45
+ const FENCE = /^ {0,3}(`{3,}|~{3,})/;
46
+
47
+ /**
48
+ * The sections of a Markdown text. Lines inside a fenced code block are
49
+ * text, never headings. The text before the first heading, when it is not
50
+ * blank, is section 0 of level 0, titled `untitled` (the caller names it
51
+ * after the file). A section's lines run from its heading to its last line
52
+ * that is not blank before the next heading.
53
+ */
54
+ export function markdownSections(text: string, untitled = ""): Section[] {
55
+ const lines = splitLines(text);
56
+ const headings: { line: number; level: number; title: string }[] = [];
57
+ let fence: { char: string; length: number } | null = null;
58
+ lines.forEach((line, i) => {
59
+ if (fence) {
60
+ const close = new RegExp(`^ {0,3}\\${fence.char}{${fence.length},}[ \\t]*$`);
61
+ if (close.test(line)) fence = null;
62
+ return;
63
+ }
64
+ const open = FENCE.exec(line);
65
+ if (open) {
66
+ fence = { char: open[1]![0]!, length: open[1]!.length };
67
+ return;
68
+ }
69
+ const heading = atxHeading(line);
70
+ if (heading) headings.push({ line: i + 1, ...heading });
71
+ });
72
+
73
+ /** Lines `from` to `to` (from 1) without the blank lines at either end. */
74
+ const span = (from: number, to: number) => {
75
+ while (from <= to && !lines[from - 1]!.trim()) from += 1;
76
+ while (to >= from && !lines[to - 1]!.trim()) to -= 1;
77
+ return { from, to };
78
+ };
79
+ const out: Section[] = [];
80
+ const firstHeading = headings[0]?.line ?? lines.length + 1;
81
+ const before = span(1, firstHeading - 1);
82
+ if (before.from <= before.to)
83
+ out.push({
84
+ position: 0,
85
+ level: 0,
86
+ title: untitled,
87
+ text: lines.slice(before.from - 1, before.to).join("\n"),
88
+ headingPath: [],
89
+ parent: null,
90
+ from: before.from,
91
+ to: before.to,
92
+ quote: lines[before.from - 1]!.trim(),
93
+ });
94
+
95
+ const open: Section[] = [];
96
+ headings.forEach((heading, n) => {
97
+ const end = (headings[n + 1]?.line ?? lines.length + 1) - 1;
98
+ const body = span(heading.line + 1, end);
99
+ while (open.length && open[open.length - 1]!.level >= heading.level) open.pop();
100
+ const parent = open[open.length - 1] ?? null;
101
+ const section: Section = {
102
+ position: n + 1,
103
+ level: heading.level,
104
+ title: heading.title,
105
+ text: body.from <= body.to ? lines.slice(body.from - 1, body.to).join("\n") : "",
106
+ headingPath: [...(parent?.headingPath ?? []), heading.title],
107
+ parent: parent?.position ?? null,
108
+ from: heading.line,
109
+ to: body.from <= body.to ? body.to : heading.line,
110
+ quote: lines[heading.line - 1]!.trim(),
111
+ };
112
+ open.push(section);
113
+ out.push(section);
114
+ });
115
+ return out;
116
+ }
117
+
118
+ /** The RFC 5147 fragment of a section's lines: `line=<from - 1>,<to>`. */
119
+ export function lineFragment(section: Pick<Section, "from" | "to">): string {
120
+ return `line=${section.from - 1},${section.to}`;
121
+ }
@@ -0,0 +1,82 @@
1
+ {
2
+ "support-handbook.md": {
3
+ "sections": [
4
+ {
5
+ "position": 0,
6
+ "level": 0,
7
+ "title": "support-handbook",
8
+ "parent": null,
9
+ "lines": "line=0,2",
10
+ "why": "The text before the first heading, named after the file.",
11
+ "text": "This handbook says how the support team answers requests.\nThe team reviews it every quarter."
12
+ },
13
+ {
14
+ "position": 1,
15
+ "level": 1,
16
+ "title": "Support handbook",
17
+ "parent": null,
18
+ "lines": "line=3,6",
19
+ "heading_path": "Support handbook",
20
+ "text": "The team answers requests from customers by email and by chat."
21
+ },
22
+ {
23
+ "position": 2,
24
+ "level": 2,
25
+ "title": "Opening hours",
26
+ "parent": 1,
27
+ "lines": "line=7,11",
28
+ "heading_path": "Support handbook > Opening hours",
29
+ "text": "Requests are answered from 08:00 to 18:00 on weekdays.\nOn public holidays the chat is closed."
30
+ },
31
+ {
32
+ "position": 3,
33
+ "level": 2,
34
+ "title": "Response times",
35
+ "parent": 1,
36
+ "lines": "line=12,13",
37
+ "heading_path": "Support handbook > Response times",
38
+ "why": "A heading without text of its own: its lines are its heading line.",
39
+ "text": null
40
+ },
41
+ {
42
+ "position": 4,
43
+ "level": 3,
44
+ "title": "Email",
45
+ "parent": 3,
46
+ "lines": "line=14,17",
47
+ "heading_path": "Support handbook > Response times > Email",
48
+ "text": "Every email gets a first answer within one working day."
49
+ },
50
+ {
51
+ "position": 5,
52
+ "level": 3,
53
+ "title": "Chat",
54
+ "parent": 3,
55
+ "lines": "line=18,25",
56
+ "heading_path": "Support handbook > Response times > Chat",
57
+ "why": "The line with a hash in the fenced code block is text.",
58
+ "text": "A chat gets an answer within five minutes.\n\n```text\n# This line is in a code block: no heading.\n```"
59
+ },
60
+ {
61
+ "position": 6,
62
+ "level": 2,
63
+ "title": "Escalation",
64
+ "parent": 1,
65
+ "lines": "line=26,30",
66
+ "heading_path": "Support handbook > Escalation",
67
+ "why": "The closing hashes of the heading are dropped. A hash without a space after it starts no heading.",
68
+ "text": "A request that needs an engineer goes to the escalation queue.\n#This line is no heading: the hash has no space after it."
69
+ },
70
+ {
71
+ "position": 7,
72
+ "level": 1,
73
+ "title": "Glossary",
74
+ "parent": null,
75
+ "lines": "line=31,34",
76
+ "heading_path": "Glossary",
77
+ "text": "A ticket is one customer request, with every message about it."
78
+ }
79
+ ],
80
+ "represents": "Support handbook"
81
+ }
82
+ }
@@ -0,0 +1,4 @@
1
+ [
2
+ { "name": "Support handbook", "text": "How the support team answers requests." },
3
+ { "name": "Billing guide", "text": "How invoices and refunds work." }
4
+ ]
@@ -0,0 +1,34 @@
1
+ This handbook says how the support team answers requests.
2
+ The team reviews it every quarter.
3
+
4
+ # Support handbook
5
+
6
+ The team answers requests from customers by email and by chat.
7
+
8
+ ## Opening hours
9
+
10
+ Requests are answered from 08:00 to 18:00 on weekdays.
11
+ On public holidays the chat is closed.
12
+
13
+ ## Response times
14
+
15
+ ### Email
16
+
17
+ Every email gets a first answer within one working day.
18
+
19
+ ### Chat
20
+
21
+ A chat gets an answer within five minutes.
22
+
23
+ ```text
24
+ # This line is in a code block: no heading.
25
+ ```
26
+
27
+ ## Escalation ##
28
+
29
+ A request that needs an engineer goes to the escalation queue.
30
+ #This line is no heading: the hash has no space after it.
31
+
32
+ # Glossary
33
+
34
+ A ticket is one customer request, with every message about it.
@@ -0,0 +1,41 @@
1
+ // The samples of sections, for `npm run try`: an invented support handbook
2
+ // (samples/support-handbook.md) and two guides (samples/guides.json) that a
3
+ // file's name can name.
4
+ import { readFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import type { LbbClient } from "@littlebigbrain/client";
7
+
8
+ /** The class of the guides; set `represents: { class: GUIDE_CLASS }` to link the handbook to its guide. */
9
+ export const GUIDE_CLASS = "Guide";
10
+
11
+ /** Commit the guides, then upload the sample handbook to the client's graph. */
12
+ export async function loadSamples(client: LbbClient, dir: string): Promise<void> {
13
+ const guides = JSON.parse(await readFile(join(dir, "guides.json"), "utf8")) as {
14
+ name: string;
15
+ text: string;
16
+ }[];
17
+ await client.ontology.evolve({
18
+ ops: [
19
+ { op: "add_entity_type", name: GUIDE_CLASS },
20
+ { op: "add_property", name: "text", value_type: "text" },
21
+ ],
22
+ });
23
+ await client.commit(
24
+ {
25
+ entity_properties: guides.map((guide) => ({
26
+ type: GUIDE_CLASS,
27
+ name: guide.name,
28
+ properties: { text: guide.text },
29
+ })),
30
+ },
31
+ { idempotencyKey: "sections-samples" },
32
+ );
33
+ console.log(`Committed ${guides.length} ${GUIDE_CLASS} records`);
34
+ const name = "support-handbook.md";
35
+ const bytes = await readFile(join(dir, name));
36
+ const uploaded = await client.files.upload(new Uint8Array(bytes), {
37
+ name,
38
+ contentType: "text/markdown",
39
+ });
40
+ console.log(`Uploaded ${name} (${uploaded.file.sha256.slice(0, 12)})`);
41
+ }
@@ -0,0 +1,18 @@
1
+ // Create the workflow on your graph: add the class, the properties and the
2
+ // relation it writes, then create the named workflow whose content trigger
3
+ // sends it each Markdown upload. Each call is safe to repeat.
4
+ //
5
+ // LBB_URL=... LBB_API_KEY=... LBB_GRAPH=... npm run setup
6
+ import { LbbClient, lbbActivities } from "@littlebigbrain/client";
7
+ import { ontology, trigger } from "./workflow.js";
8
+
9
+ const graph = process.env.LBB_GRAPH ?? "main";
10
+ const client = new LbbClient({
11
+ baseUrl: process.env.LBB_URL ?? "http://127.0.0.1:7400",
12
+ apiKey: process.env.LBB_API_KEY,
13
+ graph,
14
+ });
15
+ await lbbActivities(client).evolve(ontology());
16
+ const created = await client.workflows.named.put(trigger());
17
+ console.log(`Created ${created.name} on the graph ${graph}.`);
18
+ console.log(`Serve it with LBB_GRAPH=${graph} npm start.`);
@@ -0,0 +1,32 @@
1
+ // Try the workflow on its samples: create a graph (default try-sections),
2
+ // add the sample guides, upload the sample handbook, and create the workflow
3
+ // there. Then serve it with npm start and the same LBB_GRAPH: the content
4
+ // trigger reads the files uploaded before it too.
5
+ //
6
+ // LBB_URL=... LBB_API_KEY=... LBB_GRAPH=try-sections npm run try
7
+ import { fileURLToPath } from "node:url";
8
+ import { LbbClient, LbbError, lbbActivities } from "@littlebigbrain/client";
9
+ import { loadSamples } from "./samples.js";
10
+ import { ontology, trigger } from "./workflow.js";
11
+
12
+ const graph = process.env.LBB_GRAPH ?? "try-sections";
13
+ const client = new LbbClient({
14
+ baseUrl: process.env.LBB_URL ?? "http://127.0.0.1:7400",
15
+ apiKey: process.env.LBB_API_KEY,
16
+ graph,
17
+ });
18
+ try {
19
+ await client.graph(graph).create();
20
+ console.log(`Created the graph ${graph}`);
21
+ } catch (error) {
22
+ // 409: the graph exists already.
23
+ if (!(error instanceof LbbError && error.status === 409)) throw error;
24
+ }
25
+ await lbbActivities(client).evolve(ontology());
26
+ await loadSamples(client, fileURLToPath(new URL("../samples/", import.meta.url)));
27
+ const created = await client.workflows.named.put(trigger());
28
+ console.log(`Created ${created.name} on the graph ${graph}.`);
29
+ console.log(`Serve it with LBB_GRAPH=${graph} npm start, and follow it with:`);
30
+ console.log(
31
+ ` curl "$LBB_URL/v1/workflows/named/queue?graph=${graph}&name=${created.name}" -H "Authorization: Bearer $LBB_API_KEY"`,
32
+ );
@@ -0,0 +1,24 @@
1
+ // Serve the workflow: the worker claims the turns of its content trigger's
2
+ // messages and runs them, one at a time. Run more workers for more at once.
3
+ //
4
+ // LBB_URL=... LBB_API_KEY=... LBB_GRAPH=... npm start
5
+ import { LbbClient, WorkflowWorker, lbbActivities } from "@littlebigbrain/client";
6
+ import { createWorkflow } from "./workflow.js";
7
+
8
+ const graph = process.env.LBB_GRAPH ?? "main";
9
+ const client = new LbbClient({
10
+ baseUrl: process.env.LBB_URL ?? "http://127.0.0.1:7400",
11
+ apiKey: process.env.LBB_API_KEY,
12
+ graph,
13
+ });
14
+ // The client reads the files' bytes and the derivations.
15
+ const workflow = createWorkflow(lbbActivities(client), client);
16
+ const stop = new AbortController();
17
+ process.once("SIGINT", () => stop.abort());
18
+ process.once("SIGTERM", () => stop.abort());
19
+ console.log(
20
+ `Serving ${workflow.name}@${workflow.version} for the graph ${graph}. Stop with Ctrl-C.`,
21
+ );
22
+ await new WorkflowWorker(client, [workflow], {
23
+ worker: process.env.LBB_WORKER_ID ?? `${workflow.name}-1`,
24
+ }).run({ signal: stop.signal });