lorebase-scribe 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. lorebase_scribe/__init__.py +3 -0
  2. lorebase_scribe/__main__.py +156 -0
  3. lorebase_scribe/checkpoints.py +324 -0
  4. lorebase_scribe/cron.py +117 -0
  5. lorebase_scribe/embeddings.py +570 -0
  6. lorebase_scribe/extraction/__init__.py +17 -0
  7. lorebase_scribe/extraction/assemble.py +339 -0
  8. lorebase_scribe/extraction/candidates.py +136 -0
  9. lorebase_scribe/extraction/discovery.py +567 -0
  10. lorebase_scribe/extraction/llm_discovery.py +271 -0
  11. lorebase_scribe/extraction/matching.py +285 -0
  12. lorebase_scribe/extraction/ontology.py +432 -0
  13. lorebase_scribe/extraction/parsing.py +114 -0
  14. lorebase_scribe/extraction/pipeline.py +1091 -0
  15. lorebase_scribe/extraction/relations.py +194 -0
  16. lorebase_scribe/extraction/sandbox.py +269 -0
  17. lorebase_scribe/extraction/settings.py +293 -0
  18. lorebase_scribe/extraction/text.py +179 -0
  19. lorebase_scribe/extraction/tier2.py +573 -0
  20. lorebase_scribe/extraction/values.py +282 -0
  21. lorebase_scribe/jobs.py +418 -0
  22. lorebase_scribe/scheduler.py +318 -0
  23. lorebase_scribe/service.py +146 -0
  24. lorebase_scribe/settings.py +521 -0
  25. lorebase_scribe/sources/__init__.py +60 -0
  26. lorebase_scribe/sources/access.py +241 -0
  27. lorebase_scribe/sources/connectors.py +320 -0
  28. lorebase_scribe/sources/delta.py +175 -0
  29. lorebase_scribe/sources/http_json.py +283 -0
  30. lorebase_scribe/sources/mapping.py +429 -0
  31. lorebase_scribe/sources/profile.py +36 -0
  32. lorebase_scribe/sources/records.py +298 -0
  33. lorebase_scribe/sources/sql.py +332 -0
  34. lorebase_scribe/sources/sync.py +730 -0
  35. lorebase_scribe/telemetry.py +113 -0
  36. lorebase_scribe/webhooks.py +99 -0
  37. lorebase_scribe/worker.py +254 -0
  38. lorebase_scribe-0.2.3.dist-info/METADATA +23 -0
  39. lorebase_scribe-0.2.3.dist-info/RECORD +41 -0
  40. lorebase_scribe-0.2.3.dist-info/WHEEL +4 -0
  41. lorebase_scribe-0.2.3.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,573 @@
1
+ """Tier 2: one structured-output call per chunk to any chat model (design 9.2 steps 5 to 8).
2
+
3
+ The prompt holds the ontology fragment relevant to the chunk (Kinds mentioned by tiers 0 and 1,
4
+ or every Kind when none is, the Relations between them and their extractable Attributes), the
5
+ candidate Records found so far (key and title, so the model answers with `ref` instead of
6
+ inventing identities) and the chunk text. The answer must follow the JSON schema of that
7
+ fragment. The model has no tools.
8
+
9
+ Every fact must quote its evidence verbatim from the chunk (whitespace-insensitive); a value must
10
+ also appear in its quote (dates and numbers compared by value, text case-insensitively). Facts
11
+ that fail are discarded and counted. Answers are cached by model, prompt version, schema hash
12
+ and chunk hash, so a re-run on unchanged input makes no call.
13
+
14
+ With schema discovery (`scribe.extraction.discovery.tier2`), the answer also lists `unlisted`
15
+ facts the schema has no place for, and records, values and links the model names outside the
16
+ schema are kept as observations besides being discarded; both need verified evidence
17
+ (`discovery`).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import json
24
+ import re
25
+ from collections.abc import Sequence
26
+ from dataclasses import dataclass, field
27
+ from typing import Any, Protocol
28
+
29
+ from lorebase_core.infer.suggestions import Observation, SuggestionType
30
+ from lorebase_core.model import Attribute, Relation
31
+ from lorebase_core.types import AttributeType, EncodingError, encode_value
32
+ from lorebase_inference.interfaces import ChatModel, ChatOptions, Message, Usage
33
+ from lorebase_scribe.extraction.candidates import (
34
+ Evidence,
35
+ LinkCandidate,
36
+ Mention,
37
+ Resolved,
38
+ ValueCandidate,
39
+ )
40
+ from lorebase_scribe.extraction.matching import Resolver
41
+ from lorebase_scribe.extraction.ontology import (
42
+ Ontology,
43
+ fragment_description,
44
+ fragment_schema,
45
+ link_attributes,
46
+ )
47
+ from lorebase_scribe.extraction.settings import ExtractionSettings
48
+ from lorebase_scribe.extraction.text import Chunk
49
+ from lorebase_scribe.extraction.values import find_dates, find_numbers
50
+
51
+ TASK = "extraction"
52
+ SYSTEM_PROMPT = (
53
+ "You extract facts from a document into a fixed schema. Report only facts the text states; "
54
+ "never guess. For every record, value and link, copy the supporting words of the text "
55
+ "verbatim into `evidence`. When a record is one of the candidate records, set `ref` to its "
56
+ "key; otherwise set `ref` to an empty string and give its key Attribute among the values. "
57
+ "Write dates as YYYY-MM-DD and numbers without thousands separators. A link whose "
58
+ "Relation lists link attributes carries them in `values`, each with its own verbatim "
59
+ "`evidence`; always give the required ones, and propose the link only when the text "
60
+ "states them. Return an empty list when the text states nothing that fits."
61
+ )
62
+ DISCOVERY_PROMPT = (
63
+ " Facts the schema has no place for (another kind of thing, a field or a relationship it "
64
+ "lacks) go to `unlisted`, each with its verbatim evidence."
65
+ )
66
+
67
+
68
+ class AnswerCache(Protocol):
69
+ async def get(self, key: bytes) -> tuple[Any, Usage, str] | None: ...
70
+
71
+ async def put(self, key: bytes, data: Any, usage: Usage, model: str) -> None: ...
72
+
73
+
74
+ @dataclass
75
+ class Tier2Result:
76
+ values: list[ValueCandidate] = field(default_factory=list)
77
+ links: list[LinkCandidate] = field(default_factory=list)
78
+ mentions: list[Resolved] = field(default_factory=list)
79
+ usage: Usage = field(default_factory=Usage)
80
+ cached: bool = False
81
+ discarded: dict[str, int] = field(default_factory=dict)
82
+ observations: list[Observation] = field(default_factory=list)
83
+
84
+ def discard(self, reason: str) -> None:
85
+ self.discarded[reason] = self.discarded.get(reason, 0) + 1
86
+
87
+
88
+ def relevant_kinds(ontology: Ontology, hints: Sequence[Resolved]) -> list[str]:
89
+ """Kinds of the chunk's mentions, else every Kind; schema order."""
90
+ mentioned = {h.kind_id for h in hints}
91
+ return [k for k in ontology.kinds if not mentioned or k in mentioned]
92
+
93
+
94
+ def build_messages(
95
+ ontology: Ontology,
96
+ kind_ids: Sequence[str],
97
+ chunk: Chunk,
98
+ hints: Sequence[Resolved],
99
+ *,
100
+ discover: bool = False,
101
+ ) -> list[Message]:
102
+ candidates: list[str] = []
103
+ seen: set[tuple[str, str]] = set()
104
+ for hint in hints:
105
+ for candidate in hint.resolution.candidates or ():
106
+ if (candidate.kind_id, candidate.key) in seen:
107
+ continue
108
+ seen.add((candidate.kind_id, candidate.key))
109
+ kind = ontology.kinds[candidate.kind_id].api_name
110
+ title = f': "{candidate.title}"' if candidate.title else ""
111
+ candidates.append(f'- {kind} "{candidate.key}"{title}')
112
+ if hint.resolution.status == "new" and (hint.kind_id, hint.key) not in seen:
113
+ seen.add((hint.kind_id, str(hint.key)))
114
+ kind = ontology.kinds[hint.kind_id].api_name
115
+ candidates.append(f'- {kind} "{hint.key}" (not stored yet)')
116
+ user = (
117
+ "Schema:\n"
118
+ + fragment_description(ontology, kind_ids)
119
+ + "\n\nCandidate records:\n"
120
+ + ("\n".join(candidates) or "(none)")
121
+ + "\n\nText:\n<<<\n"
122
+ + chunk.text
123
+ + "\n>>>"
124
+ )
125
+ system = SYSTEM_PROMPT + (DISCOVERY_PROMPT if discover else "")
126
+ return [Message("system", system), Message("user", user)]
127
+
128
+
129
+ def cache_key(
130
+ model: str,
131
+ prompt_version: str,
132
+ schema_hash: bytes,
133
+ chunk: Chunk,
134
+ kinds: Sequence[str],
135
+ *,
136
+ discover: bool = False,
137
+ ) -> bytes:
138
+ parts: list[Any] = [2, model, prompt_version, schema_hash.hex(), chunk.hash.hex()]
139
+ parts.append(sorted(kinds))
140
+ if discover:
141
+ parts.append("unlisted")
142
+ payload = json.dumps(parts, separators=(",", ":"))
143
+ return hashlib.sha256(payload.encode("utf-8")).digest()
144
+
145
+
146
+ def locate(quote: str, text: str) -> tuple[int, int] | None:
147
+ """Offsets of `quote` in `text`, whitespace-insensitive; None when absent."""
148
+ words = quote.split()
149
+ if not words:
150
+ return None
151
+ found = text.find(quote.strip())
152
+ if found != -1:
153
+ return found, found + len(quote.strip())
154
+ match = re.search(r"\s+".join(re.escape(w) for w in words), text)
155
+ return None if match is None else (match.start(), match.end())
156
+
157
+
158
+ def grounded(attribute: Attribute, value: Any, quote: str, raw: Any = None) -> bool:
159
+ """Whether `value` (canonical) is written in `quote`.
160
+
161
+ A timestamp is grounded by its day: documents write "23 January 2024" for an approval
162
+ time. Its day is taken from the canonical value or, when given, from the `raw` answer,
163
+ whose offset may put the instant on another day in UTC.
164
+ """
165
+ name = attribute.type.name
166
+ if name is AttributeType.BOOLEAN:
167
+ return True
168
+ if name is AttributeType.DATE:
169
+ return any(d.value == value for d in find_dates(quote, 0, len(quote)))
170
+ if name is AttributeType.TIMESTAMP:
171
+ days = {str(value)[:10], str(raw)[:10] if isinstance(raw, str) else ""}
172
+ return any(d.value in days for d in find_dates(quote, 0, len(quote)))
173
+ if name in (AttributeType.INTEGER, AttributeType.DECIMAL, AttributeType.FLOAT):
174
+ try:
175
+ target = float(str(value))
176
+ except ValueError:
177
+ return False
178
+ return any(float(n.value) == target for n in find_numbers(quote, 0, len(quote), ()))
179
+ return str(value).casefold() in quote.casefold()
180
+
181
+
182
+ _NAIVE_TIMESTAMP = re.compile(r"\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?)?")
183
+
184
+
185
+ def _encode(attribute: Attribute, raw: Any) -> Any:
186
+ if (
187
+ attribute.type.name is AttributeType.TIMESTAMP
188
+ and isinstance(raw, str)
189
+ and _NAIVE_TIMESTAMP.fullmatch(raw.strip())
190
+ ):
191
+ # Documents rarely state an offset; models then answer a bare date or a local time,
192
+ # which is read as UTC.
193
+ day, _, time = raw.strip().partition("T")
194
+ time = time or "00:00:00"
195
+ raw = f"{day}T{time}{':00' if time.count(':') == 1 else ''}Z"
196
+ try:
197
+ return encode_value(attribute.type, raw)
198
+ except (EncodingError, TypeError, ValueError):
199
+ return None
200
+
201
+
202
+ async def run_tier2(
203
+ chat: ChatModel,
204
+ ontology: Ontology,
205
+ chunk: Chunk,
206
+ hints: Sequence[Resolved],
207
+ resolver: Resolver,
208
+ *,
209
+ document_id: str,
210
+ schema_hash: bytes,
211
+ model_key: str,
212
+ settings: ExtractionSettings,
213
+ cache: AnswerCache | None,
214
+ discover: bool = False,
215
+ ) -> Tier2Result:
216
+ """Candidates from one chunk; offsets of the result are document offsets. With `discover`,
217
+ observations of facts outside the schema too."""
218
+ result = Tier2Result()
219
+ kinds = relevant_kinds(ontology, hints)
220
+ if not kinds:
221
+ return result
222
+ schema = fragment_schema(ontology, kinds, unlisted=discover)
223
+ key = cache_key(
224
+ model_key, settings.prompt_version, schema_hash, chunk, kinds, discover=discover
225
+ )
226
+ stored = await cache.get(key) if cache is not None else None
227
+ if stored is not None:
228
+ data, usage, model = stored
229
+ result.cached = True
230
+ else:
231
+ answer = await chat.complete_json(
232
+ build_messages(ontology, kinds, chunk, hints, discover=discover),
233
+ schema,
234
+ ChatOptions(task=TASK),
235
+ )
236
+ data, usage, model = answer.data, answer.usage, answer.model
237
+ if cache is not None:
238
+ await cache.put(key, data, usage, model)
239
+ result.usage = usage
240
+ if not isinstance(data, dict):
241
+ result.discard("invalid_answer")
242
+ return result
243
+ seen = _Seen(document_id, model, settings.confidence.tier2, discover)
244
+ await _records(data.get("records") or [], ontology, chunk, resolver, seen, settings, result)
245
+ await _links(data.get("links") or [], ontology, chunk, resolver, hints, seen, settings, result)
246
+ if discover:
247
+ _unlisted(data.get("unlisted") or [], chunk, hints, seen, result)
248
+ return result
249
+
250
+
251
+ @dataclass(frozen=True)
252
+ class _Seen:
253
+ """Where tier 2 facts come from, and whether facts outside the schema are observed."""
254
+
255
+ document_id: str
256
+ model: str
257
+ confidence: float
258
+ discover: bool
259
+
260
+ def observe(
261
+ self,
262
+ result: Tier2Result,
263
+ type_: SuggestionType,
264
+ label: Any,
265
+ evidence: Evidence,
266
+ *,
267
+ kind: str | None = None,
268
+ target: str | None = None,
269
+ value: Any = None,
270
+ ) -> None:
271
+ if not self.discover or not isinstance(label, str) or not label.strip():
272
+ return
273
+ result.observations.append(
274
+ Observation(
275
+ type=type_,
276
+ label=label.strip(),
277
+ document_id=self.document_id,
278
+ confidence=self.confidence,
279
+ source=f"model:{self.model}",
280
+ kind=kind,
281
+ target=target,
282
+ value=None if value is None or value == "" else str(value),
283
+ evidence=evidence.as_dict(),
284
+ )
285
+ )
286
+
287
+
288
+ def _hinted(hints: Sequence[Resolved], text: Any) -> Resolved | None:
289
+ """The chunk mention whose key or text is `text` (case-insensitive)."""
290
+ if not isinstance(text, str) or not text.strip():
291
+ return None
292
+ folded = text.strip().casefold()
293
+ for hint in hints:
294
+ if hint.usable and folded in (str(hint.key).casefold(), hint.mention.text.casefold()):
295
+ return hint
296
+ return None
297
+
298
+
299
+ def _quoted(value: Any, evidence: Evidence) -> bool:
300
+ """Whether a text value is written in its evidence."""
301
+ text = "" if value is None else str(value).strip()
302
+ return bool(text) and text.casefold() in evidence.quote.casefold()
303
+
304
+
305
+ def _unlisted(
306
+ entries: Sequence[Any],
307
+ chunk: Chunk,
308
+ hints: Sequence[Resolved],
309
+ seen: _Seen,
310
+ result: Tier2Result,
311
+ ) -> None:
312
+ for entry in entries:
313
+ if not isinstance(entry, dict):
314
+ result.discard("invalid_unlisted")
315
+ continue
316
+ evidence = _evidence(chunk, seen.document_id, entry.get("evidence"))
317
+ if evidence is None:
318
+ result.discard("unverified_evidence")
319
+ continue
320
+ type_ = entry.get("type")
321
+ name, value = entry.get("name"), entry.get("value")
322
+ if type_ == "kind":
323
+ seen.observe(result, SuggestionType.KIND, name, evidence, value=value)
324
+ continue
325
+ subject = _hinted(hints, entry.get("subject"))
326
+ if subject is None:
327
+ result.discard("unresolved_unlisted")
328
+ continue
329
+ if type_ == "attribute":
330
+ if not _quoted(value, evidence):
331
+ result.discard("ungrounded_value")
332
+ continue
333
+ seen.observe(
334
+ result, SuggestionType.ATTRIBUTE, name, evidence, kind=subject.kind_id, value=value
335
+ )
336
+ elif type_ == "relation":
337
+ target = _hinted(hints, value)
338
+ if target is None:
339
+ result.discard("unresolved_unlisted")
340
+ continue
341
+ seen.observe(
342
+ result,
343
+ SuggestionType.RELATION,
344
+ name,
345
+ evidence,
346
+ kind=subject.kind_id,
347
+ target=target.kind_id,
348
+ )
349
+ else:
350
+ result.discard("invalid_unlisted")
351
+
352
+
353
+ def _evidence(chunk: Chunk, document_id: str, quote: Any) -> Evidence | None:
354
+ if not isinstance(quote, str):
355
+ return None
356
+ found = locate(quote, chunk.text)
357
+ if found is None:
358
+ return None
359
+ start, end = found
360
+ return Evidence(
361
+ document_id, chunk.seq, chunk.start + start, chunk.start + end, chunk.text[start:end]
362
+ )
363
+
364
+
365
+ async def _subject(
366
+ view_kind: str,
367
+ ref: str,
368
+ key_value: Any,
369
+ evidence: Evidence,
370
+ resolver: Resolver,
371
+ chunk: Chunk,
372
+ model: str,
373
+ ) -> Resolved | None:
374
+ attempts: list[tuple[str, str]] = []
375
+ if ref.strip():
376
+ attempts.append((ref.strip(), "model"))
377
+ if isinstance(key_value, str) and key_value.strip():
378
+ attempts.append((key_value.strip(), "key"))
379
+ for text, source in attempts:
380
+ resolved = await resolver.resolve(
381
+ Mention(
382
+ kind_id=view_kind,
383
+ text=text,
384
+ start=evidence.start,
385
+ end=evidence.end,
386
+ chunk=chunk.seq,
387
+ score=1.0,
388
+ tier=2,
389
+ source="key" if source == "key" else "model",
390
+ extractor=model,
391
+ document_id=evidence.document_id,
392
+ )
393
+ )
394
+ if resolved.usable:
395
+ return resolved
396
+ return None
397
+
398
+
399
+ async def _records(
400
+ entries: Sequence[Any],
401
+ ontology: Ontology,
402
+ chunk: Chunk,
403
+ resolver: Resolver,
404
+ seen: _Seen,
405
+ settings: ExtractionSettings,
406
+ result: Tier2Result,
407
+ ) -> None:
408
+ document_id, model = seen.document_id, seen.model
409
+ by_name = {view.api_name: view for view in ontology.kinds.values()}
410
+ for entry in entries:
411
+ if not isinstance(entry, dict):
412
+ result.discard("invalid_record")
413
+ continue
414
+ view = by_name.get(str(entry.get("kind")))
415
+ evidence = _evidence(chunk, document_id, entry.get("evidence"))
416
+ if view is None:
417
+ result.discard("unknown_kind")
418
+ if evidence is not None:
419
+ seen.observe(
420
+ result, SuggestionType.KIND, entry.get("kind"), evidence, value=entry.get("ref")
421
+ )
422
+ continue
423
+ if evidence is None:
424
+ result.discard("unverified_evidence")
425
+ continue
426
+ raw_values = entry.get("values")
427
+ values: dict[str, Any] = raw_values if isinstance(raw_values, dict) else {}
428
+ key_value = None
429
+ if view.key_attribute is not None:
430
+ raw = values.get(view.key_attribute.api_name)
431
+ key_value = raw.get("value") if isinstance(raw, dict) else None
432
+ subject = await _subject(
433
+ view.id, str(entry.get("ref") or ""), key_value, evidence, resolver, chunk, model
434
+ )
435
+ if subject is None:
436
+ result.discard("unresolved_record")
437
+ continue
438
+ result.mentions.append(subject)
439
+ attributes = {a.api_name: a for a in view.attributes}
440
+ for name, item in values.items():
441
+ attribute = attributes.get(name)
442
+ if attribute is None or not isinstance(item, dict):
443
+ result.discard("unknown_attribute")
444
+ if isinstance(item, dict):
445
+ unknown = _evidence(chunk, document_id, item.get("evidence"))
446
+ raw = item.get("value")
447
+ if unknown is not None and _quoted(raw, unknown):
448
+ seen.observe(
449
+ result,
450
+ SuggestionType.ATTRIBUTE,
451
+ name,
452
+ unknown,
453
+ kind=subject.kind_id,
454
+ value=raw,
455
+ )
456
+ continue
457
+ if view.key_attribute is not None and attribute.id == view.key_attribute.id:
458
+ continue
459
+ value_evidence = _evidence(chunk, document_id, item.get("evidence"))
460
+ if value_evidence is None:
461
+ result.discard("unverified_evidence")
462
+ continue
463
+ value = _encode(attribute, item.get("value"))
464
+ if value is None:
465
+ result.discard("invalid_value")
466
+ continue
467
+ if not grounded(attribute, value, value_evidence.quote, item.get("value")):
468
+ result.discard("ungrounded_value")
469
+ continue
470
+ result.values.append(
471
+ ValueCandidate(
472
+ subject=subject,
473
+ attribute_id=attribute.id,
474
+ value=value,
475
+ evidence=value_evidence,
476
+ confidence=settings.confidence.tier2,
477
+ tier=2,
478
+ model=model,
479
+ )
480
+ )
481
+
482
+
483
+ async def _links(
484
+ entries: Sequence[Any],
485
+ ontology: Ontology,
486
+ chunk: Chunk,
487
+ resolver: Resolver,
488
+ hints: Sequence[Resolved],
489
+ seen: _Seen,
490
+ settings: ExtractionSettings,
491
+ result: Tier2Result,
492
+ ) -> None:
493
+ document_id, model = seen.document_id, seen.model
494
+ by_name = {r.api_name: r for r in ontology.relations.values()}
495
+ for entry in entries:
496
+ if not isinstance(entry, dict):
497
+ result.discard("invalid_link")
498
+ continue
499
+ relation = by_name.get(str(entry.get("relation")))
500
+ evidence = _evidence(chunk, document_id, entry.get("evidence"))
501
+ if relation is None:
502
+ result.discard("unknown_relation")
503
+ source_hint = _hinted(hints, entry.get("from"))
504
+ target_hint = _hinted(hints, entry.get("to"))
505
+ if evidence is not None and source_hint is not None and target_hint is not None:
506
+ seen.observe(
507
+ result,
508
+ SuggestionType.RELATION,
509
+ entry.get("relation"),
510
+ evidence,
511
+ kind=source_hint.kind_id,
512
+ target=target_hint.kind_id,
513
+ )
514
+ continue
515
+ if evidence is None:
516
+ result.discard("unverified_evidence")
517
+ continue
518
+ source = await _subject(
519
+ relation.from_kind, str(entry.get("from") or ""), None, evidence, resolver, chunk, model
520
+ )
521
+ target = await _subject(
522
+ relation.to_kind, str(entry.get("to") or ""), None, evidence, resolver, chunk, model
523
+ )
524
+ if source is None or target is None:
525
+ result.discard("unresolved_link")
526
+ continue
527
+ result.links.append(
528
+ LinkCandidate(
529
+ relation_id=relation.id,
530
+ source=source,
531
+ target=target,
532
+ evidence=evidence,
533
+ confidence=settings.confidence.tier2,
534
+ tier=2,
535
+ model=model,
536
+ values=_link_values(
537
+ entry.get("values"), relation, ontology, chunk, document_id, result
538
+ ),
539
+ )
540
+ )
541
+
542
+
543
+ def _link_values(
544
+ raw: Any,
545
+ relation: Relation,
546
+ ontology: Ontology,
547
+ chunk: Chunk,
548
+ document_id: str,
549
+ result: Tier2Result,
550
+ ) -> dict[str, Any]:
551
+ """Encoded link Attribute values by Attribute id; unverified or ungrounded ones are dropped."""
552
+ if not isinstance(raw, dict):
553
+ return {}
554
+ attributes = {a.api_name: a for a in link_attributes(relation, ontology.tags)}
555
+ found: dict[str, Any] = {}
556
+ for name, item in raw.items():
557
+ attribute = attributes.get(name)
558
+ if attribute is None or not isinstance(item, dict):
559
+ result.discard("unknown_attribute")
560
+ continue
561
+ evidence = _evidence(chunk, document_id, item.get("evidence"))
562
+ if evidence is None:
563
+ result.discard("unverified_evidence")
564
+ continue
565
+ value = _encode(attribute, item.get("value"))
566
+ if value is None:
567
+ result.discard("invalid_value")
568
+ continue
569
+ if not grounded(attribute, value, evidence.quote, item.get("value")):
570
+ result.discard("ungrounded_value")
571
+ continue
572
+ found[attribute.id] = value
573
+ return found