@littlebigbrain/create 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +42 -6
  2. package/lib/create.mjs +228 -14
  3. package/package.json +2 -1
  4. package/project/python/values_extract/tests/fake_lbb.py +226 -0
  5. package/project/python/values_extract/tests/test_workflow.py +150 -0
  6. package/python/values_extract/README.md +52 -0
  7. package/python/values_extract/__init__.py +3 -0
  8. package/python/values_extract/__main__.py +97 -0
  9. package/python/values_extract/samples/expected.json +149 -0
  10. package/python/values_extract/samples/records.json +26 -0
  11. package/python/values_extract/samples.py +37 -0
  12. package/python/values_extract/workflow.py +1044 -0
  13. package/python/versions.json +3 -0
  14. package/templates/_shared/testing.ts +259 -5
  15. package/templates/ontology-fit/README.md +41 -32
  16. package/templates/ontology-fit/workflow.test.ts +56 -5
  17. package/templates/ontology-fit/workflow.ts +708 -207
  18. package/templates/review/README.md +56 -29
  19. package/templates/review/decision.ts +118 -37
  20. package/templates/review/workflow.test.ts +30 -10
  21. package/templates/review/workflow.ts +123 -76
  22. package/templates/rows/README.md +8 -1
  23. package/templates/rows/workflow.test.ts +56 -0
  24. package/templates/rows/workflow.ts +182 -148
  25. package/templates/sections/README.md +8 -1
  26. package/templates/sections/workflow.test.ts +51 -1
  27. package/templates/sections/workflow.ts +137 -98
  28. package/templates/values-extract/README.md +92 -23
  29. package/templates/values-extract/samples/expected.json +1 -0
  30. package/templates/values-extract/test-examples.ts +155 -0
  31. package/templates/values-extract/workflow.test.ts +532 -27
  32. package/templates/values-extract/workflow.ts +719 -179
  33. package/templates/versions.json +1 -1
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
- | `--client <spec>` | The `@littlebigbrain/client` dependency of the project. Default: the version the templates are written for |
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
- `npm pack` copies them into the package (`prepack`) with the client version,
60
- and removes the copy afterwards.
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
- `templates/versions.json` already copied in. There `prepack` keeps that copy.
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
- --client <spec> the @littlebigbrain/client dependency (default: ^<the current version>)
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 {{ template?: string, dir?: string, client?: string, help: boolean }}
80
+ * @returns {Args}
47
81
  */
48
82
  export function parseArgs(argv) {
49
- /** @type {{ template?: string, dir?: string, client?: string, help: boolean }} */
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.startsWith("-")) throw new Error(`unknown option ${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`. Returns the files written,
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 dir = options.dir ?? options.template;
262
- const files = await create({ template: options.template, dir, client: options.client });
263
- log(`Created ${dir} from the ${options.template} template (${files.length} files). Next:`);
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-${options.template} npm run 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.4.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())