lorebase-scribe 0.2.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lorebase_scribe/__init__.py +3 -0
- lorebase_scribe/__main__.py +156 -0
- lorebase_scribe/checkpoints.py +324 -0
- lorebase_scribe/cron.py +117 -0
- lorebase_scribe/embeddings.py +570 -0
- lorebase_scribe/extraction/__init__.py +17 -0
- lorebase_scribe/extraction/assemble.py +339 -0
- lorebase_scribe/extraction/candidates.py +136 -0
- lorebase_scribe/extraction/discovery.py +567 -0
- lorebase_scribe/extraction/llm_discovery.py +271 -0
- lorebase_scribe/extraction/matching.py +285 -0
- lorebase_scribe/extraction/ontology.py +432 -0
- lorebase_scribe/extraction/parsing.py +114 -0
- lorebase_scribe/extraction/pipeline.py +1091 -0
- lorebase_scribe/extraction/relations.py +194 -0
- lorebase_scribe/extraction/sandbox.py +269 -0
- lorebase_scribe/extraction/settings.py +293 -0
- lorebase_scribe/extraction/text.py +179 -0
- lorebase_scribe/extraction/tier2.py +573 -0
- lorebase_scribe/extraction/values.py +282 -0
- lorebase_scribe/jobs.py +418 -0
- lorebase_scribe/scheduler.py +318 -0
- lorebase_scribe/service.py +146 -0
- lorebase_scribe/settings.py +521 -0
- lorebase_scribe/sources/__init__.py +60 -0
- lorebase_scribe/sources/access.py +241 -0
- lorebase_scribe/sources/connectors.py +320 -0
- lorebase_scribe/sources/delta.py +175 -0
- lorebase_scribe/sources/http_json.py +283 -0
- lorebase_scribe/sources/mapping.py +429 -0
- lorebase_scribe/sources/profile.py +36 -0
- lorebase_scribe/sources/records.py +298 -0
- lorebase_scribe/sources/sql.py +332 -0
- lorebase_scribe/sources/sync.py +730 -0
- lorebase_scribe/telemetry.py +113 -0
- lorebase_scribe/webhooks.py +99 -0
- lorebase_scribe/worker.py +254 -0
- lorebase_scribe-0.2.3.dist-info/METADATA +23 -0
- lorebase_scribe-0.2.3.dist-info/RECORD +41 -0
- lorebase_scribe-0.2.3.dist-info/WHEEL +4 -0
- lorebase_scribe-0.2.3.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,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()
|