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.
- lorebase_scribe/__init__.py +3 -0
- lorebase_scribe/__main__.py +156 -0
- lorebase_scribe/checkpoints.py +324 -0
- lorebase_scribe/cron.py +117 -0
- lorebase_scribe/embeddings.py +570 -0
- lorebase_scribe/extraction/__init__.py +17 -0
- lorebase_scribe/extraction/assemble.py +339 -0
- lorebase_scribe/extraction/candidates.py +136 -0
- lorebase_scribe/extraction/discovery.py +567 -0
- lorebase_scribe/extraction/llm_discovery.py +271 -0
- lorebase_scribe/extraction/matching.py +285 -0
- lorebase_scribe/extraction/ontology.py +432 -0
- lorebase_scribe/extraction/parsing.py +114 -0
- lorebase_scribe/extraction/pipeline.py +1091 -0
- lorebase_scribe/extraction/relations.py +194 -0
- lorebase_scribe/extraction/sandbox.py +269 -0
- lorebase_scribe/extraction/settings.py +293 -0
- lorebase_scribe/extraction/text.py +179 -0
- lorebase_scribe/extraction/tier2.py +573 -0
- lorebase_scribe/extraction/values.py +282 -0
- lorebase_scribe/jobs.py +418 -0
- lorebase_scribe/scheduler.py +318 -0
- lorebase_scribe/service.py +146 -0
- lorebase_scribe/settings.py +521 -0
- lorebase_scribe/sources/__init__.py +60 -0
- lorebase_scribe/sources/access.py +241 -0
- lorebase_scribe/sources/connectors.py +320 -0
- lorebase_scribe/sources/delta.py +175 -0
- lorebase_scribe/sources/http_json.py +283 -0
- lorebase_scribe/sources/mapping.py +429 -0
- lorebase_scribe/sources/profile.py +36 -0
- lorebase_scribe/sources/records.py +298 -0
- lorebase_scribe/sources/sql.py +332 -0
- lorebase_scribe/sources/sync.py +730 -0
- lorebase_scribe/telemetry.py +113 -0
- lorebase_scribe/webhooks.py +99 -0
- lorebase_scribe/worker.py +254 -0
- lorebase_scribe-0.2.3.dist-info/METADATA +23 -0
- lorebase_scribe-0.2.3.dist-info/RECORD +41 -0
- lorebase_scribe-0.2.3.dist-info/WHEEL +4 -0
- 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
|