@littlebigbrain/create 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,7 @@
1
1
  // values-extract: fill properties of a class from the text of its
2
- // instances, each value with a verbatim quote.
2
+ // instances, each value with a verbatim quote. The rules are those of LBB's
3
+ // `values.extract@v1` (lbb-graph-query/src/infer_llm.rs); its eval
4
+ // (eval.test.ts) runs both on the same recorded model answers.
3
5
  //
4
6
  // A triggered workflow of the starter workflow `workflow` watches a class and
5
7
  // its text field, and sends the new and changed instances to this workflow.
@@ -7,26 +9,50 @@
7
9
  //
8
10
  // 1. asks the reasoning model (`reason`) for the values of the properties the
9
11
  // 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
+ // 2. checks each value without a model, in this order: its property is one
13
+ // the workflow fills, it is the first value of its property, it takes its
14
+ // property's type, and its quote is in the text. A number of a property
15
+ // with a unit must come in that unit, with the unit in its quote;
16
+ // otherwise it goes to review at most (the LBB-run values.extract converts
17
+ // a number in another unit instead: the one rule the template leaves to
18
+ // it);
12
19
  // 3. asks the decision model (`decide`) how well each quote supports its
13
20
  // value;
14
21
  // 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.
22
+ // evidence is the quote (with the instance's page box as its region, when
23
+ // the instance has one), and asks a person about each value between 0.5
24
+ // and the gate: a `value` review item in the property's unit, one per
25
+ // property of an instance. A value below 0.5 is dropped.
26
+ //
27
+ // A person's answer wins over the model, for the evidence it was given on
28
+ // (the value's quote and the unit the text states). A value a person
29
+ // accepted or corrected is written as answered (a correction is a number in
30
+ // the property's unit, as entered); one a person rejected, or one the graph
31
+ // refused, is not written again. New evidence with another value asks again,
32
+ // and the answered value and its source stay until the person answers. A
33
+ // value an earlier version filed as a suggestion keeps working: an accepted
34
+ // one is written again, an open one stays the question, and a dismissed one
35
+ // is a reject of its value.
17
36
  //
18
37
  // 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.
38
+ // runs again after a crash pays for nothing twice. The write's request id is
39
+ // its step's effect id, which no other turn repeats: a value that changes
40
+ // back (A, then B, then A) is written again. An instance whose model call is
41
+ // refused goes back to the watch (`lbb.requeue`, at most 3 times) and keeps
42
+ // its values; the turn completes, so the shard goes on. A spent budget fails
43
+ // the turn with the time to retry, and the worker waits without an attempt.
20
44
  import {
21
45
  entityIdFromIri,
22
46
  entityRef,
47
+ isActivityRefused,
48
+ stableHashHex,
23
49
  triggeredWorkflow,
24
50
  type LbbActivities,
25
51
  type Schemas,
26
52
  type WorkflowTriggerMessage,
27
53
  } from "@littlebigbrain/client";
28
54
 
29
- export type ValueType = "text" | "keyword" | "i64" | "f64" | "bool" | "date_time";
55
+ export type ValueType = "text" | "keyword" | "i64" | "f64" | "bool" | "date_time" | "datetime";
30
56
 
31
57
  export interface Settings {
32
58
  /** The class whose instances the workflow reads. */
@@ -39,6 +65,8 @@ export interface Settings {
39
65
  context: string;
40
66
  /** A value whose support is below this goes to review (from 0.5) or is dropped. */
41
67
  gate: number;
68
+ /** The unit of each number property (`cm`, `mm`, `kg`, `m2`, …), declared in the ontology by `ontology()`. */
69
+ units?: Record<string, string>;
42
70
  }
43
71
 
44
72
  /** The settings: edit them for your class. These fit the samples. */
@@ -48,6 +76,7 @@ export const defaults: Settings = {
48
76
  properties: { width_cm: "f64", depth_cm: "f64", material: "text", water_supply: "keyword" },
49
77
  context: "Entries of the sanitary equipment list of a school building tender, in German.",
50
78
  gate: 0.8,
79
+ units: { width_cm: "cm", depth_cm: "cm" },
51
80
  };
52
81
 
53
82
  /** The workflow type and version the triggered workflow names. */
@@ -71,16 +100,19 @@ export function trigger(settings: Settings = defaults): Schemas["TriggeredWorkfl
71
100
 
72
101
  /** The classes, properties and relation the workflow reads and writes, for `lbb.evolve`. */
73
102
  export function ontology(settings: Settings = defaults): Schemas["OntologyEvolveOp"][] {
74
- const property = (name: string, value_type: string) => ({
103
+ const property = (name: string, value_type: string, unit?: string) => ({
75
104
  op: "add_property" as const,
76
105
  name,
77
106
  value_type,
107
+ ...(unit ? { unit } : {}),
78
108
  });
79
109
  return [
80
110
  { op: "add_entity_type", name: settings.className },
81
111
  { op: "add_entity_type", name: "ValueSource" },
82
112
  property(settings.textField, "text"),
83
- ...Object.entries(settings.properties).map(([name, type]) => property(name, type)),
113
+ ...Object.entries(settings.properties).map(([name, type]) =>
114
+ property(name, type, unitOf(settings, name)),
115
+ ),
84
116
  property("property", "keyword"),
85
117
  property("value", "text"),
86
118
  property("support", "f64"),
@@ -93,27 +125,100 @@ export function ontology(settings: Settings = defaults): Schemas["OntologyEvolve
93
125
  ];
94
126
  }
95
127
 
128
+ /** What a value became: the outcomes of LBB's `values.extract`. */
129
+ export type Outcome =
130
+ | "written"
131
+ | "review"
132
+ | "weak_support"
133
+ | "quote_not_found"
134
+ | "invalid_value"
135
+ | "unknown_property"
136
+ | "duplicate";
137
+
138
+ /** The counters of the workflow, those of LBB's `values.extract`. */
96
139
  export interface ValuesState {
97
140
  instances: number;
141
+ values_proposed: number;
98
142
  written: number;
99
143
  review: number;
100
- dropped: number;
144
+ weak_support: number;
145
+ quote_not_found: number;
146
+ invalid_value: number;
147
+ unknown_property: number;
148
+ duplicate: number;
149
+ failed: number;
150
+ commits: number;
151
+ removed: number;
101
152
  }
102
153
 
103
- /** A value after the checks: its property, its typed value, its quote and its support. */
104
- interface Value {
154
+ const ZERO: ValuesState = {
155
+ instances: 0,
156
+ values_proposed: 0,
157
+ written: 0,
158
+ review: 0,
159
+ weak_support: 0,
160
+ quote_not_found: 0,
161
+ invalid_value: 0,
162
+ unknown_property: 0,
163
+ duplicate: 0,
164
+ failed: 0,
165
+ commits: 0,
166
+ removed: 0,
167
+ };
168
+
169
+ /** A proposed value after the checks: its outcome, and its typed value when it passed them. */
170
+ export interface Checked {
105
171
  property: string;
106
- value: string | number | boolean;
172
+ /** The value as the model wrote it, or its canonical text once it took its type. */
173
+ value: string;
107
174
  quote: string;
175
+ outcome: Outcome | "";
176
+ detail?: string;
177
+ typed?: string | number | boolean;
108
178
  support?: number;
179
+ /** Why the unit of a number is not confirmed: the value goes to review at most. */
180
+ unconfirmed?: string;
181
+ /**
182
+ * The unit the text states, folded (`cm` for `CM`): part of the value's evidence. LBB maps the
183
+ * names of a unit (`centimetres`) to its symbol too.
184
+ */
185
+ statedUnit?: string;
186
+ }
187
+
188
+ /** What a person's answer decides for a value. */
189
+ type Decision =
190
+ /** Write the answered value. */
191
+ | { kind: "write"; value: string | number | boolean; corrected: boolean }
192
+ /** Keep the property as it is in the graph (and ask again when `ask`). */
193
+ | { kind: "hold"; ask: boolean }
194
+ /** Write nothing for the value. */
195
+ | { kind: "skip" }
196
+ /** Write the facts of a suggestion an earlier version filed and a person accepted. */
197
+ | { kind: "facts"; facts: Schemas["SuggestedFacts"] };
198
+
199
+ /** The text of one instance as the workflow reads it. */
200
+ interface Instance {
201
+ label: string;
202
+ text: string;
203
+ box: Schemas["RegionAnchorInput"] | null;
109
204
  }
110
205
 
111
- const SYSTEM = `You read the text of one record of a knowledge graph and fill in property values the text states. Rules:
206
+ /** What the models made of one instance. */
207
+ interface Read {
208
+ values: Checked[];
209
+ /** The instance was not found at the message's commit: nothing is written for it. */
210
+ missing?: boolean;
211
+ /** A model call was refused: the instance goes back to the watch. */
212
+ failed?: string;
213
+ }
214
+
215
+ export const SYSTEM = `You read the text of one record of a knowledge graph and fill in property values the text states. Rules:
112
216
  - Give a value only when the text states it. Do not guess and do not compute.
113
217
  - 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
218
  - A value the text gives as approximate ("about 40 kg", "approx. 2 m") is stated: give it without the approximation.
115
219
  - 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.
220
+ - Write each value in its property's type: a number for i64 and f64 (digits, a dot for decimals, without its unit), true or false for bool, YYYY-MM-DD for datetime, and the words of the text for text and keyword.
221
+ - Give a number as the text states it, and in \`unit\` the unit the text states it in, as a symbol (mm, cm, m, m2, l, kg, kW, h). Do not convert it to the property's unit. Give an empty \`unit\` when the text states no unit, and for a value that is not a number.
117
222
  - Give at most one value per property. Leave a property out when the text does not state it.`;
118
223
 
119
224
  const SUPPORT: Record<string, string> = {
@@ -128,41 +233,184 @@ const SUPPORT: Record<string, string> = {
128
233
  const TURN_INSTANCES = 24;
129
234
  /** Characters of a text the models read. */
130
235
  const TEXT_CHARS = 40_000;
236
+ /** Below this support a value is dropped. */
237
+ const SUPPORT_MIN = 0.5;
131
238
  const PLAIN_IRI = /^[^\s<>"{}|\\^`]+$/;
239
+ const ENTITY_IRI = /^https:\/\/littlebigbrain\.com\/e\/[0-9a-fA-F]{32}$/;
132
240
  const LABEL = "http://www.w3.org/2000/01/rdf-schema#label";
241
+ const P = "https://littlebigbrain.com/p/";
242
+ const BOX = ["page", "x0", "y0", "x1", "y1"] as const;
133
243
  const normalize = (text: string) => text.split(/\s+/).filter(Boolean).join(" ");
244
+ /** The first `n` characters (code points, as Rust counts them). */
245
+ const chars = (text: string, n: number) => [...text].slice(0, n).join("");
246
+ const NUMBER = /^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/;
247
+ const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
134
248
 
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 {
249
+ /** The value of `raw` in its property's type, or why it does not take the type. */
250
+ export function coerce(
251
+ raw: string,
252
+ type: ValueType,
253
+ ): { value: string | number | boolean } | { error: string } {
137
254
  const text = raw.trim();
138
- if (!text) return null;
139
- const number = Number(text.replace(",", ".").replace(/\s+/g, ""));
255
+ if (!text) return { error: "the value is empty" };
256
+ const cleaned = text.replaceAll(",", ".").replace(/\s+/g, "");
257
+ const number = NUMBER.test(cleaned) ? Number(cleaned) : NaN;
140
258
  switch (type) {
141
259
  case "i64":
142
- return Number.isInteger(number) ? number : null;
260
+ if (!Number.isFinite(number)) return { error: `\`${text}\` is not a number` };
261
+ return Number.isInteger(number)
262
+ ? { value: number }
263
+ : { error: `\`${text}\` is not a whole number` };
143
264
  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;
265
+ return Number.isFinite(number) ? { value: number } : { error: `\`${text}\` is not a number` };
266
+ case "bool": {
267
+ const lower = text.toLowerCase();
268
+ if (["true", "yes", "ja"].includes(lower)) return { value: true };
269
+ if (["false", "no", "nein"].includes(lower)) return { value: false };
270
+ return { error: `\`${text}\` is not true or false` };
271
+ }
147
272
  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;
273
+ case "datetime": {
274
+ const value = new TextEncoder().encode(text).length === 10 ? `${text}T00:00:00Z` : text;
275
+ return RFC3339.test(value) && !Number.isNaN(Date.parse(value))
276
+ ? { value }
277
+ : { error: `\`${text}\` is not a date` };
278
+ }
151
279
  default:
152
- return text;
280
+ return { value: text };
153
281
  }
154
282
  }
155
283
 
284
+ /** The value of `raw` in its property's type, or null when it does not take the type. */
285
+ export function typed(raw: string, type: ValueType): string | number | boolean | null {
286
+ const coerced = coerce(raw, type);
287
+ return "value" in coerced ? coerced.value : null;
288
+ }
289
+
290
+ /** Why a quote does not hold, or null when it does: it is in the text, with at least three words or the value in it. */
291
+ export function quoteProblem(quote: string, value: string, text: string): string | null {
292
+ const q = normalize(quote);
293
+ const words = q ? q.split(" ").length : 0;
294
+ if (!words) return "the quote is empty";
295
+ if (!normalize(text).includes(q)) return "the quote is not in the text";
296
+ const v = normalize(value).toLowerCase();
297
+ return words >= 3 || (v !== "" && q.toLowerCase().includes(v))
298
+ ? null
299
+ : "the quote has fewer than 3 words and does not hold the value";
300
+ }
301
+
156
302
  /** A quote must be in the text, with at least three words or the value in it. */
157
303
  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());
304
+ return quoteProblem(quote, value, text) === null;
305
+ }
306
+
307
+ /** The unit a number property holds, from the settings; none for other types. */
308
+ export function unitOf(settings: Settings, property: string): string | undefined {
309
+ const type = settings.properties[property];
310
+ return type === "i64" || type === "f64" ? settings.units?.[property] : undefined;
311
+ }
312
+
313
+ const unitText = (text: string) =>
314
+ text.trim().replace(/\.+$/, "").toLowerCase().replace(/²/g, "2").replace(/³/g, "3");
315
+
316
+ /**
317
+ * Why a number of a property that holds `unit` cannot be confirmed in that unit: the text states
318
+ * no unit or another one, or the quote does not hold it. Undefined when it is confirmed. The
319
+ * messages are those of LBB's `values.extract`; a number in another unit is LBB's to convert.
320
+ */
321
+ export function unitProblem(unit: string, stated: string, quote: string): string | undefined {
322
+ const want = unitText(unit);
323
+ const got = unitText(stated);
324
+ if (!got) return `the text states no unit; the property holds ${unit}`;
325
+ if (got !== want) return `the text states ${stated.trim()}; the property holds ${unit}`;
326
+ const token = want.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
327
+ return new RegExp(`(?<!\\p{L})${token}(?![\\p{L}\\p{N}])`, "u").test(unitText(quote))
328
+ ? undefined
329
+ : `the quote does not state the unit ${unit}`;
330
+ }
331
+
332
+ /**
333
+ * A number and the unit after it, read apart (`600 mm` gives `600` and `mm`), as LBB does when
334
+ * a model gives the unit in `value`; undefined when the text does not start with a number or has
335
+ * no unit after it.
336
+ */
337
+ export function splitNumberUnit(text: string): { number: string; unit: string } | undefined {
338
+ const trimmed = text.trim();
339
+ const end = [...trimmed].findIndex((c) => !/[0-9.,+\- ]/.test(c));
340
+ if (end < 0) return undefined;
341
+ const points = [...trimmed];
342
+ const number = points.slice(0, end).join("").trim();
343
+ const unit = points.slice(end).join("").trim();
344
+ return /[0-9]/.test(number) && unit ? { number, unit } : undefined;
345
+ }
346
+
347
+ /** The checks without a model, in the order of LBB's `values.extract`. */
348
+ export function check(
349
+ values: { property?: string; value?: string; unit?: string; quote?: string }[],
350
+ properties: Record<string, ValueType>,
351
+ text: string,
352
+ units: Record<string, string> = {},
353
+ ): Checked[] {
354
+ const seen = new Set<string>();
355
+ return values.map((item): Checked => {
356
+ const property = item.property ?? "";
357
+ let raw = item.value ?? "";
358
+ let stated = (item.unit ?? "").trim();
359
+ const quote = item.quote ?? "";
360
+ // A number given with its unit in `value` (`600 mm`), against the
361
+ // rules: read the two apart.
362
+ const split = stated ? undefined : splitNumberUnit(raw);
363
+ if (split) ({ number: raw, unit: stated } = split);
364
+ const out: Checked = { property, value: raw, quote, outcome: "" };
365
+ const type = Object.hasOwn(properties, property) ? properties[property] : undefined;
366
+ if (!type) return { ...out, outcome: "unknown_property" };
367
+ if (seen.has(property)) return { ...out, outcome: "duplicate" };
368
+ seen.add(property);
369
+ const coerced = coerce(raw, type);
370
+ if ("error" in coerced) return { ...out, outcome: "invalid_value", detail: coerced.error };
371
+ const problem = quoteProblem(quote, raw, text);
372
+ if (problem) return { ...out, outcome: "quote_not_found", detail: problem };
373
+ const unit =
374
+ (type === "i64" || type === "f64") && Object.hasOwn(units, property)
375
+ ? units[property]
376
+ : undefined;
377
+ const unconfirmed = unit ? unitProblem(unit, stated, quote) : undefined;
378
+ return {
379
+ ...out,
380
+ value: String(coerced.value),
381
+ typed: coerced.value,
382
+ ...(unconfirmed ? { unconfirmed } : {}),
383
+ ...(stated ? { statedUnit: unitText(stated) } : {}),
384
+ };
385
+ });
386
+ }
387
+
388
+ /** The key of the value item of a property of an instance, as LBB's values.extract gives it. */
389
+ export function valueItemKey(workflow: string, iri: string, property: string): string {
390
+ const hash = stableHashHex(["value_item_v2", entityIdFromIri(iri), property]).slice(0, 24);
391
+ return `starter:${workflow}:value:${hash}`;
392
+ }
393
+
394
+ /** The key an earlier version filed a value under, as a suggestion: one per value. */
395
+ export function legacyValueKey(workflow: string, iri: string, v: Checked): string {
396
+ const hash = stableHashHex([entityIdFromIri(iri), v.property, v.value]).slice(0, 24);
397
+ return `starter:${workflow}:value:${hash}`;
398
+ }
399
+
400
+ /** The evidence a value is read from, as LBB marks it: its quote and the unit the text states. */
401
+ export function evidenceOf(v: Checked): string {
402
+ return stableHashHex(["values_evidence_v2", normalize(v.quote), v.statedUnit ?? ""]).slice(0, 32);
161
403
  }
162
404
 
163
405
  /** The workflow, for a `WorkflowWorker`. `lbb` is `lbbActivities(client)`, or a mock in a test. */
164
406
  export function createWorkflow(lbb: LbbActivities, settings: Settings = defaults) {
165
- const names = Object.keys(settings.properties);
407
+ // The properties in name order, each with the name the property registry
408
+ // gives its type, as LBB's values.extract sends them.
409
+ const names = Object.keys(settings.properties).sort();
410
+ const typeName = (name: string) => {
411
+ const type = settings.properties[name];
412
+ return type === "date_time" ? "datetime" : type;
413
+ };
166
414
  const schema = {
167
415
  type: "object",
168
416
  additionalProperties: false,
@@ -173,10 +421,18 @@ export function createWorkflow(lbb: LbbActivities, settings: Settings = defaults
173
421
  items: {
174
422
  type: "object",
175
423
  additionalProperties: false,
176
- required: ["property", "value", "quote"],
424
+ required: ["property", "value", "unit", "quote"],
177
425
  properties: {
178
426
  property: { type: "string", enum: names },
179
- value: { type: "string", description: "The value in the property's type." },
427
+ value: {
428
+ type: "string",
429
+ description: "The value in the property's type; a number as the text states it.",
430
+ },
431
+ unit: {
432
+ type: "string",
433
+ description:
434
+ "The unit the text states a number in, as a symbol; empty when it states none or the value is not a number.",
435
+ },
180
436
  quote: {
181
437
  type: "string",
182
438
  description: "The words of the text that state the value, copied exactly.",
@@ -187,58 +443,63 @@ export function createWorkflow(lbb: LbbActivities, settings: Settings = defaults
187
443
  },
188
444
  };
189
445
 
190
- return triggeredWorkflow<ValuesState>({
446
+ return triggeredWorkflow<ValuesState, { counters: Partial<ValuesState> }>({
191
447
  name: workflowType.workflow_type,
192
448
  version: workflowType.version,
193
- initialState: { instances: 0, written: 0, review: 0, dropped: 0 },
449
+ initialState: ZERO,
194
450
  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
- });
451
+ const tally: ValuesState = { ...ZERO };
452
+ // An instance that left the class keeps what was written for it; its
453
+ // values go with its fields when it is retracted.
454
+ tally.removed = message.removed.length;
203
455
  const batch = message.instances.slice(0, TURN_INSTANCES);
204
456
  const texts = reader(message, batch);
205
- const read: Value[][] = [];
457
+ const reads: Read[] = [];
206
458
  for (const [i, iri] of batch.entries()) {
207
- const proposed = await ctx.step(`values-${i}`, async (s) => {
459
+ const proposed = await ctx.step(`values-${i}`, async (s): Promise<Read> => {
208
460
  s.progress("reading values", { done: i, total: batch.length, unit: "instances" });
209
461
  const record = (await texts())[iri];
210
- if (!record?.text.trim()) return [];
462
+ if (!record) return { values: [], missing: true };
463
+ if (!record.text.trim() || !names.length) return { values: [] };
211
464
  const prompt = JSON.stringify({
212
465
  class: settings.className,
213
466
  instance: record.label,
214
467
  context: settings.context,
215
- properties: names.map((name) => ({ name, type: settings.properties[name] })),
468
+ properties: names.map((name) => {
469
+ const unit = unitOf(settings, name);
470
+ return { name, type: typeName(name), ...(unit ? { unit } : {}) };
471
+ }),
216
472
  text: record.text,
217
473
  });
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
- });
474
+ try {
475
+ const answer = await lbb.reason<{
476
+ values?: { property?: string; value?: string; unit?: string; quote?: string }[];
477
+ }>(
478
+ { system: SYSTEM, prompt, schema, max_tokens: 8_000 },
479
+ { effectId: s.effectId, label: `${chars(record.label, 50)} values` },
480
+ );
481
+ return {
482
+ values: check(
483
+ answer.value.values ?? [],
484
+ settings.properties,
485
+ record.text,
486
+ Object.fromEntries(
487
+ names.flatMap((name) => {
488
+ const unit = unitOf(settings, name);
489
+ return unit ? [[name, unit]] : [];
490
+ }),
491
+ ),
492
+ ),
493
+ };
494
+ } catch (error) {
495
+ if (!isActivityRefused(error)) throw error;
496
+ return { values: [], failed: String((error as Error).message ?? error) };
497
+ }
238
498
  });
239
- read.push(
240
- await ctx.step(`support-${i}`, async (s) => {
241
- if (!proposed.length) return [];
499
+ reads.push(
500
+ await ctx.step(`support-${i}`, async (s): Promise<Read> => {
501
+ const asked = proposed.values.flatMap((v, n) => (v.typed !== undefined ? [n] : []));
502
+ if (!asked.length) return proposed;
242
503
  s.progress("asking the decision model", {
243
504
  done: i,
244
505
  total: batch.length,
@@ -246,147 +507,419 @@ export function createWorkflow(lbb: LbbActivities, settings: Settings = defaults
246
507
  });
247
508
  const record = (await texts())[iri]!;
248
509
  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
- ]),
510
+ asked.map((n) => {
511
+ const v = proposed.values[n]!;
512
+ return [
513
+ `v${n}`,
514
+ {
515
+ 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} (${typeName(v.property)})\nValue: ${v.value}\nQuote: "${v.quote}"`,
516
+ criteria: SUPPORT,
517
+ },
518
+ ];
519
+ }),
256
520
  );
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,
521
+ let answers: Awaited<ReturnType<LbbActivities["decide"]>>["answers"];
522
+ try {
523
+ ({ answers } = await lbb.decide(
524
+ {
525
+ state: {
526
+ class: settings.className,
527
+ record: record.label,
528
+ context: settings.context,
529
+ text: record.text,
530
+ },
531
+ questions,
264
532
  },
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
- });
533
+ { effectId: s.effectId, label: `${chars(record.label, 50)} support` },
534
+ ));
535
+ } catch (error) {
536
+ if (!isActivityRefused(error)) throw error;
537
+ return { values: [], failed: String((error as Error).message ?? error) };
538
+ }
539
+ return {
540
+ values: proposed.values.map((v, n): Checked => {
541
+ if (v.typed === undefined) return v;
542
+ const a = answers[`v${n}`];
543
+ const support =
544
+ a?.probabilities?.supported ??
545
+ (a?.choice == null
546
+ ? undefined
547
+ : a.choice === "supported"
548
+ ? (a.confidence ?? 1)
549
+ : 0);
550
+ // A value whose unit is not confirmed goes to review at most.
551
+ const outcome: Outcome =
552
+ support === undefined
553
+ ? "weak_support"
554
+ : support >= settings.gate && !v.unconfirmed
555
+ ? "written"
556
+ : support >= SUPPORT_MIN
557
+ ? "review"
558
+ : "weak_support";
559
+ const detail =
560
+ outcome === "review" && v.detail === undefined && v.unconfirmed
561
+ ? { detail: v.unconfirmed }
562
+ : {};
563
+ return {
564
+ ...v,
565
+ outcome,
566
+ ...detail,
567
+ ...(support !== undefined ? { support } : {}),
568
+ };
569
+ }),
570
+ };
276
571
  }),
277
572
  );
278
573
  }
279
574
 
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)
575
+ // A person's answer wins over the model, for the evidence it was given
576
+ // on. One read of the items of the turn's values: the value item of each
577
+ // property of an instance, and the suggestion an earlier version filed
578
+ // for each value.
579
+ const candidates = reads.flatMap((read, i) =>
580
+ read.values
581
+ .filter((v) => v.outcome === "written" || v.outcome === "review")
285
582
  .map((v) => ({ i, v })),
286
583
  );
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)}"`,
584
+ const keyOf = (i: number, property: string) =>
585
+ valueItemKey(message.workflow, batch[i]!, property);
586
+ const legacyKeyOf = (i: number, v: Checked) => legacyValueKey(message.workflow, batch[i]!, v);
587
+ const known = await ctx.step("answers", async () => {
588
+ const items: Record<string, Schemas["ReviewItem"]> = {};
589
+ const legacy: Record<string, { status: string; facts: Schemas["SuggestedFacts"] | null }> =
590
+ {};
591
+ if (!candidates.length) return { items, legacy };
592
+ const found = await lbb.reviewItems(
593
+ candidates.flatMap(({ i, v }) => [keyOf(i, v.property), legacyKeyOf(i, v)]),
594
+ );
595
+ for (const item of found) {
596
+ if (item.kind === "value") items[item.key] = item;
597
+ else if (item.kind === "change") {
598
+ const accepted = item.status === "answered" && item.answer?.decision === "accept";
599
+ const status = accepted
600
+ ? "accepted"
601
+ : item.status === "answered"
602
+ ? "dismissed"
603
+ : item.status;
604
+ // An accepted suggestion is written again with its facts.
605
+ const filed = accepted ? await lbb.reviewItem(item.key) : null;
606
+ legacy[item.key] = { status, facts: filed?.facts ?? null };
607
+ }
608
+ }
609
+ return { items, legacy };
610
+ });
611
+ const decide = (i: number, v: Checked): Decision | undefined => {
612
+ const item = known.items[keyOf(i, v.property)];
613
+ if (item) {
614
+ // The same evidence, the value in force, or the value the item proposed when it was
615
+ // answered (the person decided on it) asks nothing.
616
+ const inForce = valueInForce(item);
617
+ if (inForce !== undefined) {
618
+ const same = !item.evidence_hash || item.evidence_hash === evidenceOf(v);
619
+ return same || inForce.value === v.typed || item.question?.value === v.typed
620
+ ? { kind: "write", ...inForce }
621
+ : { kind: "hold", ask: true };
622
+ }
623
+ // Asked again: hold, and update the question only for a value it does not ask yet.
624
+ if (item.previous_answer)
625
+ return {
626
+ kind: "hold",
627
+ ask: item.question?.value !== v.typed && rejectedValue(item) !== v.typed,
628
+ };
629
+ // A value a person rejected, or one the graph refused, is not written again.
630
+ if (rejectedValue(item) === v.typed || refusedValue(item) === v.typed)
631
+ return { kind: "skip" };
632
+ return undefined;
633
+ }
634
+ const legacy = known.legacy[legacyKeyOf(i, v)];
635
+ if (legacy?.status === "accepted")
636
+ return legacy.facts ? { kind: "facts", facts: legacy.facts } : { kind: "skip" };
637
+ if (legacy?.status === "dismissed") return { kind: "skip" };
638
+ // An open suggestion stays the question.
639
+ if (legacy?.status === "open" && v.outcome === "review") return { kind: "skip" };
640
+ return undefined;
641
+ };
642
+
643
+ // The value items: one per property of an instance, for a value between
644
+ // 0.5 and the gate, or new evidence against an answer in force.
645
+ const asking = candidates.filter(({ i, v }) => {
646
+ const decision = decide(i, v);
647
+ return decision ? decision.kind === "hold" && decision.ask : v.outcome === "review";
648
+ });
649
+ const answered = await ctx.step("review", async (s) => {
650
+ const kept: {
651
+ i: number;
652
+ property: string;
653
+ value: string | number | boolean;
654
+ corrected: boolean;
655
+ }[] = [];
656
+ for (const [n, { i, v }] of asking.entries()) {
657
+ s.progress("filing review items", { done: n, total: asking.length, unit: "items" });
658
+ const iri = batch[i]!;
659
+ const record = (await texts())[iri]!;
660
+ const facts = valueFacts(iri, record, v);
661
+ const unit = unitOf(settings, v.property);
662
+ const support = v.support ?? 0;
663
+ const item = await lbb.review({
664
+ kind: "value",
665
+ workflow: { name: message.workflow },
666
+ origin: { kind: "agent", id: `starter:${message.workflow}`, label: "values.extract" },
667
+ key: keyOf(i, v.property),
668
+ title: chars(`Set ${v.property} of ${record.label} to ${v.value}`, 200),
669
+ rationale: v.unconfirmed
670
+ ? `The unit is not confirmed: ${v.unconfirmed}. The text supports the value with ${support.toFixed(2)}.`
671
+ : `The text supports the value with ${support.toFixed(2)}, below the gate ${settings.gate}.`,
672
+ subject: { iri, type: settings.className, name: record.label },
673
+ question: {
674
+ property: v.property,
675
+ value: v.typed!,
676
+ ...(unit ? { unit } : {}),
677
+ confidence: support,
678
+ },
679
+ // The quote, with the instance's page box as its region.
680
+ evidence: [
681
+ {
682
+ quote: chars(v.quote, 600),
683
+ source_id: iri,
684
+ ...(facts.edge.evidence.region ? { region: facts.edge.evidence.region } : {}),
685
+ confidence: support,
686
+ },
687
+ ],
688
+ evidence_hash: evidenceOf(v),
298
689
  facts: {
299
- entity_properties: facts.properties,
300
690
  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 }],
691
+ entity_properties: [facts.onInstance, facts.onSource],
692
+ summary: chars(`${v.property} = ${v.value}`, 500),
306
693
  },
307
694
  });
308
- if (filed.status === "accepted") kept.push({ i, property: v.property });
695
+ if (decide(i, v)) continue; // asked again: the answered value holds
696
+ // Answered already (a person answered between the read and now).
697
+ const inForce = valueInForce(item);
698
+ if (inForce) kept.push({ i, property: v.property, ...inForce });
309
699
  }
310
700
  return kept;
311
701
  });
312
- await lbb.writeOwned(ctx, "write", async (s) => {
313
- s.progress("writing facts", { done: 0, total: batch.length, unit: "instances" });
702
+
703
+ /** What the write sets on instance `i`, and the properties it keeps as they are in the graph. */
704
+ const plan = (i: number, record: Instance) => {
705
+ const entity_properties: Schemas["EntityPropertiesInput"][] = [];
706
+ const triplets: Schemas["TripletInput"][] = [];
707
+ const written = new Set<string>();
708
+ const held = new Set<string>();
709
+ for (const v of reads[i]!.values) {
710
+ if (v.outcome !== "written" && v.outcome !== "review") continue;
711
+ const decision = decide(i, v);
712
+ if (decision?.kind === "hold") held.add(v.property);
713
+ if (decision?.kind === "facts") {
714
+ entity_properties.push(...(decision.facts.entity_properties ?? []));
715
+ triplets.push(...(decision.facts.triplets ?? []));
716
+ written.add(v.property);
717
+ continue;
718
+ }
719
+ const answer =
720
+ decision?.kind === "write"
721
+ ? decision
722
+ : decision
723
+ ? undefined
724
+ : answered.find((a) => a.i === i && a.property === v.property);
725
+ const kept =
726
+ answer !== undefined
727
+ ? answeredAs(v, answer)
728
+ : !decision && v.outcome === "written"
729
+ ? v
730
+ : undefined;
731
+ if (!kept) continue;
732
+ const facts = valueFacts(batch[i]!, record, kept);
733
+ entity_properties.push(facts.onInstance, facts.onSource);
734
+ triplets.push(facts.edge);
735
+ written.add(v.property);
736
+ }
737
+ return { entity_properties, triplets, written, held };
738
+ };
739
+ /** The properties of instance `i` whose answered value stays as it is in the graph. */
740
+ const heldOf = (i: number) =>
741
+ new Set(
742
+ reads[i]!.values.filter(
743
+ (v) =>
744
+ (v.outcome === "written" || v.outcome === "review") && decide(i, v)?.kind === "hold",
745
+ ).map((v) => v.property),
746
+ );
747
+ /** One owned write of instances; a held property is neither set nor replaced. */
748
+ const ownedWrite = async (indexes: number[], held: Set<string>) => {
314
749
  const records = await texts();
315
- const write = {
750
+ const out = {
316
751
  subjects: [] as Schemas["NamedEntityInput"][],
317
- properties: [] as Schemas["EntityPropertiesInput"][],
752
+ entity_properties: [] as Schemas["EntityPropertiesInput"][],
318
753
  triplets: [] as Schemas["TripletInput"][],
319
754
  retract: [] as Schemas["NamedEntityInput"][],
320
755
  };
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)),
756
+ for (const i of indexes) {
757
+ const iri = batch[i]!;
758
+ const record = records[iri];
759
+ if (!record) continue;
760
+ out.subjects.push(entityRef(iri, settings.className, record.label));
761
+ const planned = plan(i, record);
762
+ out.entity_properties.push(...planned.entity_properties);
763
+ out.triplets.push(...planned.triplets);
764
+ out.retract.push(
765
+ ...names
766
+ .filter((p) => !planned.written.has(p) && !held.has(p))
767
+ .map((p) => source(iri, p)),
336
768
  );
337
769
  }
338
770
  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,
771
+ subjects: out.subjects,
772
+ // A held property keeps its source link: an instance that holds one
773
+ // does not replace its links (a source it no longer has is retracted
774
+ // whole, with its link).
775
+ relations: held.size ? [] : ["HAS_VALUE_SOURCE"],
776
+ properties: names.filter((p) => !held.has(p)),
777
+ entity_properties: out.entity_properties,
778
+ triplets: out.triplets,
779
+ retract_entities: out.retract,
345
780
  };
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
781
  };
782
+
783
+ const failed = batch.flatMap((iri, i) =>
784
+ reads[i]!.failed !== undefined ? [{ instance: iri, error: reads[i]!.failed }] : [],
785
+ );
786
+ const writable = batch
787
+ .map((_, i) => i)
788
+ .filter((i) => !reads[i]!.missing && reads[i]!.failed === undefined);
789
+ const holding = writable.filter((i) => heldOf(i).size > 0);
790
+ const writes = [
791
+ await lbb.writeOwned(ctx, "write", async (s) => {
792
+ s.progress("writing facts", { done: 0, total: batch.length, unit: "instances" });
793
+ return ownedWrite(
794
+ writable.filter((i) => !holding.includes(i)),
795
+ new Set(),
796
+ );
797
+ }),
798
+ ];
799
+ // An instance that holds an answered value is written on its own, so
800
+ // the held property stays out of its replace.
801
+ for (const i of holding)
802
+ writes.push(await lbb.writeOwned(ctx, `write-held-${i}`, () => ownedWrite([i], heldOf(i))));
803
+ // Refused instances go back to the watch; the turn completes.
804
+ await lbb.requeue(ctx, "requeue", failed, { graph: message.graph });
805
+
806
+ for (const read of reads) {
807
+ if (read.missing) continue;
808
+ tally.instances += 1;
809
+ if (read.failed !== undefined) tally.failed += 1;
810
+ for (const v of read.values) {
811
+ tally.values_proposed += 1;
812
+ if (v.outcome) tally[v.outcome] += 1;
813
+ }
814
+ }
815
+ for (const write of writes)
816
+ if (write.commit_seq !== null && !write.replayed) tally.commits += 1;
817
+ const next = Object.fromEntries(
818
+ Object.entries(ZERO).map(([key]) => [
819
+ key,
820
+ (state[key as keyof ValuesState] ?? 0) + tally[key as keyof ValuesState],
821
+ ]),
822
+ ) as unknown as ValuesState;
823
+ const result = { counters: tally };
357
824
  // More instances than a turn reads: the rest goes to a new turn.
358
825
  const rest = message.instances.slice(TURN_INSTANCES);
359
826
  return rest.length
360
- ? ctx.continue(next, { ...message, instances: rest, removed: [] })
361
- : { state: next };
827
+ ? { ...ctx.continue(next, { ...message, instances: rest, removed: [] }), result }
828
+ : { state: next, result };
362
829
  },
363
830
  });
364
831
 
365
- /** The label and text of each instance of the turn, read once per attempt at the message's commit. */
832
+ /** The label, text and page box of each instance of the turn, read once per attempt at the message's commit. */
366
833
  function reader(message: WorkflowTriggerMessage, iris: string[]) {
367
- let read: Promise<Record<string, { label: string; text: string }>> | undefined;
834
+ let read: Promise<Record<string, Instance>> | undefined;
368
835
  return () =>
369
836
  (read ??= (async () => {
837
+ // An instance that is no entity of the graph is not read (LBB skips it too).
838
+ const plain = iris.filter((iri) => PLAIN_IRI.test(iri) && ENTITY_IRI.test(iri));
370
839
  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(" ")} }
840
+ SELECT ?s ?label ?text ${BOX.map((b) => `?${b}`).join(" ")} WHERE {
841
+ VALUES ?s { ${plain.map((iri) => `<${iri}>`).join(" ")} }
842
+ ?s a ?class .
376
843
  OPTIONAL { ?s <${LABEL}> ?label }
377
- OPTIONAL { ?s <https://littlebigbrain.com/p/${encodeURIComponent(settings.textField.toLowerCase())}> ?text }
844
+ OPTIONAL { ?s <${P}${encodeURIComponent(settings.textField.toLowerCase())}> ?text }
845
+ ${BOX.map((b) => ` OPTIONAL { ?s <${P}${b}> ?${b} }`).join("\n")}
378
846
  }`;
379
847
  const { rows } = await lbb.sparql({ query, as_of_commit_seq: message.as_of_commit_seq });
380
- const out: Record<string, { label: string; text: string }> = {};
848
+ const found = new Map<
849
+ string,
850
+ { labels: Set<string>; texts: Set<string>; box: Record<string, string> }
851
+ >();
381
852
  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);
853
+ const at = found.get(row.s!) ?? { labels: new Set(), texts: new Set(), box: {} };
854
+ found.set(row.s!, at);
855
+ if (row.label && normalize(row.label)) at.labels.add(normalize(row.label));
856
+ if (row.text && normalize(row.text)) at.texts.add(normalize(row.text));
857
+ for (const b of BOX) if (row[b] !== undefined) at.box[b] ??= row[b]!;
858
+ }
859
+ const out: Record<string, Instance> = {};
860
+ for (const [iri, at] of found) {
861
+ // As LBB composes a text from its fields: `<field>: <values>`, each
862
+ // value's whitespace as single spaces, the values in order.
863
+ const values = [...at.texts].sort();
864
+ const text = values.length
865
+ ? chars(`${settings.textField}: ${values.join(", ")}`, TEXT_CHARS)
866
+ : "";
867
+ const label = [...at.labels].sort()[0] ?? entityIdFromIri(iri);
868
+ out[iri] = { label, text, box: region(at.box) };
385
869
  }
386
870
  return out;
387
871
  })());
388
872
  }
389
873
 
874
+ /** The instance's page box as a region, when it has a valid one (`page`, `x0`, `y0`, `x1`, `y1`). */
875
+ function region(box: Record<string, string>): Schemas["RegionAnchorInput"] | null {
876
+ const [page, x0, y0, x1, y1] = BOX.map((b) => (box[b] === undefined ? NaN : Number(box[b])));
877
+ if (![page, x0, y0, x1, y1].every(Number.isFinite)) return null;
878
+ if (page! < 0 || ![x0, y0, x1, y1].every((v) => v! >= 0 && v! <= 1)) return null;
879
+ if (x0! > x1! || y0! > y1!) return null;
880
+ return { page: Math.trunc(page!), x0: x0!, y0: y0!, x1: x1!, y1: y1!, confidence: 0 };
881
+ }
882
+
883
+ /** The value of an answered item that holds in the graph: an accept or a correction the graph took. */
884
+ function valueInForce(
885
+ item: Schemas["ReviewItem"],
886
+ ): { value: string | number | boolean; corrected: boolean } | undefined {
887
+ const answer = item.answer;
888
+ if (!answer || item.status !== "answered" || item.effects?.commit_error) return undefined;
889
+ const value =
890
+ answer.decision === "correct"
891
+ ? answer.value
892
+ : answer.decision === "accept"
893
+ ? (answer.value ?? item.question?.value)
894
+ : undefined;
895
+ return value === undefined || value === null
896
+ ? undefined
897
+ : { value: value as string | number | boolean, corrected: answer.decision === "correct" };
898
+ }
899
+
900
+ /** The proposed value a person rejected on an item. */
901
+ function rejectedValue(item: Schemas["ReviewItem"]): unknown {
902
+ return item.answer?.decision === "reject" ? item.question?.value : undefined;
903
+ }
904
+
905
+ /** The value of an answer the graph refused to write: the item is open again for the person. */
906
+ function refusedValue(item: Schemas["ReviewItem"]): unknown {
907
+ if (!item.effects?.commit_error) return undefined;
908
+ if (item.answer?.decision === "correct") return item.answer.value;
909
+ if (item.answer?.decision === "accept") return item.answer.value ?? item.question?.value;
910
+ return undefined;
911
+ }
912
+
913
+ /** A value as a person answered it: a correction in place of the read value, with support 1. */
914
+ function answeredAs(
915
+ v: Checked,
916
+ answer: { value: string | number | boolean; corrected: boolean },
917
+ ): Checked {
918
+ return answer.corrected
919
+ ? { ...v, typed: answer.value, value: String(answer.value), support: 1 }
920
+ : { ...v, typed: answer.value, value: String(answer.value) };
921
+ }
922
+
390
923
  /** The source entity of a property's value on an instance. */
391
924
  function source(iri: string, property: string): Schemas["NamedEntityInput"] {
392
925
  return {
@@ -397,22 +930,29 @@ SELECT ?s ?label ?text WHERE {
397
930
  }
398
931
 
399
932
  /** 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) };
933
+ function valueFacts(iri: string, record: Instance, v: Checked) {
934
+ const support = v.support ?? 0;
935
+ const target = { ...source(iri, v.property), name: chars(`${v.property} = ${v.value}`, 200) };
936
+ const instance = entityRef(iri, settings.className, record.label);
937
+ const region = record.box
938
+ ? { ...record.box, confidence: Math.min(1, Math.max(0, support)) }
939
+ : undefined;
402
940
  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
- ],
941
+ onInstance: { ...instance, properties: { [v.property]: v.typed! } },
942
+ onSource: {
943
+ ...target,
944
+ properties: { property: v.property, value: v.value, support },
945
+ },
410
946
  edge: {
411
- source: entityRef(iri, settings.className, label),
947
+ source: instance,
412
948
  relation: "HAS_VALUE_SOURCE",
413
949
  target,
414
- confidence: v.support!,
415
- evidence: { text: v.quote, source_id: label.slice(0, 200) },
950
+ confidence: support,
951
+ evidence: {
952
+ text: v.quote,
953
+ source_id: chars(record.label, 200),
954
+ ...(region ? { region } : {}),
955
+ },
416
956
  },
417
957
  };
418
958
  }