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,567 @@
|
|
|
1
|
+
"""Schema discovery: observations of Kinds, Attributes and Relations the schema does not have
|
|
2
|
+
(design 9.2 step 10).
|
|
3
|
+
|
|
4
|
+
Every tier can see something outside the schema:
|
|
5
|
+
|
|
6
|
+
- tier 0 (`rules`): a `**Label:** value` field line or a table column whose label no Attribute
|
|
7
|
+
of the Record it describes matches (an Attribute; a Relation when the label names a Kind or
|
|
8
|
+
the value names a Record of another Kind), and a heading naming a thing by a code that no
|
|
9
|
+
Kind's key pattern matches (`# Quality notice QN-2025-114`: a Kind, which then owns the
|
|
10
|
+
fields of its section);
|
|
11
|
+
- tier 1: spans found with `discovery.span_labels` (a Kind), relations found with
|
|
12
|
+
`discovery.relation_labels` between Records of known Kinds (a Relation);
|
|
13
|
+
- tier 2: facts the chat model reports in `unlisted`, and records, values or links it answers
|
|
14
|
+
with names outside the schema, each with verified evidence (see `tier2`).
|
|
15
|
+
|
|
16
|
+
`Discovery.finish` drops what the schema already has (`existing_match`: names after
|
|
17
|
+
normalization, synonyms, similarity), never keeps an observation that matches a sensitive
|
|
18
|
+
Attribute (`pii`, `confidential`, `restricted`, carried through Derivations), and leaves out the
|
|
19
|
+
evidence and sample of an observation whose quote holds a value of a sensitive Attribute of a
|
|
20
|
+
Record the job read, or a vaulted value of any Kind (found by its keyed fingerprint, see
|
|
21
|
+
`evidence_texts`; a text too long to check counts as holding one). Nothing tells a submitter
|
|
22
|
+
that evidence was withheld: the suggestion simply has none. The rest is aggregated per Domain
|
|
23
|
+
by the store
|
|
24
|
+
(`SchemaSuggestionRepository`) into suggestions that a reviewer turns into a schema change
|
|
25
|
+
Proposal; nothing here changes a schema.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import re
|
|
31
|
+
from collections.abc import Collection, Iterable, Mapping, Sequence
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from itertools import pairwise
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from lorebase_core.infer.suggestions import (
|
|
37
|
+
PROPOSED,
|
|
38
|
+
Observation,
|
|
39
|
+
SuggestionOptions,
|
|
40
|
+
SuggestionType,
|
|
41
|
+
dangling_reference_names,
|
|
42
|
+
existing_match,
|
|
43
|
+
kind_is_filtered,
|
|
44
|
+
proposed_kind,
|
|
45
|
+
)
|
|
46
|
+
from lorebase_core.model import Schema, Sensitivity
|
|
47
|
+
from lorebase_inference.interfaces import RelationExtractor, RelationLabel, Span
|
|
48
|
+
from lorebase_scribe.extraction.candidates import Evidence, Resolved
|
|
49
|
+
from lorebase_scribe.extraction.ontology import Ontology
|
|
50
|
+
from lorebase_scribe.extraction.settings import DiscoverySettings
|
|
51
|
+
from lorebase_scribe.extraction.text import Chunk, heading_sections
|
|
52
|
+
from lorebase_scribe.extraction.values import find_dates, find_numbers
|
|
53
|
+
|
|
54
|
+
RULES = "rules"
|
|
55
|
+
_FIELD = re.compile(
|
|
56
|
+
r"^[ \t]*(?:[-*+][ \t]+)?\*\*(?P<a>[^*:\n]{1,60}):\*\*[ \t]*(?P<v>\S[^\n]*)$"
|
|
57
|
+
r"|^[ \t]*(?:[-*+][ \t]+)?\*\*(?P<b>[^*:\n]{1,60})\*\*:[ \t]*(?P<w>\S[^\n]*)$",
|
|
58
|
+
re.MULTILINE,
|
|
59
|
+
)
|
|
60
|
+
_CODE = re.compile(r"(?<![\w-])[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)+(?![\w-])")
|
|
61
|
+
_WORDS = re.compile(r"[A-Za-z][A-Za-z'-]*")
|
|
62
|
+
_TABLE_LINE = re.compile(r"^[ \t]*\|.*\|[ \t]*$", re.MULTILINE)
|
|
63
|
+
_SEPARATOR = re.compile(r"^[ \t]*\|?(?:[ \t]*:?-{2,}:?[ \t]*\|)+[ \t]*:?-*:?[ \t]*\|?[ \t]*$")
|
|
64
|
+
_MAX_HEADING_WORDS = 3
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def chunk_of(chunks: Sequence[Chunk], offset: int) -> int:
|
|
68
|
+
"""Sequence number of the chunk holding `offset` (the last chunk when past the end)."""
|
|
69
|
+
for chunk in chunks:
|
|
70
|
+
if chunk.start <= offset < chunk.end:
|
|
71
|
+
return chunk.seq
|
|
72
|
+
return chunks[-1].seq if chunks else 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def sample_value(text: str, limit: int) -> str | None:
|
|
76
|
+
"""A sample value as written, dates as ISO and lone numbers without separators; markup
|
|
77
|
+
and a final period removed, cut to `limit` characters."""
|
|
78
|
+
value = text.replace("**", "").replace("`", "").strip().rstrip(".").strip()
|
|
79
|
+
if not value:
|
|
80
|
+
return None
|
|
81
|
+
dates = find_dates(value, 0, len(value))
|
|
82
|
+
if len(dates) == 1 and dates[0].end - dates[0].start >= 0.6 * len(value):
|
|
83
|
+
return str(dates[0].value)
|
|
84
|
+
numbers = find_numbers(value, 0, len(value), ())
|
|
85
|
+
if len(numbers) == 1 and numbers[0].end - numbers[0].start == len(value):
|
|
86
|
+
return str(numbers[0].value)
|
|
87
|
+
return value[:limit]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True, slots=True)
|
|
91
|
+
class _Owner:
|
|
92
|
+
"""What a section or a table row describes: a Record of a Kind, or a proposed Kind."""
|
|
93
|
+
|
|
94
|
+
ref: str
|
|
95
|
+
mention: Resolved | None = None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class Discovery:
|
|
100
|
+
"""Observations of one extraction job (see module doc)."""
|
|
101
|
+
|
|
102
|
+
ontology: Ontology
|
|
103
|
+
settings: DiscoverySettings
|
|
104
|
+
options: SuggestionOptions
|
|
105
|
+
observations: list[Observation] = field(default_factory=list)
|
|
106
|
+
stats: dict[str, int] = field(default_factory=dict)
|
|
107
|
+
evidence_withheld: int = 0
|
|
108
|
+
"""Observations kept without evidence because it held a sensitive value. Only an aggregate
|
|
109
|
+
for operators (a metric): never part of the job report, a suggestion or a log, so a
|
|
110
|
+
submitter cannot learn which of their quotes held a stored value."""
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def schema(self) -> Schema:
|
|
114
|
+
return self.ontology.schema
|
|
115
|
+
|
|
116
|
+
def count(self, name: str, n: int = 1) -> None:
|
|
117
|
+
self.stats[name] = self.stats.get(name, 0) + n
|
|
118
|
+
|
|
119
|
+
def add(self, observation: Observation) -> None:
|
|
120
|
+
if observation.confidence < self.settings.min_confidence:
|
|
121
|
+
self.count("suggestion_observations_weak")
|
|
122
|
+
return
|
|
123
|
+
self.observations.append(observation)
|
|
124
|
+
|
|
125
|
+
def _sample(self, text: str) -> str | None:
|
|
126
|
+
return sample_value(text, self.settings.max_value_chars)
|
|
127
|
+
|
|
128
|
+
def _kind_named(self, label: str) -> str | None:
|
|
129
|
+
"""Id of the Kind a label names, if any."""
|
|
130
|
+
match = existing_match(
|
|
131
|
+
self.schema, SuggestionType.KIND, label, options=self.options, tags={}
|
|
132
|
+
)
|
|
133
|
+
return None if match is None else match.id
|
|
134
|
+
|
|
135
|
+
# Tier 0 -----------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
def rules(
|
|
138
|
+
self,
|
|
139
|
+
text: str,
|
|
140
|
+
chunks: Sequence[Chunk],
|
|
141
|
+
resolved: Sequence[Resolved],
|
|
142
|
+
*,
|
|
143
|
+
document_id: str,
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Headings, field lines and table columns of one document."""
|
|
146
|
+
if not self.settings.rules:
|
|
147
|
+
return
|
|
148
|
+
owners = self._headings(text, chunks, resolved, document_id)
|
|
149
|
+
self._fields(text, chunks, resolved, owners, document_id)
|
|
150
|
+
self._tables(text, chunks, resolved, document_id)
|
|
151
|
+
|
|
152
|
+
def _headings(
|
|
153
|
+
self,
|
|
154
|
+
text: str,
|
|
155
|
+
chunks: Sequence[Chunk],
|
|
156
|
+
resolved: Sequence[Resolved],
|
|
157
|
+
document_id: str,
|
|
158
|
+
) -> list[tuple[int, int, _Owner]]:
|
|
159
|
+
"""`(start, end, owner)` of the sections whose heading names a Record or a thing of a
|
|
160
|
+
proposed Kind, innermost last."""
|
|
161
|
+
found: list[tuple[int, int, _Owner]] = []
|
|
162
|
+
key_patterns = [v.key_pattern for v in self.ontology.kinds.values() if v.key_pattern]
|
|
163
|
+
for section in heading_sections(text):
|
|
164
|
+
named = [
|
|
165
|
+
r
|
|
166
|
+
for r in resolved
|
|
167
|
+
if r.usable and section.start <= r.mention.start < section.heading_end
|
|
168
|
+
]
|
|
169
|
+
if named:
|
|
170
|
+
found.append((section.start, section.end, _Owner(named[0].kind_id, named[0])))
|
|
171
|
+
continue
|
|
172
|
+
line = text[section.start : section.heading_end]
|
|
173
|
+
for code in _CODE.finditer(line):
|
|
174
|
+
token = code.group()
|
|
175
|
+
if not any(c.isdigit() for c in token) or any(
|
|
176
|
+
p.fullmatch(token) for p in key_patterns
|
|
177
|
+
):
|
|
178
|
+
continue
|
|
179
|
+
words = _WORDS.findall(line[: code.start()])[-_MAX_HEADING_WORDS:]
|
|
180
|
+
if not words:
|
|
181
|
+
continue
|
|
182
|
+
label = " ".join(words)
|
|
183
|
+
if self._kind_named(label) is not None:
|
|
184
|
+
continue
|
|
185
|
+
start = section.start + line.index(line.lstrip("#").strip())
|
|
186
|
+
quote = text[start : section.heading_end]
|
|
187
|
+
self.add(
|
|
188
|
+
Observation(
|
|
189
|
+
type=SuggestionType.KIND,
|
|
190
|
+
label=label,
|
|
191
|
+
document_id=document_id,
|
|
192
|
+
confidence=self.settings.heading_confidence,
|
|
193
|
+
source=RULES,
|
|
194
|
+
value=token,
|
|
195
|
+
evidence=Evidence(
|
|
196
|
+
document_id,
|
|
197
|
+
chunk_of(chunks, start),
|
|
198
|
+
start,
|
|
199
|
+
section.heading_end,
|
|
200
|
+
quote,
|
|
201
|
+
).as_dict(),
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
found.append((section.start, section.end, _Owner(proposed_kind(label))))
|
|
205
|
+
break
|
|
206
|
+
return found
|
|
207
|
+
|
|
208
|
+
def _field(
|
|
209
|
+
self,
|
|
210
|
+
owner: _Owner,
|
|
211
|
+
label: str,
|
|
212
|
+
value_start: int,
|
|
213
|
+
value_end: int,
|
|
214
|
+
evidence: Evidence,
|
|
215
|
+
text: str,
|
|
216
|
+
resolved: Sequence[Resolved],
|
|
217
|
+
document_id: str,
|
|
218
|
+
) -> None:
|
|
219
|
+
"""One label and value about `owner`: a Relation when the label names a Kind or the
|
|
220
|
+
value names Records of another Kind, an Attribute otherwise."""
|
|
221
|
+
label = label.strip()
|
|
222
|
+
if not label:
|
|
223
|
+
return
|
|
224
|
+
others = [
|
|
225
|
+
r
|
|
226
|
+
for r in resolved
|
|
227
|
+
if r.usable
|
|
228
|
+
and value_start <= r.mention.start < value_end
|
|
229
|
+
and (owner.mention is None or r.key != owner.mention.key or r.kind_id != owner.ref)
|
|
230
|
+
]
|
|
231
|
+
named = self._kind_named(label)
|
|
232
|
+
targets = [named] if named is not None else list(dict.fromkeys(r.kind_id for r in others))
|
|
233
|
+
targets = [t for t in targets if t != owner.ref]
|
|
234
|
+
confidence = self.settings.field_confidence
|
|
235
|
+
if owner.mention is not None:
|
|
236
|
+
confidence *= owner.mention.confidence
|
|
237
|
+
if targets:
|
|
238
|
+
for target in targets[:1]:
|
|
239
|
+
self.add(
|
|
240
|
+
Observation(
|
|
241
|
+
type=SuggestionType.RELATION,
|
|
242
|
+
label=label,
|
|
243
|
+
document_id=document_id,
|
|
244
|
+
confidence=confidence,
|
|
245
|
+
source=RULES,
|
|
246
|
+
kind=owner.ref,
|
|
247
|
+
target=target,
|
|
248
|
+
evidence=evidence.as_dict(),
|
|
249
|
+
)
|
|
250
|
+
)
|
|
251
|
+
return
|
|
252
|
+
if named is not None or any(r.kind_id == owner.ref for r in others):
|
|
253
|
+
return # a field naming the owner's own Kind or Record
|
|
254
|
+
self.add(
|
|
255
|
+
Observation(
|
|
256
|
+
type=SuggestionType.ATTRIBUTE,
|
|
257
|
+
label=label,
|
|
258
|
+
document_id=document_id,
|
|
259
|
+
confidence=confidence,
|
|
260
|
+
source=RULES,
|
|
261
|
+
kind=owner.ref,
|
|
262
|
+
value=self._sample(text[value_start:value_end]),
|
|
263
|
+
evidence=evidence.as_dict(),
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def _fields(
|
|
268
|
+
self,
|
|
269
|
+
text: str,
|
|
270
|
+
chunks: Sequence[Chunk],
|
|
271
|
+
resolved: Sequence[Resolved],
|
|
272
|
+
owners: Sequence[tuple[int, int, _Owner]],
|
|
273
|
+
document_id: str,
|
|
274
|
+
) -> None:
|
|
275
|
+
for match in _FIELD.finditer(text):
|
|
276
|
+
label = match.group("a") or match.group("b") or ""
|
|
277
|
+
value_group = "v" if match.group("a") is not None else "w"
|
|
278
|
+
enclosing = [o for s, e, o in owners if s <= match.start() < e]
|
|
279
|
+
if not enclosing:
|
|
280
|
+
continue
|
|
281
|
+
start, end = match.start(), match.end()
|
|
282
|
+
while start < end and text[start].isspace():
|
|
283
|
+
start += 1
|
|
284
|
+
evidence = Evidence(document_id, chunk_of(chunks, start), start, end, text[start:end])
|
|
285
|
+
self._field(
|
|
286
|
+
enclosing[-1],
|
|
287
|
+
label,
|
|
288
|
+
match.start(value_group),
|
|
289
|
+
match.end(value_group),
|
|
290
|
+
evidence,
|
|
291
|
+
text,
|
|
292
|
+
resolved,
|
|
293
|
+
document_id,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
def _tables(
|
|
297
|
+
self,
|
|
298
|
+
text: str,
|
|
299
|
+
chunks: Sequence[Chunk],
|
|
300
|
+
resolved: Sequence[Resolved],
|
|
301
|
+
document_id: str,
|
|
302
|
+
) -> None:
|
|
303
|
+
lines = [(m.start(), m.end()) for m in _TABLE_LINE.finditer(text)]
|
|
304
|
+
index = 0
|
|
305
|
+
while index + 1 < len(lines):
|
|
306
|
+
header, separator = lines[index], lines[index + 1]
|
|
307
|
+
contiguous = text[header[1] : separator[0]].strip() == ""
|
|
308
|
+
if not (contiguous and _SEPARATOR.match(text[separator[0] : separator[1]])):
|
|
309
|
+
index += 1
|
|
310
|
+
continue
|
|
311
|
+
labels = [c.strip() for c in _cells(text, *header)]
|
|
312
|
+
rows: list[tuple[int, int]] = []
|
|
313
|
+
previous = separator[1]
|
|
314
|
+
index += 2
|
|
315
|
+
while index < len(lines) and not text[previous : lines[index][0]].strip():
|
|
316
|
+
rows.append(lines[index])
|
|
317
|
+
previous = lines[index][1]
|
|
318
|
+
index += 1
|
|
319
|
+
for start, end in rows:
|
|
320
|
+
self._row(text, chunks, resolved, labels, start, end, document_id)
|
|
321
|
+
|
|
322
|
+
def _row(
|
|
323
|
+
self,
|
|
324
|
+
text: str,
|
|
325
|
+
chunks: Sequence[Chunk],
|
|
326
|
+
resolved: Sequence[Resolved],
|
|
327
|
+
labels: Sequence[str],
|
|
328
|
+
start: int,
|
|
329
|
+
end: int,
|
|
330
|
+
document_id: str,
|
|
331
|
+
) -> None:
|
|
332
|
+
inside = sorted(
|
|
333
|
+
(r for r in resolved if r.usable and start <= r.mention.start < end),
|
|
334
|
+
key=lambda r: r.mention.start,
|
|
335
|
+
)
|
|
336
|
+
if not inside:
|
|
337
|
+
return
|
|
338
|
+
first = inside[0]
|
|
339
|
+
owner = _Owner(first.kind_id, first)
|
|
340
|
+
evidence = Evidence(document_id, chunk_of(chunks, start), start, end, text[start:end])
|
|
341
|
+
cells = _cell_bounds(text, start, end)
|
|
342
|
+
for label, (cell_start, cell_end) in zip(labels, cells, strict=False):
|
|
343
|
+
if cell_start <= first.mention.start < cell_end:
|
|
344
|
+
continue
|
|
345
|
+
if not text[cell_start:cell_end].strip():
|
|
346
|
+
continue
|
|
347
|
+
self._field(owner, label, cell_start, cell_end, evidence, text, resolved, document_id)
|
|
348
|
+
|
|
349
|
+
# Tier 1 -----------------------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
def span_labels(self) -> list[str]:
|
|
352
|
+
"""Extra span labels, those that name no Kind."""
|
|
353
|
+
labels = self.settings.span_labels
|
|
354
|
+
return [name for name in labels if self.ontology.kind_by_label(name) is None]
|
|
355
|
+
|
|
356
|
+
def spans(self, spans: Iterable[Span], chunk: Chunk, *, document_id: str, model: str) -> None:
|
|
357
|
+
wanted = {name.casefold() for name in self.span_labels()}
|
|
358
|
+
for span in spans:
|
|
359
|
+
if span.label.casefold() not in wanted:
|
|
360
|
+
continue
|
|
361
|
+
start, end = chunk.start + span.start, chunk.start + span.end
|
|
362
|
+
self.add(
|
|
363
|
+
Observation(
|
|
364
|
+
type=SuggestionType.KIND,
|
|
365
|
+
label=span.label,
|
|
366
|
+
document_id=document_id,
|
|
367
|
+
confidence=span.score,
|
|
368
|
+
source=f"span:{model}",
|
|
369
|
+
value=self._sample(span.text),
|
|
370
|
+
evidence=Evidence(document_id, chunk.seq, start, end, span.text).as_dict(),
|
|
371
|
+
)
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
async def relations(
|
|
375
|
+
self,
|
|
376
|
+
extractor: RelationExtractor,
|
|
377
|
+
resolved: Sequence[Resolved],
|
|
378
|
+
chunk: Chunk,
|
|
379
|
+
*,
|
|
380
|
+
document_id: str,
|
|
381
|
+
model: str,
|
|
382
|
+
) -> None:
|
|
383
|
+
"""Relations with `relation_labels` between the Records named in one chunk."""
|
|
384
|
+
known = {r.api_name for r in self.ontology.relations.values()}
|
|
385
|
+
labels = [RelationLabel(name=n) for n in self.settings.relation_labels if n not in known]
|
|
386
|
+
inside = [
|
|
387
|
+
r
|
|
388
|
+
for r in resolved
|
|
389
|
+
if r.usable and chunk.start <= r.mention.start and r.mention.end <= chunk.end
|
|
390
|
+
]
|
|
391
|
+
if not labels or len(inside) < 2:
|
|
392
|
+
return
|
|
393
|
+
by_offsets: dict[tuple[int, int], Resolved] = {}
|
|
394
|
+
spans: list[Span] = []
|
|
395
|
+
for item in inside:
|
|
396
|
+
start, end = item.mention.start - chunk.start, item.mention.end - chunk.start
|
|
397
|
+
by_offsets[(start, end)] = item
|
|
398
|
+
label = self.ontology.kinds[item.kind_id].api_name
|
|
399
|
+
spans.append(Span(label, chunk.text[start:end], start, end))
|
|
400
|
+
names = {label.name for label in labels}
|
|
401
|
+
for relation in await extractor.extract(chunk.text, spans, labels):
|
|
402
|
+
source = by_offsets.get((relation.head.start, relation.head.end))
|
|
403
|
+
target = by_offsets.get((relation.tail.start, relation.tail.end))
|
|
404
|
+
if relation.label not in names or source is None or target is None:
|
|
405
|
+
continue
|
|
406
|
+
if source.kind_id == target.kind_id and source.key == target.key:
|
|
407
|
+
continue
|
|
408
|
+
lo = min(relation.head.start, relation.tail.start)
|
|
409
|
+
hi = max(relation.head.end, relation.tail.end)
|
|
410
|
+
self.add(
|
|
411
|
+
Observation(
|
|
412
|
+
type=SuggestionType.RELATION,
|
|
413
|
+
label=relation.label,
|
|
414
|
+
document_id=document_id,
|
|
415
|
+
confidence=relation.score * min(source.confidence, target.confidence),
|
|
416
|
+
source=f"relation:{model}",
|
|
417
|
+
kind=source.kind_id,
|
|
418
|
+
target=target.kind_id,
|
|
419
|
+
evidence=Evidence(
|
|
420
|
+
document_id,
|
|
421
|
+
chunk.seq,
|
|
422
|
+
chunk.start + lo,
|
|
423
|
+
chunk.start + hi,
|
|
424
|
+
chunk.text[lo:hi],
|
|
425
|
+
).as_dict(),
|
|
426
|
+
)
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
# Tier 2 -----------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
def extend(self, observations: Iterable[Observation]) -> None:
|
|
432
|
+
for observation in observations:
|
|
433
|
+
self.add(observation)
|
|
434
|
+
|
|
435
|
+
# Result -----------------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
def _noise(self, name: str, fields_known: Sequence[str]) -> bool:
|
|
438
|
+
"""Whether the proposed Kind `name` is noise (`kind_is_filtered`), unless the job's
|
|
439
|
+
observations show an entity of its own: several distinct codes seen for it, or
|
|
440
|
+
several Attributes it owns."""
|
|
441
|
+
instances = {
|
|
442
|
+
o.value for o in self.observations if o.type is SuggestionType.KIND and o.name == name
|
|
443
|
+
}
|
|
444
|
+
owner = f"{PROPOSED}{name}"
|
|
445
|
+
fields = {
|
|
446
|
+
o.name
|
|
447
|
+
for o in self.observations
|
|
448
|
+
if o.type is SuggestionType.ATTRIBUTE and o.kind == owner
|
|
449
|
+
}
|
|
450
|
+
instances.discard(None)
|
|
451
|
+
return kind_is_filtered(
|
|
452
|
+
name, fields_known, self.options, instances=len(instances), fields=len(fields)
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
def evidence_texts(self) -> list[str]:
|
|
456
|
+
"""Distinct quotes and sample values of the observations, for the check against
|
|
457
|
+
vaulted values (`finish` takes the ones that hold one as `vaulted`)."""
|
|
458
|
+
texts: dict[str, None] = {}
|
|
459
|
+
for observation in self.observations:
|
|
460
|
+
for text in ((observation.evidence or {}).get("quote"), observation.value):
|
|
461
|
+
if text:
|
|
462
|
+
texts[str(text)] = None
|
|
463
|
+
return list(texts)
|
|
464
|
+
|
|
465
|
+
def finish(
|
|
466
|
+
self,
|
|
467
|
+
records: Mapping[tuple[str, str], Mapping[str, Any]],
|
|
468
|
+
vaulted: Collection[str] = (),
|
|
469
|
+
) -> list[Observation]:
|
|
470
|
+
"""The observations to aggregate: new to the schema, never about a sensitive
|
|
471
|
+
Attribute, without evidence holding a sensitive value of the Records in `records`
|
|
472
|
+
(what the job read of the target branch) or one of the `vaulted` texts (those of
|
|
473
|
+
`evidence_texts` that hold a vault value)."""
|
|
474
|
+
tags = self.ontology.tags
|
|
475
|
+
secrets = _sensitive_values(records, tags)
|
|
476
|
+
kept: list[Observation] = []
|
|
477
|
+
fields = dangling_reference_names(self.schema)
|
|
478
|
+
verdicts: list[tuple[Observation, str | None]] = []
|
|
479
|
+
for observation in self.observations:
|
|
480
|
+
if observation.type is SuggestionType.KIND and self._noise(observation.name, fields):
|
|
481
|
+
verdicts.append((observation, "suggestions_noise"))
|
|
482
|
+
continue
|
|
483
|
+
match = existing_match(
|
|
484
|
+
self.schema,
|
|
485
|
+
observation.type,
|
|
486
|
+
observation.label,
|
|
487
|
+
kind=observation.kind,
|
|
488
|
+
target=observation.target,
|
|
489
|
+
options=self.options,
|
|
490
|
+
tags=tags,
|
|
491
|
+
)
|
|
492
|
+
if match is not None:
|
|
493
|
+
verdicts.append(
|
|
494
|
+
(
|
|
495
|
+
observation,
|
|
496
|
+
"suggestions_withheld_sensitive"
|
|
497
|
+
if match.sensitive
|
|
498
|
+
else "suggestions_known",
|
|
499
|
+
)
|
|
500
|
+
)
|
|
501
|
+
continue
|
|
502
|
+
verdicts.append((observation, None))
|
|
503
|
+
# An Attribute owned by a proposed Kind that was filtered out has no owner to join.
|
|
504
|
+
kept_owners = {f"{PROPOSED}{o.name}" for o, why in verdicts if why is None}
|
|
505
|
+
lost_owners = {
|
|
506
|
+
f"{PROPOSED}{o.name}"
|
|
507
|
+
for o, why in verdicts
|
|
508
|
+
if why is not None and o.type is SuggestionType.KIND
|
|
509
|
+
} - kept_owners
|
|
510
|
+
for observation, why in verdicts:
|
|
511
|
+
if why is not None:
|
|
512
|
+
self.count(why)
|
|
513
|
+
continue
|
|
514
|
+
if observation.type is SuggestionType.ATTRIBUTE and observation.kind in lost_owners:
|
|
515
|
+
self.count("suggestions_orphaned")
|
|
516
|
+
continue
|
|
517
|
+
quote = str((observation.evidence or {}).get("quote") or "")
|
|
518
|
+
value = observation.value or ""
|
|
519
|
+
if (
|
|
520
|
+
quote in vaulted
|
|
521
|
+
or value in vaulted
|
|
522
|
+
or any(s in quote.casefold() or s in value.casefold() for s in secrets)
|
|
523
|
+
):
|
|
524
|
+
self.evidence_withheld += 1
|
|
525
|
+
observation = Observation(
|
|
526
|
+
type=observation.type,
|
|
527
|
+
label=observation.label,
|
|
528
|
+
document_id=observation.document_id,
|
|
529
|
+
confidence=observation.confidence,
|
|
530
|
+
source=observation.source,
|
|
531
|
+
kind=observation.kind,
|
|
532
|
+
target=observation.target,
|
|
533
|
+
)
|
|
534
|
+
kept.append(observation)
|
|
535
|
+
limit = self.settings.max_observations_per_job
|
|
536
|
+
if len(kept) > limit:
|
|
537
|
+
self.count("suggestion_observations_dropped", len(kept) - limit)
|
|
538
|
+
kept = kept[:limit]
|
|
539
|
+
self.count("suggestion_observations", len(kept))
|
|
540
|
+
return kept
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _sensitive_values(
|
|
544
|
+
records: Mapping[tuple[str, str], Mapping[str, Any]],
|
|
545
|
+
tags: Mapping[str, frozenset[Sensitivity]],
|
|
546
|
+
) -> set[str]:
|
|
547
|
+
"""Text of the stored values of sensitive Attributes (vault references excluded)."""
|
|
548
|
+
found: set[str] = set()
|
|
549
|
+
for values in records.values():
|
|
550
|
+
for attribute_id, value in values.items():
|
|
551
|
+
if not tags.get(attribute_id) or isinstance(value, dict | list) or value is None:
|
|
552
|
+
continue
|
|
553
|
+
text = str(value).strip().casefold()
|
|
554
|
+
if len(text) >= 3:
|
|
555
|
+
found.add(text)
|
|
556
|
+
return found
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _cell_bounds(text: str, start: int, end: int) -> list[tuple[int, int]]:
|
|
560
|
+
"""`(start, end)` of the cells of a table row, pipes excluded."""
|
|
561
|
+
line = text[start:end]
|
|
562
|
+
pipes = [i for i, c in enumerate(line) if c == "|"]
|
|
563
|
+
return [(start + left + 1, start + right) for left, right in pairwise(pipes)]
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _cells(text: str, start: int, end: int) -> list[str]:
|
|
567
|
+
return [text[s:e] for s, e in _cell_bounds(text, start, end)]
|