@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,419 @@
1
+ // values-extract: fill properties of a class from the text of its
2
+ // instances, each value with a verbatim quote.
3
+ //
4
+ // A triggered workflow of the starter workflow `workflow` watches a class and
5
+ // its text field, and sends the new and changed instances to this workflow.
6
+ // For each instance the workflow:
7
+ //
8
+ // 1. asks the reasoning model (`reason`) for the values of the properties the
9
+ // text states, each with the words that state it;
10
+ // 2. checks each value without a model: it takes its property's type, and its
11
+ // quote is in the text;
12
+ // 3. asks the decision model (`decide`) how well each quote supports its
13
+ // value;
14
+ // 4. writes the values at or above the gate, each with a `ValueSource` whose
15
+ // evidence is the quote, and files a review item for each value between
16
+ // 0.5 and the gate. A value below 0.5 is dropped.
17
+ //
18
+ // Every model call runs in a step with the step's effect id, so a turn that
19
+ // runs again after a crash pays for nothing twice.
20
+ import {
21
+ entityIdFromIri,
22
+ entityRef,
23
+ triggeredWorkflow,
24
+ type LbbActivities,
25
+ type Schemas,
26
+ type WorkflowTriggerMessage,
27
+ } from "@littlebigbrain/client";
28
+
29
+ export type ValueType = "text" | "keyword" | "i64" | "f64" | "bool" | "date_time";
30
+
31
+ export interface Settings {
32
+ /** The class whose instances the workflow reads. */
33
+ className: string;
34
+ /** The property that holds each instance's text. */
35
+ textField: string;
36
+ /** The properties to fill, with their types. */
37
+ properties: Record<string, ValueType>;
38
+ /** One sentence about what the text is, for the models. */
39
+ context: string;
40
+ /** A value whose support is below this goes to review (from 0.5) or is dropped. */
41
+ gate: number;
42
+ }
43
+
44
+ /** The settings: edit them for your class. These fit the samples. */
45
+ export const defaults: Settings = {
46
+ className: "EquipmentItem",
47
+ textField: "text",
48
+ properties: { width_cm: "f64", depth_cm: "f64", material: "text", water_supply: "keyword" },
49
+ context: "Entries of the sanitary equipment list of a school building tender, in German.",
50
+ gate: 0.8,
51
+ };
52
+
53
+ /** The workflow type and version the triggered workflow names. */
54
+ export const workflowType = { workflow_type: "values-extract", version: "v1" };
55
+
56
+ /** The triggered workflow that sends the instances to this workflow: `client.workflows.starters.use(trigger())`. */
57
+ export function trigger(settings: Settings = defaults): Schemas["TriggeredWorkflowRequest"] {
58
+ return {
59
+ starter: "workflow",
60
+ name: "values-extract",
61
+ watch: { class: settings.className, fields: [settings.textField] },
62
+ workflow: workflowType,
63
+ owns: {
64
+ classes: ["ValueSource"],
65
+ relations: ["HAS_VALUE_SOURCE"],
66
+ properties: Object.keys(settings.properties),
67
+ },
68
+ batch: 8,
69
+ };
70
+ }
71
+
72
+ /** The classes, properties and relation the workflow reads and writes, for `lbb.evolve`. */
73
+ export function ontology(settings: Settings = defaults): Schemas["OntologyEvolveOp"][] {
74
+ const property = (name: string, value_type: string) => ({
75
+ op: "add_property" as const,
76
+ name,
77
+ value_type,
78
+ });
79
+ return [
80
+ { op: "add_entity_type", name: settings.className },
81
+ { op: "add_entity_type", name: "ValueSource" },
82
+ property(settings.textField, "text"),
83
+ ...Object.entries(settings.properties).map(([name, type]) => property(name, type)),
84
+ property("property", "keyword"),
85
+ property("value", "text"),
86
+ property("support", "f64"),
87
+ {
88
+ op: "add_relation",
89
+ name: "HAS_VALUE_SOURCE",
90
+ domain: [settings.className],
91
+ range: ["ValueSource"],
92
+ },
93
+ ];
94
+ }
95
+
96
+ export interface ValuesState {
97
+ instances: number;
98
+ written: number;
99
+ review: number;
100
+ dropped: number;
101
+ }
102
+
103
+ /** A value after the checks: its property, its typed value, its quote and its support. */
104
+ interface Value {
105
+ property: string;
106
+ value: string | number | boolean;
107
+ quote: string;
108
+ support?: number;
109
+ }
110
+
111
+ const SYSTEM = `You read the text of one record of a knowledge graph and fill in property values the text states. Rules:
112
+ - Give a value only when the text states it. Do not guess and do not compute.
113
+ - Give the values of the record's own item in its base version. Leave out the values of a part, an accessory or an alternative the text names (the battery of a drill, the shade of a lamp), and the values of a variant that holds only under a condition ("if …", "when used with …").
114
+ - A value the text gives as approximate ("about 40 kg", "approx. 2 m") is stated: give it without the approximation.
115
+ - Each value quotes the words of the text that state it in \`quote\`, copied exactly, character for character. A value without an exact quote is dropped.
116
+ - Write each value in its property's type: a number for i64 and f64 (digits, a dot for decimals, no unit), true or false for bool, YYYY-MM-DD for date_time, and the words of the text for text and keyword.
117
+ - Give at most one value per property. Leave a property out when the text does not state it.`;
118
+
119
+ const SUPPORT: Record<string, string> = {
120
+ supported:
121
+ "The quote states this value of this property for the record's own item. A value stated as approximate counts: 'approx. 40 kg' supports a weight of 40.",
122
+ weak: "The text implies the value but does not state it.",
123
+ not_supported:
124
+ "The quote does not state this value, or it states the value of a part, an accessory, an alternative or a variant under a condition, not of the record's own item.",
125
+ };
126
+
127
+ /** Instances one turn reads: two steps each, in a turn of 64 steps. */
128
+ const TURN_INSTANCES = 24;
129
+ /** Characters of a text the models read. */
130
+ const TEXT_CHARS = 40_000;
131
+ const PLAIN_IRI = /^[^\s<>"{}|\\^`]+$/;
132
+ const LABEL = "http://www.w3.org/2000/01/rdf-schema#label";
133
+ const normalize = (text: string) => text.split(/\s+/).filter(Boolean).join(" ");
134
+
135
+ /** The value of `raw` in its property's type, or null when it does not take the type. */
136
+ export function typed(raw: string, type: ValueType): string | number | boolean | null {
137
+ const text = raw.trim();
138
+ if (!text) return null;
139
+ const number = Number(text.replace(",", ".").replace(/\s+/g, ""));
140
+ switch (type) {
141
+ case "i64":
142
+ return Number.isInteger(number) ? number : null;
143
+ case "f64":
144
+ return Number.isFinite(number) ? number : null;
145
+ case "bool":
146
+ return /^(true|yes|ja)$/i.test(text) ? true : /^(false|no|nein)$/i.test(text) ? false : null;
147
+ case "date_time":
148
+ return /^\d{4}-\d{2}-\d{2}$/.test(text) && !Number.isNaN(Date.parse(text))
149
+ ? `${text}T00:00:00Z`
150
+ : null;
151
+ default:
152
+ return text;
153
+ }
154
+ }
155
+
156
+ /** A quote must be in the text, with at least three words or the value in it. */
157
+ export function quoteHolds(quote: string, value: string, text: string): boolean {
158
+ const q = normalize(quote);
159
+ if (!q || !normalize(text).includes(q)) return false;
160
+ return q.split(" ").length >= 3 || q.toLowerCase().includes(normalize(value).toLowerCase());
161
+ }
162
+
163
+ /** The workflow, for a `WorkflowWorker`. `lbb` is `lbbActivities(client)`, or a mock in a test. */
164
+ export function createWorkflow(lbb: LbbActivities, settings: Settings = defaults) {
165
+ const names = Object.keys(settings.properties);
166
+ const schema = {
167
+ type: "object",
168
+ additionalProperties: false,
169
+ required: ["values"],
170
+ properties: {
171
+ values: {
172
+ type: "array",
173
+ items: {
174
+ type: "object",
175
+ additionalProperties: false,
176
+ required: ["property", "value", "quote"],
177
+ properties: {
178
+ property: { type: "string", enum: names },
179
+ value: { type: "string", description: "The value in the property's type." },
180
+ quote: {
181
+ type: "string",
182
+ description: "The words of the text that state the value, copied exactly.",
183
+ },
184
+ },
185
+ },
186
+ },
187
+ },
188
+ };
189
+
190
+ return triggeredWorkflow<ValuesState>({
191
+ name: workflowType.workflow_type,
192
+ version: workflowType.version,
193
+ initialState: { instances: 0, written: 0, review: 0, dropped: 0 },
194
+ async onInstances(ctx, message, state) {
195
+ // Instances that left the class lose the values this workflow wrote.
196
+ if (message.removed.length)
197
+ await lbb.writeOwned(ctx, "drop", {
198
+ subjects: message.removed.map((iri) => entityRef(iri, settings.className)),
199
+ relations: ["HAS_VALUE_SOURCE"],
200
+ properties: names,
201
+ retract_entities: message.removed.flatMap((iri) => names.map((p) => source(iri, p))),
202
+ });
203
+ const batch = message.instances.slice(0, TURN_INSTANCES);
204
+ const texts = reader(message, batch);
205
+ const read: Value[][] = [];
206
+ for (const [i, iri] of batch.entries()) {
207
+ const proposed = await ctx.step(`values-${i}`, async (s) => {
208
+ s.progress("reading values", { done: i, total: batch.length, unit: "instances" });
209
+ const record = (await texts())[iri];
210
+ if (!record?.text.trim()) return [];
211
+ const prompt = JSON.stringify({
212
+ class: settings.className,
213
+ instance: record.label,
214
+ context: settings.context,
215
+ properties: names.map((name) => ({ name, type: settings.properties[name] })),
216
+ text: record.text,
217
+ });
218
+ const answer = await lbb.reason<{
219
+ values: { property: string; value: string; quote: string }[];
220
+ }>(
221
+ { system: SYSTEM, prompt, schema, max_tokens: 8_000 },
222
+ { effectId: s.effectId, label: `${record.label.slice(0, 50)} values` },
223
+ );
224
+ // The checks without a model: one value per property, of its type, quoted from the text.
225
+ const seen = new Set<string>();
226
+ return (answer.value.values ?? []).flatMap((v): Value[] => {
227
+ const type = settings.properties[v.property];
228
+ const value = type ? typed(v.value, type) : null;
229
+ if (
230
+ value === null ||
231
+ seen.has(v.property) ||
232
+ !quoteHolds(v.quote, v.value, record.text)
233
+ )
234
+ return [];
235
+ seen.add(v.property);
236
+ return [{ property: v.property, value, quote: v.quote }];
237
+ });
238
+ });
239
+ read.push(
240
+ await ctx.step(`support-${i}`, async (s) => {
241
+ if (!proposed.length) return [];
242
+ s.progress("asking the decision model", {
243
+ done: i,
244
+ total: batch.length,
245
+ unit: "instances",
246
+ });
247
+ const record = (await texts())[iri]!;
248
+ const questions = Object.fromEntries(
249
+ proposed.map((v, n) => [
250
+ `v${n}`,
251
+ {
252
+ instructions: `The state holds the text of one record of the class ${settings.className}. A model read the value of a property from the text and quoted the words that state it. How well does the quote support the value?\nProperty: ${v.property} (${settings.properties[v.property]})\nValue: ${v.value}\nQuote: "${v.quote}"`,
253
+ criteria: SUPPORT,
254
+ },
255
+ ]),
256
+ );
257
+ const { answers } = await lbb.decide(
258
+ {
259
+ state: {
260
+ class: settings.className,
261
+ record: record.label,
262
+ context: settings.context,
263
+ text: record.text,
264
+ },
265
+ questions,
266
+ },
267
+ { effectId: s.effectId, label: `${record.label.slice(0, 50)} support` },
268
+ );
269
+ return proposed.map((v, n) => {
270
+ const a = answers[`v${n}`];
271
+ const support =
272
+ a?.probabilities?.supported ??
273
+ (a?.choice === "supported" ? (a.confidence ?? 1) : 0);
274
+ return { ...v, support };
275
+ });
276
+ }),
277
+ );
278
+ }
279
+
280
+ // The gate: written, review, or dropped.
281
+ const at = (i: number) => ({ iri: batch[i]!, values: read[i]! });
282
+ const below = batch.flatMap((_, i) =>
283
+ at(i)
284
+ .values.filter((v) => v.support! < settings.gate && v.support! >= 0.5)
285
+ .map((v) => ({ i, v })),
286
+ );
287
+ const accepted = await ctx.step("review", async (s) => {
288
+ const kept: { i: number; property: string }[] = [];
289
+ for (const [n, { i, v }] of below.entries()) {
290
+ s.progress("filing review items", { done: n, total: below.length, unit: "items" });
291
+ const record = (await texts())[batch[i]!]!;
292
+ const facts = valueFacts(batch[i]!, record.label, v);
293
+ const filed = await lbb.review({
294
+ workflow: message.workflow,
295
+ key: `starter:${message.workflow}:value:${entityIdFromIri(batch[i]!)}:${v.property}`,
296
+ title: `Set ${v.property} of ${record.label} to ${v.value}`.slice(0, 200),
297
+ rationale: `The text supports the value with ${v.support!.toFixed(2)}, below the gate ${settings.gate}: "${v.quote.slice(0, 600)}"`,
298
+ facts: {
299
+ entity_properties: facts.properties,
300
+ triplets: [facts.edge],
301
+ summary: `${v.property} = ${v.value}`,
302
+ },
303
+ evidence: {
304
+ records: 1,
305
+ samples: [{ entity: batch[i], quote: v.quote.slice(0, 600), support: v.support }],
306
+ },
307
+ });
308
+ if (filed.status === "accepted") kept.push({ i, property: v.property });
309
+ }
310
+ return kept;
311
+ });
312
+ await lbb.writeOwned(ctx, "write", async (s) => {
313
+ s.progress("writing facts", { done: 0, total: batch.length, unit: "instances" });
314
+ const records = await texts();
315
+ const write = {
316
+ subjects: [] as Schemas["NamedEntityInput"][],
317
+ properties: [] as Schemas["EntityPropertiesInput"][],
318
+ triplets: [] as Schemas["TripletInput"][],
319
+ retract: [] as Schemas["NamedEntityInput"][],
320
+ };
321
+ for (const [i, iri] of batch.entries()) {
322
+ const label = records[iri]?.label ?? iri;
323
+ write.subjects.push(entityRef(iri, settings.className, label));
324
+ const kept = at(i).values.filter(
325
+ (v) =>
326
+ v.support! >= settings.gate ||
327
+ accepted.some((a) => a.i === i && a.property === v.property),
328
+ );
329
+ for (const v of kept) {
330
+ const facts = valueFacts(iri, label, v);
331
+ write.properties.push(...facts.properties);
332
+ write.triplets.push(facts.edge);
333
+ }
334
+ write.retract.push(
335
+ ...names.filter((p) => !kept.some((v) => v.property === p)).map((p) => source(iri, p)),
336
+ );
337
+ }
338
+ return {
339
+ subjects: write.subjects,
340
+ relations: ["HAS_VALUE_SOURCE"],
341
+ properties: names,
342
+ entity_properties: write.properties,
343
+ triplets: write.triplets,
344
+ retract_entities: write.retract,
345
+ };
346
+ });
347
+ const values = read.flat();
348
+ const next: ValuesState = {
349
+ instances: state.instances + batch.length,
350
+ written:
351
+ state.written +
352
+ values.filter((v) => v.support! >= settings.gate).length +
353
+ accepted.length,
354
+ review: state.review + below.length - accepted.length,
355
+ dropped: state.dropped + values.filter((v) => v.support! < 0.5).length,
356
+ };
357
+ // More instances than a turn reads: the rest goes to a new turn.
358
+ const rest = message.instances.slice(TURN_INSTANCES);
359
+ return rest.length
360
+ ? ctx.continue(next, { ...message, instances: rest, removed: [] })
361
+ : { state: next };
362
+ },
363
+ });
364
+
365
+ /** The label and text of each instance of the turn, read once per attempt at the message's commit. */
366
+ function reader(message: WorkflowTriggerMessage, iris: string[]) {
367
+ let read: Promise<Record<string, { label: string; text: string }>> | undefined;
368
+ return () =>
369
+ (read ??= (async () => {
370
+ const query = `# values-extract: texts
371
+ SELECT ?s ?label ?text WHERE {
372
+ VALUES ?s { ${iris
373
+ .filter((iri) => PLAIN_IRI.test(iri))
374
+ .map((iri) => `<${iri}>`)
375
+ .join(" ")} }
376
+ OPTIONAL { ?s <${LABEL}> ?label }
377
+ OPTIONAL { ?s <https://littlebigbrain.com/p/${encodeURIComponent(settings.textField.toLowerCase())}> ?text }
378
+ }`;
379
+ const { rows } = await lbb.sparql({ query, as_of_commit_seq: message.as_of_commit_seq });
380
+ const out: Record<string, { label: string; text: string }> = {};
381
+ for (const row of rows) {
382
+ const record = (out[row.s!] ??= { label: row.label ?? row.s!, text: "" });
383
+ if (row.text && !record.text.includes(row.text))
384
+ record.text = `${record.text}\n${row.text}`.trim().slice(0, TEXT_CHARS);
385
+ }
386
+ return out;
387
+ })());
388
+ }
389
+
390
+ /** The source entity of a property's value on an instance. */
391
+ function source(iri: string, property: string): Schemas["NamedEntityInput"] {
392
+ return {
393
+ type: "ValueSource",
394
+ name: property,
395
+ key: `value:${entityIdFromIri(iri)}:${property}`,
396
+ };
397
+ }
398
+
399
+ /** The facts of one value: the property on the instance, its source, and the edge with the quote. */
400
+ function valueFacts(iri: string, label: string, v: Value) {
401
+ const target = { ...source(iri, v.property), name: `${v.property} = ${v.value}`.slice(0, 200) };
402
+ return {
403
+ properties: [
404
+ { ...entityRef(iri, settings.className, label), properties: { [v.property]: v.value } },
405
+ {
406
+ ...target,
407
+ properties: { property: v.property, value: String(v.value), support: v.support! },
408
+ },
409
+ ],
410
+ edge: {
411
+ source: entityRef(iri, settings.className, label),
412
+ relation: "HAS_VALUE_SOURCE",
413
+ target,
414
+ confidence: v.support!,
415
+ evidence: { text: v.quote, source_id: label.slice(0, 200) },
416
+ },
417
+ };
418
+ }
419
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "client": "0.25.0"
3
+ }