@littlebigbrain/create 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -6
- package/lib/create.mjs +228 -14
- package/package.json +2 -1
- package/project/python/values_extract/tests/fake_lbb.py +226 -0
- package/project/python/values_extract/tests/test_workflow.py +150 -0
- package/python/values_extract/README.md +52 -0
- package/python/values_extract/__init__.py +3 -0
- package/python/values_extract/__main__.py +97 -0
- package/python/values_extract/samples/expected.json +149 -0
- package/python/values_extract/samples/records.json +26 -0
- package/python/values_extract/samples.py +37 -0
- package/python/values_extract/workflow.py +1044 -0
- package/python/versions.json +3 -0
- package/templates/_shared/testing.ts +259 -5
- package/templates/ontology-fit/README.md +41 -32
- package/templates/ontology-fit/workflow.test.ts +56 -5
- package/templates/ontology-fit/workflow.ts +708 -207
- package/templates/review/README.md +56 -29
- package/templates/review/decision.ts +118 -37
- package/templates/review/workflow.test.ts +30 -10
- package/templates/review/workflow.ts +123 -76
- package/templates/rows/README.md +8 -1
- package/templates/rows/workflow.test.ts +56 -0
- package/templates/rows/workflow.ts +182 -148
- package/templates/sections/README.md +8 -1
- package/templates/sections/workflow.test.ts +51 -1
- package/templates/sections/workflow.ts +137 -98
- package/templates/values-extract/README.md +92 -23
- package/templates/values-extract/samples/expected.json +1 -0
- package/templates/values-extract/test-examples.ts +155 -0
- package/templates/values-extract/workflow.test.ts +532 -27
- package/templates/values-extract/workflow.ts +719 -179
- package/templates/versions.json +1 -1
|
@@ -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
|
|
11
|
-
//
|
|
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
|
|
16
|
-
//
|
|
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]) =>
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
136
|
-
export function
|
|
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
|
|
139
|
-
const
|
|
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
|
-
|
|
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 :
|
|
145
|
-
case "bool":
|
|
146
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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: {
|
|
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:
|
|
449
|
+
initialState: ZERO,
|
|
194
450
|
async onInstances(ctx, message, state) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
|
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
|
|
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) =>
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
240
|
-
await ctx.step(`support-${i}`, async (s) => {
|
|
241
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
//
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
313
|
-
|
|
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
|
|
750
|
+
const out = {
|
|
316
751
|
subjects: [] as Schemas["NamedEntityInput"][],
|
|
317
|
-
|
|
752
|
+
entity_properties: [] as Schemas["EntityPropertiesInput"][],
|
|
318
753
|
triplets: [] as Schemas["TripletInput"][],
|
|
319
754
|
retract: [] as Schemas["NamedEntityInput"][],
|
|
320
755
|
};
|
|
321
|
-
for (const
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
);
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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:
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
|
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,
|
|
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 { ${
|
|
373
|
-
|
|
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
|
|
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
|
|
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
|
|
383
|
-
|
|
384
|
-
|
|
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,
|
|
401
|
-
const
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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:
|
|
947
|
+
source: instance,
|
|
412
948
|
relation: "HAS_VALUE_SOURCE",
|
|
413
949
|
target,
|
|
414
|
-
confidence:
|
|
415
|
-
evidence: {
|
|
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
|
}
|