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,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)]