@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.
@@ -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
+ )