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,1091 @@
1
+ """The `extraction` job (design 9.2): documents in, a reviewable Proposal out.
2
+
3
+ 1. Record the job in `extraction_jobs` against the schema of the Domain's default branch.
4
+ 2. For each document: read the bytes from the blob store, parse, chunk, store the chunks and
5
+ the text hash.
6
+ 3. Find mentions: keys and titles (tier 0), spans of the local model (tier 1); resolve them
7
+ against the default branch. Attributes sensitive by their effective tags (the schema's,
8
+ the default branch's and those carried through Derivations) are never extracted.
9
+ 4. Find values (tier 0 rules), Links (co-occurrence and headings, tier 0; the local relation
10
+ model, tier 1) and ask the chat model chunk by chunk (tier 2), within the token budget.
11
+ 5. Assemble the items. With at least one item: create branch `extract/<job id>` from the
12
+ default branch, open the Proposal (origin `extraction`), store the items, commit the items
13
+ accepted at once on the branch (with `extraction` Lineage) and compute the merge preview.
14
+ 6. Schema discovery: what the tiers saw outside the schema, and what the optional dedicated
15
+ discovery pass (`discovery.llm_pass`, task route `schema_discovery`) proposes, is folded
16
+ into the Domain's schema suggestions (`discovery`, `schema_suggestions`), whether or not
17
+ the job proposes items.
18
+
19
+ Nothing is written to the default branch: reviewers decide item by item and merge the
20
+ Proposal. A tier 1 model that is not installed degrades to tier 0 with a warning in the job's
21
+ stats; a tier 2 model that cannot be created fails the job, and a chunk whose answer stays
22
+ invalid after the repair attempts is skipped with a warning.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import hashlib
28
+ import logging
29
+ import time
30
+ from collections import defaultdict
31
+ from collections.abc import Callable, Mapping, Sequence
32
+ from dataclasses import dataclass, field
33
+ from datetime import UTC, datetime
34
+ from typing import Any
35
+
36
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
37
+
38
+ import lorebase_inference
39
+ from lorebase_core.expr.sensitivity import Tags
40
+ from lorebase_core.model import Schema, new_ulid
41
+ from lorebase_inference import (
42
+ InferenceError,
43
+ InferenceSettings,
44
+ InvalidOutputError,
45
+ ProviderUnavailableError,
46
+ UnknownProviderError,
47
+ )
48
+ from lorebase_inference.interfaces import (
49
+ ChatModel,
50
+ Label,
51
+ RelationExtractor,
52
+ Span,
53
+ SpanExtractor,
54
+ Usage,
55
+ )
56
+ from lorebase_scribe.extraction.assemble import CurrentState, assemble_items
57
+ from lorebase_scribe.extraction.candidates import (
58
+ LinkCandidate,
59
+ MatchCandidate,
60
+ Mention,
61
+ Resolved,
62
+ ValueCandidate,
63
+ )
64
+ from lorebase_scribe.extraction.discovery import Discovery
65
+ from lorebase_scribe.extraction.llm_discovery import TASK as DISCOVERY_TASK
66
+ from lorebase_scribe.extraction.llm_discovery import run_llm_discovery
67
+ from lorebase_scribe.extraction.matching import (
68
+ Dictionary,
69
+ Resolver,
70
+ TitleEntry,
71
+ disambiguate,
72
+ non_overlapping,
73
+ tier0_mentions,
74
+ )
75
+ from lorebase_scribe.extraction.ontology import Ontology, build_ontology
76
+ from lorebase_scribe.extraction.parsing import ParseError
77
+ from lorebase_scribe.extraction.relations import cooccurrence_links, heading_links, model_links
78
+ from lorebase_scribe.extraction.sandbox import parse_isolated
79
+ from lorebase_scribe.extraction.settings import ExtractionSettings
80
+ from lorebase_scribe.extraction.text import (
81
+ Chunk,
82
+ chunk_text,
83
+ heading_sections,
84
+ is_heading,
85
+ sentence_bounds,
86
+ )
87
+ from lorebase_scribe.extraction.tier2 import run_tier2
88
+ from lorebase_scribe.extraction.values import heading_title, sentence_values
89
+ from lorebase_scribe.sources.sync import POLICY_KEY, domain_merge_policy
90
+ from lorebase_scribe.telemetry import EVIDENCE_WITHHELD, EXTRACTION_ITEMS
91
+ from lorebase_store.blobs import TEXT_REF_KEY, BlobNotFoundError, BlobStore, PostgresBlobStore
92
+ from lorebase_store.commit import ValidationFailedError
93
+ from lorebase_store.commit.schema import load_compiled
94
+ from lorebase_store.extraction import (
95
+ EXTRACTION_BRANCH_PREFIX,
96
+ MaterializeContext,
97
+ Materialized,
98
+ materialize_items,
99
+ refresh_proposal,
100
+ )
101
+ from lorebase_store.materialize import create_branch
102
+ from lorebase_store.materialize.sensitivity import reference_tags
103
+ from lorebase_store.models.enums import ItemDecision, ProposalOrigin, ProposalStatus, RunStatus
104
+ from lorebase_store.reads import similar_titles
105
+ from lorebase_store.repositories import (
106
+ ChunkRepository,
107
+ ChunkRow,
108
+ DocumentRepository,
109
+ DocumentRow,
110
+ DomainRepository,
111
+ ExtractionCacheRepository,
112
+ ExtractionJobRepository,
113
+ ExtractionJobRow,
114
+ ItemRow,
115
+ MaterializedRepository,
116
+ ProposalItemRepository,
117
+ ProposalRepository,
118
+ ProposalRow,
119
+ SchemaSuggestionRepository,
120
+ scope_to_domain,
121
+ )
122
+ from lorebase_store.runtime import StoreRuntime
123
+ from lorebase_store.vault import QuoteLimits, has_vault_values, quoted_values
124
+
125
+ log = logging.getLogger("lorebase.scribe.extraction")
126
+
127
+ DISCOVERY_TOKENS = "discovery_tokens"
128
+ JOB_PREFIX = "exj"
129
+ PROPOSAL_PREFIX = "prp"
130
+ SUGGESTION_PREFIX = "ssg"
131
+ Clock = Callable[[], datetime]
132
+ _TIER1_ERRORS = (
133
+ NotImplementedError,
134
+ ProviderUnavailableError,
135
+ UnknownProviderError,
136
+ ImportError,
137
+ OSError,
138
+ ValueError,
139
+ )
140
+ DETERMINISTIC_RELATIONS = frozenset({"cooccurrence", "fake"})
141
+ """Relation providers that pair spans of a sentence without a model, as tier 0 does."""
142
+
143
+
144
+ class ExtractionError(RuntimeError):
145
+ """The job cannot succeed as queued (unknown document, unusable model configuration)."""
146
+
147
+
148
+ PERMANENT_ERRORS: tuple[type[Exception], ...] = (ExtractionError, ParseError, BlobNotFoundError)
149
+
150
+
151
+ def utc_now() -> datetime:
152
+ return datetime.now(UTC)
153
+
154
+
155
+ # Models -----------------------------------------------------------------------------------------
156
+
157
+
158
+ @dataclass(frozen=True)
159
+ class ExtractionModels:
160
+ """The models a job uses; `None` where a tier does not run or its model is missing."""
161
+
162
+ spans: SpanExtractor | None = None
163
+ span_model: str = ""
164
+ relations: RelationExtractor | None = None
165
+ relation_model: str = ""
166
+ chat: ChatModel | None = None
167
+ chat_model: str = ""
168
+ warnings: tuple[str, ...] = ()
169
+ discovery_chat: ChatModel | None = None
170
+ discovery_model: str = ""
171
+
172
+ def describe(self) -> str:
173
+ parts = ["rules", self.span_model, self.relation_model, self.chat_model]
174
+ return ",".join(p for p in parts if p)
175
+
176
+
177
+ def build_models(
178
+ inference: InferenceSettings, settings: ExtractionSettings, tiers: Sequence[int]
179
+ ) -> ExtractionModels:
180
+ """Models of the configured providers for `tiers`."""
181
+ warnings: list[str] = []
182
+ spans: SpanExtractor | None = None
183
+ span_model = relation_model = chat_model = ""
184
+ relations: RelationExtractor | None = None
185
+ chat: ChatModel | None = None
186
+ local = inference.local_extractors
187
+ if 1 in tiers:
188
+ try:
189
+ spans = lorebase_inference.create_span_extractor(inference)
190
+ span_model = f"{local.provider}:{local.gliner.model}"
191
+ except _TIER1_ERRORS as error:
192
+ warnings.append(f"tier 1 span extractor unavailable: {error}")
193
+ # A provider without a model pairs the spans of a sentence, which tier 0 already does.
194
+ if 0 not in tiers or local.relation_provider not in DETERMINISTIC_RELATIONS:
195
+ try:
196
+ relations = lorebase_inference.create_relation_extractor(inference)
197
+ relation_model = (
198
+ f"glirel:{local.glirel.model}"
199
+ if local.relation_provider == "glirel"
200
+ else local.relation_provider
201
+ )
202
+ except _TIER1_ERRORS as error:
203
+ warnings.append(f"tier 1 relation extractor unavailable: {error}")
204
+ if 2 in tiers:
205
+ llm = inference.llm.for_task("extraction")
206
+ try:
207
+ chat = lorebase_inference.create_chat_model(inference, task="extraction")
208
+ except (NotImplementedError, UnknownProviderError, ValueError) as error:
209
+ raise ExtractionError(f"tier 2 chat model unavailable: {error}") from None
210
+ chat_model = f"{llm.provider}:{llm.model}"
211
+ discovery_chat: ChatModel | None = None
212
+ discovery_model = ""
213
+ if settings.discovery.enabled and settings.discovery.llm_pass:
214
+ llm = inference.llm.for_task(DISCOVERY_TASK)
215
+ try:
216
+ discovery_chat = lorebase_inference.create_chat_model(inference, task=DISCOVERY_TASK)
217
+ except (NotImplementedError, UnknownProviderError, ValueError) as error:
218
+ raise ExtractionError(f"schema discovery chat model unavailable: {error}") from None
219
+ discovery_model = f"{llm.provider}:{llm.model}"
220
+ return ExtractionModels(
221
+ spans,
222
+ span_model,
223
+ relations,
224
+ relation_model,
225
+ chat,
226
+ chat_model,
227
+ tuple(warnings),
228
+ discovery_chat,
229
+ discovery_model,
230
+ )
231
+
232
+
233
+ # Store adapters ---------------------------------------------------------------------------------
234
+
235
+
236
+ class StoreLookup:
237
+ """`RecordLookup` over the materialized head of a branch, one transaction per call."""
238
+
239
+ def __init__(
240
+ self,
241
+ sessions: async_sessionmaker[AsyncSession],
242
+ domain_id: str,
243
+ branch: str,
244
+ runtime: StoreRuntime,
245
+ ) -> None:
246
+ self._sessions = sessions
247
+ self._domain = domain_id
248
+ self._branch = branch
249
+ self._runtime = runtime
250
+
251
+ async def titles_of_keys(self, kind_id: str, keys: Sequence[str]) -> dict[str, str | None]:
252
+ async with self._sessions() as session, session.begin():
253
+ await scope_to_domain(session, self._domain)
254
+ rows = await MaterializedRepository(
255
+ session, self._domain, self._branch, writes=self._runtime.settings.writes
256
+ ).records(kind_id, keys)
257
+ return {key: row.title for key, row in rows.items()}
258
+
259
+ async def similar(
260
+ self, text: str, kind_ids: Sequence[str], *, threshold: float, limit: int
261
+ ) -> list[MatchCandidate]:
262
+ async with self._sessions() as session, session.begin():
263
+ await scope_to_domain(session, self._domain)
264
+ hits = await similar_titles(
265
+ session,
266
+ self._domain,
267
+ self._branch,
268
+ text,
269
+ kind_ids=kind_ids,
270
+ threshold=threshold,
271
+ limit=limit,
272
+ runtime=self._runtime,
273
+ )
274
+ return [MatchCandidate(h.kind_id, h.key, h.title, h.score, "trigram") for h in hits]
275
+
276
+
277
+ class StoreCache:
278
+ """`AnswerCache` over `extraction_cache`."""
279
+
280
+ def __init__(self, sessions: async_sessionmaker[AsyncSession]) -> None:
281
+ self._sessions = sessions
282
+
283
+ async def get(self, key: bytes) -> tuple[Any, Usage, str] | None:
284
+ async with self._sessions() as session, session.begin():
285
+ found = await ExtractionCacheRepository(session).get(key)
286
+ if found is None:
287
+ return None
288
+ output, usage, model = found
289
+ return (
290
+ output,
291
+ Usage(int(usage.get("input_tokens", 0)), int(usage.get("output_tokens", 0))),
292
+ model,
293
+ )
294
+
295
+ async def put(self, key: bytes, data: Any, usage: Usage, model: str) -> None:
296
+ async with self._sessions() as session, session.begin():
297
+ await ExtractionCacheRepository(session).put(
298
+ key,
299
+ model=model,
300
+ output=data,
301
+ usage={"input_tokens": usage.input_tokens, "output_tokens": usage.output_tokens},
302
+ )
303
+
304
+
305
+ # The run ----------------------------------------------------------------------------------------
306
+
307
+
308
+ @dataclass
309
+ class _Found:
310
+ mentions: list[Resolved] = field(default_factory=list)
311
+ values: list[ValueCandidate] = field(default_factory=list)
312
+ links: list[LinkCandidate] = field(default_factory=list)
313
+
314
+
315
+ @dataclass
316
+ class ExtractionReport:
317
+ job_id: str
318
+ domain: str
319
+ documents: list[str]
320
+ branch: str
321
+ target: str
322
+ tiers: list[int]
323
+ proposal_id: str | None = None
324
+ items: int = 0
325
+ accepted: int = 0
326
+ pending: int = 0
327
+ stats: dict[str, Any] = field(default_factory=dict)
328
+ usage: dict[str, int] = field(default_factory=lambda: {"input_tokens": 0, "output_tokens": 0})
329
+ warnings: list[str] = field(default_factory=list)
330
+ materialized: dict[str, Any] | None = None
331
+ duration_ms: int = 0
332
+
333
+ def count(self, name: str, n: int = 1) -> None:
334
+ self.stats[name] = int(self.stats.get(name, 0)) + n
335
+
336
+ def as_dict(self) -> dict[str, Any]:
337
+ return {
338
+ "job": self.job_id,
339
+ "domain": self.domain,
340
+ "documents": list(self.documents),
341
+ "branch": self.branch,
342
+ "target": self.target,
343
+ "tiers": list(self.tiers),
344
+ "proposal": self.proposal_id,
345
+ "items": self.items,
346
+ "accepted": self.accepted,
347
+ "pending": self.pending,
348
+ "stats": dict(self.stats),
349
+ "usage": dict(self.usage),
350
+ "warnings": list(self.warnings),
351
+ "materialized": self.materialized,
352
+ "duration_ms": self.duration_ms,
353
+ }
354
+
355
+
356
+ class ExtractionRun:
357
+ """Runs extraction jobs against one database."""
358
+
359
+ def __init__(
360
+ self,
361
+ sessions: async_sessionmaker[AsyncSession],
362
+ runtime: StoreRuntime,
363
+ settings: ExtractionSettings,
364
+ inference: InferenceSettings,
365
+ *,
366
+ principal: str,
367
+ models: Callable[[Sequence[int]], ExtractionModels] | None = None,
368
+ blobs: BlobStore | None = None,
369
+ clock: Clock = utc_now,
370
+ ) -> None:
371
+ self._sessions = sessions
372
+ self._runtime = runtime
373
+ self._settings = settings
374
+ self._inference = inference
375
+ self._principal = principal
376
+ self._models = models or (lambda tiers: build_models(inference, settings, tiers))
377
+ self._blobs = blobs or PostgresBlobStore()
378
+ self._clock = clock
379
+
380
+ async def run(
381
+ self,
382
+ domain_id: str,
383
+ document_ids: Sequence[str],
384
+ *,
385
+ tiers: Sequence[int] | None = None,
386
+ requested_by: str | None = None,
387
+ ) -> ExtractionReport:
388
+ """Extract the documents into one Proposal on the Domain's default branch."""
389
+ started = time.monotonic()
390
+ chosen = sorted(set(tiers or self._settings.tiers))
391
+ if not document_ids:
392
+ raise ExtractionError("an extraction job needs at least one document")
393
+ if not set(chosen) <= {0, 1, 2}:
394
+ raise ExtractionError(f"unknown tiers {chosen}")
395
+ models = self._models(chosen)
396
+ job_id = f"{JOB_PREFIX}_{new_ulid()}"
397
+ async with self._sessions() as session, session.begin():
398
+ await scope_to_domain(session, domain_id)
399
+ domain = await DomainRepository(session).get(domain_id)
400
+ if domain is None:
401
+ raise ExtractionError(f"no Domain {domain_id!r}")
402
+ writes = self._runtime.settings.writes
403
+ compiled = await MaterializedRepository(
404
+ session, domain_id, domain.default_branch, writes=writes
405
+ ).schema()
406
+ if compiled is None:
407
+ raise ExtractionError(f"branch {domain.default_branch!r} is not materialized")
408
+ # What may be extracted follows the effective tags, as reads do: the default
409
+ # branch's tags are added to those of the schema extracted against.
410
+ reference = await reference_tags(
411
+ session, domain_id, domain.default_branch, runtime=self._runtime
412
+ )
413
+ documents: list[DocumentRow] = []
414
+ repository = DocumentRepository(session, domain_id)
415
+ for document_id in dict.fromkeys(document_ids):
416
+ row = await repository.get(document_id)
417
+ if row is None:
418
+ raise ExtractionError(f"no document {document_id!r} in {domain_id!r}")
419
+ documents.append(row)
420
+ report = ExtractionReport(
421
+ job_id=job_id,
422
+ domain=domain_id,
423
+ documents=[d.id for d in documents],
424
+ branch=f"{EXTRACTION_BRANCH_PREFIX}{job_id}",
425
+ target=domain.default_branch,
426
+ tiers=chosen,
427
+ warnings=list(models.warnings),
428
+ )
429
+ await ExtractionJobRepository(session, domain_id).insert(
430
+ ExtractionJobRow(
431
+ id=job_id,
432
+ documents=report.documents,
433
+ schema_hash=compiled.schema_hash,
434
+ model=models.describe(),
435
+ prompt_version=self._settings.prompt_version,
436
+ status=RunStatus.RUNNING,
437
+ budget={"max_tokens": self._settings.max_tokens_per_job},
438
+ created_by=requested_by or self._principal,
439
+ branch=report.branch,
440
+ started_at=self._clock(),
441
+ stats={"tiers": chosen},
442
+ )
443
+ )
444
+ schema = load_compiled(compiled.compiled)
445
+ try:
446
+ await self._run(
447
+ domain, schema, compiled.schema_hash, documents, models, report, reference
448
+ )
449
+ except Exception as error:
450
+ report.duration_ms = int((time.monotonic() - started) * 1000)
451
+ await self._finish(
452
+ report, RunStatus.FAILED, {"type": type(error).__name__, "message": str(error)}
453
+ )
454
+ raise
455
+ report.duration_ms = int((time.monotonic() - started) * 1000)
456
+ await self._finish(report, RunStatus.SUCCEEDED)
457
+ log.info(
458
+ "documents extracted",
459
+ extra={
460
+ "domain": domain_id,
461
+ "job": job_id,
462
+ "proposal": report.proposal_id,
463
+ "items": report.items,
464
+ "accepted": report.accepted,
465
+ "duration_ms": report.duration_ms,
466
+ },
467
+ )
468
+ return report
469
+
470
+ async def _finish(
471
+ self, report: ExtractionReport, status: RunStatus, error: dict[str, Any] | None = None
472
+ ) -> None:
473
+ async with self._sessions() as session, session.begin():
474
+ await scope_to_domain(session, report.domain)
475
+ await ExtractionJobRepository(session, report.domain).update(
476
+ report.job_id,
477
+ status=status,
478
+ finished_at=self._clock(),
479
+ stats=report.as_dict(),
480
+ usage=dict(report.usage),
481
+ proposal_id=report.proposal_id,
482
+ error=error,
483
+ )
484
+
485
+ # Extraction -------------------------------------------------------------------------
486
+
487
+ async def _dictionary(self, domain_id: str, branch: str, ontology: Ontology) -> Dictionary:
488
+ budget = self._settings.matching.dictionary_max_titles
489
+ entries: list[TitleEntry] = []
490
+ settings = self._runtime.settings
491
+ async with self._sessions() as session, session.begin():
492
+ await scope_to_domain(session, domain_id)
493
+ rows = MaterializedRepository(session, domain_id, branch, writes=settings.writes)
494
+ counts = await rows.record_counts()
495
+ for kind_id, view in ontology.kinds.items():
496
+ count = counts.get(kind_id, 0)
497
+ if view.title_attribute is None or count == 0 or count > budget:
498
+ continue
499
+ budget -= count
500
+ async for page in rows.stream_records(
501
+ kind_id, batch=settings.materialize.read_batch_rows
502
+ ):
503
+ entries.extend(TitleEntry(kind_id, r.key, r.title) for r in page if r.title)
504
+ return Dictionary(entries, min_chars=self._settings.matching.min_title_chars)
505
+
506
+ async def _run(
507
+ self,
508
+ domain: Any,
509
+ schema: Schema,
510
+ schema_hash: bytes,
511
+ documents: Sequence[DocumentRow],
512
+ models: ExtractionModels,
513
+ report: ExtractionReport,
514
+ reference: Mapping[str, Tags] | None = None,
515
+ ) -> None:
516
+ ontology = build_ontology(schema, self._settings.new_record_kinds, reference)
517
+ dictionary = await self._dictionary(domain.id, report.target, ontology)
518
+ resolver = Resolver(
519
+ ontology,
520
+ self._settings,
521
+ StoreLookup(self._sessions, domain.id, report.target, self._runtime),
522
+ dictionary,
523
+ )
524
+ found = _Found()
525
+ discovery = (
526
+ Discovery(ontology, self._settings.discovery, self._runtime.schema.suggestions)
527
+ if self._settings.discovery.enabled
528
+ else None
529
+ )
530
+ for document in documents:
531
+ await self._document(
532
+ document,
533
+ ontology,
534
+ dictionary,
535
+ resolver,
536
+ schema_hash,
537
+ models,
538
+ report,
539
+ found,
540
+ discovery,
541
+ )
542
+ current = await self._current(domain.id, report.target, found)
543
+ if discovery is not None:
544
+ await self._suggest(discovery, current, report)
545
+ assembled = assemble_items(
546
+ ontology,
547
+ found.mentions,
548
+ found.values,
549
+ found.links,
550
+ current,
551
+ job_id=report.job_id,
552
+ principal=self._principal,
553
+ now=self._clock(),
554
+ settings=self._settings,
555
+ )
556
+ for name, count in assembled.stats.items():
557
+ report.count(name, count)
558
+ items = assembled.items
559
+ report.items = len(items)
560
+ for item in items:
561
+ tier = "none" if item.tier is None else str(item.tier)
562
+ EXTRACTION_ITEMS.add(1, {"tier": tier, "decision": item.decision.value})
563
+ if not items:
564
+ return
565
+ await self._propose(domain, schema, documents, items, report)
566
+
567
+ async def _document(
568
+ self,
569
+ document: DocumentRow,
570
+ ontology: Ontology,
571
+ dictionary: Dictionary,
572
+ resolver: Resolver,
573
+ schema_hash: bytes,
574
+ models: ExtractionModels,
575
+ report: ExtractionReport,
576
+ found: _Found,
577
+ discovery: Discovery | None = None,
578
+ ) -> None:
579
+ domain_id = report.domain
580
+ async with self._sessions() as session, session.begin():
581
+ await scope_to_domain(session, domain_id)
582
+ data = await self._blobs.get(session, domain_id, document.blob_ref)
583
+ parsed = await parse_isolated(
584
+ data,
585
+ document.media_type,
586
+ self._settings.parser,
587
+ max_chars=self._settings.max_document_chars,
588
+ )
589
+ text = parsed.text
590
+ report.warnings.extend(f"{document.id}: {w}" for w in parsed.warnings)
591
+ if len(text) > self._settings.max_document_chars:
592
+ report.warnings.append(
593
+ f"{document.id}: text cut at {self._settings.max_document_chars} characters"
594
+ )
595
+ text = text[: self._settings.max_document_chars]
596
+ chunking = self._settings.chunking
597
+ chunks = chunk_text(
598
+ text, max_chars=chunking.max_chars, overlap_chars=chunking.overlap_chars
599
+ )
600
+ encoded = text.encode("utf-8")
601
+ async with self._sessions() as session, session.begin():
602
+ await scope_to_domain(session, domain_id)
603
+ await ChunkRepository(session, domain_id).replace(
604
+ document.id,
605
+ [ChunkRow(c.seq, c.start, c.end, c.text, c.hash) for c in chunks],
606
+ )
607
+ # The parsed text the evidence offsets index, served by Keeper's document text route.
608
+ text_ref = await self._blobs.put(session, domain_id, encoded)
609
+ await DocumentRepository(session, domain_id).set_text(
610
+ document.id,
611
+ text_hash=hashlib.sha256(encoded).digest(),
612
+ metadata={**parsed.metadata(), "chunks": len(chunks), TEXT_REF_KEY: text_ref},
613
+ )
614
+ report.count("chunks", len(chunks))
615
+ sentences = sentence_bounds(text)
616
+ tiers = report.tiers
617
+ raw: list[Mention] = []
618
+ for chunk in chunks:
619
+ if 0 in tiers:
620
+ raw.extend(
621
+ tier0_mentions(
622
+ ontology,
623
+ dictionary,
624
+ chunk.text,
625
+ offset=chunk.start,
626
+ chunk=chunk.seq,
627
+ document_id=document.id,
628
+ settings=self._settings,
629
+ )
630
+ )
631
+ if models.spans is not None:
632
+ raw.extend(await self._spans(models, ontology, chunk, document.id, discovery))
633
+ mentions = non_overlapping(raw)
634
+ resolved = disambiguate([await resolver.resolve(m) for m in mentions], sentences)
635
+ report.count("mentions", len(resolved))
636
+ if discovery is not None and 0 in tiers:
637
+ discovery.rules(text, chunks, resolved, document_id=document.id)
638
+ if 0 in tiers:
639
+ found.values.extend(self._values(ontology, text, sentences, resolved))
640
+ found.links.extend(
641
+ cooccurrence_links(
642
+ ontology,
643
+ resolved,
644
+ text,
645
+ sentences,
646
+ document_id=document.id,
647
+ settings=self._settings,
648
+ )
649
+ )
650
+ found.links.extend(
651
+ heading_links(
652
+ ontology,
653
+ resolved,
654
+ text,
655
+ sentences,
656
+ document_id=document.id,
657
+ settings=self._settings,
658
+ )
659
+ )
660
+ if models.relations is not None:
661
+ for chunk in chunks:
662
+ found.links.extend(
663
+ await model_links(
664
+ models.relations,
665
+ ontology,
666
+ resolved,
667
+ chunk.text,
668
+ chunk.start,
669
+ document_id=document.id,
670
+ model=models.relation_model,
671
+ )
672
+ )
673
+ if models.relations is not None and discovery is not None:
674
+ for chunk in chunks:
675
+ await discovery.relations(
676
+ models.relations,
677
+ resolved,
678
+ chunk,
679
+ document_id=document.id,
680
+ model=models.relation_model,
681
+ )
682
+ found.mentions.extend(resolved)
683
+ if models.chat is not None:
684
+ await self._tier2(
685
+ models,
686
+ ontology,
687
+ resolver,
688
+ chunks,
689
+ resolved,
690
+ document,
691
+ schema_hash,
692
+ report,
693
+ found,
694
+ discovery,
695
+ )
696
+ if discovery is not None and models.discovery_chat is not None:
697
+ # After tier 2, so its calls come first in the job's token budget.
698
+ await self._llm_discovery(
699
+ models, ontology, chunks, document, schema_hash, report, discovery
700
+ )
701
+
702
+ async def _spans(
703
+ self,
704
+ models: ExtractionModels,
705
+ ontology: Ontology,
706
+ chunk: Chunk,
707
+ document_id: str,
708
+ discovery: Discovery | None = None,
709
+ ) -> list[Mention]:
710
+ assert models.spans is not None
711
+ labels = [
712
+ Label(name=view.label, description=view.describe()) for view in ontology.kinds.values()
713
+ ]
714
+ if discovery is not None:
715
+ labels += [Label(name=name) for name in discovery.span_labels()]
716
+ mentions: list[Mention] = []
717
+ unknown: list[Span] = []
718
+ spans = await models.spans.extract(chunk.text, labels)
719
+ for span in spans:
720
+ view = ontology.kind_by_label(span.label)
721
+ if view is None:
722
+ unknown.append(span)
723
+ continue
724
+ mentions.append(
725
+ Mention(
726
+ kind_id=view.id,
727
+ text=span.text,
728
+ start=chunk.start + span.start,
729
+ end=chunk.start + span.end,
730
+ chunk=chunk.seq,
731
+ score=span.score,
732
+ tier=1,
733
+ source="span",
734
+ extractor=models.span_model,
735
+ document_id=document_id,
736
+ )
737
+ )
738
+ if discovery is not None and unknown:
739
+ discovery.spans(unknown, chunk, document_id=document_id, model=models.span_model)
740
+ return mentions
741
+
742
+ def _values(
743
+ self,
744
+ ontology: Ontology,
745
+ text: str,
746
+ sentences: Sequence[tuple[int, int]],
747
+ resolved: Sequence[Resolved],
748
+ ) -> list[ValueCandidate]:
749
+ """Values sentence by sentence: for the Records a sentence names, else for the Record
750
+ its section heading names; titles of new Records from their headings."""
751
+ excluded = [(r.mention.start, r.mention.end) for r in resolved]
752
+ sections = heading_sections(text)
753
+ found: list[ValueCandidate] = []
754
+ for start, end in sentences:
755
+ owners = [r for r in resolved if start <= r.mention.start < end]
756
+ enclosing = sorted(
757
+ (s for s in sections if s.start <= start < s.end),
758
+ key=lambda s: s.start,
759
+ reverse=True,
760
+ )
761
+ for section in enclosing:
762
+ if owners:
763
+ break
764
+ owners = [
765
+ r for r in resolved if section.start <= r.mention.start < section.heading_end
766
+ ]
767
+ if owners:
768
+ found.extend(
769
+ sentence_values(
770
+ ontology,
771
+ owners,
772
+ text,
773
+ (start, end),
774
+ excluded=excluded,
775
+ settings=self._settings,
776
+ )
777
+ )
778
+ for item in resolved:
779
+ if item.resolution.status == "new" and is_heading(text, item.mention.start):
780
+ title = heading_title(ontology, item, text, settings=self._settings)
781
+ if title is not None:
782
+ found.append(title)
783
+ return found
784
+
785
+ async def _tier2(
786
+ self,
787
+ models: ExtractionModels,
788
+ ontology: Ontology,
789
+ resolver: Resolver,
790
+ chunks: Sequence[Chunk],
791
+ resolved: Sequence[Resolved],
792
+ document: DocumentRow,
793
+ schema_hash: bytes,
794
+ report: ExtractionReport,
795
+ found: _Found,
796
+ discovery: Discovery | None = None,
797
+ ) -> None:
798
+ assert models.chat is not None
799
+ discover = discovery is not None and self._settings.discovery.tier2
800
+ budget = self._settings.max_tokens_per_job
801
+ cache = StoreCache(self._sessions) if self._settings.cache else None
802
+ for chunk in chunks:
803
+ # The discovery pass has a budget of its own: its tokens do not count here.
804
+ used = (
805
+ report.usage["input_tokens"]
806
+ + report.usage["output_tokens"]
807
+ - int(report.stats.get(DISCOVERY_TOKENS, 0))
808
+ )
809
+ if budget and used >= budget:
810
+ report.count("chunks_over_budget")
811
+ continue
812
+ hints = [r for r in resolved if chunk.start <= r.mention.start < chunk.end]
813
+ try:
814
+ result = await run_tier2(
815
+ models.chat,
816
+ ontology,
817
+ chunk,
818
+ hints,
819
+ resolver,
820
+ document_id=document.id,
821
+ schema_hash=schema_hash,
822
+ model_key=models.chat_model,
823
+ settings=self._settings,
824
+ cache=cache,
825
+ discover=discover,
826
+ )
827
+ except InvalidOutputError as error:
828
+ # One unusable answer costs that chunk's tier 2 facts, not the whole job.
829
+ report.count("model_calls")
830
+ report.count("model_invalid_answers")
831
+ report.usage["input_tokens"] += error.usage.input_tokens
832
+ report.usage["output_tokens"] += error.usage.output_tokens
833
+ report.warnings.append(f"{document.id}: chunk {chunk.seq}: {error}")
834
+ continue
835
+ report.count("model_calls", 0 if result.cached else 1)
836
+ report.count("cache_hits", 1 if result.cached else 0)
837
+ report.usage["input_tokens"] += result.usage.input_tokens
838
+ report.usage["output_tokens"] += result.usage.output_tokens
839
+ for reason, count in result.discarded.items():
840
+ report.count(f"discarded_{reason}", count)
841
+ found.mentions.extend(result.mentions)
842
+ found.values.extend(result.values)
843
+ found.links.extend(result.links)
844
+ if discovery is not None:
845
+ discovery.extend(result.observations)
846
+
847
+ async def _llm_discovery(
848
+ self,
849
+ models: ExtractionModels,
850
+ ontology: Ontology,
851
+ chunks: Sequence[Chunk],
852
+ document: DocumentRow,
853
+ schema_hash: bytes,
854
+ report: ExtractionReport,
855
+ discovery: Discovery,
856
+ ) -> None:
857
+ """The dedicated discovery pass over the chunks of one document, within its own token
858
+ cap (`discovery.llm_max_tokens`); a provider error costs that chunk, not the job."""
859
+ assert models.discovery_chat is not None
860
+ cap = self._settings.discovery.llm_max_tokens
861
+ cache = StoreCache(self._sessions) if self._settings.cache else None
862
+
863
+ def spend(usage: Usage) -> None:
864
+ report.usage["input_tokens"] += usage.input_tokens
865
+ report.usage["output_tokens"] += usage.output_tokens
866
+ report.count(DISCOVERY_TOKENS, usage.input_tokens + usage.output_tokens)
867
+
868
+ for chunk in chunks:
869
+ left = None if not cap else max(cap - int(report.stats.get(DISCOVERY_TOKENS, 0)), 0)
870
+ if left == 0:
871
+ report.count("discovery_chunks_over_budget")
872
+ continue
873
+ try:
874
+ result = await run_llm_discovery(
875
+ models.discovery_chat,
876
+ ontology,
877
+ chunk,
878
+ document_id=document.id,
879
+ schema_hash=schema_hash,
880
+ model_key=models.discovery_model,
881
+ settings=self._settings,
882
+ cache=cache,
883
+ token_budget=left,
884
+ )
885
+ except InvalidOutputError as error:
886
+ report.count("discovery_calls")
887
+ report.count("discovery_invalid_answers")
888
+ spend(error.usage)
889
+ report.warnings.append(f"{document.id}: chunk {chunk.seq}: discovery: {error}")
890
+ continue
891
+ except InferenceError as error:
892
+ report.count("discovery_errors")
893
+ log.warning(
894
+ "discovery call failed for %s chunk %s: %s", document.id, chunk.seq, error
895
+ )
896
+ report.warnings.append(f"{document.id}: chunk {chunk.seq}: discovery: {error}")
897
+ continue
898
+ if result.skipped:
899
+ report.count("discovery_chunks_over_budget")
900
+ continue
901
+ report.count("discovery_calls", 0 if result.cached else 1)
902
+ report.count("discovery_cache_hits", 1 if result.cached else 0)
903
+ spend(result.usage)
904
+ for reason, count in result.discarded.items():
905
+ report.count(f"discovery_discarded_{reason}", count)
906
+ discovery.extend(result.observations)
907
+
908
+ async def _current(self, domain_id: str, branch: str, found: _Found) -> CurrentState:
909
+ keys: dict[str, set[str]] = defaultdict(set)
910
+ for item in [
911
+ *found.mentions,
912
+ *(v.subject for v in found.values),
913
+ *(side for link in found.links for side in (link.source, link.target)),
914
+ ]:
915
+ if item.usable and item.resolution.status != "new":
916
+ keys[item.kind_id].add(str(item.key))
917
+ pairs: dict[str, set[tuple[str, str]]] = defaultdict(set)
918
+ for link in found.links:
919
+ if link.source.usable and link.target.usable:
920
+ pairs[link.relation_id].add((str(link.source.key), str(link.target.key)))
921
+ records: dict[tuple[str, str], dict[str, Any]] = {}
922
+ links: set[tuple[str, str, str]] = set()
923
+ writes = self._runtime.settings.writes
924
+ async with self._sessions() as session, session.begin():
925
+ await scope_to_domain(session, domain_id)
926
+ rows = MaterializedRepository(session, domain_id, branch, writes=writes)
927
+ for kind_id, kind_keys in keys.items():
928
+ for key, row in (await rows.records(kind_id, kind_keys)).items():
929
+ records[(kind_id, key)] = dict(row.values)
930
+ for relation_id, relation_pairs in pairs.items():
931
+ for pair in await rows.links(relation_id, relation_pairs):
932
+ links.add((relation_id, *pair))
933
+ return CurrentState(records=records, links=frozenset(links))
934
+
935
+ async def _vaulted(self, discovery: Discovery, domain_id: str) -> set[str]:
936
+ """The evidence texts of `discovery` to withhold: those that hold a vault value of any
937
+ Kind of the Domain (compared by keyed fingerprint; nothing is decrypted) and those too
938
+ large to compare. Without vault keys nothing can be compared: a Domain that holds vault
939
+ values then loses the evidence of every observation."""
940
+ keys = self._runtime.vault
941
+ texts = discovery.evidence_texts()
942
+ if not texts:
943
+ return set()
944
+ settings = self._settings.discovery
945
+ async with self._sessions() as session, session.begin():
946
+ await scope_to_domain(session, domain_id)
947
+ if keys is None:
948
+ return set(texts) if await has_vault_values(session, domain_id) else set()
949
+ hits = await quoted_values(
950
+ session,
951
+ keys,
952
+ domain_id,
953
+ discovery.schema,
954
+ texts,
955
+ QuoteLimits(
956
+ max_words=settings.vault_match_max_words,
957
+ max_text_words=settings.vault_match_max_text_words,
958
+ max_text_chars=settings.vault_match_max_text_chars,
959
+ max_candidates=settings.vault_match_max_candidates,
960
+ ),
961
+ )
962
+ return {texts[index] for index in hits}
963
+
964
+ async def _suggest(
965
+ self, discovery: Discovery, current: CurrentState, report: ExtractionReport
966
+ ) -> None:
967
+ """Fold the job's observations into the Domain's schema suggestions."""
968
+ vaulted = await self._vaulted(discovery, report.domain)
969
+ observations = discovery.finish(current.records, vaulted)
970
+ for name, count in discovery.stats.items():
971
+ report.count(name, count)
972
+ if discovery.evidence_withheld:
973
+ EVIDENCE_WITHHELD.add(discovery.evidence_withheld)
974
+ if not observations:
975
+ return
976
+ async with self._sessions() as session, session.begin():
977
+ await scope_to_domain(session, report.domain)
978
+ outcome = await SchemaSuggestionRepository(session, report.domain).record(
979
+ observations,
980
+ job_id=report.job_id,
981
+ options=self._runtime.schema.suggestions,
982
+ now=self._clock(),
983
+ new_id=lambda: f"{SUGGESTION_PREFIX}_{new_ulid()}",
984
+ )
985
+ report.count("suggestions_created", outcome.created)
986
+ report.count("suggestions_updated", outcome.updated)
987
+ if outcome.dropped:
988
+ report.count("suggestion_observations_untracked", outcome.dropped)
989
+
990
+ # Proposal ---------------------------------------------------------------------------
991
+
992
+ async def _propose(
993
+ self,
994
+ domain: Any,
995
+ schema: Schema,
996
+ documents: Sequence[DocumentRow],
997
+ items: list[ItemRow],
998
+ report: ExtractionReport,
999
+ ) -> None:
1000
+ policy = domain_merge_policy(domain)
1001
+ context = MaterializeContext(
1002
+ domain_id=domain.id,
1003
+ branch=report.branch,
1004
+ target=report.target,
1005
+ job_id=report.job_id,
1006
+ prompt_version=self._settings.prompt_version,
1007
+ actor=self._principal,
1008
+ runtime=self._runtime,
1009
+ clock=self._clock,
1010
+ )
1011
+ async with self._sessions() as session, session.begin():
1012
+ await scope_to_domain(session, domain.id)
1013
+ await create_branch(
1014
+ session,
1015
+ domain.id,
1016
+ report.branch,
1017
+ created_by=self._principal,
1018
+ source=report.target,
1019
+ runtime=self._runtime,
1020
+ clock=self._clock,
1021
+ )
1022
+ now = self._clock()
1023
+ proposal = ProposalRow(
1024
+ id=f"{PROPOSAL_PREFIX}_{new_ulid()}",
1025
+ title=f"Extract {report.job_id}",
1026
+ description=None,
1027
+ source_ref=report.branch,
1028
+ target_ref=report.target,
1029
+ origin=ProposalOrigin.EXTRACTION,
1030
+ status=ProposalStatus.OPEN,
1031
+ author=self._principal,
1032
+ created_at=now,
1033
+ checks={},
1034
+ policies={POLICY_KEY: policy.model_dump(mode="json", exclude_defaults=True)},
1035
+ idempotency_key=report.job_id,
1036
+ updated_at=now,
1037
+ )
1038
+ await ProposalRepository(session, domain.id).insert(proposal)
1039
+ repository = ProposalItemRepository(session, domain.id)
1040
+ await repository.insert(proposal.id, items)
1041
+ outcome = await self._materialize(session, context, schema, proposal, items, report)
1042
+ stored = await repository.all(proposal.id)
1043
+ await refresh_proposal(
1044
+ session,
1045
+ context,
1046
+ proposal,
1047
+ stored,
1048
+ schema,
1049
+ outcome,
1050
+ documents=[d.filename for d in documents],
1051
+ policy=policy,
1052
+ )
1053
+ await ExtractionJobRepository(session, domain.id).update(
1054
+ report.job_id, proposal_id=proposal.id
1055
+ )
1056
+ report.proposal_id = proposal.id
1057
+ report.accepted = sum(1 for i in stored if i.decision is ItemDecision.ACCEPTED)
1058
+ report.pending = sum(1 for i in stored if i.decision is ItemDecision.PENDING)
1059
+ report.materialized = outcome.as_dict()
1060
+
1061
+ async def _materialize(
1062
+ self,
1063
+ session: AsyncSession,
1064
+ context: MaterializeContext,
1065
+ schema: Schema,
1066
+ proposal: ProposalRow,
1067
+ items: list[ItemRow],
1068
+ report: ExtractionReport,
1069
+ ) -> Materialized:
1070
+ """Commit the items accepted at once; when the branch rejects them, leave them pending
1071
+ for review instead."""
1072
+ if not any(i.decision is ItemDecision.ACCEPTED for i in items):
1073
+ return Materialized()
1074
+ message = f"Extraction {context.job_id}: items accepted at once"
1075
+ try:
1076
+ async with session.begin_nested():
1077
+ return await materialize_items(session, context, items, schema, message=message)
1078
+ except ValidationFailedError as error:
1079
+ report.warnings.append(f"items accepted at once were left pending: {error}")
1080
+ repository = ProposalItemRepository(session, context.domain_id)
1081
+ for item in items:
1082
+ if item.decision is ItemDecision.ACCEPTED:
1083
+ await repository.decide(
1084
+ proposal.id,
1085
+ item.item_id,
1086
+ decision=ItemDecision.PENDING,
1087
+ decided_by=None,
1088
+ decided_at=None,
1089
+ edits=None,
1090
+ )
1091
+ return Materialized()