@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.
- 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 +197 -2
- 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/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
|
@@ -0,0 +1,1044 @@
|
|
|
1
|
+
"""values-extract: fill properties of a class from the text of its instances,
|
|
2
|
+
each value with a verbatim quote.
|
|
3
|
+
|
|
4
|
+
The Python port of the template ``sdk/templates/values-extract/workflow.ts``,
|
|
5
|
+
with the same rules, prompts, checks, review items and counters. Those are
|
|
6
|
+
the rules of LBB's ``values.extract@v1``.
|
|
7
|
+
|
|
8
|
+
A triggered workflow of the starter workflow ``workflow`` watches a class and
|
|
9
|
+
its text field, and sends the new and changed instances to this workflow.
|
|
10
|
+
For each instance the workflow:
|
|
11
|
+
|
|
12
|
+
1. asks the reasoning model (``reason``) for the values of the properties the
|
|
13
|
+
text states, each with the words that state it;
|
|
14
|
+
2. checks each value without a model, in this order: its property is one the
|
|
15
|
+
workflow fills, it is the first value of its property, it takes its
|
|
16
|
+
property's type, and its quote is in the text. A number of a property with
|
|
17
|
+
a unit must come in that unit, with the unit in its quote; otherwise it
|
|
18
|
+
goes to review at most;
|
|
19
|
+
3. asks the decision model (``decide``) how well each quote supports its
|
|
20
|
+
value;
|
|
21
|
+
4. writes the values at or above the gate, each with a ``ValueSource`` whose
|
|
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 (the
|
|
28
|
+
value's quote and the unit the text states). A value a person accepted or
|
|
29
|
+
corrected is written as answered; one a person rejected, or one the graph
|
|
30
|
+
refused, is not written again. New evidence with another value asks again,
|
|
31
|
+
and the answered value and its source stay until the person answers. A value
|
|
32
|
+
an earlier version filed as a suggestion keeps working: an accepted one is
|
|
33
|
+
written again, an open one stays the question, and a dismissed one is a
|
|
34
|
+
reject of its value.
|
|
35
|
+
|
|
36
|
+
Every model call runs in a step with the step's effect id, so a turn that
|
|
37
|
+
runs again after a crash pays for nothing twice. The write's request id is
|
|
38
|
+
its step's effect id, which no other turn repeats. An instance whose model
|
|
39
|
+
call is refused goes back to the watch (``lbb.requeue``, at most 3 times)
|
|
40
|
+
and keeps its values; the turn completes, so the shard goes on. A spent
|
|
41
|
+
budget fails the turn with the time to retry, and the worker waits without
|
|
42
|
+
an attempt.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import json
|
|
48
|
+
import math
|
|
49
|
+
import re
|
|
50
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
51
|
+
from dataclasses import dataclass, field
|
|
52
|
+
from datetime import datetime
|
|
53
|
+
from typing import Any, Literal
|
|
54
|
+
from urllib.parse import quote as url_quote
|
|
55
|
+
|
|
56
|
+
from lbb import models, stable_hash_hex
|
|
57
|
+
from lbb.workflows import (
|
|
58
|
+
FailedInstance,
|
|
59
|
+
LbbActivities,
|
|
60
|
+
OwnedWrite,
|
|
61
|
+
TriggeredWorkflowDefinition,
|
|
62
|
+
WorkflowContext,
|
|
63
|
+
WorkflowResult,
|
|
64
|
+
WorkflowStepContext,
|
|
65
|
+
entity_id_from_iri,
|
|
66
|
+
entity_ref,
|
|
67
|
+
is_activity_refused,
|
|
68
|
+
triggered_workflow,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
ValueType = Literal["text", "keyword", "i64", "f64", "bool", "date_time", "datetime"]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class Settings:
|
|
76
|
+
"""The settings: edit them for your class. The defaults fit the samples."""
|
|
77
|
+
|
|
78
|
+
#: The class whose instances the workflow reads.
|
|
79
|
+
class_name: str = "EquipmentItem"
|
|
80
|
+
#: The property that holds each instance's text.
|
|
81
|
+
text_field: str = "text"
|
|
82
|
+
#: The properties to fill, with their types.
|
|
83
|
+
properties: dict[str, ValueType] = field(
|
|
84
|
+
default_factory=lambda: {
|
|
85
|
+
"width_cm": "f64",
|
|
86
|
+
"depth_cm": "f64",
|
|
87
|
+
"material": "text",
|
|
88
|
+
"water_supply": "keyword",
|
|
89
|
+
}
|
|
90
|
+
)
|
|
91
|
+
#: One sentence about what the text is, for the models.
|
|
92
|
+
context: str = "Entries of the sanitary equipment list of a school building tender, in German."
|
|
93
|
+
#: A value whose support is below this goes to review (from 0.5) or is dropped.
|
|
94
|
+
gate: float = 0.8
|
|
95
|
+
#: The unit of each number property (cm, mm, kg, m2, …), declared in the
|
|
96
|
+
#: ontology by ``ontology()``.
|
|
97
|
+
units: dict[str, str] = field(default_factory=lambda: {"width_cm": "cm", "depth_cm": "cm"})
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
DEFAULTS = Settings()
|
|
101
|
+
|
|
102
|
+
#: The workflow type and version the triggered workflow names.
|
|
103
|
+
WORKFLOW_TYPE = {"workflow_type": "values-extract", "version": "v1"}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def trigger(settings: Settings = DEFAULTS) -> dict[str, Any]:
|
|
107
|
+
"""The triggered workflow that sends the instances to this workflow:
|
|
108
|
+
``client.use_workflow_starter(**trigger())``."""
|
|
109
|
+
return {
|
|
110
|
+
"starter": "workflow",
|
|
111
|
+
"name": "values-extract",
|
|
112
|
+
"class_": settings.class_name,
|
|
113
|
+
"fields": [settings.text_field],
|
|
114
|
+
"workflow": WORKFLOW_TYPE,
|
|
115
|
+
"owns": {
|
|
116
|
+
"classes": ["ValueSource"],
|
|
117
|
+
"relations": ["HAS_VALUE_SOURCE"],
|
|
118
|
+
"properties": list(settings.properties),
|
|
119
|
+
},
|
|
120
|
+
"batch": 8,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def ontology(settings: Settings = DEFAULTS) -> list[dict[str, Any]]:
|
|
125
|
+
"""The classes, properties and relation the workflow reads and writes, for
|
|
126
|
+
``lbb.evolve``."""
|
|
127
|
+
|
|
128
|
+
def prop(name: str, value_type: str, unit: str | None = None) -> dict[str, Any]:
|
|
129
|
+
op: dict[str, Any] = {"op": "add_property", "name": name, "value_type": value_type}
|
|
130
|
+
if unit:
|
|
131
|
+
op["unit"] = unit
|
|
132
|
+
return op
|
|
133
|
+
|
|
134
|
+
return [
|
|
135
|
+
{"op": "add_entity_type", "name": settings.class_name},
|
|
136
|
+
{"op": "add_entity_type", "name": "ValueSource"},
|
|
137
|
+
prop(settings.text_field, "text"),
|
|
138
|
+
*(
|
|
139
|
+
prop(name, value_type, unit_of(settings, name))
|
|
140
|
+
for name, value_type in settings.properties.items()
|
|
141
|
+
),
|
|
142
|
+
prop("property", "keyword"),
|
|
143
|
+
prop("value", "text"),
|
|
144
|
+
prop("support", "f64"),
|
|
145
|
+
{
|
|
146
|
+
"op": "add_relation",
|
|
147
|
+
"name": "HAS_VALUE_SOURCE",
|
|
148
|
+
"domain": [settings.class_name],
|
|
149
|
+
"range": ["ValueSource"],
|
|
150
|
+
},
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
#: What a value became: the outcomes of LBB's ``values.extract``.
|
|
155
|
+
OUTCOMES = (
|
|
156
|
+
"written",
|
|
157
|
+
"review",
|
|
158
|
+
"weak_support",
|
|
159
|
+
"quote_not_found",
|
|
160
|
+
"invalid_value",
|
|
161
|
+
"unknown_property",
|
|
162
|
+
"duplicate",
|
|
163
|
+
)
|
|
164
|
+
#: The counters of the workflow, those of LBB's ``values.extract``.
|
|
165
|
+
ZERO: dict[str, int] = {
|
|
166
|
+
"instances": 0,
|
|
167
|
+
"values_proposed": 0,
|
|
168
|
+
**{outcome: 0 for outcome in OUTCOMES},
|
|
169
|
+
"failed": 0,
|
|
170
|
+
"commits": 0,
|
|
171
|
+
"removed": 0,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
SYSTEM = """You read the text of one record of a knowledge graph and fill in property values the text states. Rules:
|
|
175
|
+
- Give a value only when the text states it. Do not guess and do not compute.
|
|
176
|
+
- 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 …").
|
|
177
|
+
- A value the text gives as approximate ("about 40 kg", "approx. 2 m") is stated: give it without the approximation.
|
|
178
|
+
- 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.
|
|
179
|
+
- 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.
|
|
180
|
+
- 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.
|
|
181
|
+
- Give at most one value per property. Leave a property out when the text does not state it.""" # noqa: E501
|
|
182
|
+
|
|
183
|
+
SUPPORT = {
|
|
184
|
+
"supported": (
|
|
185
|
+
"The quote states this value of this property for the record's own item. "
|
|
186
|
+
"A value stated as approximate counts: 'approx. 40 kg' supports a weight of 40."
|
|
187
|
+
),
|
|
188
|
+
"weak": "The text implies the value but does not state it.",
|
|
189
|
+
"not_supported": (
|
|
190
|
+
"The quote does not state this value, or it states the value of a part, an accessory, "
|
|
191
|
+
"an alternative or a variant under a condition, not of the record's own item."
|
|
192
|
+
),
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
#: Instances one turn reads: two steps each, in a turn of 64 steps.
|
|
196
|
+
TURN_INSTANCES = 24
|
|
197
|
+
#: Characters of a text the models read.
|
|
198
|
+
TEXT_CHARS = 40_000
|
|
199
|
+
#: Below this support a value is dropped.
|
|
200
|
+
SUPPORT_MIN = 0.5
|
|
201
|
+
_PLAIN_IRI = re.compile(r"[^\s<>\"{}|\\^`]+")
|
|
202
|
+
_ENTITY_IRI = re.compile(r"https://littlebigbrain\.com/e/[0-9a-fA-F]{32}")
|
|
203
|
+
_LABEL = "http://www.w3.org/2000/01/rdf-schema#label"
|
|
204
|
+
_P = "https://littlebigbrain.com/p/"
|
|
205
|
+
_BOX = ("page", "x0", "y0", "x1", "y1")
|
|
206
|
+
_NUMBER = re.compile(r"[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?")
|
|
207
|
+
_RFC3339 = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _normalize(text: str) -> str:
|
|
211
|
+
return " ".join(text.split())
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _number(value: float) -> int | float:
|
|
215
|
+
"""A number as JavaScript keeps it: a whole float is the integer (``60``,
|
|
216
|
+
not ``60.0``), so the JSON and the text of a value are those of the
|
|
217
|
+
TypeScript template."""
|
|
218
|
+
return int(value) if value.is_integer() and abs(value) < 2**53 else value
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _text(value: Any) -> str:
|
|
222
|
+
if isinstance(value, bool):
|
|
223
|
+
return "true" if value else "false"
|
|
224
|
+
return str(value)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _js_json(value: Any) -> str:
|
|
228
|
+
"""``JSON.stringify``: the text the TypeScript template sends."""
|
|
229
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def coerce(raw: str, value_type: str) -> dict[str, Any]:
|
|
233
|
+
"""The value of ``raw`` in its property's type (``{"value": v}``), or why
|
|
234
|
+
it does not take the type (``{"error": why}``)."""
|
|
235
|
+
text = raw.strip()
|
|
236
|
+
if not text:
|
|
237
|
+
return {"error": "the value is empty"}
|
|
238
|
+
cleaned = re.sub(r"\s+", "", text.replace(",", "."))
|
|
239
|
+
number = float(cleaned) if _NUMBER.fullmatch(cleaned) else math.nan
|
|
240
|
+
if value_type == "i64":
|
|
241
|
+
if not math.isfinite(number):
|
|
242
|
+
return {"error": f"`{text}` is not a number"}
|
|
243
|
+
if number.is_integer():
|
|
244
|
+
return {"value": _number(number)}
|
|
245
|
+
return {"error": f"`{text}` is not a whole number"}
|
|
246
|
+
if value_type == "f64":
|
|
247
|
+
if math.isfinite(number):
|
|
248
|
+
return {"value": _number(number)}
|
|
249
|
+
return {"error": f"`{text}` is not a number"}
|
|
250
|
+
if value_type == "bool":
|
|
251
|
+
lower = text.lower()
|
|
252
|
+
if lower in ("true", "yes", "ja"):
|
|
253
|
+
return {"value": True}
|
|
254
|
+
if lower in ("false", "no", "nein"):
|
|
255
|
+
return {"value": False}
|
|
256
|
+
return {"error": f"`{text}` is not true or false"}
|
|
257
|
+
if value_type in ("date_time", "datetime"):
|
|
258
|
+
value = f"{text}T00:00:00Z" if len(text.encode()) == 10 else text
|
|
259
|
+
try:
|
|
260
|
+
ok = bool(_RFC3339.fullmatch(value)) and bool(
|
|
261
|
+
datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
262
|
+
)
|
|
263
|
+
except ValueError:
|
|
264
|
+
ok = False
|
|
265
|
+
return {"value": value} if ok else {"error": f"`{text}` is not a date"}
|
|
266
|
+
return {"value": text}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def typed(raw: str, value_type: str) -> Any:
|
|
270
|
+
"""The value of ``raw`` in its property's type, or ``None`` when it does not
|
|
271
|
+
take the type."""
|
|
272
|
+
return coerce(raw, value_type).get("value")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def quote_problem(quote: str, value: str, text: str) -> str | None:
|
|
276
|
+
"""Why a quote does not hold, or ``None`` when it does: it is in the text,
|
|
277
|
+
with at least three words or the value in it."""
|
|
278
|
+
q = _normalize(quote)
|
|
279
|
+
words = len(q.split(" ")) if q else 0
|
|
280
|
+
if not words:
|
|
281
|
+
return "the quote is empty"
|
|
282
|
+
if q not in _normalize(text):
|
|
283
|
+
return "the quote is not in the text"
|
|
284
|
+
v = _normalize(value).lower()
|
|
285
|
+
if words >= 3 or (v != "" and v in q.lower()):
|
|
286
|
+
return None
|
|
287
|
+
return "the quote has fewer than 3 words and does not hold the value"
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def quote_holds(quote: str, value: str, text: str) -> bool:
|
|
291
|
+
"""A quote must be in the text, with at least three words or the value in it."""
|
|
292
|
+
return quote_problem(quote, value, text) is None
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def unit_of(settings: Settings, prop: str) -> str | None:
|
|
296
|
+
"""The unit a number property holds, from the settings; none for other types."""
|
|
297
|
+
if settings.properties.get(prop) in ("i64", "f64"):
|
|
298
|
+
return settings.units.get(prop)
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _unit_text(text: str) -> str:
|
|
303
|
+
return re.sub(r"\.+$", "", text.strip()).lower().replace("²", "2").replace("³", "3")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def unit_problem(unit: str, stated: str, quote: str) -> str | None:
|
|
307
|
+
"""Why a number of a property that holds ``unit`` cannot be confirmed in
|
|
308
|
+
that unit: the text states no unit or another one, or the quote does not
|
|
309
|
+
hold it. ``None`` when it is confirmed. The messages are those of LBB's
|
|
310
|
+
``values.extract``; a number in another unit is LBB's to convert."""
|
|
311
|
+
want = _unit_text(unit)
|
|
312
|
+
got = _unit_text(stated)
|
|
313
|
+
if not got:
|
|
314
|
+
return f"the text states no unit; the property holds {unit}"
|
|
315
|
+
if got != want:
|
|
316
|
+
return f"the text states {stated.strip()}; the property holds {unit}"
|
|
317
|
+
# Not after a letter, and not before a letter or a digit.
|
|
318
|
+
pattern = rf"(?<![^\W\d_]){re.escape(want)}(?![^\W_])"
|
|
319
|
+
if re.search(pattern, _unit_text(quote)):
|
|
320
|
+
return None
|
|
321
|
+
return f"the quote does not state the unit {unit}"
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def split_number_unit(text: str) -> tuple[str, str] | None:
|
|
325
|
+
"""A number and the unit after it, read apart (``600 mm`` gives ``600`` and
|
|
326
|
+
``mm``), as LBB does when a model gives the unit in ``value``; ``None`` when
|
|
327
|
+
the text does not start with a number or has no unit after it."""
|
|
328
|
+
trimmed = text.strip()
|
|
329
|
+
end = next((i for i, c in enumerate(trimmed) if c not in "0123456789.,+- "), -1)
|
|
330
|
+
if end < 0:
|
|
331
|
+
return None
|
|
332
|
+
number = trimmed[:end].strip()
|
|
333
|
+
unit = trimmed[end:].strip()
|
|
334
|
+
return (number, unit) if re.search(r"[0-9]", number) and unit else None
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def check(
|
|
338
|
+
values: list[Mapping[str, Any]],
|
|
339
|
+
properties: Mapping[str, str],
|
|
340
|
+
text: str,
|
|
341
|
+
units: Mapping[str, str] | None = None,
|
|
342
|
+
) -> list[dict[str, Any]]:
|
|
343
|
+
"""The checks without a model, in the order of LBB's ``values.extract``.
|
|
344
|
+
Each value comes back with its ``outcome`` (empty when it passed) and,
|
|
345
|
+
when it took its type, ``typed``."""
|
|
346
|
+
units = units or {}
|
|
347
|
+
seen: set[str] = set()
|
|
348
|
+
out: list[dict[str, Any]] = []
|
|
349
|
+
for item in values:
|
|
350
|
+
prop = str(item.get("property") or "")
|
|
351
|
+
raw = str(item.get("value") or "")
|
|
352
|
+
stated = str(item.get("unit") or "").strip()
|
|
353
|
+
quote = str(item.get("quote") or "")
|
|
354
|
+
# A number given with its unit in `value` (`600 mm`), against the
|
|
355
|
+
# rules: read the two apart.
|
|
356
|
+
split = None if stated else split_number_unit(raw)
|
|
357
|
+
if split:
|
|
358
|
+
raw, stated = split
|
|
359
|
+
checked: dict[str, Any] = {"property": prop, "value": raw, "quote": quote, "outcome": ""}
|
|
360
|
+
value_type = properties.get(prop)
|
|
361
|
+
if not value_type:
|
|
362
|
+
out.append({**checked, "outcome": "unknown_property"})
|
|
363
|
+
continue
|
|
364
|
+
if prop in seen:
|
|
365
|
+
out.append({**checked, "outcome": "duplicate"})
|
|
366
|
+
continue
|
|
367
|
+
seen.add(prop)
|
|
368
|
+
coerced = coerce(raw, value_type)
|
|
369
|
+
if "error" in coerced:
|
|
370
|
+
out.append({**checked, "outcome": "invalid_value", "detail": coerced["error"]})
|
|
371
|
+
continue
|
|
372
|
+
problem = quote_problem(quote, raw, text)
|
|
373
|
+
if problem:
|
|
374
|
+
out.append({**checked, "outcome": "quote_not_found", "detail": problem})
|
|
375
|
+
continue
|
|
376
|
+
unit = units.get(prop) if value_type in ("i64", "f64") else None
|
|
377
|
+
unconfirmed = unit_problem(unit, stated, quote) if unit else None
|
|
378
|
+
checked.update(value=_text(coerced["value"]), typed=coerced["value"])
|
|
379
|
+
if unconfirmed:
|
|
380
|
+
checked["unconfirmed"] = unconfirmed
|
|
381
|
+
if stated:
|
|
382
|
+
# The unit the text states, folded: part of the value's evidence.
|
|
383
|
+
checked["stated_unit"] = _unit_text(stated)
|
|
384
|
+
out.append(checked)
|
|
385
|
+
return out
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def value_item_key(workflow: str, iri: str, prop: str) -> str:
|
|
389
|
+
"""The key of the value item of a property of an instance, as LBB's
|
|
390
|
+
values.extract gives it."""
|
|
391
|
+
digest = stable_hash_hex(["value_item_v2", entity_id_from_iri(iri), prop])[:24]
|
|
392
|
+
return f"starter:{workflow}:value:{digest}"
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def legacy_value_key(workflow: str, iri: str, v: Mapping[str, Any]) -> str:
|
|
396
|
+
"""The key an earlier version filed a value under, as a suggestion: one per
|
|
397
|
+
value."""
|
|
398
|
+
digest = stable_hash_hex([entity_id_from_iri(iri), v["property"], v["value"]])[:24]
|
|
399
|
+
return f"starter:{workflow}:value:{digest}"
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def evidence_of(v: Mapping[str, Any]) -> str:
|
|
403
|
+
"""The evidence a value is read from, as LBB marks it: its quote and the
|
|
404
|
+
unit the text states."""
|
|
405
|
+
return stable_hash_hex(
|
|
406
|
+
["values_evidence_v2", _normalize(v["quote"]), v.get("stated_unit") or ""]
|
|
407
|
+
)[:32]
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
#: No value: a comparison with it never holds.
|
|
411
|
+
_NONE: Any = object()
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _value_in_force(item: Mapping[str, Any]) -> dict[str, Any] | None:
|
|
415
|
+
"""The value of an answered item that holds in the graph: an accept or a
|
|
416
|
+
correction the graph took (``value`` and whether it was ``corrected``)."""
|
|
417
|
+
answer = item.get("answer")
|
|
418
|
+
if not answer or item.get("status") != "answered" or (item.get("effects") or {}).get(
|
|
419
|
+
"commit_error"
|
|
420
|
+
):
|
|
421
|
+
return None
|
|
422
|
+
decision = answer.get("decision")
|
|
423
|
+
if decision == "correct":
|
|
424
|
+
value = answer.get("value")
|
|
425
|
+
elif decision == "accept":
|
|
426
|
+
value = answer.get("value")
|
|
427
|
+
if value is None:
|
|
428
|
+
value = (item.get("question") or {}).get("value")
|
|
429
|
+
else:
|
|
430
|
+
return None
|
|
431
|
+
return None if value is None else {"value": value, "corrected": decision == "correct"}
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _rejected_value(item: Mapping[str, Any]) -> Any:
|
|
435
|
+
"""The proposed value a person rejected on an item."""
|
|
436
|
+
if (item.get("answer") or {}).get("decision") != "reject":
|
|
437
|
+
return _NONE
|
|
438
|
+
return (item.get("question") or {}).get("value", _NONE)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _refused_value(item: Mapping[str, Any]) -> Any:
|
|
442
|
+
"""The value of an answer the graph refused to write: the item is open
|
|
443
|
+
again for the person."""
|
|
444
|
+
if not (item.get("effects") or {}).get("commit_error"):
|
|
445
|
+
return _NONE
|
|
446
|
+
answer = item.get("answer") or {}
|
|
447
|
+
if answer.get("decision") == "correct":
|
|
448
|
+
return answer.get("value", _NONE)
|
|
449
|
+
if answer.get("decision") == "accept":
|
|
450
|
+
value = answer.get("value")
|
|
451
|
+
return value if value is not None else (item.get("question") or {}).get("value", _NONE)
|
|
452
|
+
return _NONE
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _enum(value: Any) -> Any:
|
|
456
|
+
return getattr(value, "value", value)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _chars(text: str, n: int) -> str:
|
|
460
|
+
"""The first ``n`` characters (code points, as Rust counts them)."""
|
|
461
|
+
return text[:n]
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
@dataclass
|
|
465
|
+
class _Instance:
|
|
466
|
+
"""The text of one instance as the workflow reads it."""
|
|
467
|
+
|
|
468
|
+
label: str
|
|
469
|
+
text: str
|
|
470
|
+
box: dict[str, Any] | None
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def create_workflow(lbb: LbbActivities, settings: Settings = DEFAULTS) -> TriggeredWorkflowDefinition[Any]:
|
|
474
|
+
"""The workflow, for a ``WorkflowWorker``. ``lbb`` is ``lbb_activities(client)``, or a
|
|
475
|
+
mock in a test."""
|
|
476
|
+
# The properties in name order, each with the name the property registry
|
|
477
|
+
# gives its type, as LBB's values.extract sends them.
|
|
478
|
+
names = sorted(settings.properties)
|
|
479
|
+
|
|
480
|
+
def type_name(name: str) -> str:
|
|
481
|
+
value_type = settings.properties[name]
|
|
482
|
+
return "datetime" if value_type == "date_time" else value_type
|
|
483
|
+
|
|
484
|
+
units = {name: unit for name in names if (unit := unit_of(settings, name))}
|
|
485
|
+
schema = {
|
|
486
|
+
"type": "object",
|
|
487
|
+
"additionalProperties": False,
|
|
488
|
+
"required": ["values"],
|
|
489
|
+
"properties": {
|
|
490
|
+
"values": {
|
|
491
|
+
"type": "array",
|
|
492
|
+
"items": {
|
|
493
|
+
"type": "object",
|
|
494
|
+
"additionalProperties": False,
|
|
495
|
+
"required": ["property", "value", "unit", "quote"],
|
|
496
|
+
"properties": {
|
|
497
|
+
"property": {"type": "string", "enum": names},
|
|
498
|
+
"value": {
|
|
499
|
+
"type": "string",
|
|
500
|
+
"description": (
|
|
501
|
+
"The value in the property's type; a number as the text states it."
|
|
502
|
+
),
|
|
503
|
+
},
|
|
504
|
+
"unit": {
|
|
505
|
+
"type": "string",
|
|
506
|
+
"description": (
|
|
507
|
+
"The unit the text states a number in, as a symbol; "
|
|
508
|
+
"empty when it states none or the value is not a number."
|
|
509
|
+
),
|
|
510
|
+
},
|
|
511
|
+
"quote": {
|
|
512
|
+
"type": "string",
|
|
513
|
+
"description": (
|
|
514
|
+
"The words of the text that state the value, copied exactly."
|
|
515
|
+
),
|
|
516
|
+
},
|
|
517
|
+
},
|
|
518
|
+
},
|
|
519
|
+
}
|
|
520
|
+
},
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
def region(box: Mapping[str, str]) -> dict[str, Any] | None:
|
|
524
|
+
"""The instance's page box as a region, when it has a valid one."""
|
|
525
|
+
try:
|
|
526
|
+
page, x0, y0, x1, y1 = (float(box[b]) for b in _BOX)
|
|
527
|
+
except (KeyError, ValueError):
|
|
528
|
+
return None
|
|
529
|
+
if not all(math.isfinite(v) for v in (page, x0, y0, x1, y1)):
|
|
530
|
+
return None
|
|
531
|
+
if page < 0 or not all(0 <= v <= 1 for v in (x0, y0, x1, y1)):
|
|
532
|
+
return None
|
|
533
|
+
if x0 > x1 or y0 > y1:
|
|
534
|
+
return None
|
|
535
|
+
return {"page": int(page), "x0": x0, "y0": y0, "x1": x1, "y1": y1, "confidence": 0}
|
|
536
|
+
|
|
537
|
+
def source(iri: str, prop: str) -> dict[str, Any]:
|
|
538
|
+
"""The source entity of a property's value on an instance."""
|
|
539
|
+
return {"type": "ValueSource", "name": prop, "key": f"value:{entity_id_from_iri(iri)}:{prop}"}
|
|
540
|
+
|
|
541
|
+
def value_facts(iri: str, record: _Instance, v: Mapping[str, Any]) -> dict[str, Any]:
|
|
542
|
+
"""The facts of one value: the property on the instance, its source,
|
|
543
|
+
and the edge with the quote."""
|
|
544
|
+
support = v.get("support", 0)
|
|
545
|
+
target = {**source(iri, v["property"]), "name": _chars(f"{v['property']} = {v['value']}", 200)}
|
|
546
|
+
instance = entity_ref(iri, settings.class_name, record.label)
|
|
547
|
+
box = (
|
|
548
|
+
{**record.box, "confidence": min(1, max(0, support))} if record.box is not None else None
|
|
549
|
+
)
|
|
550
|
+
evidence: dict[str, Any] = {"text": v["quote"], "source_id": _chars(record.label, 200)}
|
|
551
|
+
if box:
|
|
552
|
+
evidence["region"] = box
|
|
553
|
+
return {
|
|
554
|
+
"on_instance": {**instance, "properties": {v["property"]: v["typed"]}},
|
|
555
|
+
"on_source": {
|
|
556
|
+
**target,
|
|
557
|
+
"properties": {"property": v["property"], "value": v["value"], "support": support},
|
|
558
|
+
},
|
|
559
|
+
"edge": {
|
|
560
|
+
"source": instance,
|
|
561
|
+
"relation": "HAS_VALUE_SOURCE",
|
|
562
|
+
"target": target,
|
|
563
|
+
"confidence": support,
|
|
564
|
+
"evidence": evidence,
|
|
565
|
+
},
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
def reader(
|
|
569
|
+
message: models.WorkflowTriggerMessage, iris: list[str]
|
|
570
|
+
) -> Callable[[], Awaitable[dict[str, _Instance]]]:
|
|
571
|
+
"""The label, text and page box of each instance of the turn, read once
|
|
572
|
+
per attempt at the message's commit."""
|
|
573
|
+
cache: dict[str, _Instance] | None = None
|
|
574
|
+
|
|
575
|
+
async def texts() -> dict[str, _Instance]:
|
|
576
|
+
nonlocal cache
|
|
577
|
+
if cache is not None:
|
|
578
|
+
return cache
|
|
579
|
+
# An instance that is no entity of the graph is not read (LBB skips it too).
|
|
580
|
+
plain = [iri for iri in iris if _PLAIN_IRI.fullmatch(iri) and _ENTITY_IRI.fullmatch(iri)]
|
|
581
|
+
field_iri = f"{_P}{url_quote(settings.text_field.lower(), safe='')}"
|
|
582
|
+
optional_box = "\n".join(f" OPTIONAL {{ ?s <{_P}{b}> ?{b} }}" for b in _BOX)
|
|
583
|
+
query = (
|
|
584
|
+
"# values-extract: texts\n"
|
|
585
|
+
f"SELECT ?s ?label ?text {' '.join(f'?{b}' for b in _BOX)} WHERE {{\n"
|
|
586
|
+
f" VALUES ?s {{ {' '.join(f'<{iri}>' for iri in plain)} }}\n"
|
|
587
|
+
" ?s a ?class .\n"
|
|
588
|
+
f" OPTIONAL {{ ?s <{_LABEL}> ?label }}\n"
|
|
589
|
+
f" OPTIONAL {{ ?s <{field_iri}> ?text }}\n"
|
|
590
|
+
f"{optional_box}\n"
|
|
591
|
+
"}"
|
|
592
|
+
)
|
|
593
|
+
rows = (await lbb.sparql(query, as_of_commit_seq=message.as_of_commit_seq)).rows()
|
|
594
|
+
found: dict[str, dict[str, Any]] = {}
|
|
595
|
+
for row in rows:
|
|
596
|
+
at = found.setdefault(row["s"], {"labels": set(), "texts": set(), "box": {}})
|
|
597
|
+
if row.get("label") and _normalize(row["label"]):
|
|
598
|
+
at["labels"].add(_normalize(row["label"]))
|
|
599
|
+
if row.get("text") and _normalize(row["text"]):
|
|
600
|
+
at["texts"].add(_normalize(row["text"]))
|
|
601
|
+
for b in _BOX:
|
|
602
|
+
if row.get(b) is not None:
|
|
603
|
+
at["box"].setdefault(b, row[b])
|
|
604
|
+
out: dict[str, _Instance] = {}
|
|
605
|
+
for iri, at in found.items():
|
|
606
|
+
# As LBB composes a text from its fields: `<field>: <values>`,
|
|
607
|
+
# each value's whitespace as single spaces, the values in order.
|
|
608
|
+
values = sorted(at["texts"])
|
|
609
|
+
text = (
|
|
610
|
+
_chars(f"{settings.text_field}: {', '.join(values)}", TEXT_CHARS)
|
|
611
|
+
if values
|
|
612
|
+
else ""
|
|
613
|
+
)
|
|
614
|
+
label = sorted(at["labels"])[0] if at["labels"] else entity_id_from_iri(iri)
|
|
615
|
+
out[iri] = _Instance(label=label, text=text, box=region(at["box"]))
|
|
616
|
+
cache = out
|
|
617
|
+
return out
|
|
618
|
+
|
|
619
|
+
return texts
|
|
620
|
+
|
|
621
|
+
async def on_instances(
|
|
622
|
+
ctx: WorkflowContext, message: models.WorkflowTriggerMessage, state: dict[str, int]
|
|
623
|
+
) -> WorkflowResult[dict[str, int]]:
|
|
624
|
+
tally = dict(ZERO)
|
|
625
|
+
# An instance that left the class keeps what was written for it; its
|
|
626
|
+
# values go with its fields when it is retracted.
|
|
627
|
+
tally["removed"] = len(message.removed)
|
|
628
|
+
batch = message.instances[:TURN_INSTANCES]
|
|
629
|
+
texts = reader(message, batch)
|
|
630
|
+
reads: list[dict[str, Any]] = []
|
|
631
|
+
for i, iri in enumerate(batch):
|
|
632
|
+
|
|
633
|
+
async def read_values(s: WorkflowStepContext, i: int = i, iri: str = iri) -> dict[str, Any]:
|
|
634
|
+
s.progress("reading values", done=i, total=len(batch), unit="instances")
|
|
635
|
+
record = (await texts()).get(iri)
|
|
636
|
+
if record is None:
|
|
637
|
+
return {"values": [], "missing": True}
|
|
638
|
+
if not record.text.strip() or not names:
|
|
639
|
+
return {"values": []}
|
|
640
|
+
prompt = _js_json(
|
|
641
|
+
{
|
|
642
|
+
"class": settings.class_name,
|
|
643
|
+
"instance": record.label,
|
|
644
|
+
"context": settings.context,
|
|
645
|
+
"properties": [
|
|
646
|
+
{
|
|
647
|
+
"name": name,
|
|
648
|
+
"type": type_name(name),
|
|
649
|
+
**({"unit": units[name]} if name in units else {}),
|
|
650
|
+
}
|
|
651
|
+
for name in names
|
|
652
|
+
],
|
|
653
|
+
"text": record.text,
|
|
654
|
+
}
|
|
655
|
+
)
|
|
656
|
+
try:
|
|
657
|
+
answer = await lbb.reason(
|
|
658
|
+
prompt,
|
|
659
|
+
schema,
|
|
660
|
+
system=SYSTEM,
|
|
661
|
+
max_tokens=8_000,
|
|
662
|
+
effect_id=s.effect_id,
|
|
663
|
+
label=f"{_chars(record.label, 50)} values",
|
|
664
|
+
)
|
|
665
|
+
except Exception as error:
|
|
666
|
+
if not is_activity_refused(error):
|
|
667
|
+
raise
|
|
668
|
+
return {"values": [], "failed": str(error)}
|
|
669
|
+
proposed = (answer.value or {}).get("values") or []
|
|
670
|
+
return {"values": check(proposed, settings.properties, record.text, units)}
|
|
671
|
+
|
|
672
|
+
proposed = await ctx.step(f"values-{i}", read_values)
|
|
673
|
+
|
|
674
|
+
async def read_support(
|
|
675
|
+
s: WorkflowStepContext, i: int = i, iri: str = iri, proposed: dict[str, Any] = proposed
|
|
676
|
+
) -> dict[str, Any]:
|
|
677
|
+
asked = [n for n, v in enumerate(proposed["values"]) if "typed" in v]
|
|
678
|
+
if not asked:
|
|
679
|
+
return proposed
|
|
680
|
+
s.progress("asking the decision model", done=i, total=len(batch), unit="instances")
|
|
681
|
+
record = (await texts())[iri]
|
|
682
|
+
questions = {}
|
|
683
|
+
for n in asked:
|
|
684
|
+
v = proposed["values"][n]
|
|
685
|
+
questions[f"v{n}"] = {
|
|
686
|
+
"instructions": (
|
|
687
|
+
f"The state holds the text of one record of the class "
|
|
688
|
+
f"{settings.class_name}. A model read the value of a property from "
|
|
689
|
+
"the text and quoted the words that state it. How well does the "
|
|
690
|
+
"quote support the value?\n"
|
|
691
|
+
f"Property: {v['property']} ({type_name(v['property'])})\n"
|
|
692
|
+
f"Value: {v['value']}\n"
|
|
693
|
+
f'Quote: "{v["quote"]}"'
|
|
694
|
+
),
|
|
695
|
+
"criteria": SUPPORT,
|
|
696
|
+
}
|
|
697
|
+
try:
|
|
698
|
+
decided = await lbb.decide(
|
|
699
|
+
{
|
|
700
|
+
"class": settings.class_name,
|
|
701
|
+
"record": record.label,
|
|
702
|
+
"context": settings.context,
|
|
703
|
+
"text": record.text,
|
|
704
|
+
},
|
|
705
|
+
questions,
|
|
706
|
+
effect_id=s.effect_id,
|
|
707
|
+
label=f"{_chars(record.label, 50)} support",
|
|
708
|
+
)
|
|
709
|
+
except Exception as error:
|
|
710
|
+
if not is_activity_refused(error):
|
|
711
|
+
raise
|
|
712
|
+
return {"values": [], "failed": str(error)}
|
|
713
|
+
values = []
|
|
714
|
+
for n, v in enumerate(proposed["values"]):
|
|
715
|
+
if "typed" not in v:
|
|
716
|
+
values.append(v)
|
|
717
|
+
continue
|
|
718
|
+
a = decided.answers.get(f"v{n}")
|
|
719
|
+
support: float | None = None
|
|
720
|
+
if a is not None and a.probabilities and "supported" in a.probabilities:
|
|
721
|
+
support = a.probabilities["supported"]
|
|
722
|
+
elif a is not None and a.choice is not None:
|
|
723
|
+
support = (
|
|
724
|
+
(a.confidence if a.confidence is not None else 1)
|
|
725
|
+
if a.choice == "supported"
|
|
726
|
+
else 0
|
|
727
|
+
)
|
|
728
|
+
# A value whose unit is not confirmed goes to review at most.
|
|
729
|
+
if support is None:
|
|
730
|
+
outcome = "weak_support"
|
|
731
|
+
elif support >= settings.gate and not v.get("unconfirmed"):
|
|
732
|
+
outcome = "written"
|
|
733
|
+
elif support >= SUPPORT_MIN:
|
|
734
|
+
outcome = "review"
|
|
735
|
+
else:
|
|
736
|
+
outcome = "weak_support"
|
|
737
|
+
out = {**v, "outcome": outcome}
|
|
738
|
+
if outcome == "review" and "detail" not in v and v.get("unconfirmed"):
|
|
739
|
+
out["detail"] = v["unconfirmed"]
|
|
740
|
+
if support is not None:
|
|
741
|
+
out["support"] = support
|
|
742
|
+
values.append(out)
|
|
743
|
+
return {"values": values}
|
|
744
|
+
|
|
745
|
+
reads.append(await ctx.step(f"support-{i}", read_support))
|
|
746
|
+
|
|
747
|
+
# A person's answer wins over the model, for the evidence it was given
|
|
748
|
+
# on. One read of the items of the turn's values: the value item of
|
|
749
|
+
# each property of an instance, and the suggestion an earlier version
|
|
750
|
+
# filed for each value.
|
|
751
|
+
candidates = [
|
|
752
|
+
(i, v)
|
|
753
|
+
for i, read in enumerate(reads)
|
|
754
|
+
for v in read["values"]
|
|
755
|
+
if v["outcome"] in ("written", "review")
|
|
756
|
+
]
|
|
757
|
+
|
|
758
|
+
def key_of(i: int, prop: str) -> str:
|
|
759
|
+
return value_item_key(message.workflow, batch[i], prop)
|
|
760
|
+
|
|
761
|
+
def legacy_key_of(i: int, v: Mapping[str, Any]) -> str:
|
|
762
|
+
return legacy_value_key(message.workflow, batch[i], v)
|
|
763
|
+
|
|
764
|
+
def dump(item: Any) -> dict[str, Any]:
|
|
765
|
+
return dict(item.model_dump(mode="json", by_alias=True, exclude_none=True))
|
|
766
|
+
|
|
767
|
+
async def read_answers(s: WorkflowStepContext) -> dict[str, Any]:
|
|
768
|
+
items: dict[str, Any] = {}
|
|
769
|
+
legacy: dict[str, Any] = {}
|
|
770
|
+
if not candidates:
|
|
771
|
+
return {"items": items, "legacy": legacy}
|
|
772
|
+
keys = [
|
|
773
|
+
key
|
|
774
|
+
for i, v in candidates
|
|
775
|
+
for key in (key_of(i, v["property"]), legacy_key_of(i, v))
|
|
776
|
+
]
|
|
777
|
+
for item in await lbb.review_items(keys):
|
|
778
|
+
if _enum(item.kind) == "value":
|
|
779
|
+
items[item.key] = dump(item)
|
|
780
|
+
elif _enum(item.kind) == "change":
|
|
781
|
+
status = _enum(item.status)
|
|
782
|
+
decision = _enum(item.answer.decision) if item.answer else None
|
|
783
|
+
accepted = status == "answered" and decision == "accept"
|
|
784
|
+
# An accepted suggestion is written again with its facts.
|
|
785
|
+
filed = await lbb.review_item(item.key) if accepted else None
|
|
786
|
+
legacy[item.key] = {
|
|
787
|
+
"status": "accepted"
|
|
788
|
+
if accepted
|
|
789
|
+
else ("dismissed" if status == "answered" else status),
|
|
790
|
+
"facts": dump(filed.facts) if filed and filed.facts else None,
|
|
791
|
+
}
|
|
792
|
+
return {"items": items, "legacy": legacy}
|
|
793
|
+
|
|
794
|
+
known = await ctx.step("answers", read_answers)
|
|
795
|
+
|
|
796
|
+
def decide(i: int, v: Mapping[str, Any]) -> dict[str, Any] | None:
|
|
797
|
+
"""What a person's answer decides for a value: ``write`` the
|
|
798
|
+
answered value, ``hold`` the property as it is in the graph (and
|
|
799
|
+
``ask`` again), ``skip`` it, or write the ``facts`` of a suggestion
|
|
800
|
+
an earlier version filed and a person accepted."""
|
|
801
|
+
item = known["items"].get(key_of(i, v["property"]))
|
|
802
|
+
if item is not None:
|
|
803
|
+
# The same evidence, the value in force, or the value the item
|
|
804
|
+
# proposed when it was answered (the person decided on it)
|
|
805
|
+
# asks nothing.
|
|
806
|
+
in_force = _value_in_force(item)
|
|
807
|
+
if in_force is not None:
|
|
808
|
+
same = not item.get("evidence_hash") or item["evidence_hash"] == evidence_of(v)
|
|
809
|
+
decided = (item.get("question") or {}).get("value", _NONE) == v["typed"]
|
|
810
|
+
if same or decided or in_force["value"] == v["typed"]:
|
|
811
|
+
return {"kind": "write", **in_force}
|
|
812
|
+
return {"kind": "hold", "ask": True}
|
|
813
|
+
# Asked again: hold, and update the question only for a value
|
|
814
|
+
# it does not ask yet.
|
|
815
|
+
if item.get("previous_answer"):
|
|
816
|
+
asks = (item.get("question") or {}).get("value", _NONE)
|
|
817
|
+
return {
|
|
818
|
+
"kind": "hold",
|
|
819
|
+
"ask": asks != v["typed"] and _rejected_value(item) != v["typed"],
|
|
820
|
+
}
|
|
821
|
+
# A value a person rejected, or one the graph refused, is not
|
|
822
|
+
# written again.
|
|
823
|
+
if v["typed"] in (_rejected_value(item), _refused_value(item)):
|
|
824
|
+
return {"kind": "skip"}
|
|
825
|
+
return None
|
|
826
|
+
legacy = known["legacy"].get(legacy_key_of(i, v))
|
|
827
|
+
if legacy is not None:
|
|
828
|
+
if legacy["status"] == "accepted":
|
|
829
|
+
return {"kind": "facts", "facts": legacy["facts"]} if legacy["facts"] else {"kind": "skip"}
|
|
830
|
+
if legacy["status"] == "dismissed":
|
|
831
|
+
return {"kind": "skip"}
|
|
832
|
+
# An open suggestion stays the question.
|
|
833
|
+
if legacy["status"] == "open" and v["outcome"] == "review":
|
|
834
|
+
return {"kind": "skip"}
|
|
835
|
+
return None
|
|
836
|
+
|
|
837
|
+
def asks(i: int, v: Mapping[str, Any]) -> bool:
|
|
838
|
+
decision = decide(i, v)
|
|
839
|
+
if decision is not None:
|
|
840
|
+
return decision["kind"] == "hold" and bool(decision["ask"])
|
|
841
|
+
return bool(v["outcome"] == "review")
|
|
842
|
+
|
|
843
|
+
# The value items: one per property of an instance, for a value between
|
|
844
|
+
# 0.5 and the gate, or new evidence against an answer in force.
|
|
845
|
+
asking = [(i, v) for i, v in candidates if asks(i, v)]
|
|
846
|
+
|
|
847
|
+
async def file_reviews(s: WorkflowStepContext) -> list[dict[str, Any]]:
|
|
848
|
+
kept: list[dict[str, Any]] = []
|
|
849
|
+
for n, (i, v) in enumerate(asking):
|
|
850
|
+
s.progress("filing review items", done=n, total=len(asking), unit="items")
|
|
851
|
+
iri = batch[i]
|
|
852
|
+
record = (await texts())[iri]
|
|
853
|
+
facts = value_facts(iri, record, v)
|
|
854
|
+
unit = unit_of(settings, v["property"])
|
|
855
|
+
support = v.get("support", 0)
|
|
856
|
+
rationale = (
|
|
857
|
+
f"The unit is not confirmed: {v['unconfirmed']}. The text supports the "
|
|
858
|
+
f"value with {support:.2f}."
|
|
859
|
+
if v.get("unconfirmed")
|
|
860
|
+
else f"The text supports the value with {support:.2f}, below the gate "
|
|
861
|
+
f"{settings.gate}."
|
|
862
|
+
)
|
|
863
|
+
# The quote, with the instance's page box as its region.
|
|
864
|
+
evidence: dict[str, Any] = {"quote": _chars(v["quote"], 600), "source_id": iri}
|
|
865
|
+
if facts["edge"]["evidence"].get("region"):
|
|
866
|
+
evidence["region"] = facts["edge"]["evidence"]["region"]
|
|
867
|
+
evidence["confidence"] = support
|
|
868
|
+
item = await lbb.review(
|
|
869
|
+
{
|
|
870
|
+
"kind": "value",
|
|
871
|
+
"workflow": {"name": message.workflow},
|
|
872
|
+
"origin": {
|
|
873
|
+
"kind": "agent",
|
|
874
|
+
"id": f"starter:{message.workflow}",
|
|
875
|
+
"label": "values.extract",
|
|
876
|
+
},
|
|
877
|
+
"key": key_of(i, v["property"]),
|
|
878
|
+
"title": _chars(f"Set {v['property']} of {record.label} to {v['value']}", 200),
|
|
879
|
+
"rationale": rationale,
|
|
880
|
+
"subject": {"iri": iri, "type": settings.class_name, "name": record.label},
|
|
881
|
+
"question": {
|
|
882
|
+
"property": v["property"],
|
|
883
|
+
"value": v["typed"],
|
|
884
|
+
**({"unit": unit} if unit else {}),
|
|
885
|
+
"confidence": support,
|
|
886
|
+
},
|
|
887
|
+
"evidence": [evidence],
|
|
888
|
+
"evidence_hash": evidence_of(v),
|
|
889
|
+
"facts": {
|
|
890
|
+
"triplets": [facts["edge"]],
|
|
891
|
+
"entity_properties": [facts["on_instance"], facts["on_source"]],
|
|
892
|
+
"summary": _chars(f"{v['property']} = {v['value']}", 500),
|
|
893
|
+
},
|
|
894
|
+
}
|
|
895
|
+
)
|
|
896
|
+
if decide(i, v) is not None:
|
|
897
|
+
continue # asked again: the answered value holds
|
|
898
|
+
# Answered already (a person answered between the read and now).
|
|
899
|
+
in_force = _value_in_force(dump(item))
|
|
900
|
+
if in_force is not None:
|
|
901
|
+
kept.append({"i": i, "property": v["property"], **in_force})
|
|
902
|
+
return kept
|
|
903
|
+
|
|
904
|
+
answered = await ctx.step("review", file_reviews)
|
|
905
|
+
|
|
906
|
+
def answered_as(v: Mapping[str, Any], answer: Mapping[str, Any]) -> dict[str, Any]:
|
|
907
|
+
"""A value as a person answered it: a correction in place of the
|
|
908
|
+
read value, with support 1."""
|
|
909
|
+
out = {**v, "typed": answer["value"], "value": _text(answer["value"])}
|
|
910
|
+
if answer["corrected"]:
|
|
911
|
+
out["support"] = 1
|
|
912
|
+
return out
|
|
913
|
+
|
|
914
|
+
def plan(i: int, record: _Instance) -> tuple[list[Any], list[Any], set[str]]:
|
|
915
|
+
"""What the write sets on instance ``i``: its entity properties,
|
|
916
|
+
its triplets and the properties it writes."""
|
|
917
|
+
entity_properties: list[Any] = []
|
|
918
|
+
triplets: list[Any] = []
|
|
919
|
+
written: set[str] = set()
|
|
920
|
+
for v in reads[i]["values"]:
|
|
921
|
+
if v["outcome"] not in ("written", "review"):
|
|
922
|
+
continue
|
|
923
|
+
decision = decide(i, v)
|
|
924
|
+
if decision is not None and decision["kind"] == "facts":
|
|
925
|
+
entity_properties.extend(decision["facts"].get("entity_properties", []))
|
|
926
|
+
triplets.extend(decision["facts"].get("triplets", []))
|
|
927
|
+
written.add(v["property"])
|
|
928
|
+
continue
|
|
929
|
+
if decision is not None and decision["kind"] == "write":
|
|
930
|
+
kept: Mapping[str, Any] | None = answered_as(v, decision)
|
|
931
|
+
elif decision is not None:
|
|
932
|
+
kept = None
|
|
933
|
+
else:
|
|
934
|
+
answer = next(
|
|
935
|
+
(a for a in answered if a["i"] == i and a["property"] == v["property"]),
|
|
936
|
+
None,
|
|
937
|
+
)
|
|
938
|
+
if answer is not None:
|
|
939
|
+
kept = answered_as(v, answer)
|
|
940
|
+
else:
|
|
941
|
+
kept = v if v["outcome"] == "written" else None
|
|
942
|
+
if kept is None:
|
|
943
|
+
continue
|
|
944
|
+
facts = value_facts(batch[i], record, kept)
|
|
945
|
+
entity_properties.extend([facts["on_instance"], facts["on_source"]])
|
|
946
|
+
triplets.append(facts["edge"])
|
|
947
|
+
written.add(v["property"])
|
|
948
|
+
return entity_properties, triplets, written
|
|
949
|
+
|
|
950
|
+
def held_of(i: int) -> set[str]:
|
|
951
|
+
"""The properties of instance ``i`` whose answered value stays as it
|
|
952
|
+
is in the graph."""
|
|
953
|
+
return {
|
|
954
|
+
v["property"]
|
|
955
|
+
for v in reads[i]["values"]
|
|
956
|
+
if v["outcome"] in ("written", "review")
|
|
957
|
+
and (decision := decide(i, v)) is not None
|
|
958
|
+
and decision["kind"] == "hold"
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
async def owned_write(indexes: list[int], held: set[str]) -> OwnedWrite:
|
|
962
|
+
"""One owned write of instances; a held property is neither set nor
|
|
963
|
+
replaced."""
|
|
964
|
+
records = await texts()
|
|
965
|
+
subjects: list[dict[str, Any]] = []
|
|
966
|
+
entity_properties: list[Any] = []
|
|
967
|
+
triplets: list[Any] = []
|
|
968
|
+
retract: list[dict[str, Any]] = []
|
|
969
|
+
for i in indexes:
|
|
970
|
+
iri = batch[i]
|
|
971
|
+
record = records.get(iri)
|
|
972
|
+
if record is None:
|
|
973
|
+
continue
|
|
974
|
+
subjects.append(entity_ref(iri, settings.class_name, record.label))
|
|
975
|
+
props, edges, written = plan(i, record)
|
|
976
|
+
entity_properties.extend(props)
|
|
977
|
+
triplets.extend(edges)
|
|
978
|
+
retract.extend(source(iri, p) for p in names if p not in written and p not in held)
|
|
979
|
+
return OwnedWrite(
|
|
980
|
+
subjects=subjects,
|
|
981
|
+
# A held property keeps its source link: an instance that holds
|
|
982
|
+
# one does not replace its links (a source it no longer has is
|
|
983
|
+
# retracted whole, with its link).
|
|
984
|
+
relations=[] if held else ["HAS_VALUE_SOURCE"],
|
|
985
|
+
properties=[p for p in names if p not in held],
|
|
986
|
+
entity_properties=entity_properties,
|
|
987
|
+
triplets=triplets,
|
|
988
|
+
retract_entities=retract,
|
|
989
|
+
)
|
|
990
|
+
|
|
991
|
+
failed = [
|
|
992
|
+
FailedInstance(instance=iri, error=reads[i]["failed"])
|
|
993
|
+
for i, iri in enumerate(batch)
|
|
994
|
+
if "failed" in reads[i]
|
|
995
|
+
]
|
|
996
|
+
writable = [
|
|
997
|
+
i for i in range(len(batch)) if not reads[i].get("missing") and "failed" not in reads[i]
|
|
998
|
+
]
|
|
999
|
+
holding = [i for i in writable if held_of(i)]
|
|
1000
|
+
|
|
1001
|
+
async def plan_write(s: WorkflowStepContext) -> OwnedWrite:
|
|
1002
|
+
s.progress("writing facts", done=0, total=len(batch), unit="instances")
|
|
1003
|
+
return await owned_write([i for i in writable if i not in holding], set())
|
|
1004
|
+
|
|
1005
|
+
writes = [await lbb.write_owned(ctx, "write", plan_write)]
|
|
1006
|
+
# An instance that holds an answered value is written on its own, so
|
|
1007
|
+
# the held property stays out of its replace.
|
|
1008
|
+
for i in holding:
|
|
1009
|
+
|
|
1010
|
+
async def plan_held(s: WorkflowStepContext, i: int = i) -> OwnedWrite:
|
|
1011
|
+
return await owned_write([i], held_of(i))
|
|
1012
|
+
|
|
1013
|
+
writes.append(await lbb.write_owned(ctx, f"write-held-{i}", plan_held))
|
|
1014
|
+
# Refused instances go back to the watch; the turn completes.
|
|
1015
|
+
await lbb.requeue(ctx, "requeue", failed, graph=message.graph)
|
|
1016
|
+
|
|
1017
|
+
for read in reads:
|
|
1018
|
+
if read.get("missing"):
|
|
1019
|
+
continue
|
|
1020
|
+
tally["instances"] += 1
|
|
1021
|
+
if "failed" in read:
|
|
1022
|
+
tally["failed"] += 1
|
|
1023
|
+
for v in read["values"]:
|
|
1024
|
+
tally["values_proposed"] += 1
|
|
1025
|
+
if v["outcome"]:
|
|
1026
|
+
tally[v["outcome"]] += 1
|
|
1027
|
+
for write in writes:
|
|
1028
|
+
if write.commit_seq is not None and not write.replayed:
|
|
1029
|
+
tally["commits"] += 1
|
|
1030
|
+
after = {key: state.get(key, 0) + tally[key] for key in ZERO}
|
|
1031
|
+
result = {"counters": tally}
|
|
1032
|
+
# More instances than a turn reads: the rest goes to a new turn.
|
|
1033
|
+
rest = message.instances[TURN_INSTANCES:]
|
|
1034
|
+
if rest:
|
|
1035
|
+
more = message.model_copy(update={"instances": rest, "removed": []})
|
|
1036
|
+
return ctx.continue_(after, more, result=result)
|
|
1037
|
+
return WorkflowResult(state=after, result=result)
|
|
1038
|
+
|
|
1039
|
+
return triggered_workflow(
|
|
1040
|
+
name=WORKFLOW_TYPE["workflow_type"],
|
|
1041
|
+
version=WORKFLOW_TYPE["version"],
|
|
1042
|
+
initial_state=ZERO,
|
|
1043
|
+
on_instances=on_instances,
|
|
1044
|
+
)
|