@littlebigbrain/create 0.1.0 → 0.2.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 (30) hide show
  1. package/README.md +3 -1
  2. package/lib/create.mjs +7 -1
  3. package/package.json +1 -1
  4. package/templates/_shared/testing.ts +36 -5
  5. package/templates/documents-link/README.md +9 -7
  6. package/templates/documents-link/workflow.test.ts +28 -1
  7. package/templates/documents-link/workflow.ts +24 -14
  8. package/templates/documents-map/README.md +212 -0
  9. package/templates/documents-map/map.ts +1135 -0
  10. package/templates/documents-map/samples/ausstattungsliste.pdf +76 -0
  11. package/templates/documents-map/samples/baubeschreibung.pdf +54 -0
  12. package/templates/documents-map/samples/expected.json +50 -0
  13. package/templates/documents-map/samples/pages/raumblaetter.json +1580 -0
  14. package/templates/documents-map/samples/raumblaetter.pdf +109 -0
  15. package/templates/documents-map/samples/trace.json +613 -0
  16. package/templates/documents-map/samples.ts +182 -0
  17. package/templates/documents-map/workflow.test.ts +770 -0
  18. package/templates/documents-map/workflow.ts +1199 -0
  19. package/templates/ontology-fit/workflow.test.ts +7 -1
  20. package/templates/ontology-fit/workflow.ts +5 -2
  21. package/templates/review/README.md +158 -0
  22. package/templates/review/decision.ts +483 -0
  23. package/templates/review/samples/expected.json +37 -0
  24. package/templates/review/samples/proposals.json +35 -0
  25. package/templates/review/samples.ts +154 -0
  26. package/templates/review/workflow.test.ts +438 -0
  27. package/templates/review/workflow.ts +459 -0
  28. package/templates/values-extract/workflow.test.ts +24 -1
  29. package/templates/values-extract/workflow.ts +11 -3
  30. package/templates/versions.json +1 -1
@@ -0,0 +1,459 @@
1
+ // review: decide which fact proposals become facts.
2
+ //
3
+ // A proposer (documents-map, LBB's documents.map, or any workflow of yours) does not write the
4
+ // facts of the graph's own ontology itself: it writes one `FactProposal` per statement, with its
5
+ // confidence and evidence. A triggered workflow of the starter workflow `workflow` watches the
6
+ // proposals' statements and sends the new ones here. For each proposal, once:
7
+ //
8
+ // - refused when the graph cannot hold the statement (an unknown predicate, a value of the wrong
9
+ // type, a new instance without its class, a domain or a range the ends do not fit);
10
+ // - to a person when a rule of `alwaysReview` names it (`new_instance` or a predicate; by default
11
+ // no rule), when it would replace another writer's value or name, or when its confidence is
12
+ // below the gate;
13
+ // - written otherwise: the statement, with the proposal as its evidence (`writeOwned`).
14
+ //
15
+ // The proposals for a person go into one review item per instance (`review`): the instance a
16
+ // proposal is about (its subject, the new object of a link from an instance the graph has, and
17
+ // always the object of the trace). The item's facts are each statement and its proposal's
18
+ // `fact_status: accepted`; a person can leave facts out of the accept, except the type of a new
19
+ // instance. The proposal gets its `fact_status` (`written`, `in_review`, `refused`) and
20
+ // `review_note`, owned by this workflow.
21
+ import { createHash } from "node:crypto";
22
+ import {
23
+ entityRef,
24
+ triggeredWorkflow,
25
+ type LbbActivities,
26
+ type Schemas,
27
+ type WorkflowTriggerMessage,
28
+ } from "@littlebigbrain/client";
29
+ import * as review from "./decision.js";
30
+
31
+ export interface Settings {
32
+ /** The class of the proposals. */
33
+ className: string;
34
+ /** A proposal below this confidence goes to a person. */
35
+ gate: number;
36
+ /** What goes to a person whatever its confidence: `new_instance` (a statement about an instance the graph does not have yet) and predicate IRIs. Empty: the gate decides everything. */
37
+ alwaysReview: string[];
38
+ }
39
+
40
+ /** The settings of LBB's review: edit them for your graph. */
41
+ export const defaults: Settings = {
42
+ className: "FactProposal",
43
+ gate: 0.8,
44
+ alwaysReview: [],
45
+ };
46
+
47
+ /** The workflow type and version the triggered workflow names. */
48
+ export const workflowType = { workflow_type: "review", version: "v1" };
49
+
50
+ /** The triggered workflow that sends the proposals here: `client.workflows.starters.use(trigger())`. */
51
+ export function trigger(settings: Settings = defaults): Schemas["TriggeredWorkflowRequest"] {
52
+ return {
53
+ starter: "workflow",
54
+ name: "review",
55
+ watch: { class: settings.className, fields: ["fact_subject", "fact_predicate", "fact_object"] },
56
+ workflow: workflowType,
57
+ owns: { properties: ["fact_status", "review_note"] },
58
+ batch: 32,
59
+ };
60
+ }
61
+
62
+ /** The class of the proposals and the properties of a proposal and of the review: `lbb.evolve(ontology())`. */
63
+ export function ontology(settings: Settings = defaults): Schemas["OntologyEvolveOp"][] {
64
+ return [
65
+ { op: "add_entity_type", name: settings.className },
66
+ ...Object.entries({ ...review.PROPOSAL_FIELDS, ...review.REVIEW_FIELDS }).map(
67
+ ([name, value_type]) => ({ op: "add_property" as const, name, value_type }),
68
+ ),
69
+ ];
70
+ }
71
+
72
+ /** What the workflow decided, over all its turns. */
73
+ export interface ReviewState {
74
+ proposals: number;
75
+ written: number;
76
+ held: number;
77
+ in_review: number;
78
+ refused: number;
79
+ }
80
+
81
+ /** Proposals one turn decides: two steps each, and at most one review item each, in a turn of 64 steps. */
82
+ const TURN_PROPOSALS = 20;
83
+ const PLAIN_IRI = /^[^\s<>"{}|\\^`]+$/;
84
+ const values = (iris: Iterable<string>) =>
85
+ [...iris]
86
+ .filter((iri) => PLAIN_IRI.test(iri))
87
+ .map((iri) => `<${iri}>`)
88
+ .join(" ");
89
+ const literals = (texts: Iterable<string>) => [...texts].map((t) => JSON.stringify(t)).join(" ");
90
+ const P = "PREFIX p: <https://littlebigbrain.com/p/>\n";
91
+
92
+ /** One proposal as the graph holds it. */
93
+ interface Proposal {
94
+ iri: string;
95
+ label?: string;
96
+ statement?: review.Statement;
97
+ confidence: number;
98
+ evidence?: string;
99
+ proposedBy: string;
100
+ status?: string;
101
+ }
102
+
103
+ /** What one proposal's step decided, and the facts that write it. */
104
+ interface Outcome {
105
+ status: "written" | "in_review" | "refused" | "decided";
106
+ note?: string;
107
+ facts?: review.Facts;
108
+ holds?: boolean;
109
+ /** For a person: the item's instance, its view, and what the item shows of the fact. */
110
+ item?: {
111
+ instance: string;
112
+ class?: string;
113
+ label?: string;
114
+ isNew: boolean;
115
+ shown: review.Shown;
116
+ };
117
+ }
118
+
119
+ /** The workflow, for a `WorkflowWorker`. `lbb` is `lbbActivities(client)`, or a mock in a test. */
120
+ export function createWorkflow(lbb: LbbActivities, settings: Settings = defaults) {
121
+ return triggeredWorkflow<ReviewState>({
122
+ name: workflowType.workflow_type,
123
+ version: workflowType.version,
124
+ initialState: { proposals: 0, written: 0, held: 0, in_review: 0, refused: 0 },
125
+ async onInstances(ctx, message, state) {
126
+ const batch = message.instances.slice(0, TURN_PROPOSALS);
127
+ let cache: Promise<Read> | undefined;
128
+ const read = () => (cache ??= reader(lbb, batch, settings));
129
+ const next = { ...state };
130
+ const forPeople: { iri: string; outcome: Outcome }[] = [];
131
+ for (const [i, iri] of batch.entries()) {
132
+ const outcome = await ctx.step(`decide-${i}`, async (s) => {
133
+ s.progress("deciding proposals", { done: i, total: batch.length, unit: "proposals" });
134
+ return decideOne(lbb, await read(), iri, settings);
135
+ });
136
+ await lbb.writeOwned(ctx, `write-${i}`, async (s) => {
137
+ s.progress("writing facts", { done: i, total: batch.length, unit: "proposals" });
138
+ return statusWrite(await read(), iri, outcome, settings);
139
+ });
140
+ if (outcome.status === "decided") continue;
141
+ next.proposals += 1;
142
+ if (outcome.status === "written") next[outcome.holds ? "held" : "written"] += 1;
143
+ else next[outcome.status] += 1;
144
+ if (outcome.status === "in_review") forPeople.push({ iri, outcome });
145
+ }
146
+ // One review item per instance of this turn.
147
+ const groups = new Map<string, { iri: string; outcome: Outcome }[]>();
148
+ for (const one of forPeople) {
149
+ const instance = one.outcome.item!.instance;
150
+ groups.set(instance, [...(groups.get(instance) ?? []), one]);
151
+ }
152
+ for (const [n, members] of [...groups.values()].entries())
153
+ await ctx.step(`review-${n}`, async (s) => {
154
+ s.progress("filing review items", { done: n, total: groups.size, unit: "instances" });
155
+ await fileItem(lbb, await read(), members, message, settings);
156
+ return true;
157
+ });
158
+ // A proposal its proposer withdrew (message.removed) keeps the fact written for it: see the README.
159
+ const rest = message.instances.slice(TURN_PROPOSALS);
160
+ return rest.length
161
+ ? ctx.continue(next, { ...message, instances: rest, removed: [] })
162
+ : { state: next };
163
+ },
164
+ });
165
+ }
166
+
167
+ type Read = Awaited<ReturnType<typeof reader>>;
168
+
169
+ /** The proposals of the turn and the ontology, read once per attempt, with the commits not yet published. */
170
+ async function reader(lbb: LbbActivities, iris: string[], settings: Settings) {
171
+ const [{ rows }, view] = [
172
+ await lbb.sparql(
173
+ `# review: proposals\n${P}SELECT ?x ?label ?s ?p ?o ?dt ?c ?ev ?by ?status WHERE {
174
+ VALUES ?x { ${values(iris)} }
175
+ OPTIONAL { ?x <${review.RDFS_LABEL}> ?label }
176
+ OPTIONAL { ?x p:fact_subject ?s } OPTIONAL { ?x p:fact_predicate ?p } OPTIONAL { ?x p:fact_object ?o }
177
+ OPTIONAL { ?x p:fact_datatype ?dt } OPTIONAL { ?x p:fact_confidence ?c } OPTIONAL { ?x p:fact_evidence ?ev }
178
+ OPTIONAL { ?x p:proposed_by ?by } OPTIONAL { ?x p:fact_status ?status }
179
+ }`,
180
+ { consistency: "strong" },
181
+ ),
182
+ await lbb.ontology(),
183
+ ];
184
+ const proposals = new Map<string, Proposal>();
185
+ for (const r of rows) {
186
+ if (proposals.has(r.x!)) continue;
187
+ proposals.set(r.x!, {
188
+ iri: r.x!,
189
+ ...(r.label ? { label: r.label } : {}),
190
+ ...(r.s && r.p && r.o !== undefined
191
+ ? {
192
+ statement: {
193
+ subject: r.s.trim(),
194
+ predicate: r.p.trim(),
195
+ object: r.o,
196
+ ...(r.dt?.trim() ? { datatype: r.dt.trim() } : {}),
197
+ },
198
+ }
199
+ : {}),
200
+ confidence: Number(r.c ?? 0),
201
+ ...(r.ev ? { evidence: r.ev } : {}),
202
+ proposedBy: r.by ?? "",
203
+ ...(r.status ? { status: r.status } : {}),
204
+ });
205
+ }
206
+ return { proposals, ontology: review.readOntology(view), className: settings.className };
207
+ }
208
+
209
+ /** The entities a statement names: the graph's instances, and new ones with the class and label their proposals give. */
210
+ async function entities(
211
+ lbb: LbbActivities,
212
+ read: Read,
213
+ iris: string[],
214
+ ): Promise<Map<string, review.Known>> {
215
+ const out = new Map<string, review.Known>();
216
+ const named = iris.filter(review.isEntity);
217
+ if (!named.length) return out;
218
+ const { rows } = await lbb.sparql(
219
+ `# review: entities\nSELECT ?s ?type ?label WHERE {
220
+ VALUES ?s { ${values(named)} }
221
+ OPTIONAL { ?s a ?type } OPTIONAL { ?s <${review.RDFS_LABEL}> ?label }
222
+ }`,
223
+ { consistency: "strong" },
224
+ );
225
+ for (const iri of named) {
226
+ const found = rows.filter((r) => r.s === iri);
227
+ const cls = found.map((r) => read.ontology.classes.get(r.type ?? "")).find(Boolean);
228
+ if (cls)
229
+ out.set(iri, { iri, class: cls, label: found.find((r) => r.label)?.label, exists: true });
230
+ }
231
+ // A new instance: the class of its rdf:type proposal, and the name its rdfs:label proposals
232
+ // give it (the first in order), so every write names it alike.
233
+ const fresh = named.filter((iri) => !out.has(iri));
234
+ if (fresh.length) {
235
+ const { rows: proposed } = await lbb.sparql(
236
+ `# review: new instances\n${P}SELECT ?s ?p ?o WHERE {
237
+ VALUES ?s { ${literals(fresh)} }
238
+ VALUES ?p { ${literals([review.RDF_TYPE, review.RDFS_LABEL])} }
239
+ ?x p:fact_subject ?s ; p:fact_predicate ?p ; p:fact_object ?o .
240
+ }`,
241
+ { consistency: "strong" },
242
+ );
243
+ for (const iri of fresh) {
244
+ const of = (p: string) =>
245
+ proposed
246
+ .filter((r) => r.s === iri && r.p === p)
247
+ .map((r) => r.o!.trim())
248
+ .filter(Boolean)
249
+ .sort();
250
+ const cls = of(review.RDF_TYPE)
251
+ .map((c) => read.ontology.classes.get(c))
252
+ .find(Boolean);
253
+ out.set(iri, {
254
+ iri,
255
+ ...(cls ? { class: cls } : {}),
256
+ label: of(review.RDFS_LABEL)[0],
257
+ exists: false,
258
+ });
259
+ }
260
+ }
261
+ return out;
262
+ }
263
+
264
+ /** Decide one proposal; one for a person carries what its item shows. */
265
+ async function decideOne(
266
+ lbb: LbbActivities,
267
+ read: Read,
268
+ iri: string,
269
+ settings: Settings,
270
+ ): Promise<Outcome> {
271
+ const proposal = read.proposals.get(iri);
272
+ // Gone, or decided once already: a proposer's rewrite of the same statement changes nothing.
273
+ if (!proposal || proposal.status) return { status: "decided" };
274
+ const s = proposal.statement;
275
+ if (!s)
276
+ return {
277
+ status: "refused",
278
+ note: "The proposal has no fact_subject, fact_predicate or fact_object.",
279
+ };
280
+ const seen: review.Seen = { entities: await entities(lbb, read, [s.subject, s.object]) };
281
+ const property = read.ontology.properties.get(s.predicate);
282
+ if (property && review.isEntity(s.subject)) {
283
+ const { rows } = await lbb.sparql(
284
+ `# review: values\nSELECT ?v WHERE { <${s.subject}> <${s.predicate}> ?v } LIMIT 16`,
285
+ { consistency: "strong" },
286
+ );
287
+ seen.current = rows.map((r) => r.v!);
288
+ }
289
+ const terms = review.classify(s, read.ontology, seen, settings.className);
290
+ let edgeHolds = false;
291
+ if (terms.kind === "relation" && terms.subject.exists && terms.object.exists) {
292
+ const { rows } = await lbb.sparql(
293
+ `# review: edge\nSELECT ?s WHERE { VALUES ?s { <${s.subject}> } ?s <${s.predicate}> <${s.object}> } LIMIT 1`,
294
+ { consistency: "strong" },
295
+ );
296
+ edgeHolds = rows.length > 0;
297
+ }
298
+ const decision = review.decide(terms, s.predicate, proposal.confidence, settings, edgeHolds);
299
+ if (decision.status === "refused" || terms.kind === "refused")
300
+ return { status: "refused", note: decision.status === "refused" ? decision.why : "" };
301
+ const facts = review.factsOf(terms, iri, proposal.confidence, proposal.evidence);
302
+ if (decision.status === "written")
303
+ return decision.holds
304
+ ? { status: "written", holds: true }
305
+ : { status: "written", holds: false, facts };
306
+ // To a person: into the item of its instance.
307
+ const instance = review.groupOf(terms, s)!;
308
+ const known = [...seen.entities.values()].find((e) => e.iri === instance) ?? {
309
+ iri: instance,
310
+ exists: false,
311
+ };
312
+ return {
313
+ status: "in_review",
314
+ note: decision.why,
315
+ facts,
316
+ item: {
317
+ instance,
318
+ ...(known.class || terms.kind === "type"
319
+ ? { class: terms.kind === "type" ? terms.class : known.class }
320
+ : {}),
321
+ ...(known.label ? { label: known.label } : {}),
322
+ isNew: !known.exists,
323
+ shown: review.shownOf(terms, s, instance),
324
+ },
325
+ };
326
+ }
327
+
328
+ const proposalRef = (proposal: Proposal, settings: Settings) =>
329
+ entityRef(proposal.iri, settings.className, proposal.label);
330
+
331
+ /**
332
+ * File the review item of one instance: each fact's statement and its proposal's
333
+ * `fact_status: accepted`, in the item's order (the type, the label, values, links, the trace),
334
+ * with one evidence sample per fact. The key carries the proposals, so other proposals file
335
+ * another item.
336
+ */
337
+ async function fileItem(
338
+ lbb: LbbActivities,
339
+ read: Read,
340
+ members: { iri: string; outcome: Outcome }[],
341
+ message: WorkflowTriggerMessage,
342
+ settings: Settings,
343
+ ) {
344
+ const first = members[0]!.outcome.item!;
345
+ const instance = first.instance;
346
+ const kind = (m: { outcome: Outcome }) => m.outcome.item!.shown.kind;
347
+ const ordered = [...members].sort(
348
+ (a, b) =>
349
+ review.KIND_ORDER.indexOf(kind(a)) - review.KIND_ORDER.indexOf(kind(b)) ||
350
+ a.outcome.item!.shown.text.localeCompare(b.outcome.item!.shown.text),
351
+ );
352
+ const creates = first.isNew && ordered.some((m) => kind(m) === "type");
353
+ const className = ordered.map((m) => m.outcome.item!.class).find(Boolean) ?? "Instance";
354
+ const label =
355
+ ordered.map((m) => m.outcome.item!.label).find(Boolean) ??
356
+ instance.slice(instance.lastIndexOf("/") + 1);
357
+ const triplets: Schemas["TripletInput"][] = [];
358
+ const rows: Schemas["EntityPropertiesInput"][] = [];
359
+ // The rows an accept must keep (`required`, from client 0.24; an older client sends it as is).
360
+ const required: { row: number; reason: string }[] = [];
361
+ const samples: Record<string, unknown>[] = [];
362
+ const whys = new Set<string>();
363
+ for (const { iri, outcome } of ordered) {
364
+ const proposal = read.proposals.get(iri)!;
365
+ const shown = outcome.item!.shown;
366
+ const needed = shown.kind === "source" || (creates && shown.kind === "type");
367
+ const writeTriplets = (outcome.facts?.triplets ?? []).map((t) => triplets.push(t) - 1);
368
+ const writeRows = (outcome.facts?.entity_properties ?? []).map((r) => rows.push(r) - 1);
369
+ const row =
370
+ rows.push({ ...proposalRef(proposal, settings), properties: { fact_status: "accepted" } }) -
371
+ 1;
372
+ if (needed)
373
+ for (const at of [...writeRows, row])
374
+ required.push({
375
+ row: at,
376
+ reason:
377
+ shown.kind === "type"
378
+ ? "a new instance needs its type"
379
+ : "the source of an instance goes with it",
380
+ });
381
+ if (outcome.note) whys.add(outcome.note);
382
+ samples.push({
383
+ proposal: iri,
384
+ row,
385
+ ...(writeRows.length ? { rows: writeRows } : {}),
386
+ ...(writeTriplets.length ? { triplets: writeTriplets } : {}),
387
+ kind: shown.kind,
388
+ fact: shown.text,
389
+ confidence: Math.round(proposal.confidence * 1000) / 1000,
390
+ ...(review.place(proposal.evidence) ?? {}),
391
+ ...(needed ? { required: true } : {}),
392
+ });
393
+ }
394
+ const proposalsHash = createHash("sha256")
395
+ .update(
396
+ ordered
397
+ .map((m) => m.iri)
398
+ .sort()
399
+ .join("\n"),
400
+ )
401
+ .digest("hex")
402
+ .slice(0, 12);
403
+ const title = creates
404
+ ? `New ${className} ${label}`
405
+ : `${className} ${label}: ${members.length === 1 ? "1 fact" : `${members.length} facts`} to check`;
406
+ const proposers = [...new Set(ordered.map((m) => read.proposals.get(m.iri)!.proposedBy))].filter(
407
+ Boolean,
408
+ );
409
+ await lbb.review({
410
+ workflow: message.workflow,
411
+ key: `starter:${message.workflow}:instance:${instance.slice(instance.lastIndexOf("/") + 1)}:${proposalsHash}`,
412
+ title: review.clip(title, 200),
413
+ rationale: review.clip(
414
+ `${[...whys].slice(0, 3).join(" ")}${proposers.length ? ` Proposed by ${proposers.join(", ")}.` : ""}`,
415
+ 500,
416
+ ),
417
+ facts: {
418
+ triplets,
419
+ entity_properties: rows,
420
+ required,
421
+ summary: review.clip(ordered.map((m) => m.outcome.item!.shown.text).join(" · "), 500),
422
+ } as Schemas["SuggestedFacts"],
423
+ mapping: {
424
+ review: {
425
+ instance,
426
+ class: className,
427
+ label,
428
+ new: creates,
429
+ facts: members.length,
430
+ shown: members.length,
431
+ truncated: false,
432
+ },
433
+ },
434
+ evidence: { records: members.length, samples },
435
+ });
436
+ }
437
+
438
+ /** The owned write of a decision: the proposal's status and note, and the facts of a written statement. */
439
+ function statusWrite(read: Read, iri: string, outcome: Outcome, settings: Settings) {
440
+ const proposal = read.proposals.get(iri);
441
+ if (!proposal || outcome.status === "decided") return { subjects: [] };
442
+ const own = proposalRef(proposal, settings);
443
+ const written = outcome.status === "written" ? outcome.facts : undefined;
444
+ return {
445
+ subjects: [own],
446
+ properties: ["fact_status", "review_note"],
447
+ entity_properties: [
448
+ ...(written?.entity_properties ?? []),
449
+ {
450
+ ...own,
451
+ properties: {
452
+ fact_status: outcome.status,
453
+ ...(outcome.note ? { review_note: review.clip(outcome.note, 500) } : {}),
454
+ },
455
+ },
456
+ ],
457
+ triplets: written?.triplets ?? [],
458
+ };
459
+ }
@@ -11,6 +11,7 @@ import { ENTITY_IRI_NAMESPACE, type DecideAnswer } from "@littlebigbrain/client"
11
11
  import {
12
12
  MockActivities,
13
13
  mockId,
14
+ phases,
14
15
  readJson,
15
16
  runMessage,
16
17
  samplesDir,
@@ -176,6 +177,24 @@ test("each written value has a source whose evidence is a quote of the text", as
176
177
  }
177
178
  });
178
179
 
180
+ test("each step reports its phase as the LBB-run values.extract words it", async () => {
181
+ const lbb = new MockActivities(rules(RECORDS));
182
+ const { turns } = await runMessage(
183
+ createWorkflow(lbb),
184
+ triggerMessage(
185
+ "values-extract",
186
+ RECORDS.map((r) => iriOf(r.name)),
187
+ ),
188
+ );
189
+ assert.deepEqual(phases(turns), ["reading values", "asking the decision model", "writing facts"]);
190
+ assert.deepEqual(
191
+ turns[0]!.progress
192
+ .filter((p) => p.phase === "reading values")
193
+ .map((p) => [p.step, p.done, p.total, p.unit]),
194
+ RECORDS.map((_, i) => [`values-${i}`, i, RECORDS.length, "instances"]),
195
+ );
196
+ });
197
+
179
198
  test("a value below the gate goes to review, and the checks drop wrong types and quotes", async () => {
180
199
  const record = {
181
200
  name: "S 9.1",
@@ -188,10 +207,14 @@ test("a value below the gate goes to review, and the checks drop wrong types and
188
207
  { property: "material", value: "Kunststoff", quote: "Becken aus Kunststoff" },
189
208
  ];
190
209
  const lbb = new MockActivities(rules([record], { reason: () => ({ values: proposals }) }));
191
- const { state } = await runMessage(
210
+ const { state, turns } = await runMessage(
192
211
  createWorkflow(lbb),
193
212
  triggerMessage("values-extract", [iriOf(record.name)]),
194
213
  );
214
+ assert.deepEqual(
215
+ turns[0]!.progress.find((p) => p.phase === "filing review items"),
216
+ { step: "review", phase: "filing review items", done: 0, total: 1, unit: "items" },
217
+ );
195
218
 
196
219
  assert.deepEqual(written(lbb, record.name), { material: "Kunststoff" });
197
220
  assert.deepEqual(state, { instances: 1, written: 1, review: 1, dropped: 0 });
@@ -205,6 +205,7 @@ export function createWorkflow(lbb: LbbActivities, settings: Settings = defaults
205
205
  const read: Value[][] = [];
206
206
  for (const [i, iri] of batch.entries()) {
207
207
  const proposed = await ctx.step(`values-${i}`, async (s) => {
208
+ s.progress("reading values", { done: i, total: batch.length, unit: "instances" });
208
209
  const record = (await texts())[iri];
209
210
  if (!record?.text.trim()) return [];
210
211
  const prompt = JSON.stringify({
@@ -238,6 +239,11 @@ export function createWorkflow(lbb: LbbActivities, settings: Settings = defaults
238
239
  read.push(
239
240
  await ctx.step(`support-${i}`, async (s) => {
240
241
  if (!proposed.length) return [];
242
+ s.progress("asking the decision model", {
243
+ done: i,
244
+ total: batch.length,
245
+ unit: "instances",
246
+ });
241
247
  const record = (await texts())[iri]!;
242
248
  const questions = Object.fromEntries(
243
249
  proposed.map((v, n) => [
@@ -278,9 +284,10 @@ export function createWorkflow(lbb: LbbActivities, settings: Settings = defaults
278
284
  .values.filter((v) => v.support! < settings.gate && v.support! >= 0.5)
279
285
  .map((v) => ({ i, v })),
280
286
  );
281
- const accepted = await ctx.step("review", async () => {
287
+ const accepted = await ctx.step("review", async (s) => {
282
288
  const kept: { i: number; property: string }[] = [];
283
- for (const { i, v } of below) {
289
+ for (const [n, { i, v }] of below.entries()) {
290
+ s.progress("filing review items", { done: n, total: below.length, unit: "items" });
284
291
  const record = (await texts())[batch[i]!]!;
285
292
  const facts = valueFacts(batch[i]!, record.label, v);
286
293
  const filed = await lbb.review({
@@ -302,7 +309,8 @@ export function createWorkflow(lbb: LbbActivities, settings: Settings = defaults
302
309
  }
303
310
  return kept;
304
311
  });
305
- await lbb.writeOwned(ctx, "write", async () => {
312
+ await lbb.writeOwned(ctx, "write", async (s) => {
313
+ s.progress("writing facts", { done: 0, total: batch.length, unit: "instances" });
306
314
  const records = await texts();
307
315
  const write = {
308
316
  subjects: [] as Schemas["NamedEntityInput"][],
@@ -1,3 +1,3 @@
1
1
  {
2
- "client": "0.23.0"
2
+ "client": "0.25.0"
3
3
  }