@littlebigbrain/create 0.0.0-stage → 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 (52) hide show
  1. package/LICENSE +55 -0
  2. package/README.md +73 -2
  3. package/bin/create-littlebigbrain.mjs +9 -0
  4. package/lib/create.mjs +244 -0
  5. package/package.json +46 -4
  6. package/project/setup.ts +18 -0
  7. package/project/try.ts +30 -0
  8. package/project/worker.ts +23 -0
  9. package/templates/_shared/testing.ts +521 -0
  10. package/templates/documents-link/README.md +146 -0
  11. package/templates/documents-link/link.ts +580 -0
  12. package/templates/documents-link/samples/ausstattungsliste.pdf +76 -0
  13. package/templates/documents-link/samples/baubeschreibung.pdf +54 -0
  14. package/templates/documents-link/samples/expected.json +404 -0
  15. package/templates/documents-link/samples/pages/ausstattungsliste.json +1150 -0
  16. package/templates/documents-link/samples/pages/baubeschreibung.json +802 -0
  17. package/templates/documents-link/samples/pages/raumblaetter.json +1580 -0
  18. package/templates/documents-link/samples/raumblaetter.pdf +109 -0
  19. package/templates/documents-link/samples.ts +31 -0
  20. package/templates/documents-link/workflow.test.ts +497 -0
  21. package/templates/documents-link/workflow.ts +467 -0
  22. package/templates/documents-map/README.md +212 -0
  23. package/templates/documents-map/map.ts +1135 -0
  24. package/templates/documents-map/samples/ausstattungsliste.pdf +76 -0
  25. package/templates/documents-map/samples/baubeschreibung.pdf +54 -0
  26. package/templates/documents-map/samples/expected.json +50 -0
  27. package/templates/documents-map/samples/pages/raumblaetter.json +1580 -0
  28. package/templates/documents-map/samples/raumblaetter.pdf +109 -0
  29. package/templates/documents-map/samples/trace.json +613 -0
  30. package/templates/documents-map/samples.ts +182 -0
  31. package/templates/documents-map/workflow.test.ts +770 -0
  32. package/templates/documents-map/workflow.ts +1199 -0
  33. package/templates/ontology-fit/README.md +81 -0
  34. package/templates/ontology-fit/samples/expected.md +88 -0
  35. package/templates/ontology-fit/samples/texts.json +22 -0
  36. package/templates/ontology-fit/samples.ts +40 -0
  37. package/templates/ontology-fit/workflow.test.ts +270 -0
  38. package/templates/ontology-fit/workflow.ts +416 -0
  39. package/templates/review/README.md +158 -0
  40. package/templates/review/decision.ts +483 -0
  41. package/templates/review/samples/expected.json +37 -0
  42. package/templates/review/samples/proposals.json +35 -0
  43. package/templates/review/samples.ts +154 -0
  44. package/templates/review/workflow.test.ts +438 -0
  45. package/templates/review/workflow.ts +459 -0
  46. package/templates/values-extract/README.md +81 -0
  47. package/templates/values-extract/samples/expected.json +148 -0
  48. package/templates/values-extract/samples/records.json +26 -0
  49. package/templates/values-extract/samples.ts +27 -0
  50. package/templates/values-extract/workflow.test.ts +279 -0
  51. package/templates/values-extract/workflow.ts +419 -0
  52. package/templates/versions.json +3 -0
@@ -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
+ }
@@ -0,0 +1,81 @@
1
+ # values-extract
2
+
3
+ Fills properties of a class from the text of its instances. Each value
4
+ comes with the words of the text that state it: `width_cm` 60 and
5
+ `depth_cm` 40 from `ca. 60x40 cm`. The value is written on the instance,
6
+ with an edge `HAS_VALUE_SOURCE` to a `ValueSource` entity whose evidence is
7
+ the quote.
8
+
9
+ The LBB-run version is the starter workflow `values.extract@v1`
10
+ (`docs/reference/starter-workflows.md`, "Values from text").
11
+
12
+ ## How it runs
13
+
14
+ The triggered workflow watches the class and its text field. For each
15
+ instance of a trigger message, two steps:
16
+
17
+ 1. **Values.** The reasoning model reads the text and returns
18
+ `{values: [{property, value, quote}]}`; the schema allows only the
19
+ settings' property names. The prompt is the LBB version's: the values of
20
+ the instance's own item in its base version, not those of a part, an
21
+ accessory, an alternative or a variant under a condition; an approximate
22
+ value is stated. Then the checks without a model: a value takes its
23
+ property's type (`typed`), its quote is in the text with at least three
24
+ words or the value in it (`quoteHolds`), and one value per property.
25
+ 2. **Support.** The decision model scores how well each quote supports its
26
+ value: `supported`, `weak` or `not_supported`. The support is the
27
+ probability of `supported`.
28
+
29
+ Then the gate: a value at or above `gate` is written, one from 0.5 to the
30
+ gate becomes a review item with the value as its facts, one below 0.5 is
31
+ dropped. One owned write per turn replaces the values of the turn's
32
+ instances: a property the workflow does not write again loses its value and
33
+ its source. An accepted review item's value is written with the others the
34
+ next time its instance is read. An instance that leaves the class loses the
35
+ values this workflow wrote.
36
+
37
+ A turn reads at most 24 instances; the rest of a message goes to a new turn
38
+ (`ctx.continue`).
39
+
40
+ ## Settings
41
+
42
+ `defaults` in `workflow.ts`, which fit the samples:
43
+
44
+ | Setting | Default | Meaning |
45
+ | --- | --- | --- |
46
+ | `className` | `EquipmentItem` | The class whose instances are read |
47
+ | `textField` | `text` | The property that holds the text |
48
+ | `properties` | `width_cm`, `depth_cm` (`f64`), `material` (`text`), `water_supply` (`keyword`) | The properties to fill, with their types (`text`, `keyword`, `i64`, `f64`, `bool`, `date_time`) |
49
+ | `context` | the samples' | One sentence about what the text is |
50
+ | `gate` | 0.8 | The support a value needs to be written |
51
+
52
+ `trigger(settings)` names the triggered workflow `values-extract` and
53
+ owns the class `ValueSource`, the relation `HAS_VALUE_SOURCE` and the
54
+ properties. `ontology(settings)` adds the class, the properties and the
55
+ relation.
56
+
57
+ ## What the LBB-run version adds
58
+
59
+ - `params.where`: a SPARQL pattern that limits the instances it reads.
60
+ - Texts built from several watched fields, as fit sources build them, and
61
+ the fields chosen for you when the watch names none.
62
+ - The instance's page box as the region of each value's evidence, for an
63
+ instance that has one (a `DocEntry`).
64
+ - A check at creation that each property is in the property registry with
65
+ a type it can write.
66
+ - Its own monthly budget, a preview with the values of up to 3 instances,
67
+ and counters per outcome in the workflow's status.
68
+
69
+ ## Samples and test
70
+
71
+ `samples/records.json` holds six invented entries of a sanitary equipment
72
+ list; `samples/expected.json` gives the values a correct run writes, and the
73
+ ones it leaves out ([`../_samples/README.md`](../_samples/README.md)).
74
+
75
+ `workflow.test.ts` runs the workflow with mock models: the reasoning model
76
+ reads the first size, material and water supply of each text, and the
77
+ decision model says a quote from a sentence about an accessory (`Zubehör`)
78
+ or a conditional variant (`Bei Einsatz`) does not support its value. The run
79
+ writes the values of the answer key and none it leaves out. The test also
80
+ checks the review path, the checks of type and quote, an instance that
81
+ leaves the class, and a message larger than a turn.
@@ -0,0 +1,148 @@
1
+ {
2
+ "about": [
3
+ "The answer key for the values-extract samples: the values a correct run reads from each record, with the quote that supports each, and the values it must leave out.",
4
+ "All text is invented. make-samples.mjs checks that every quote is in its record's text (whitespace runs as one space) and that a number's quote holds the number.",
5
+ "Numbers compare as numbers. A text value compares in lower case, trimmed, against accept. A run may quote more or less of the text, as long as its quote is in the text and holds the evidence.",
6
+ "leave_out lists values a correct run does not write. With value null, the run writes no value for that property at all."
7
+ ],
8
+ "install": {
9
+ "properties": {
10
+ "width_cm": "f64",
11
+ "depth_cm": "f64",
12
+ "material": "text",
13
+ "water_supply": "keyword"
14
+ },
15
+ "context": "Entries of the sanitary equipment list of a school building tender, in German."
16
+ },
17
+ "records": [
18
+ {
19
+ "name": "S 4.1",
20
+ "case": "An approximate size (ca.) is still the item's size.",
21
+ "values": [
22
+ { "property": "width_cm", "value": 60, "quote": "ca. 60x40 cm" },
23
+ { "property": "depth_cm", "value": 40, "quote": "ca. 60x40 cm" },
24
+ {
25
+ "property": "material",
26
+ "value": "Sanitärkeramik",
27
+ "accept": ["sanitärkeramik", "keramik", "sanitärkeramik, weiß", "sanitärkeramik weiß"],
28
+ "quote": "aus Sanitärkeramik, weiß"
29
+ },
30
+ {
31
+ "property": "water_supply",
32
+ "value": "kalt und warm",
33
+ "accept": ["kalt und warm", "warm und kalt", "kalt- und warmwasser", "kaltwasser und warmwasser"],
34
+ "quote": "Anschluss an Kalt- und Warmwasser"
35
+ }
36
+ ],
37
+ "leave_out": []
38
+ },
39
+ {
40
+ "name": "S 4.2",
41
+ "case": "An accessory of another material: the Klapprost.",
42
+ "values": [
43
+ { "property": "width_cm", "value": 50, "quote": "Stahl emailliert, 50x35 cm" },
44
+ { "property": "depth_cm", "value": 35, "quote": "Stahl emailliert, 50x35 cm" },
45
+ {
46
+ "property": "material",
47
+ "value": "Stahl emailliert",
48
+ "accept": ["stahl emailliert", "stahl, emailliert", "emaillierter stahl"],
49
+ "quote": "Ausgussbecken für Putzräume, Stahl emailliert"
50
+ },
51
+ {
52
+ "property": "water_supply",
53
+ "value": "kalt",
54
+ "accept": ["kalt", "kaltwasser", "nur kaltwasser", "nur kalt"],
55
+ "quote": "Anschluss nur an Kaltwasser"
56
+ }
57
+ ],
58
+ "leave_out": [
59
+ { "property": "material", "value": "Edelstahl", "reason": "Edelstahl is the material of the Klapprost, an accessory." }
60
+ ]
61
+ },
62
+ {
63
+ "name": "S 4.3",
64
+ "case": "The only size in the text belongs to an accessory, the Glasablage.",
65
+ "values": [
66
+ {
67
+ "property": "material",
68
+ "value": "Mineralguss",
69
+ "accept": ["mineralguss"],
70
+ "quote": "Waschtisch, unterfahrbar, aus Mineralguss"
71
+ },
72
+ {
73
+ "property": "water_supply",
74
+ "value": "kalt und warm",
75
+ "accept": ["kalt und warm", "warm und kalt", "kalt- und warmwasser", "kaltwasser und warmwasser"],
76
+ "quote": "Anschluss an Kalt- und Warmwasser"
77
+ }
78
+ ],
79
+ "leave_out": [
80
+ { "property": "width_cm", "value": null, "reason": "50x12 cm is the size of the Glasablage. The basin's size is left to the maker (Maße nach Hersteller)." },
81
+ { "property": "depth_cm", "value": null, "reason": "50x12 cm is the size of the Glasablage. The basin's size is left to the maker (Maße nach Hersteller)." },
82
+ { "property": "material", "value": "Glas", "reason": "Glas is the material of the Glasablage, an accessory." },
83
+ { "property": "material", "value": "Edelstahl", "reason": "Edelstahl is the material of the Haltegriff, an accessory." }
84
+ ]
85
+ },
86
+ {
87
+ "name": "S 4.4",
88
+ "case": "Every value stated once, plainly.",
89
+ "values": [
90
+ { "property": "width_cm", "value": 40, "quote": "Edelstahl, 40x30 cm" },
91
+ { "property": "depth_cm", "value": 30, "quote": "Edelstahl, 40x30 cm" },
92
+ {
93
+ "property": "material",
94
+ "value": "Edelstahl",
95
+ "accept": ["edelstahl"],
96
+ "quote": "Trinkbrunnen für den Flur, Edelstahl"
97
+ },
98
+ {
99
+ "property": "water_supply",
100
+ "value": "kalt",
101
+ "accept": ["kalt", "kaltwasser", "nur kaltwasser", "nur kalt"],
102
+ "quote": "Anschluss an Kaltwasser in Trinkwasserqualität"
103
+ }
104
+ ],
105
+ "leave_out": []
106
+ },
107
+ {
108
+ "name": "S 4.5",
109
+ "case": "A variant that holds only under a condition (Bei Einsatz von Geschirrspülern).",
110
+ "values": [
111
+ { "property": "width_cm", "value": 100, "quote": "Edelstahl, 100x50 cm" },
112
+ { "property": "depth_cm", "value": 50, "quote": "Edelstahl, 100x50 cm" },
113
+ {
114
+ "property": "material",
115
+ "value": "Edelstahl",
116
+ "accept": ["edelstahl"],
117
+ "quote": "Spüle für die Lehrerküche, Edelstahl"
118
+ },
119
+ {
120
+ "property": "water_supply",
121
+ "value": "kalt und warm",
122
+ "accept": ["kalt und warm", "warm und kalt", "kalt- und warmwasser", "kaltwasser und warmwasser"],
123
+ "quote": "Anschluss an Kalt- und Warmwasser"
124
+ }
125
+ ],
126
+ "leave_out": [
127
+ { "property": "width_cm", "value": 120, "reason": "The width of the variant with two basins, which holds only bei Einsatz von Geschirrspülern." }
128
+ ]
129
+ },
130
+ {
131
+ "name": "S 4.6",
132
+ "case": "A second size that is a height, and no water supply.",
133
+ "values": [
134
+ { "property": "width_cm", "value": 60, "quote": "Breite 60 cm" },
135
+ {
136
+ "property": "material",
137
+ "value": "Kristallglas",
138
+ "accept": ["kristallglas", "glas"],
139
+ "quote": "Spiegel für Waschplätze, Kristallglas"
140
+ }
141
+ ],
142
+ "leave_out": [
143
+ { "property": "depth_cm", "value": null, "reason": "45 cm is the height (Höhe). The text gives no depth." },
144
+ { "property": "water_supply", "value": null, "reason": "The text names no water supply." }
145
+ ]
146
+ }
147
+ ]
148
+ }