@littlebigbrain/create 0.5.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.
- package/README.md +42 -6
- package/lib/create.mjs +228 -14
- package/package.json +2 -1
- package/project/python/values_extract/tests/fake_lbb.py +226 -0
- package/project/python/values_extract/tests/test_workflow.py +150 -0
- package/python/values_extract/README.md +52 -0
- package/python/values_extract/__init__.py +3 -0
- package/python/values_extract/__main__.py +97 -0
- package/python/values_extract/samples/expected.json +149 -0
- package/python/values_extract/samples/records.json +26 -0
- package/python/values_extract/samples.py +37 -0
- package/python/values_extract/workflow.py +1044 -0
- package/python/versions.json +3 -0
- package/templates/_shared/testing.ts +197 -2
- package/templates/ontology-fit/README.md +41 -32
- package/templates/ontology-fit/workflow.test.ts +56 -5
- package/templates/ontology-fit/workflow.ts +708 -207
- package/templates/review/README.md +56 -29
- package/templates/review/decision.ts +118 -37
- package/templates/review/workflow.test.ts +30 -10
- package/templates/review/workflow.ts +123 -76
- package/templates/values-extract/README.md +92 -23
- package/templates/values-extract/samples/expected.json +1 -0
- package/templates/values-extract/test-examples.ts +155 -0
- package/templates/values-extract/workflow.test.ts +532 -27
- package/templates/values-extract/workflow.ts +719 -179
- package/templates/versions.json +1 -1
package/README.md
CHANGED
|
@@ -11,6 +11,18 @@ npm install
|
|
|
11
11
|
npm test
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
+
`--lang python` creates the Python port of a template, for the
|
|
15
|
+
`littlebigbrain` Python SDK:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm create @littlebigbrain -- --template values-extract --lang python my-values
|
|
19
|
+
cd my-values
|
|
20
|
+
python3 -m venv .venv
|
|
21
|
+
source .venv/bin/activate
|
|
22
|
+
pip install -e ".[test]"
|
|
23
|
+
pytest
|
|
24
|
+
```
|
|
25
|
+
|
|
14
26
|
## Templates
|
|
15
27
|
|
|
16
28
|
| Template | Does |
|
|
@@ -44,33 +56,57 @@ the samples on it and creates the workflow there. `npm run setup` creates the
|
|
|
44
56
|
workflow on your own graph, and `npm start` serves it. All three read
|
|
45
57
|
`LBB_URL` and `LBB_API_KEY`.
|
|
46
58
|
|
|
59
|
+
## The Python project
|
|
60
|
+
|
|
61
|
+
`--lang python` takes a template with a Python port: `values-extract`. The
|
|
62
|
+
project's package is the template name with underscores (`values_extract`).
|
|
63
|
+
|
|
64
|
+
| File | Holds |
|
|
65
|
+
| --- | --- |
|
|
66
|
+
| `values_extract/workflow.py` | The workflow, its settings, the triggered workflow that sends it the instances (`trigger()`) and the ontology it writes (`ontology()`) |
|
|
67
|
+
| `values_extract/samples.py`, `values_extract/samples/` | The samples, their answer key, and the loader that puts them on a graph |
|
|
68
|
+
| `values_extract/__main__.py` | The commands `python -m values_extract try`, `setup` and `start` |
|
|
69
|
+
| `tests/test_workflow.py`, `tests/fake_lbb.py` | The workflow on its samples, with mock models and no server (`pytest`) |
|
|
70
|
+
| `pyproject.toml` | The project, its dependency `littlebigbrain>=<the version the port is written for>` and the `test` extra with `pytest` |
|
|
71
|
+
|
|
72
|
+
`try`, `setup` and `start` do what `npm run try`, `npm run setup` and
|
|
73
|
+
`npm start` do in a TypeScript project. They read `LBB_URL`, `LBB_API_KEY`
|
|
74
|
+
and `LBB_GRAPH`.
|
|
75
|
+
|
|
47
76
|
## Options
|
|
48
77
|
|
|
49
78
|
| Option | Meaning |
|
|
50
79
|
| --- | --- |
|
|
51
80
|
| `--template <name>` | The template (required) |
|
|
52
|
-
| `--
|
|
81
|
+
| `--lang <ts\|python>` | The project's language. Default: `ts`. `python` takes a template with a Python port |
|
|
82
|
+
| `--client <spec>` | The `@littlebigbrain/client` dependency of a TypeScript project. Default: the version the templates are written for |
|
|
53
83
|
| `[dir]` | The new project's directory. Default: the template's name. It must be empty or missing |
|
|
54
84
|
|
|
55
85
|
## Development
|
|
56
86
|
|
|
57
87
|
The package is developed in the little big brain monorepo, and the templates
|
|
58
88
|
live in its `sdk/templates`. There the command reads them from that folder.
|
|
59
|
-
|
|
60
|
-
and
|
|
89
|
+
The Python ports live in `sdk/python/examples` (`PYTHON_TEMPLATES` in
|
|
90
|
+
`lib/create.mjs`), and the test of a Python project in
|
|
91
|
+
`project/python/<package>`. `npm pack` copies the templates into the package
|
|
92
|
+
(`prepack`) with the client version, and the Python ports into `python/` with
|
|
93
|
+
the Python SDK version. It removes both copies afterwards.
|
|
61
94
|
|
|
62
95
|
```sh
|
|
63
96
|
node sdk/create/bin/create-littlebigbrain.mjs --template documents-link /tmp/my-links
|
|
97
|
+
node sdk/create/bin/create-littlebigbrain.mjs --template values-extract --lang python /tmp/my-values
|
|
64
98
|
npm test -w @littlebigbrain/create
|
|
65
99
|
```
|
|
66
100
|
|
|
67
101
|
The test creates each project, compiles it against the workspace's client
|
|
68
|
-
and runs its test.
|
|
102
|
+
and runs its test. The Python SDK's suite
|
|
103
|
+
(`sdk/python/tests/test_create_python_project.py`) creates the Python project
|
|
104
|
+
and runs its `pytest` on the SDK of the repository.
|
|
69
105
|
|
|
70
106
|
The public repository
|
|
71
107
|
[littlebigbrains/lbb-typescript](https://github.com/littlebigbrains/lbb-typescript)
|
|
72
|
-
holds the package in `create/`, with the templates and
|
|
73
|
-
`
|
|
108
|
+
holds the package in `create/`, with the templates, the Python ports and
|
|
109
|
+
their `versions.json` already copied in. There `prepack` keeps those copies.
|
|
74
110
|
A new version in `create/package.json` is published to npm by the
|
|
75
111
|
repository's `release-create.yml` workflow.
|
|
76
112
|
|
package/lib/create.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Create a project from a starter workflow template:
|
|
3
3
|
//
|
|
4
4
|
// npm create @littlebigbrain -- --template documents-link [dir]
|
|
5
|
+
// npm create @littlebigbrain -- --template values-extract --lang python [dir]
|
|
5
6
|
//
|
|
6
7
|
// The project holds the template's workflow, helpers, test and samples, and
|
|
7
8
|
// the scripts that run it: `npm test` (the workflow on its samples, with mock
|
|
@@ -9,6 +10,12 @@
|
|
|
9
10
|
// setup` and `npm start` (your graph). The templates come from `templates/`
|
|
10
11
|
// in the package (copied there when it is packed) or, in the repository,
|
|
11
12
|
// from `sdk/templates`.
|
|
13
|
+
//
|
|
14
|
+
// `--lang python` creates the Python port of a template: its package, a test
|
|
15
|
+
// with mock models (`pytest`) and the commands `python -m <package>
|
|
16
|
+
// try|setup|start`. The ports come from `python/` in the package (copied
|
|
17
|
+
// there when it is packed) or, in the repository, from `sdk/python/examples`.
|
|
18
|
+
// The test and its fake LBB come from `project/python/<package>`.
|
|
12
19
|
import { existsSync, readFileSync } from "node:fs";
|
|
13
20
|
import { cp, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
14
21
|
import { basename, dirname, join, resolve } from "node:path";
|
|
@@ -26,27 +33,54 @@ export const TEMPLATES = [
|
|
|
26
33
|
"sections",
|
|
27
34
|
];
|
|
28
35
|
|
|
36
|
+
/**
|
|
37
|
+
* The templates with a Python port: the folder names under
|
|
38
|
+
* `sdk/python/examples`, with dashes for underscores.
|
|
39
|
+
*/
|
|
40
|
+
export const PYTHON_TEMPLATES = ["values-extract"];
|
|
41
|
+
|
|
42
|
+
/** The languages of a project: `ts` (the default) and `python`. */
|
|
43
|
+
export const LANGS = ["ts", "python"];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The Python package of a template's port: `values-extract` is `values_extract`.
|
|
47
|
+
* @param {string} template
|
|
48
|
+
*/
|
|
49
|
+
export const pythonModule = (template) => template.replaceAll("-", "_");
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A copy filter that leaves out Python's caches.
|
|
53
|
+
* @param {string} source
|
|
54
|
+
*/
|
|
55
|
+
export const withoutCaches = (source) =>
|
|
56
|
+
basename(source) !== "__pycache__" && !source.endsWith(".pyc");
|
|
57
|
+
|
|
29
58
|
const PACKAGE = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
30
59
|
|
|
31
60
|
const USAGE = `Create a project from a little big brain starter workflow template.
|
|
32
61
|
|
|
33
62
|
Usage:
|
|
34
|
-
npm create @littlebigbrain -- --template <name> [dir]
|
|
63
|
+
npm create @littlebigbrain -- --template <name> [--lang ts|python] [dir]
|
|
35
64
|
|
|
36
65
|
Templates: ${TEMPLATES.join(", ")}
|
|
66
|
+
With a Python port (--lang python): ${PYTHON_TEMPLATES.join(", ")}
|
|
37
67
|
|
|
38
68
|
Options:
|
|
39
69
|
--template <name> the template (required)
|
|
40
|
-
--
|
|
70
|
+
--lang <ts|python> the project's language (default: ts)
|
|
71
|
+
--client <spec> the @littlebigbrain/client dependency of a TypeScript project
|
|
72
|
+
(default: ^<the current version>)
|
|
41
73
|
--help this text`;
|
|
42
74
|
|
|
75
|
+
/** @typedef {{ template?: string, dir?: string, client?: string, lang?: string, help: boolean }} Args */
|
|
76
|
+
|
|
43
77
|
/**
|
|
44
78
|
* The options of the command line.
|
|
45
79
|
* @param {string[]} argv
|
|
46
|
-
* @returns {
|
|
80
|
+
* @returns {Args}
|
|
47
81
|
*/
|
|
48
82
|
export function parseArgs(argv) {
|
|
49
|
-
/** @type {
|
|
83
|
+
/** @type {Args} */
|
|
50
84
|
const out = { help: false };
|
|
51
85
|
for (let i = 0; i < argv.length; i++) {
|
|
52
86
|
const arg = argv[i];
|
|
@@ -59,7 +93,11 @@ export function parseArgs(argv) {
|
|
|
59
93
|
if (flag === "--help" || flag === "-h") out.help = true;
|
|
60
94
|
else if (flag === "--template" || flag === "-t") out.template = value();
|
|
61
95
|
else if (flag === "--client") out.client = value();
|
|
62
|
-
else if (flag
|
|
96
|
+
else if (flag === "--lang") {
|
|
97
|
+
const lang = value();
|
|
98
|
+
if (!LANGS.includes(lang)) throw new Error(`--lang is ts or python, not ${lang}`);
|
|
99
|
+
out.lang = lang;
|
|
100
|
+
} else if (flag.startsWith("-")) throw new Error(`unknown option ${flag}`);
|
|
63
101
|
else if (out.dir === undefined) out.dir = arg;
|
|
64
102
|
else throw new Error(`one directory, not ${out.dir} and ${arg}`);
|
|
65
103
|
}
|
|
@@ -88,6 +126,33 @@ export function clientVersion(templates) {
|
|
|
88
126
|
return JSON.parse(readFileSync(repo, "utf8")).version;
|
|
89
127
|
}
|
|
90
128
|
|
|
129
|
+
/**
|
|
130
|
+
* The folder that holds the Python ports: the package's own copy, else the
|
|
131
|
+
* repository's `sdk/python/examples`.
|
|
132
|
+
* @param {string} [root] the package folder
|
|
133
|
+
*/
|
|
134
|
+
export function pythonRoot(root = PACKAGE) {
|
|
135
|
+
const packed = join(root, "python");
|
|
136
|
+
if (existsSync(join(packed, "versions.json"))) return packed;
|
|
137
|
+
const repo = join(root, "..", "python", "examples");
|
|
138
|
+
if (existsSync(join(repo, "..", "lbb", "_version.py"))) return repo;
|
|
139
|
+
throw new Error(`no Python ports in ${packed} or ${repo}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The version of the `littlebigbrain` Python SDK the ports are written for.
|
|
144
|
+
* @param {string} ports the folder of the Python ports
|
|
145
|
+
* @returns {string}
|
|
146
|
+
*/
|
|
147
|
+
export function pythonSdkVersion(ports) {
|
|
148
|
+
const packed = join(ports, "versions.json");
|
|
149
|
+
if (existsSync(packed)) return JSON.parse(readFileSync(packed, "utf8")).littlebigbrain;
|
|
150
|
+
const file = join(ports, "..", "lbb", "_version.py");
|
|
151
|
+
const found = readFileSync(file, "utf8").match(/^__version__ = "([^"]+)"$/m);
|
|
152
|
+
if (!found?.[1]) throw new Error(`no __version__ in ${file}`);
|
|
153
|
+
return found[1];
|
|
154
|
+
}
|
|
155
|
+
|
|
91
156
|
/** A package name from a directory name. @param {string} dir */
|
|
92
157
|
const packageName = (dir) =>
|
|
93
158
|
basename(resolve(dir))
|
|
@@ -95,6 +160,17 @@ const packageName = (dir) =>
|
|
|
95
160
|
.replace(/[^a-z0-9-._~]+/g, "-")
|
|
96
161
|
.replace(/^[-._]+|[-._]+$/g, "") || "workflow";
|
|
97
162
|
|
|
163
|
+
/**
|
|
164
|
+
* A Python project name from a directory name: letters, digits, `.`, `_` and
|
|
165
|
+
* `-`, with a letter or a digit at each end.
|
|
166
|
+
* @param {string} dir
|
|
167
|
+
*/
|
|
168
|
+
const pythonName = (dir) =>
|
|
169
|
+
basename(resolve(dir))
|
|
170
|
+
.toLowerCase()
|
|
171
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
172
|
+
.replace(/^[-._]+|[-._]+$/g, "") || "workflow";
|
|
173
|
+
|
|
98
174
|
/**
|
|
99
175
|
* A template's README as a section of the project's: no title, its headings
|
|
100
176
|
* one level down, and its links into the repository as plain text.
|
|
@@ -107,16 +183,25 @@ const projectReadme = (text) =>
|
|
|
107
183
|
.replace(/^(#{2,}) /gm, "#$1 ");
|
|
108
184
|
|
|
109
185
|
/**
|
|
110
|
-
* Create the project of `template` in `dir
|
|
111
|
-
* relative to `dir`, sorted.
|
|
112
|
-
* @param {{ template: string, dir: string, client?: string, root?: string }} options
|
|
186
|
+
* Create the project of `template` in `dir`, in TypeScript (`lang` `ts`, the
|
|
187
|
+
* default) or in Python. Returns the files written, relative to `dir`, sorted.
|
|
188
|
+
* @param {{ template: string, dir: string, lang?: string, client?: string, root?: string }} options
|
|
113
189
|
* @returns {Promise<string[]>}
|
|
114
190
|
*/
|
|
115
|
-
export async function create({ template, dir, client, root = PACKAGE }) {
|
|
191
|
+
export async function create({ template, dir, lang = "ts", client, root = PACKAGE }) {
|
|
116
192
|
if (!TEMPLATES.includes(template))
|
|
117
193
|
throw new Error(`unknown template ${template}; the templates are ${TEMPLATES.join(", ")}`);
|
|
194
|
+
if (!LANGS.includes(lang)) throw new Error(`the language is ts or python, not ${lang}`);
|
|
195
|
+
if (lang === "python" && !PYTHON_TEMPLATES.includes(template))
|
|
196
|
+
throw new Error(
|
|
197
|
+
`the template ${template} has no Python port; ` +
|
|
198
|
+
`the templates with one are ${PYTHON_TEMPLATES.join(", ")}`,
|
|
199
|
+
);
|
|
200
|
+
if (lang === "python" && client !== undefined)
|
|
201
|
+
throw new Error("--client names the dependency of a TypeScript project (--lang ts)");
|
|
118
202
|
if (existsSync(dir) && (await readdir(dir)).length)
|
|
119
203
|
throw new Error(`${dir} is not empty: name a new directory`);
|
|
204
|
+
if (lang === "python") return createPython({ template, dir, root });
|
|
120
205
|
const templates = templatesRoot(root);
|
|
121
206
|
const from = join(templates, template);
|
|
122
207
|
const src = join(dir, "src");
|
|
@@ -156,6 +241,7 @@ export async function create({ template, dir, client, root = PACKAGE }) {
|
|
|
156
241
|
test: "tsx --test src/workflow.test.ts",
|
|
157
242
|
typecheck: "tsc --noEmit",
|
|
158
243
|
...(code.includes("grade-run.ts") ? { grade: "tsx src/grade-run.ts" } : {}),
|
|
244
|
+
...(code.includes("test-examples.ts") ? { "test:examples": "tsx src/test-examples.ts" } : {}),
|
|
159
245
|
},
|
|
160
246
|
dependencies: { "@littlebigbrain/client": client ?? `^${clientVersion(templates)}` },
|
|
161
247
|
devDependencies: { "@types/node": "^22", tsx: "^4", typescript: "^5.6" },
|
|
@@ -192,7 +278,7 @@ project of its own: a workflow that your \`WorkflowWorker\` runs on a graph.
|
|
|
192
278
|
npm install
|
|
193
279
|
npm test # the workflow on its samples, with mock models: no server
|
|
194
280
|
npm run try # the samples on a graph of your stack, and the workflow there
|
|
195
|
-
npm run setup && npm start # your graph: create the workflow, then serve it
|
|
281
|
+
${code.includes("test-examples.ts") ? "npm run test:examples # the workflow on instances of your graph; writes nothing\n" : ""}npm run setup && npm start # your graph: create the workflow, then serve it
|
|
196
282
|
\`\`\`
|
|
197
283
|
|
|
198
284
|
\`npm run try\`, \`npm run setup\` and \`npm start\` read \`LBB_URL\` (your
|
|
@@ -221,6 +307,124 @@ ${projectReadme(readme).trim()}
|
|
|
221
307
|
return listFiles(dir);
|
|
222
308
|
}
|
|
223
309
|
|
|
310
|
+
/**
|
|
311
|
+
* Create the Python project of `template` in `dir`: the port's package, the
|
|
312
|
+
* test and the fake LBB it runs on (`tests/`), `pyproject.toml`, `.gitignore`
|
|
313
|
+
* and `README.md`. Returns the files written, relative to `dir`, sorted.
|
|
314
|
+
* @param {{ template: string, dir: string, root: string }} options
|
|
315
|
+
* @returns {Promise<string[]>}
|
|
316
|
+
*/
|
|
317
|
+
async function createPython({ template, dir, root }) {
|
|
318
|
+
const ports = pythonRoot(root);
|
|
319
|
+
const module = pythonModule(template);
|
|
320
|
+
const from = join(ports, module);
|
|
321
|
+
const portReadme = join(from, "README.md");
|
|
322
|
+
await mkdir(dir, { recursive: true });
|
|
323
|
+
// The port's package, without its README: the project's README holds it.
|
|
324
|
+
await cp(from, join(dir, module), {
|
|
325
|
+
recursive: true,
|
|
326
|
+
filter: (source) => withoutCaches(source) && source !== portReadme,
|
|
327
|
+
});
|
|
328
|
+
await cp(join(root, "project", "python", module), dir, {
|
|
329
|
+
recursive: true,
|
|
330
|
+
filter: withoutCaches,
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const name = pythonName(dir);
|
|
334
|
+
await writeFile(
|
|
335
|
+
join(dir, "pyproject.toml"),
|
|
336
|
+
`[build-system]
|
|
337
|
+
requires = ["setuptools>=68"]
|
|
338
|
+
build-backend = "setuptools.build_meta"
|
|
339
|
+
|
|
340
|
+
[project]
|
|
341
|
+
name = "${name}"
|
|
342
|
+
version = "0.1.0"
|
|
343
|
+
description = "The ${template} starter workflow template of little big brain, in Python"
|
|
344
|
+
requires-python = ">=3.10"
|
|
345
|
+
dependencies = ["littlebigbrain>=${pythonSdkVersion(ports)}"]
|
|
346
|
+
|
|
347
|
+
[project.optional-dependencies]
|
|
348
|
+
# pytest: the workflow on its samples, with mock models.
|
|
349
|
+
test = ["pytest>=8"]
|
|
350
|
+
|
|
351
|
+
[tool.setuptools.packages.find]
|
|
352
|
+
include = ["${module}*"]
|
|
353
|
+
|
|
354
|
+
[tool.setuptools.package-data]
|
|
355
|
+
${module} = ["samples/**/*"]
|
|
356
|
+
|
|
357
|
+
[tool.pytest.ini_options]
|
|
358
|
+
testpaths = ["tests"]
|
|
359
|
+
pythonpath = [".", "tests"]
|
|
360
|
+
`,
|
|
361
|
+
);
|
|
362
|
+
await writeFile(
|
|
363
|
+
join(dir, ".gitignore"),
|
|
364
|
+
".venv/\n__pycache__/\n*.egg-info/\n.pytest_cache/\n.env\n",
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
// The port's README up to its first section: what the workflow does.
|
|
368
|
+
const about = projectReadme((await readFile(portReadme, "utf8")).split(/^## /m)[0] ?? "");
|
|
369
|
+
await writeFile(
|
|
370
|
+
join(dir, "README.md"),
|
|
371
|
+
`# ${name}
|
|
372
|
+
|
|
373
|
+
The \`${template}\` starter workflow template of little big brain, in Python,
|
|
374
|
+
as a project of its own: a workflow that your \`WorkflowWorker\` runs on a
|
|
375
|
+
graph.
|
|
376
|
+
|
|
377
|
+
## Commands
|
|
378
|
+
|
|
379
|
+
Create a virtual environment, install the project with its test
|
|
380
|
+
dependencies, and run the test. The test runs the workflow on its samples,
|
|
381
|
+
with mock models and no server.
|
|
382
|
+
|
|
383
|
+
\`\`\`sh
|
|
384
|
+
python3 -m venv .venv
|
|
385
|
+
source .venv/bin/activate
|
|
386
|
+
pip install -e ".[test]"
|
|
387
|
+
pytest
|
|
388
|
+
\`\`\`
|
|
389
|
+
|
|
390
|
+
Then put the samples on a graph of your stack, create the workflow there, and
|
|
391
|
+
serve it:
|
|
392
|
+
|
|
393
|
+
\`\`\`sh
|
|
394
|
+
export LBB_URL=... LBB_API_KEY=...
|
|
395
|
+
python -m ${module} try
|
|
396
|
+
LBB_GRAPH=try-${template} python -m ${module} start
|
|
397
|
+
\`\`\`
|
|
398
|
+
|
|
399
|
+
| Command | Does |
|
|
400
|
+
| --- | --- |
|
|
401
|
+
| \`python -m ${module} try\` | Creates a graph (\`LBB_GRAPH\`, default \`try-${template}\`), puts the samples on it, and creates the workflow there |
|
|
402
|
+
| \`python -m ${module} setup\` | Creates the workflow on your graph (\`LBB_GRAPH\`, default \`main\`) |
|
|
403
|
+
| \`python -m ${module} start\` | Serves the workflow on \`LBB_GRAPH\` (default \`main\`) until SIGTERM or Ctrl-C. \`LBB_WORKER_ID\` names the worker, \`LBB_CONCURRENCY\` sets how many turns run at once |
|
|
404
|
+
|
|
405
|
+
The three commands read \`LBB_URL\` (your stack's endpoint) and
|
|
406
|
+
\`LBB_API_KEY\` (a key of the stack). The server needs the workflow engine,
|
|
407
|
+
and the models need a plan with managed models.
|
|
408
|
+
|
|
409
|
+
## Files
|
|
410
|
+
|
|
411
|
+
- \`${module}/workflow.py\`: the workflow, its settings (\`Settings\`,
|
|
412
|
+
\`DEFAULTS\`), the triggered workflow that sends it the instances
|
|
413
|
+
(\`trigger()\`) and the ontology it writes (\`ontology()\`). Change it here.
|
|
414
|
+
- \`${module}/samples.py\` and \`${module}/samples/\`: the samples, their
|
|
415
|
+
answer key, and what \`try\` does with them.
|
|
416
|
+
- \`${module}/__main__.py\`: the three commands.
|
|
417
|
+
- \`tests/test_workflow.py\` and \`tests/fake_lbb.py\`: the test, and the fake
|
|
418
|
+
LBB it runs the SDK's worker against.
|
|
419
|
+
|
|
420
|
+
## The template
|
|
421
|
+
|
|
422
|
+
${about.trim()}
|
|
423
|
+
`,
|
|
424
|
+
);
|
|
425
|
+
return listFiles(dir);
|
|
426
|
+
}
|
|
427
|
+
|
|
224
428
|
/**
|
|
225
429
|
* The TypeScript files of a template folder, relative, in its folders too;
|
|
226
430
|
* the samples are copied on their own.
|
|
@@ -258,11 +462,21 @@ export async function main(argv, io = {}) {
|
|
|
258
462
|
if (!options.help) throw new Error("--template is required");
|
|
259
463
|
return;
|
|
260
464
|
}
|
|
261
|
-
const
|
|
262
|
-
const
|
|
263
|
-
|
|
465
|
+
const { template, lang = "ts", client } = options;
|
|
466
|
+
const dir = options.dir ?? template;
|
|
467
|
+
const files = await create({ template, dir, lang, client });
|
|
468
|
+
if (lang === "python") {
|
|
469
|
+
log(`Created ${dir} from the ${template} template, in Python (${files.length} files). Next:`);
|
|
470
|
+
log(` cd ${dir}`);
|
|
471
|
+
log(" python3 -m venv .venv && source .venv/bin/activate");
|
|
472
|
+
log(' pip install -e ".[test]"');
|
|
473
|
+
log(" pytest");
|
|
474
|
+
log(` LBB_URL=... LBB_API_KEY=... python -m ${pythonModule(template)} try`);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
log(`Created ${dir} from the ${template} template (${files.length} files). Next:`);
|
|
264
478
|
log(` cd ${dir}`);
|
|
265
479
|
log(" npm install");
|
|
266
480
|
log(" npm test");
|
|
267
|
-
log(` LBB_URL=... LBB_API_KEY=... LBB_GRAPH=try-${
|
|
481
|
+
log(` LBB_URL=... LBB_API_KEY=... LBB_GRAPH=try-${template} npm run try`);
|
|
268
482
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@littlebigbrain/create",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Create a project from a little big brain starter workflow template: npm create @littlebigbrain",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"lib",
|
|
26
26
|
"project",
|
|
27
27
|
"templates",
|
|
28
|
+
"python",
|
|
28
29
|
"README.md",
|
|
29
30
|
"LICENSE"
|
|
30
31
|
],
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""A fake LBB for the test of the workflow, in memory: the workflow engine,
|
|
2
|
+
the model activities with the mock rules the test gives, the SPARQL rows of
|
|
3
|
+
the samples, commits and review items. The real ``WorkflowWorker`` and
|
|
4
|
+
``lbb_activities`` of the SDK run against it, so the test needs no server."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from lbb import AsyncLbbClient
|
|
16
|
+
from lbb.workflows import WorkflowWorker, lbb_activities
|
|
17
|
+
|
|
18
|
+
from values_extract.workflow import DEFAULTS, Settings, create_workflow
|
|
19
|
+
|
|
20
|
+
BASE = "/v1/workflows/turns"
|
|
21
|
+
TIME = "2026-10-21T10:00:00Z"
|
|
22
|
+
|
|
23
|
+
# The rows of a SPARQL query: `s` (the IRI), `label` and `text`.
|
|
24
|
+
Rows = Callable[[str], list[dict[str, str]]]
|
|
25
|
+
# The value of the reasoning model for a `reason` request.
|
|
26
|
+
Reason = Callable[[dict[str, Any]], dict[str, Any]]
|
|
27
|
+
# The answers of the decision model for a `decide` request, by question id.
|
|
28
|
+
Decide = Callable[[dict[str, Any]], dict[str, Any]]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def iri_of(name: str, class_name: str = DEFAULTS.class_name) -> str:
|
|
32
|
+
"""The IRI of the instance ``name`` in the fake graph."""
|
|
33
|
+
digest = hashlib.sha256(f"{class_name}:{name}".encode()).hexdigest()[:32]
|
|
34
|
+
return f"https://littlebigbrain.com/e/{digest}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def message(iris: list[str]) -> dict[str, Any]:
|
|
38
|
+
"""The message the triggered workflow sends for new or changed instances."""
|
|
39
|
+
return {
|
|
40
|
+
"type": "instances",
|
|
41
|
+
"workflow": "values-extract",
|
|
42
|
+
"graph": "main",
|
|
43
|
+
"as_of_commit_seq": 1,
|
|
44
|
+
"reason": "changed",
|
|
45
|
+
"instances": iris,
|
|
46
|
+
"removed": [],
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class FakeLbb:
|
|
51
|
+
"""The routes the workflow calls. Each queued message is one turn,
|
|
52
|
+
claimed in order."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, rows: Rows, *, reason: Reason, decide: Decide) -> None:
|
|
55
|
+
self.rows = rows
|
|
56
|
+
self.reason = reason
|
|
57
|
+
self.decide = decide
|
|
58
|
+
self.turns = 0
|
|
59
|
+
self.pending: list[dict[str, Any]] = []
|
|
60
|
+
self.model_calls: list[str] = []
|
|
61
|
+
self.commits: dict[str, dict[str, Any]] = {}
|
|
62
|
+
# The review items the workflow filed, by key.
|
|
63
|
+
self.reviews: dict[str, dict[str, Any]] = {}
|
|
64
|
+
self.requeued: list[str] = []
|
|
65
|
+
self.completed: list[dict[str, Any]] = []
|
|
66
|
+
|
|
67
|
+
def queue(self, message: dict[str, Any]) -> None:
|
|
68
|
+
"""A turn for ``message``, claimed next."""
|
|
69
|
+
self.turns += 1
|
|
70
|
+
number = self.turns
|
|
71
|
+
workflow_id = f"starter.{message['workflow']}"
|
|
72
|
+
self.pending.append(
|
|
73
|
+
{
|
|
74
|
+
"workflow_type": "values-extract",
|
|
75
|
+
"state": {},
|
|
76
|
+
"token": f"token-{number}",
|
|
77
|
+
"lease_ms": 60_000,
|
|
78
|
+
"effect_prefix": f"e{number}",
|
|
79
|
+
"turn": {
|
|
80
|
+
"workflow_id": workflow_id,
|
|
81
|
+
"number": number,
|
|
82
|
+
"message_id": f"{workflow_id}-{number}",
|
|
83
|
+
"message": message,
|
|
84
|
+
"version": "v1",
|
|
85
|
+
"status": "running",
|
|
86
|
+
"attempt": 1,
|
|
87
|
+
"created_at_ms": 1,
|
|
88
|
+
"updated_at_ms": 1,
|
|
89
|
+
"ready_at_ms": 1,
|
|
90
|
+
"steps": [],
|
|
91
|
+
"result": None,
|
|
92
|
+
"state_after": None,
|
|
93
|
+
"sequence": 1,
|
|
94
|
+
"run": {"id": f"run-{number}", "started_at_ms": 1, "queue_before": 1},
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def sparql(self, query: str) -> dict[str, Any]:
|
|
100
|
+
bindings = []
|
|
101
|
+
for row in self.rows(query):
|
|
102
|
+
binding = {"s": {"type": "uri", "value": row["s"]}}
|
|
103
|
+
for name, value in row.items():
|
|
104
|
+
if name != "s":
|
|
105
|
+
binding[name] = {"type": "literal", "value": value}
|
|
106
|
+
bindings.append(binding)
|
|
107
|
+
doc = {"head": {"vars": ["s", "label", "text"]}, "results": {"bindings": bindings}}
|
|
108
|
+
return {"results": json.dumps(doc)}
|
|
109
|
+
|
|
110
|
+
def model(self, kind: str, body: dict[str, Any]) -> httpx.Response:
|
|
111
|
+
self.model_calls.append(body.get("effect_id", ""))
|
|
112
|
+
usage = {"model": "mock", "tokens_in": 10, "tokens_out": 5, "cost_micro_usd": 1}
|
|
113
|
+
if kind == "reason":
|
|
114
|
+
answer = {"value": self.reason(body), **usage, "cached": False}
|
|
115
|
+
else:
|
|
116
|
+
answer = {"answers": self.decide(body), **usage, "cached": False}
|
|
117
|
+
return httpx.Response(200, json=answer)
|
|
118
|
+
|
|
119
|
+
def item_of(self, request: dict[str, Any]) -> dict[str, Any]:
|
|
120
|
+
"""A review item as the server keeps it: open, with no answer yet."""
|
|
121
|
+
key = request["key"]
|
|
122
|
+
return {
|
|
123
|
+
"id": f"rev_{key[-8:]}",
|
|
124
|
+
"object": "review_item",
|
|
125
|
+
"created": TIME,
|
|
126
|
+
"updated": TIME,
|
|
127
|
+
"kind": request["kind"],
|
|
128
|
+
"scope": "live",
|
|
129
|
+
"status": "open",
|
|
130
|
+
"key": key,
|
|
131
|
+
"title": request["title"],
|
|
132
|
+
"rationale": request.get("rationale"),
|
|
133
|
+
"origin": request["origin"],
|
|
134
|
+
"workflow": request.get("workflow"),
|
|
135
|
+
"subject": request.get("subject"),
|
|
136
|
+
"question": request["question"],
|
|
137
|
+
"evidence": request.get("evidence", []),
|
|
138
|
+
"evidence_hash": request.get("evidence_hash"),
|
|
139
|
+
"facts": request.get("facts"),
|
|
140
|
+
"revision": 1,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async def handle(self, request: httpx.Request) -> httpx.Response:
|
|
144
|
+
path = request.url.path
|
|
145
|
+
body: Any = json.loads(request.content) if request.content else None
|
|
146
|
+
if path == f"{BASE}/claim":
|
|
147
|
+
task = self.pending.pop(0) if self.pending else None
|
|
148
|
+
return httpx.Response(200, json={"task": task})
|
|
149
|
+
if path == f"{BASE}/complete":
|
|
150
|
+
self.completed.append(body)
|
|
151
|
+
return httpx.Response(200, json={})
|
|
152
|
+
if path.startswith(BASE):
|
|
153
|
+
# Checkpoints and heartbeats: one attempt per turn needs no saved steps.
|
|
154
|
+
return httpx.Response(200, json={})
|
|
155
|
+
if path == "/v1/workflows/activities/reason":
|
|
156
|
+
return self.model("reason", body)
|
|
157
|
+
if path == "/v1/workflows/activities/decide":
|
|
158
|
+
return self.model("decide", body)
|
|
159
|
+
if path == "/v1/query/sparql-text":
|
|
160
|
+
return httpx.Response(200, json=self.sparql(body["query"]))
|
|
161
|
+
if path == "/v1/graph/commit":
|
|
162
|
+
key = request.headers["idempotency-key"]
|
|
163
|
+
replayed = key in self.commits
|
|
164
|
+
self.commits.setdefault(key, body)
|
|
165
|
+
return httpx.Response(
|
|
166
|
+
200,
|
|
167
|
+
json={
|
|
168
|
+
"commit_seq": list(self.commits).index(key) + 1,
|
|
169
|
+
"idempotency_key": key,
|
|
170
|
+
"replayed": replayed,
|
|
171
|
+
"entities_written": len(body.get("entity_properties", [])),
|
|
172
|
+
"edges_written": len(body.get("triplets", [])),
|
|
173
|
+
},
|
|
174
|
+
)
|
|
175
|
+
if path == "/v1/reviews" and request.method == "POST":
|
|
176
|
+
self.reviews[body["key"]] = body
|
|
177
|
+
return httpx.Response(200, json=self.item_of(body))
|
|
178
|
+
if path == "/v1/reviews":
|
|
179
|
+
keys = [key for key in request.url.params.get("key", "").split(",") if key]
|
|
180
|
+
data = [
|
|
181
|
+
self.item_of(self.reviews[key])
|
|
182
|
+
for key in dict.fromkeys(keys)
|
|
183
|
+
if key in self.reviews
|
|
184
|
+
]
|
|
185
|
+
return httpx.Response(
|
|
186
|
+
200, json={"object": "list", "data": data, "has_more": False, "next_cursor": None}
|
|
187
|
+
)
|
|
188
|
+
if path == "/v1/workflows/named/requeue":
|
|
189
|
+
self.requeued += [item["instance"] for item in body["items"]]
|
|
190
|
+
return httpx.Response(
|
|
191
|
+
200,
|
|
192
|
+
json={
|
|
193
|
+
"attempt": 1,
|
|
194
|
+
"requeued": [item["instance"] for item in body["items"]],
|
|
195
|
+
"exhausted": [],
|
|
196
|
+
"not_before": TIME,
|
|
197
|
+
},
|
|
198
|
+
)
|
|
199
|
+
raise AssertionError(f"the fake LBB has no route {request.method} {path}")
|
|
200
|
+
|
|
201
|
+
def written(self, name: str, class_name: str = DEFAULTS.class_name) -> dict[str, Any]:
|
|
202
|
+
"""The values written on the instance ``name``, over every commit."""
|
|
203
|
+
values: dict[str, Any] = {}
|
|
204
|
+
for commit in self.commits.values():
|
|
205
|
+
for row in commit.get("entity_properties", []):
|
|
206
|
+
if row["type"] == class_name and row["name"] == name:
|
|
207
|
+
values.update(row["properties"])
|
|
208
|
+
return values
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def serve(fake: FakeLbb, settings: Settings = DEFAULTS) -> None:
|
|
212
|
+
"""Run the workflow with the SDK's worker on each queued turn."""
|
|
213
|
+
|
|
214
|
+
async def main() -> None:
|
|
215
|
+
async with AsyncLbbClient(
|
|
216
|
+
"http://localhost:7400",
|
|
217
|
+
graph="main",
|
|
218
|
+
retry_delay=0,
|
|
219
|
+
transport=httpx.MockTransport(fake.handle),
|
|
220
|
+
) as client:
|
|
221
|
+
workflow = create_workflow(lbb_activities(client), settings)
|
|
222
|
+
worker = WorkflowWorker(client, [workflow], worker="test-1")
|
|
223
|
+
while fake.pending:
|
|
224
|
+
await worker.run_once()
|
|
225
|
+
|
|
226
|
+
asyncio.run(main())
|