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,293 @@
1
+ """The `scribe.extraction` settings: tiers, chunking, entity resolution, confidence, budgets.
2
+
3
+ Models themselves (chat model, span and relation extractors) are configured in the `inference`
4
+ section; this section decides how Scribe uses them.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Literal, Self
10
+
11
+ from pydantic import Field, model_validator
12
+
13
+ from lorebase_platform.settings import SectionModel
14
+
15
+ Tier = Literal[0, 1, 2]
16
+
17
+
18
+ class ChunkSettings(SectionModel):
19
+ """Structure-aware chunks: paragraphs and headings are kept whole when they fit."""
20
+
21
+ max_chars: int = Field(
22
+ default=2000, ge=200, description="Largest chunk, in characters of parsed text."
23
+ )
24
+ overlap_chars: int = Field(
25
+ default=200,
26
+ ge=0,
27
+ description="Characters a chunk repeats from the end of the previous one, so a fact "
28
+ "cut by a boundary is still seen whole.",
29
+ )
30
+
31
+ @model_validator(mode="after")
32
+ def _overlap_below_size(self) -> Self:
33
+ if self.overlap_chars * 2 >= self.max_chars:
34
+ raise ValueError("overlap_chars must be less than half of max_chars")
35
+ return self
36
+
37
+
38
+ class MatchSettings(SectionModel):
39
+ """Entity resolution of mentions against existing Records (design 9.2 step 9)."""
40
+
41
+ trigram_threshold: float = Field(
42
+ default=0.45, ge=0, le=1, description="Least trigram similarity of a fuzzy title match."
43
+ )
44
+ match_threshold: float = Field(
45
+ default=0.6,
46
+ ge=0,
47
+ le=1,
48
+ description="Least score for a mention to be matched to a Record; below, the mention "
49
+ "is a new-Record candidate (when its Kind allows it) or dropped.",
50
+ )
51
+ ambiguity_margin: float = Field(
52
+ default=0.05,
53
+ ge=0,
54
+ le=1,
55
+ description="Candidates scoring within this margin of the best one make the match "
56
+ "ambiguous; the reviewer picks.",
57
+ )
58
+ candidates: int = Field(default=5, ge=1, le=50, description="Candidates kept per mention.")
59
+ dictionary_max_titles: int = Field(
60
+ default=20000,
61
+ ge=0,
62
+ description="Titles of existing Records loaded per job for tier 0 dictionary matching; "
63
+ "Kinds beyond the budget are matched by keys, tier 1 spans and trigrams only.",
64
+ )
65
+ min_title_chars: int = Field(
66
+ default=4, ge=1, description="Shorter titles are not used for dictionary matching."
67
+ )
68
+
69
+
70
+ class ConfidenceSettings(SectionModel):
71
+ """Confidence given to candidates by the deterministic tiers and tier 2."""
72
+
73
+ key: float = Field(default=1.0, ge=0, le=1, description="A key found by its pattern.")
74
+ title: float = Field(default=0.95, ge=0, le=1, description="An exact title match.")
75
+ normalized_title: float = Field(
76
+ default=0.9, ge=0, le=1, description="A title equal after case and punctuation folding."
77
+ )
78
+ value_max: float = Field(
79
+ default=0.85,
80
+ ge=0,
81
+ le=1,
82
+ description="Ceiling of rule-detected Attribute values (closer cues score higher).",
83
+ )
84
+ cooccurrence_max: float = Field(
85
+ default=0.7, ge=0, le=1, description="Ceiling of Links proposed from co-occurrence."
86
+ )
87
+ heading: float = Field(
88
+ default=0.6,
89
+ ge=0,
90
+ le=1,
91
+ description="Links between the Record a section heading names and the Records its "
92
+ "text mentions.",
93
+ )
94
+ tier2: float = Field(
95
+ default=0.8, ge=0, le=1, description="Facts returned by tier 2 with verified evidence."
96
+ )
97
+
98
+
99
+ class ParserSettings(SectionModel):
100
+ """Limits of the process that turns PDF and DOCX bytes into text (design 9.2 step 2).
101
+
102
+ A crafted file can make a parser loop or allocate without bound, so PDF and DOCX are parsed
103
+ in a child process with no network access, limited in wall-clock time, CPU time and address
104
+ space; a document over `max_input_bytes` is not parsed at all. A document that breaks a
105
+ limit fails its extraction job with a message naming the setting. Markdown and plain text
106
+ are decoded in the worker under the same size limit.
107
+ """
108
+
109
+ isolated: bool = Field(
110
+ default=True,
111
+ description="Parse PDF and DOCX in a child process with the limits below. Off: in a "
112
+ "worker thread, bounded only by max_input_bytes.",
113
+ )
114
+ timeout_s: float = Field(
115
+ default=60.0, gt=0, description="Wall-clock time a document may take to parse."
116
+ )
117
+ cpu_s: int = Field(
118
+ default=60, ge=1, description="CPU seconds of the parser process (RLIMIT_CPU, POSIX)."
119
+ )
120
+ memory_mb: int = Field(
121
+ default=1024,
122
+ ge=128,
123
+ description="Address space of the parser process in MiB (RLIMIT_AS; Linux and other "
124
+ "POSIX systems that enforce it).",
125
+ )
126
+ max_input_bytes: int = Field(
127
+ default=50 * 1024 * 1024, ge=1, description="Largest document parsed, in bytes."
128
+ )
129
+
130
+
131
+ class DiscoverySettings(SectionModel):
132
+ """Schema suggestions (design 9.2 step 10): what extraction reports when it finds types,
133
+ fields or relations the schema does not have. How suggestions are aggregated, listed and
134
+ matched against the schema is set in `schema.suggestions`."""
135
+
136
+ enabled: bool = Field(
137
+ default=True, description="Record schema suggestions from extraction jobs."
138
+ )
139
+ rules: bool = Field(
140
+ default=True,
141
+ description="Tier 0: `**Label:** value` field lines and table columns no Attribute "
142
+ "matches, and headings naming a thing by a code no Kind's key pattern knows.",
143
+ )
144
+ span_labels: tuple[str, ...] = Field(
145
+ default=(),
146
+ description="Tier 1: extra labels for the span extractor; spans found with them "
147
+ "suggest a Kind of that name (e.g. certificate, warehouse).",
148
+ )
149
+ relation_labels: tuple[str, ...] = Field(
150
+ default=(),
151
+ description="Tier 1: extra labels for the relation extractor; relations found with "
152
+ "them between Records of known Kinds suggest a Relation of that name.",
153
+ )
154
+ tier2: bool = Field(
155
+ default=True,
156
+ description="Tier 2: the chat model may report facts outside the schema (`unlisted`) "
157
+ "with verified evidence, and its answers outside the schema are kept as observations.",
158
+ )
159
+ llm_pass: bool = Field(
160
+ default=False,
161
+ description="Run a dedicated discovery call per chunk through the "
162
+ "`inference.llm.tasks.schema_discovery` route (any chat model; the extraction route "
163
+ "when the task has no override) that proposes Kinds and Attributes from the document "
164
+ "text. Needs no tier 2. It runs after tier 2 of each document and has its own token cap "
165
+ "(`llm_max_tokens`), so it never takes tokens from `max_tokens_per_job`.",
166
+ )
167
+ llm_max_tokens: int = Field(
168
+ default=200_000,
169
+ ge=0,
170
+ description="The dedicated discovery calls stop once a job used this many tokens "
171
+ "(input and output, estimated before each call); 0 means no limit. Separate from "
172
+ "`max_tokens_per_job`, which tier 2 alone spends.",
173
+ )
174
+ llm_max_quote_chars: int = Field(
175
+ default=300,
176
+ ge=16,
177
+ description="A proposal of the dedicated call whose evidence quote is longer is discarded.",
178
+ )
179
+ llm_max_quote_share: float = Field(
180
+ default=0.5,
181
+ gt=0,
182
+ le=1,
183
+ description="A proposal whose evidence quote covers more than this share of the "
184
+ "chunk is discarded: it quotes the text instead of pointing at a fact. Applies only to "
185
+ "chunks of at least `llm_quote_share_min_chunk_chars` characters.",
186
+ )
187
+ llm_quote_share_min_chunk_chars: int = Field(
188
+ default=400,
189
+ ge=0,
190
+ description="Shorter chunks are exempt from `llm_max_quote_share`: a legitimate quote "
191
+ "can cover most of a short chunk.",
192
+ )
193
+ llm_max_items: int = Field(
194
+ default=20, ge=1, description="Proposals of each list the dedicated call may return."
195
+ )
196
+ min_confidence: float = Field(
197
+ default=0.3, ge=0, le=1, description="Weaker observations are dropped."
198
+ )
199
+ field_confidence: float = Field(
200
+ default=0.6, ge=0, le=1, description="Confidence of a field line or table column."
201
+ )
202
+ heading_confidence: float = Field(
203
+ default=0.5, ge=0, le=1, description="Confidence of a Kind named by a heading."
204
+ )
205
+ max_value_chars: int = Field(
206
+ default=120, ge=8, description="Sample values are cut to this length."
207
+ )
208
+ vault_match_max_words: int = Field(
209
+ default=12,
210
+ ge=1,
211
+ le=64,
212
+ description="Longest word sequence of a quote compared with the keyed fingerprints of "
213
+ "vaulted values of any Kind; a quote holding one is kept without its evidence and sample.",
214
+ )
215
+ vault_match_max_text_words: int = Field(
216
+ default=400,
217
+ ge=1,
218
+ description="A quote or sample with more words is not compared and is treated as "
219
+ "holding a vaulted value (its evidence is withheld).",
220
+ )
221
+ vault_match_max_text_chars: int = Field(
222
+ default=4000,
223
+ ge=1,
224
+ description="Same, by characters.",
225
+ )
226
+ vault_match_max_candidates: int = Field(
227
+ default=200_000,
228
+ ge=1,
229
+ description="Distinct word sequences compared per job; past it the remaining quotes "
230
+ "are treated as holding a vaulted value.",
231
+ )
232
+ max_observations_per_job: int = Field(
233
+ default=1000, ge=1, description="Observations beyond this many are dropped and counted."
234
+ )
235
+
236
+
237
+ class ExtractionSettings(SectionModel):
238
+ """Document extraction into reviewable Proposals (design 9.2)."""
239
+
240
+ tiers: tuple[Tier, ...] = Field(
241
+ default=(0, 1),
242
+ min_length=1,
243
+ description="Tiers a job runs unless the request names others: 0 rules and "
244
+ "dictionaries, 1 local span and relation models, 2 the configured chat model. Add 2 "
245
+ "once inference.llm points at a model.",
246
+ )
247
+ auto_accept_threshold: float = Field(
248
+ default=0.9,
249
+ ge=0,
250
+ le=1.01,
251
+ description="Items at or above this confidence are accepted when the Proposal opens "
252
+ "and committed on the extraction branch; the rest stay pending for review. Above 1 "
253
+ "accepts nothing automatically.",
254
+ )
255
+ min_confidence: float = Field(
256
+ default=0.3, ge=0, le=1, description="Candidates below this confidence are dropped."
257
+ )
258
+ new_record_kinds: tuple[str, ...] | None = Field(
259
+ default=None,
260
+ description="Kind api names that may receive new-Record candidates; null allows every "
261
+ "Kind with a natural key, an empty list allows none.",
262
+ )
263
+ value_cue_words: int = Field(
264
+ default=8,
265
+ ge=1,
266
+ description="A rule-detected value counts only when a cue word of its Attribute "
267
+ "precedes it by at most this many words in the sentence.",
268
+ )
269
+ max_items_per_job: int = Field(
270
+ default=5000, ge=1, description="Items beyond this many are dropped and counted."
271
+ )
272
+ max_document_chars: int = Field(
273
+ default=2_000_000, ge=1, description="Parsed text beyond this length is not extracted."
274
+ )
275
+ prompt_version: str = Field(
276
+ default="extract-v1",
277
+ min_length=1,
278
+ description="Recorded in Lineage and in the cache key; change it when prompts change.",
279
+ )
280
+ cache: bool = Field(
281
+ default=True, description="Reuse tier 2 answers for unchanged chunks, schema and model."
282
+ )
283
+ max_tokens_per_job: int = Field(
284
+ default=2_000_000,
285
+ ge=0,
286
+ description="Tier 2 stops calling the model once a job used this many tokens; 0 means "
287
+ "no limit.",
288
+ )
289
+ parser: ParserSettings = Field(default_factory=ParserSettings)
290
+ chunking: ChunkSettings = Field(default_factory=ChunkSettings)
291
+ matching: MatchSettings = Field(default_factory=MatchSettings)
292
+ confidence: ConfidenceSettings = Field(default_factory=ConfidenceSettings)
293
+ discovery: DiscoverySettings = Field(default_factory=DiscoverySettings)
@@ -0,0 +1,179 @@
1
+ """Chunks and sentences of parsed text, with offsets into that text (design 9.2 step 3).
2
+
3
+ Chunks follow the structure: paragraphs, headings, list items and table rows are kept whole
4
+ when they fit, longer ones are cut at sentence ends and then at whitespace. Consecutive chunks
5
+ overlap by at most `overlap_chars`, starting at a whitespace boundary. A chunk's text is always
6
+ `text[start:end]`, so evidence offsets found in a chunk map back to the document by adding its
7
+ start.
8
+
9
+ Sentences end at `.`, `!` or `?` followed by whitespace and a character that is not a
10
+ lowercase letter (so `Sp. z o.o.` stays whole), at blank lines, and at line breaks before a
11
+ block marker (`#`, `|`, `-`, `*`, `>`, `1.`) or after a heading or table row. Lines wrapped
12
+ inside a paragraph stay in one sentence.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import re
19
+ from dataclasses import dataclass
20
+
21
+ _BLOCK_START = re.compile(r"\s*(#|\||[-*>]\s|\*\*|\d+[.)]\s)")
22
+ _SENTENCE_END = re.compile(r"(?<=[.!?])\s+(?=[^\sa-z])")
23
+ _HEADING = re.compile(r"\s*#{1,6}\s")
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class Chunk:
28
+ seq: int
29
+ start: int
30
+ end: int
31
+ text: str
32
+
33
+ @property
34
+ def hash(self) -> bytes:
35
+ return hashlib.sha256(self.text.encode("utf-8")).digest()
36
+
37
+
38
+ def _lines(text: str) -> list[tuple[int, int]]:
39
+ """`(start, end)` of every line, newline excluded."""
40
+ bounds: list[tuple[int, int]] = []
41
+ start = 0
42
+ for index, char in enumerate(text):
43
+ if char == "\n":
44
+ bounds.append((start, index))
45
+ start = index + 1
46
+ bounds.append((start, len(text)))
47
+ return bounds
48
+
49
+
50
+ def _groups(text: str) -> list[tuple[int, int]]:
51
+ """Blocks of lines: paragraphs, headings, list items and table rows."""
52
+ groups: list[tuple[int, int]] = []
53
+ current: list[int] | None = None
54
+ previous_single = False
55
+ for start, end in _lines(text):
56
+ line = text[start:end]
57
+ if not line.strip():
58
+ if current is not None:
59
+ groups.append((current[0], current[1]))
60
+ current, previous_single = None, False
61
+ continue
62
+ starts_block = bool(_BLOCK_START.match(line))
63
+ if current is None or starts_block or previous_single:
64
+ if current is not None:
65
+ groups.append((current[0], current[1]))
66
+ current = [start, end]
67
+ else:
68
+ current[1] = end
69
+ stripped = line.lstrip()
70
+ previous_single = stripped.startswith(("#", "|"))
71
+ if current is not None:
72
+ groups.append((current[0], current[1]))
73
+ return [_trim(text, start, end) for start, end in groups if text[start:end].strip()]
74
+
75
+
76
+ def _trim(text: str, start: int, end: int) -> tuple[int, int]:
77
+ while start < end and text[start].isspace():
78
+ start += 1
79
+ while end > start and text[end - 1].isspace():
80
+ end -= 1
81
+ return start, end
82
+
83
+
84
+ def sentence_bounds(text: str) -> list[tuple[int, int]]:
85
+ """`(start, end)` of the sentences of `text`, whitespace trimmed."""
86
+ sentences: list[tuple[int, int]] = []
87
+ for start, end in _groups(text):
88
+ position = start
89
+ for match in _SENTENCE_END.finditer(text, start, end):
90
+ sentences.append(_trim(text, position, match.start()))
91
+ position = match.end()
92
+ sentences.append(_trim(text, position, end))
93
+ return [bound for bound in sentences if bound[1] > bound[0]]
94
+
95
+
96
+ @dataclass(frozen=True, slots=True)
97
+ class Section:
98
+ """A Markdown heading and its section, up to the next heading of the same or a higher
99
+ level; `heading_end` ends the heading line."""
100
+
101
+ start: int
102
+ heading_end: int
103
+ end: int
104
+ level: int
105
+
106
+
107
+ _HEADING_LINE = re.compile(r"^(#{1,6})\s", re.MULTILINE)
108
+
109
+
110
+ def heading_sections(text: str) -> list[Section]:
111
+ headings = [(m.start(), len(m.group(1))) for m in _HEADING_LINE.finditer(text)]
112
+ sections: list[Section] = []
113
+ for index, (start, level) in enumerate(headings):
114
+ end = next(
115
+ (later for later, later_level in headings[index + 1 :] if later_level <= level),
116
+ len(text),
117
+ )
118
+ line_end = text.find("\n", start)
119
+ sections.append(Section(start, len(text) if line_end == -1 else line_end, end, level))
120
+ return sections
121
+
122
+
123
+ def is_heading(text: str, offset: int) -> bool:
124
+ """Whether `offset` lies on a Markdown heading line."""
125
+ line_start = text.rfind("\n", 0, offset) + 1
126
+ return bool(_HEADING.match(text, line_start))
127
+
128
+
129
+ def _pieces(text: str, start: int, end: int, size: int) -> list[tuple[int, int]]:
130
+ """Cut `[start, end)` into pieces of at most `size` characters at sentence ends, then at
131
+ whitespace, then anywhere."""
132
+ if end - start <= size:
133
+ return [(start, end)]
134
+ units = [(s, e) for s, e in sentence_bounds(text[start:end])]
135
+ units = [(start + s, start + e) for s, e in units] or [(start, end)]
136
+ pieces: list[tuple[int, int]] = []
137
+ for unit_start, unit_end in units:
138
+ position = unit_start
139
+ while unit_end - position > size:
140
+ cut = text.rfind(" ", position + 1, position + size)
141
+ if cut <= position:
142
+ cut = position + size
143
+ pieces.append(_trim(text, position, cut))
144
+ position = cut
145
+ pieces.append(_trim(text, position, unit_end))
146
+ return [piece for piece in pieces if piece[1] > piece[0]]
147
+
148
+
149
+ def chunk_text(text: str, *, max_chars: int, overlap_chars: int) -> list[Chunk]:
150
+ """Chunks of at most `max_chars` characters covering every non-blank part of `text`."""
151
+ if overlap_chars * 2 >= max_chars:
152
+ raise ValueError("overlap_chars must be less than half of max_chars")
153
+ unit_size = max_chars - overlap_chars
154
+ units = [
155
+ piece for start, end in _groups(text) for piece in _pieces(text, start, end, unit_size)
156
+ ]
157
+ chunks: list[Chunk] = []
158
+ index = 0
159
+ previous_end: int | None = None
160
+ while index < len(units):
161
+ start = units[index][0]
162
+ if previous_end is not None and overlap_chars:
163
+ boundary = text.find(" ", max(previous_end - overlap_chars, 0), previous_end)
164
+ if boundary != -1:
165
+ start = min(start, boundary + 1)
166
+ end = units[index][1]
167
+ index += 1
168
+ while index < len(units) and units[index][1] - start <= max_chars:
169
+ end = units[index][1]
170
+ index += 1
171
+ start, end = _trim(text, start, end)
172
+ chunks.append(Chunk(len(chunks), start, end, text[start:end]))
173
+ previous_end = end
174
+ return chunks
175
+
176
+
177
+ def normalize_space(text: str) -> str:
178
+ """Whitespace runs folded to one space, ends stripped (evidence comparison)."""
179
+ return " ".join(text.split())