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,194 @@
1
+ """Link candidates between resolved mentions.
2
+
3
+ - Co-occurrence (tier 0): two Records named in one sentence (a table row is a sentence) are
4
+ linked by every Relation whose endpoint Kinds they have; confidence decreases with the words
5
+ between them and never exceeds `confidence.cooccurrence_max`.
6
+ - Headings (tier 0): the Record a heading names is linked to the Records named in its section
7
+ (up to the next heading of the same or a higher level), at `confidence.heading`.
8
+ - Model (tier 1): a `RelationExtractor` of the inference package (GLiREL), with Relation api
9
+ names as labels constrained to their endpoint Kinds.
10
+
11
+ Co-occurrence knows nothing about meaning; its candidates are a superset that evidence, review
12
+ and the confidence threshold sort out.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from collections import defaultdict
19
+ from collections.abc import Sequence
20
+
21
+ from lorebase_inference.interfaces import RelationExtractor, RelationLabel, Span
22
+ from lorebase_scribe.extraction.candidates import Evidence, LinkCandidate, Resolved
23
+ from lorebase_scribe.extraction.ontology import Ontology
24
+ from lorebase_scribe.extraction.settings import ExtractionSettings
25
+ from lorebase_scribe.extraction.text import heading_sections
26
+
27
+ _WORD = re.compile(r"\w+")
28
+ COOCCURRENCE = "cooccurrence"
29
+ HEADING = "heading"
30
+
31
+
32
+ def _pairs(
33
+ ontology: Ontology, first: Resolved, second: Resolved
34
+ ) -> list[tuple[str, Resolved, Resolved]]:
35
+ """`(relation id, from, to)` for every Relation the two mentions can stand in."""
36
+ if (first.kind_id, first.key) == (second.kind_id, second.key):
37
+ return []
38
+ found: list[tuple[str, Resolved, Resolved]] = []
39
+ for relation in ontology.relations.values():
40
+ if relation.from_kind == first.kind_id and relation.to_kind == second.kind_id:
41
+ found.append((relation.id, first, second))
42
+ elif relation.from_kind == second.kind_id and relation.to_kind == first.kind_id:
43
+ found.append((relation.id, second, first))
44
+ return found
45
+
46
+
47
+ def cooccurrence_links(
48
+ ontology: Ontology,
49
+ resolved: Sequence[Resolved],
50
+ text: str,
51
+ sentences: Sequence[tuple[int, int]],
52
+ *,
53
+ document_id: str,
54
+ settings: ExtractionSettings,
55
+ ) -> list[LinkCandidate]:
56
+ by_sentence: dict[tuple[int, int], list[Resolved]] = defaultdict(list)
57
+ for item in resolved:
58
+ for bound in sentences:
59
+ if bound[0] <= item.mention.start < bound[1]:
60
+ by_sentence[bound].append(item)
61
+ break
62
+ found: list[LinkCandidate] = []
63
+ ceiling = settings.confidence.cooccurrence_max
64
+ for (start, end), group in sorted(by_sentence.items()):
65
+ ordered = sorted(group, key=lambda r: r.mention.start)
66
+ for index, first in enumerate(ordered):
67
+ for second in ordered[index + 1 :]:
68
+ gap = len(_WORD.findall(text, first.mention.end, max(second.mention.start, 0)))
69
+ proximity = 1 / (1 + gap / 10)
70
+ confidence = ceiling * min(first.confidence, second.confidence) * proximity
71
+ for relation_id, source, target in _pairs(ontology, first, second):
72
+ found.append(
73
+ LinkCandidate(
74
+ relation_id=relation_id,
75
+ source=source,
76
+ target=target,
77
+ evidence=Evidence(
78
+ document_id, first.mention.chunk, start, end, text[start:end]
79
+ ),
80
+ confidence=confidence,
81
+ tier=0,
82
+ model=COOCCURRENCE,
83
+ )
84
+ )
85
+ return found
86
+
87
+
88
+ def heading_links(
89
+ ontology: Ontology,
90
+ resolved: Sequence[Resolved],
91
+ text: str,
92
+ sentences: Sequence[tuple[int, int]],
93
+ *,
94
+ document_id: str,
95
+ settings: ExtractionSettings,
96
+ ) -> list[LinkCandidate]:
97
+ found: list[LinkCandidate] = []
98
+ for section in heading_sections(text):
99
+ start, line_end, end = section.start, section.heading_end, section.end
100
+ heads = [r for r in resolved if start <= r.mention.start < line_end]
101
+ body = [r for r in resolved if line_end <= r.mention.start < end]
102
+ for head in heads:
103
+ for other in body:
104
+ sentence = next(
105
+ ((s, e) for s, e in sentences if s <= other.mention.start < e),
106
+ (other.mention.start, other.mention.end),
107
+ )
108
+ confidence = settings.confidence.heading * min(head.confidence, other.confidence)
109
+ for relation_id, source, target in _pairs(ontology, head, other):
110
+ found.append(
111
+ LinkCandidate(
112
+ relation_id=relation_id,
113
+ source=source,
114
+ target=target,
115
+ evidence=Evidence(
116
+ document_id,
117
+ other.mention.chunk,
118
+ sentence[0],
119
+ sentence[1],
120
+ text[sentence[0] : sentence[1]],
121
+ ),
122
+ confidence=confidence,
123
+ tier=0,
124
+ model=HEADING,
125
+ )
126
+ )
127
+ return found
128
+
129
+
130
+ async def model_links(
131
+ extractor: RelationExtractor,
132
+ ontology: Ontology,
133
+ resolved: Sequence[Resolved],
134
+ chunk_text: str,
135
+ chunk_start: int,
136
+ *,
137
+ document_id: str,
138
+ model: str,
139
+ ) -> list[LinkCandidate]:
140
+ """Relations a local model finds between the mentions of one chunk."""
141
+ inside = [
142
+ r
143
+ for r in resolved
144
+ if chunk_start <= r.mention.start and r.mention.end <= chunk_start + len(chunk_text)
145
+ ]
146
+ kinds = {r.kind_id for r in inside}
147
+ relations = ontology.relations_between(kinds)
148
+ if len(inside) < 2 or not relations:
149
+ return []
150
+ by_offsets: dict[tuple[int, int], Resolved] = {}
151
+ spans: list[Span] = []
152
+ for item in inside:
153
+ start, end = item.mention.start - chunk_start, item.mention.end - chunk_start
154
+ by_offsets[(start, end)] = item
155
+ spans.append(Span(ontology.kinds[item.kind_id].api_name, chunk_text[start:end], start, end))
156
+ labels = [
157
+ RelationLabel(
158
+ name=r.api_name,
159
+ description=r.description,
160
+ head_labels=(ontology.kinds[r.from_kind].api_name,),
161
+ tail_labels=(ontology.kinds[r.to_kind].api_name,),
162
+ )
163
+ for r in relations
164
+ ]
165
+ by_name = {r.api_name: r for r in relations}
166
+ found: list[LinkCandidate] = []
167
+ for relation in await extractor.extract(chunk_text, spans, labels):
168
+ spec = by_name.get(relation.label)
169
+ source = by_offsets.get((relation.head.start, relation.head.end))
170
+ target = by_offsets.get((relation.tail.start, relation.tail.end))
171
+ if spec is None or source is None or target is None:
172
+ continue
173
+ if (source.kind_id, target.kind_id) != (spec.from_kind, spec.to_kind):
174
+ continue
175
+ lo = min(relation.head.start, relation.tail.start)
176
+ hi = max(relation.head.end, relation.tail.end)
177
+ found.append(
178
+ LinkCandidate(
179
+ relation_id=spec.id,
180
+ source=source,
181
+ target=target,
182
+ evidence=Evidence(
183
+ document_id,
184
+ source.mention.chunk,
185
+ chunk_start + lo,
186
+ chunk_start + hi,
187
+ chunk_text[lo:hi],
188
+ ),
189
+ confidence=relation.score * min(source.confidence, target.confidence),
190
+ tier=1,
191
+ model=model,
192
+ )
193
+ )
194
+ return found
@@ -0,0 +1,269 @@
1
+ """PDF and DOCX parsing in a limited child process (`scribe.extraction.parser`).
2
+
3
+ pypdf and python-docx run on bytes uploaded by Domain editors; a crafted file can make them loop
4
+ or allocate without bound. `parse_isolated` therefore hands the bytes to a child Python process
5
+ (`python -I -B -m lorebase_scribe.extraction.sandbox`) over standard input and reads a JSON
6
+ answer from standard output. The child, before it reads anything:
7
+
8
+ - lowers its own limits: CPU seconds (`RLIMIT_CPU`), address space (`RLIMIT_AS`, where the
9
+ system enforces it) and core files (none); `resource` exists on POSIX only, elsewhere only
10
+ the wall-clock limit applies;
11
+ - replaces the socket constructors and name resolution with functions that refuse, so a parser
12
+ cannot open a connection, and runs with an empty environment (no proxy variables);
13
+ - cuts the text at the character limit it is given, so the answer stays bounded.
14
+
15
+ The parent bounds the whole exchange by `timeout_s` and kills the child when it expires, reads
16
+ at most a fixed number of bytes from it, and turns every failure (limit, crash, garbled answer)
17
+ into a `ParseError`, which fails the extraction job without a retry.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import asyncio
24
+ import contextlib
25
+ import importlib
26
+ import json
27
+ import os
28
+ import signal
29
+ import socket
30
+ import sys
31
+ from collections.abc import Callable, Sequence
32
+ from typing import Any, NoReturn
33
+
34
+ from lorebase_scribe.extraction.parsing import (
35
+ MARKDOWN,
36
+ PLAIN_TEXT,
37
+ ParsedDocument,
38
+ ParseError,
39
+ parse_document,
40
+ )
41
+ from lorebase_scribe.extraction.settings import ParserSettings
42
+
43
+ DEFAULT_TARGET = "lorebase_scribe.extraction.parsing:parse_document"
44
+ """The function the child runs: `(data: bytes, media_type: str) -> ParsedDocument`."""
45
+ NATIVE = (MARKDOWN, PLAIN_TEXT)
46
+ """Media types decoded in the worker: no third-party parser is involved."""
47
+ ANSWER_OVERHEAD_BYTES = 16 * 1024 * 1024
48
+ """Room in the child's answer beyond the text: page offsets, warnings, JSON escapes."""
49
+ _READ = 64 * 1024
50
+
51
+
52
+ def _answer_limit(max_chars: int) -> int:
53
+ # JSON escapes a character into at most 12 bytes (a surrogate pair as two \\uXXXX).
54
+ return max_chars * 12 + ANSWER_OVERHEAD_BYTES
55
+
56
+
57
+ async def parse_isolated(
58
+ data: bytes,
59
+ media_type: str,
60
+ settings: ParserSettings,
61
+ *,
62
+ max_chars: int,
63
+ target: str = DEFAULT_TARGET,
64
+ python_path: Sequence[str] = (),
65
+ ) -> ParsedDocument:
66
+ """Text of a document, parsed under `settings`; raises `ParseError`.
67
+
68
+ `max_chars` bounds the text returned (one more character is kept, so the caller still sees
69
+ that the text was longer). `target` and `python_path` choose the function the child runs.
70
+ """
71
+ if len(data) > settings.max_input_bytes:
72
+ raise ParseError(
73
+ f"the document has {len(data)} bytes, more than "
74
+ f"scribe.extraction.parser.max_input_bytes ({settings.max_input_bytes})"
75
+ )
76
+ if media_type in NATIVE or not settings.isolated:
77
+ return await asyncio.to_thread(parse_document, data, media_type)
78
+ command = [
79
+ sys.executable,
80
+ "-I",
81
+ "-B",
82
+ "-m",
83
+ __name__,
84
+ "--media-type",
85
+ media_type,
86
+ "--cpu-s",
87
+ str(settings.cpu_s),
88
+ "--memory-bytes",
89
+ str(settings.memory_mb * 1024 * 1024),
90
+ "--max-chars",
91
+ str(max_chars + 1),
92
+ "--target",
93
+ target,
94
+ ]
95
+ for entry in python_path:
96
+ command += ["--path", entry]
97
+ process = await asyncio.create_subprocess_exec(
98
+ *command,
99
+ stdin=asyncio.subprocess.PIPE,
100
+ stdout=asyncio.subprocess.PIPE,
101
+ stderr=asyncio.subprocess.DEVNULL,
102
+ env=_child_environment(),
103
+ start_new_session=True,
104
+ )
105
+ try:
106
+ async with asyncio.timeout(settings.timeout_s):
107
+ answer = await _exchange(process, data, _answer_limit(max_chars + 1))
108
+ except TimeoutError:
109
+ raise ParseError(
110
+ f"parsing took longer than scribe.extraction.parser.timeout_s ({settings.timeout_s} s)"
111
+ ) from None
112
+ finally:
113
+ if process.returncode is None:
114
+ with contextlib.suppress(ProcessLookupError):
115
+ process.kill()
116
+ await process.wait()
117
+ return _decode_answer(answer, process.returncode, settings)
118
+
119
+
120
+ def _child_environment() -> dict[str, str]:
121
+ """Nothing from the worker's environment (no secrets, no proxies) but what Python needs."""
122
+ return {name: os.environ[name] for name in ("SYSTEMROOT",) if name in os.environ}
123
+
124
+
125
+ async def _exchange(process: asyncio.subprocess.Process, data: bytes, limit: int) -> bytes:
126
+ stdin, stdout = process.stdin, process.stdout
127
+ assert stdin is not None
128
+ assert stdout is not None
129
+
130
+ async def feed() -> None:
131
+ with contextlib.suppress(BrokenPipeError, ConnectionResetError):
132
+ stdin.write(data)
133
+ await stdin.drain()
134
+ stdin.close()
135
+
136
+ feeding = asyncio.create_task(feed())
137
+ answer = bytearray()
138
+ try:
139
+ while block := await stdout.read(_READ):
140
+ answer.extend(block)
141
+ if len(answer) > limit:
142
+ raise ParseError("the parser answered more than its output limit")
143
+ await feeding
144
+ finally:
145
+ feeding.cancel()
146
+ await process.wait()
147
+ return bytes(answer)
148
+
149
+
150
+ def _decode_answer(
151
+ answer: bytes, returncode: int | None, settings: ParserSettings
152
+ ) -> ParsedDocument:
153
+ if returncode is not None and returncode < 0:
154
+ stopped = -returncode
155
+ if stopped == getattr(signal, "SIGXCPU", None):
156
+ raise ParseError(
157
+ f"parsing used more than scribe.extraction.parser.cpu_s ({settings.cpu_s} s) "
158
+ "of CPU time"
159
+ )
160
+ raise ParseError(
161
+ f"the parser process was stopped by signal {stopped} (memory limit "
162
+ f"scribe.extraction.parser.memory_mb is {settings.memory_mb} MiB)"
163
+ )
164
+ try:
165
+ found = json.loads(answer)
166
+ except ValueError:
167
+ raise ParseError(f"the parser process failed (exit status {returncode})") from None
168
+ if not isinstance(found, dict):
169
+ raise ParseError("the parser process answered something other than a document")
170
+ if "error" in found:
171
+ raise ParseError(str(found["error"]))
172
+ try:
173
+ return ParsedDocument(
174
+ text=str(found["text"]),
175
+ media_type=str(found["media_type"]),
176
+ pages=tuple(int(p) for p in found.get("pages", ())),
177
+ warnings=tuple(str(w) for w in found.get("warnings", ())),
178
+ )
179
+ except (KeyError, TypeError, ValueError):
180
+ raise ParseError("the parser process answered an incomplete document") from None
181
+
182
+
183
+ # The child --------------------------------------------------------------------------------------
184
+
185
+
186
+ def _refuse(*_args: Any, **_kwargs: Any) -> NoReturn:
187
+ raise OSError("network access is disabled while parsing documents")
188
+
189
+
190
+ def _deny_network() -> None:
191
+ class _NoSocket(socket.socket):
192
+ def __init__(self, *_args: Any, **_kwargs: Any) -> None:
193
+ _refuse()
194
+
195
+ socket.socket = _NoSocket # type: ignore[misc]
196
+ for name in ("create_connection", "create_server", "getaddrinfo", "socketpair", "fromfd"):
197
+ setattr(socket, name, _refuse)
198
+
199
+
200
+ def _limit(cpu_s: int, memory_bytes: int) -> None:
201
+ try:
202
+ import resource
203
+ except ImportError: # not POSIX: the parent's wall-clock limit still applies
204
+ return
205
+ limits = (
206
+ (resource.RLIMIT_CPU, cpu_s, cpu_s + 1),
207
+ (resource.RLIMIT_AS, memory_bytes, memory_bytes),
208
+ (resource.RLIMIT_CORE, 0, 0),
209
+ )
210
+ for which, soft, hard in limits:
211
+ _, current_hard = resource.getrlimit(which)
212
+ if current_hard != resource.RLIM_INFINITY:
213
+ soft, hard = min(soft, current_hard), min(hard, current_hard)
214
+ # Some systems (macOS for RLIMIT_AS) refuse or ignore a limit; the others still hold.
215
+ with contextlib.suppress(ValueError, OSError):
216
+ resource.setrlimit(which, (soft, hard))
217
+
218
+
219
+ def _function(target: str) -> Callable[[bytes, str], ParsedDocument]:
220
+ module, _, name = target.partition(":")
221
+ function: Callable[[bytes, str], ParsedDocument] = getattr(
222
+ importlib.import_module(module), name
223
+ )
224
+ return function
225
+
226
+
227
+ def _write(answer: dict[str, Any]) -> None:
228
+ sys.stdout.buffer.write(json.dumps(answer).encode("utf-8"))
229
+ sys.stdout.buffer.flush()
230
+
231
+
232
+ def main(argv: Sequence[str] | None = None) -> int:
233
+ """Child entry point: parse the bytes of standard input, answer JSON on standard output."""
234
+ parser = argparse.ArgumentParser(prog="lorebase_scribe.extraction.sandbox")
235
+ parser.add_argument("--media-type", required=True)
236
+ parser.add_argument("--cpu-s", type=int, required=True)
237
+ parser.add_argument("--memory-bytes", type=int, required=True)
238
+ parser.add_argument("--max-chars", type=int, required=True)
239
+ parser.add_argument("--target", default=DEFAULT_TARGET)
240
+ parser.add_argument("--path", action="append", default=[])
241
+ args = parser.parse_args(argv)
242
+ _limit(args.cpu_s, args.memory_bytes)
243
+ _deny_network()
244
+ sys.path[:0] = args.path
245
+ data = sys.stdin.buffer.read()
246
+ try:
247
+ parsed = _function(args.target)(data, args.media_type)
248
+ except ParseError as error:
249
+ _write({"error": str(error)})
250
+ return 0
251
+ except MemoryError:
252
+ _write({"error": "parsing ran out of memory (scribe.extraction.parser.memory_mb)"})
253
+ return 0
254
+ except Exception as error: # any parser failure is one of the document
255
+ _write({"error": f"the parser failed on this document ({type(error).__name__})"})
256
+ return 0
257
+ _write(
258
+ {
259
+ "text": parsed.text[: args.max_chars],
260
+ "media_type": parsed.media_type,
261
+ "pages": list(parsed.pages),
262
+ "warnings": list(parsed.warnings),
263
+ }
264
+ )
265
+ return 0
266
+
267
+
268
+ if __name__ == "__main__":
269
+ sys.exit(main())