relvector 0.1.0__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.
- relvector/__init__.py +48 -0
- relvector/_version.py +1 -0
- relvector/api.py +187 -0
- relvector/embeddings.py +146 -0
- relvector/errors.py +31 -0
- relvector/extraction.py +141 -0
- relvector/indexing.py +138 -0
- relvector/integrations/__init__.py +1 -0
- relvector/integrations/sqlguard.py +33 -0
- relvector/mapping.py +405 -0
- relvector/py.typed +0 -0
- relvector/schema.py +130 -0
- relvector/store.py +410 -0
- relvector-0.1.0.dist-info/METADATA +189 -0
- relvector-0.1.0.dist-info/RECORD +18 -0
- relvector-0.1.0.dist-info/WHEEL +4 -0
- relvector-0.1.0.dist-info/licenses/LICENSE +202 -0
- relvector-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +54 -0
relvector/indexing.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""The indexing pipeline: extract a batch -> build records -> embed changed ones -> write.
|
|
2
|
+
|
|
3
|
+
Recovery model:
|
|
4
|
+
|
|
5
|
+
* Each batch is written in one store transaction together with its checkpoint, so a crash
|
|
6
|
+
leaves the index at a batch boundary. ``resume=True`` continues an unfinished run from its
|
|
7
|
+
last committed key; re-processing a batch is harmless because writes are upserts keyed by a
|
|
8
|
+
deterministic vector id. Resuming requires the mapping to be unchanged. Checkpoint keys keep
|
|
9
|
+
their types (dates, UUIDs, decimals), so a resumed scan compares them as the column does.
|
|
10
|
+
* Starting a run takes over the entity's checkpoint. An older run that is still going (another
|
|
11
|
+
process, or one that was abandoned) fails with ``RunSupersededError`` at its next batch and
|
|
12
|
+
never prunes; the newest run is the one that completes.
|
|
13
|
+
* Each ``run()`` call is a writer with its own owner token. Resuming a run takes it over, so
|
|
14
|
+
if two processes resume the same run, the one that resumed first is rejected with
|
|
15
|
+
``RunSupersededError`` at its next batch. There is never more than one writer that can
|
|
16
|
+
advance the checkpoint or prune.
|
|
17
|
+
* Records whose content hash is unchanged are not re-embedded, only marked as seen.
|
|
18
|
+
* When a run finishes, records of the entity that the run did not see are deleted (the source
|
|
19
|
+
rows were deleted or no longer map). Interrupted runs never prune.
|
|
20
|
+
|
|
21
|
+
This is a full re-scan ("manual re-indexing"); it is not change-data-capture.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import time
|
|
27
|
+
import uuid
|
|
28
|
+
from collections.abc import Callable
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
|
|
31
|
+
import numpy as np
|
|
32
|
+
|
|
33
|
+
from relvector.embeddings import EmbeddingProvider
|
|
34
|
+
from relvector.errors import RelVectorError
|
|
35
|
+
from relvector.extraction import Extractor
|
|
36
|
+
from relvector.mapping import build_record
|
|
37
|
+
from relvector.store import Checkpoint, VectorStore
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class IndexStats:
|
|
42
|
+
run_id: str
|
|
43
|
+
rows: int = 0
|
|
44
|
+
embedded: int = 0
|
|
45
|
+
unchanged: int = 0
|
|
46
|
+
deleted: int = 0
|
|
47
|
+
batches: int = 0
|
|
48
|
+
truncated_records: int = 0
|
|
49
|
+
resumed: bool = False
|
|
50
|
+
timings: dict[str, float] = field(
|
|
51
|
+
default_factory=lambda: {"extract": 0.0, "embed": 0.0, "store": 0.0})
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def rows_per_second(self) -> float:
|
|
55
|
+
total = sum(self.timings.values())
|
|
56
|
+
return self.rows / total if total else 0.0
|
|
57
|
+
|
|
58
|
+
def __str__(self) -> str:
|
|
59
|
+
t = ", ".join(f"{k} {v:.2f}s" for k, v in self.timings.items())
|
|
60
|
+
return (f"{self.rows} rows in {self.batches} batches: {self.embedded} embedded, "
|
|
61
|
+
f"{self.unchanged} unchanged, {self.deleted} deleted ({t}; "
|
|
62
|
+
f"{self.rows_per_second:.0f} rows/s)")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Indexer:
|
|
66
|
+
def __init__(self, extractor: Extractor, provider: EmbeddingProvider,
|
|
67
|
+
store: VectorStore) -> None:
|
|
68
|
+
self.extractor = extractor
|
|
69
|
+
self.provider = provider
|
|
70
|
+
self.store = store
|
|
71
|
+
|
|
72
|
+
def run(
|
|
73
|
+
self,
|
|
74
|
+
batch_size: int = 500,
|
|
75
|
+
*,
|
|
76
|
+
resume: bool = False,
|
|
77
|
+
prune: bool = True,
|
|
78
|
+
progress: Callable[[IndexStats], None] | None = None,
|
|
79
|
+
) -> IndexStats:
|
|
80
|
+
resolved = self.extractor.resolved
|
|
81
|
+
entity = resolved.entity
|
|
82
|
+
self.store.ensure_compatible(self.provider.model_id, self.provider.dimension)
|
|
83
|
+
|
|
84
|
+
owner = uuid.uuid4().hex
|
|
85
|
+
previous = self.store.checkpoint(entity)
|
|
86
|
+
if resume and previous is not None and previous.status == "running":
|
|
87
|
+
if previous.fingerprint != resolved.fingerprint:
|
|
88
|
+
raise RelVectorError(
|
|
89
|
+
f"cannot resume run {previous.run_id} for {entity!r}: the mapping changed "
|
|
90
|
+
"since it started, so resuming would mix old and new documents; run "
|
|
91
|
+
"index() without resume=True for a full re-index"
|
|
92
|
+
)
|
|
93
|
+
stats = IndexStats(run_id=previous.run_id, rows=previous.processed, resumed=True)
|
|
94
|
+
after = previous.last_key
|
|
95
|
+
self.store.resume_run(Checkpoint(entity, stats.run_id, after, "running",
|
|
96
|
+
stats.rows, resolved.fingerprint, owner))
|
|
97
|
+
else:
|
|
98
|
+
stats = IndexStats(run_id=uuid.uuid4().hex)
|
|
99
|
+
after = None
|
|
100
|
+
self.store.start_run(Checkpoint(entity, stats.run_id, None, "running", 0,
|
|
101
|
+
resolved.fingerprint, owner))
|
|
102
|
+
|
|
103
|
+
batches = self.extractor.iter_batches(batch_size, after)
|
|
104
|
+
while True:
|
|
105
|
+
t0 = time.perf_counter()
|
|
106
|
+
batch = next(batches, None)
|
|
107
|
+
t1 = time.perf_counter()
|
|
108
|
+
stats.timings["extract"] += t1 - t0
|
|
109
|
+
if batch is None:
|
|
110
|
+
break
|
|
111
|
+
records = [build_record(resolved, b.row, b.related) for b in batch.bundles]
|
|
112
|
+
known = self.store.content_hashes([r.vector_id for r in records])
|
|
113
|
+
changed = [r for r in records if known.get(r.vector_id) != r.content_hash]
|
|
114
|
+
unchanged = [r.vector_id for r in records if known.get(r.vector_id) == r.content_hash]
|
|
115
|
+
t2 = time.perf_counter()
|
|
116
|
+
stats.timings["extract"] += t2 - t1 # record building is part of extraction
|
|
117
|
+
vectors = (self.provider.embed([r.text for r in changed]) if changed
|
|
118
|
+
else np.zeros((0, self.provider.dimension), dtype=np.float32))
|
|
119
|
+
t3 = time.perf_counter()
|
|
120
|
+
stats.timings["embed"] += t3 - t2
|
|
121
|
+
stats.rows += len(records)
|
|
122
|
+
stats.embedded += len(changed)
|
|
123
|
+
stats.unchanged += len(unchanged)
|
|
124
|
+
stats.batches += 1
|
|
125
|
+
stats.truncated_records += sum(1 for r in records if r.truncated)
|
|
126
|
+
self.store.write_batch(changed, vectors, unchanged,
|
|
127
|
+
Checkpoint(entity, stats.run_id, batch.last_key, "running",
|
|
128
|
+
stats.rows, resolved.fingerprint, owner))
|
|
129
|
+
stats.timings["store"] += time.perf_counter() - t3
|
|
130
|
+
if progress is not None:
|
|
131
|
+
progress(stats)
|
|
132
|
+
|
|
133
|
+
t4 = time.perf_counter()
|
|
134
|
+
stats.deleted = self.store.finish_run(
|
|
135
|
+
Checkpoint(entity, stats.run_id, None, "running", stats.rows, resolved.fingerprint,
|
|
136
|
+
owner), prune=prune)
|
|
137
|
+
stats.timings["store"] += time.perf_counter() - t4
|
|
138
|
+
return stats
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Optional integrations with other projects (each imports its dependency lazily)."""
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Optional SQLGuard Ninja integration: validate every extraction query against a policy.
|
|
2
|
+
|
|
3
|
+
``pip install sqlguard-ninja``. Only the public API (``Guard.validate``) is used. A denial (or
|
|
4
|
+
an approval that requires human review) blocks extraction with :class:`QueryRejectedError`.
|
|
5
|
+
|
|
6
|
+
SQLGuard approval means the *query* is permitted for the role; it is not permission to embed
|
|
7
|
+
and store the returned values. Sensitive-field authorisation remains the mapping's job
|
|
8
|
+
(``exclude_fields`` / ``allow_sensitive``).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from relvector.errors import QueryRejectedError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SQLGuardValidator:
|
|
20
|
+
"""Callable for ``RelVector(sql_validator=...)``."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, guard: Any, *, role: str | None = None, dialect: str | None = None,
|
|
23
|
+
context: Mapping[str, Any] | None = None) -> None:
|
|
24
|
+
self.guard = guard
|
|
25
|
+
self.role = role
|
|
26
|
+
self.dialect = dialect
|
|
27
|
+
self.context = dict(context or {})
|
|
28
|
+
|
|
29
|
+
def __call__(self, sql: str) -> None:
|
|
30
|
+
result = self.guard.validate(sql, role=self.role, dialect=self.dialect,
|
|
31
|
+
context=self.context)
|
|
32
|
+
if not result.allowed:
|
|
33
|
+
raise QueryRejectedError(f"SQLGuard blocked an extraction query: {result.explain()}")
|
relvector/mapping.py
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""Entity mappings: which relational data becomes one semantic record, and how it is worded.
|
|
2
|
+
|
|
3
|
+
A mapping names a root table (one semantic record per root row), directly related tables,
|
|
4
|
+
and the exact columns that may be embedded (``text_fields``) or stored alongside the vector
|
|
5
|
+
(``metadata_fields``). Columns that are not listed are never selected from the database.
|
|
6
|
+
|
|
7
|
+
Text is produced by deterministic templates, never by a language model, so the same rows and
|
|
8
|
+
the same mapping always produce byte-identical text and the same content hash.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import datetime as _dt
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import math
|
|
17
|
+
import re
|
|
18
|
+
import string
|
|
19
|
+
from collections.abc import Mapping, Sequence
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from decimal import Decimal
|
|
22
|
+
from typing import Any, Literal
|
|
23
|
+
|
|
24
|
+
from relvector.errors import MappingError, SensitiveFieldError
|
|
25
|
+
from relvector.schema import Schema, TableInfo
|
|
26
|
+
|
|
27
|
+
NULL_TEXT = "not recorded"
|
|
28
|
+
|
|
29
|
+
# Adapted from sqlguard_ninja.privacy.suggest (Apache-2.0, same authors). Name heuristics are
|
|
30
|
+
# unreliable, so they only *block* by default; a data owner authorises via ``allow_sensitive``.
|
|
31
|
+
_SENSITIVE_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
|
32
|
+
("payment_card", re.compile(r"(card_?(number|no|num)|\bpan\b|cc_?num|cvv)")),
|
|
33
|
+
("financial", re.compile(r"(iban|bank|account_?number|salary|wage|income)")),
|
|
34
|
+
("government_id", re.compile(r"(ssn|national_?id|passport|tax_?id|\bnin\b)")),
|
|
35
|
+
("contact", re.compile(r"(e_?mail|phone|mobile|address|postcode|zip)")),
|
|
36
|
+
("personal_identifier", re.compile(r"(full_?name|first_?name|last_?name|birth|\bdob\b)")),
|
|
37
|
+
("special_category", re.compile(r"(health|medical|diagnos|religio|ethnic|biometric)")),
|
|
38
|
+
("credentials", re.compile(r"(password|passwd|secret|token|api_?key)")),
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def sensitive_label(column: str) -> str | None:
|
|
43
|
+
"""Return a sensitivity label if the column *name* looks sensitive, else ``None``."""
|
|
44
|
+
name = column.lower()
|
|
45
|
+
for label, pattern in _SENSITIVE_PATTERNS:
|
|
46
|
+
if pattern.search(name):
|
|
47
|
+
return label
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class Include:
|
|
53
|
+
"""A table directly related to the root table.
|
|
54
|
+
|
|
55
|
+
``via`` makes the join explicit as ``("root.col", "related.col")``; without it the single
|
|
56
|
+
declared foreign key between the two tables is used.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
table: str
|
|
60
|
+
via: tuple[str, str] | None = None
|
|
61
|
+
max_items: int | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class EntityMapping:
|
|
66
|
+
root_table: str
|
|
67
|
+
text_fields: Sequence[str]
|
|
68
|
+
include: Sequence[str | Include] = ()
|
|
69
|
+
metadata_fields: Sequence[str] = ()
|
|
70
|
+
exclude_fields: Sequence[str] = ()
|
|
71
|
+
allow_sensitive: Sequence[str] = ()
|
|
72
|
+
templates: Mapping[str, str] = field(default_factory=dict)
|
|
73
|
+
labels: Mapping[str, str] = field(default_factory=dict)
|
|
74
|
+
name: str | None = None
|
|
75
|
+
version: str = "1"
|
|
76
|
+
max_children: int = 20
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def entity(self) -> str:
|
|
80
|
+
return self.name or self.root_table
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class Relation:
|
|
85
|
+
table: str
|
|
86
|
+
kind: Literal["many_to_one", "one_to_many"]
|
|
87
|
+
root_column: str
|
|
88
|
+
related_column: str
|
|
89
|
+
primary_key: tuple[str, ...]
|
|
90
|
+
text_columns: tuple[str, ...]
|
|
91
|
+
metadata_columns: tuple[str, ...]
|
|
92
|
+
max_items: int
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def select_columns(self) -> tuple[str, ...]:
|
|
96
|
+
return _unique((self.related_column, *self.primary_key, *self.text_columns,
|
|
97
|
+
*self.metadata_columns))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class ResolvedMapping:
|
|
102
|
+
"""A mapping validated against a concrete schema."""
|
|
103
|
+
|
|
104
|
+
spec: EntityMapping
|
|
105
|
+
primary_key: tuple[str, ...]
|
|
106
|
+
text_columns: tuple[str, ...]
|
|
107
|
+
metadata_columns: tuple[str, ...]
|
|
108
|
+
relations: tuple[Relation, ...]
|
|
109
|
+
fingerprint: str
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def entity(self) -> str:
|
|
113
|
+
return self.spec.entity
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def root_table(self) -> str:
|
|
117
|
+
return self.spec.root_table
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def root_select_columns(self) -> tuple[str, ...]:
|
|
121
|
+
return _unique((*self.primary_key, *self.text_columns, *self.metadata_columns,
|
|
122
|
+
*(r.root_column for r in self.relations)))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class SemanticRecord:
|
|
127
|
+
vector_id: str
|
|
128
|
+
entity: str
|
|
129
|
+
source_table: str
|
|
130
|
+
source_pk: dict[str, Any]
|
|
131
|
+
text: str
|
|
132
|
+
metadata: dict[str, Any]
|
|
133
|
+
references: list[dict[str, Any]]
|
|
134
|
+
truncated: dict[str, int]
|
|
135
|
+
mapping_version: str
|
|
136
|
+
content_hash: str
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _unique(items: Sequence[str]) -> tuple[str, ...]:
|
|
140
|
+
return tuple(dict.fromkeys(items))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _split(ref: str, root: str) -> tuple[str, str]:
|
|
144
|
+
if "." in ref:
|
|
145
|
+
table, _, column = ref.partition(".")
|
|
146
|
+
if not table or not column or "." in column:
|
|
147
|
+
raise MappingError(f"field {ref!r} must be 'table.column'")
|
|
148
|
+
return table, column
|
|
149
|
+
return root, ref
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def resolve(spec: EntityMapping, schema: Schema) -> ResolvedMapping:
|
|
153
|
+
"""Validate ``spec`` against ``schema``. Raises :class:`MappingError` with a clear reason."""
|
|
154
|
+
if not spec.text_fields:
|
|
155
|
+
raise MappingError("text_fields must name at least one column to embed")
|
|
156
|
+
if spec.max_children < 1:
|
|
157
|
+
raise MappingError("max_children must be >= 1")
|
|
158
|
+
root = schema.table(spec.root_table)
|
|
159
|
+
if not root.primary_key:
|
|
160
|
+
raise MappingError(f"root table {root.name!r} has no primary key; a stable identity "
|
|
161
|
+
"is required for idempotent indexing")
|
|
162
|
+
|
|
163
|
+
includes = [Include(i) if isinstance(i, str) else i for i in spec.include]
|
|
164
|
+
tables = {root.name: root}
|
|
165
|
+
for inc in includes:
|
|
166
|
+
if inc.table == root.name:
|
|
167
|
+
raise MappingError("self-referencing includes are not supported in this version")
|
|
168
|
+
if inc.table in tables:
|
|
169
|
+
raise MappingError(f"table {inc.table!r} is included more than once")
|
|
170
|
+
tables[inc.table] = schema.table(inc.table)
|
|
171
|
+
|
|
172
|
+
excluded = {_split(f, root.name) for f in spec.exclude_fields}
|
|
173
|
+
allowed_sensitive = {_split(f, root.name) for f in spec.allow_sensitive}
|
|
174
|
+
|
|
175
|
+
def fields(refs: Sequence[str], kind: str) -> dict[str, list[str]]:
|
|
176
|
+
out: dict[str, list[str]] = {t: [] for t in tables}
|
|
177
|
+
for ref in refs:
|
|
178
|
+
table, column = _split(ref, root.name)
|
|
179
|
+
if table not in tables:
|
|
180
|
+
raise MappingError(f"{kind} field {ref!r} uses table {table!r}, which is neither "
|
|
181
|
+
"the root table nor an included table")
|
|
182
|
+
tables[table].column(column)
|
|
183
|
+
if (table, column) in excluded:
|
|
184
|
+
raise MappingError(f"{kind} field {table}.{column} is also in exclude_fields")
|
|
185
|
+
label = sensitive_label(column)
|
|
186
|
+
if label and (table, column) not in allowed_sensitive:
|
|
187
|
+
raise SensitiveFieldError(
|
|
188
|
+
f"{kind} field {table}.{column} looks sensitive ({label}); exclude it or "
|
|
189
|
+
"list it in allow_sensitive after confirming it may be embedded and stored"
|
|
190
|
+
)
|
|
191
|
+
if column not in out[table]:
|
|
192
|
+
out[table].append(column)
|
|
193
|
+
return out
|
|
194
|
+
|
|
195
|
+
text = fields(spec.text_fields, "text")
|
|
196
|
+
meta = fields(spec.metadata_fields, "metadata")
|
|
197
|
+
|
|
198
|
+
for tname, template in spec.templates.items():
|
|
199
|
+
if tname not in tables:
|
|
200
|
+
raise MappingError(f"template given for table {tname!r}, which is not mapped")
|
|
201
|
+
allowed = {*text[tname], *tables[tname].primary_key}
|
|
202
|
+
for name in _template_fields(template):
|
|
203
|
+
if name not in allowed:
|
|
204
|
+
raise MappingError(f"template for {tname!r} uses {{{name}}}; templates may only "
|
|
205
|
+
"use that table's text_fields and primary key")
|
|
206
|
+
|
|
207
|
+
relations = tuple(
|
|
208
|
+
_resolve_relation(inc, root, tables[inc.table], schema, text[inc.table],
|
|
209
|
+
meta[inc.table], spec.max_children)
|
|
210
|
+
for inc in includes
|
|
211
|
+
)
|
|
212
|
+
resolved_payload = {
|
|
213
|
+
"entity": spec.entity, "root": root.name, "version": spec.version,
|
|
214
|
+
"text": text, "meta": meta, "templates": dict(sorted(spec.templates.items())),
|
|
215
|
+
"labels": dict(sorted(spec.labels.items())),
|
|
216
|
+
"relations": [r.__dict__ for r in relations],
|
|
217
|
+
}
|
|
218
|
+
fingerprint = hashlib.sha256(
|
|
219
|
+
json.dumps(resolved_payload, sort_keys=True, default=list).encode()
|
|
220
|
+
).hexdigest()[:16]
|
|
221
|
+
return ResolvedMapping(
|
|
222
|
+
spec=spec,
|
|
223
|
+
primary_key=root.primary_key,
|
|
224
|
+
text_columns=tuple(text[root.name]),
|
|
225
|
+
metadata_columns=tuple(meta[root.name]),
|
|
226
|
+
relations=relations,
|
|
227
|
+
fingerprint=fingerprint,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _resolve_relation(
|
|
232
|
+
inc: Include, root: TableInfo, related: TableInfo, schema: Schema,
|
|
233
|
+
text: list[str], meta: list[str], default_max: int,
|
|
234
|
+
) -> Relation:
|
|
235
|
+
if inc.via is not None:
|
|
236
|
+
(lt, lc), (rt, rc) = (_split(inc.via[0], root.name), _split(inc.via[1], related.name))
|
|
237
|
+
if lt != root.name or rt != related.name:
|
|
238
|
+
raise MappingError(f"via for {related.name!r} must be ('{root.name}.col', "
|
|
239
|
+
f"'{related.name}.col'), got {inc.via!r}")
|
|
240
|
+
root.column(lc)
|
|
241
|
+
related.column(rc)
|
|
242
|
+
root_col, rel_col = lc, rc
|
|
243
|
+
kind: Literal["many_to_one", "one_to_many"] = (
|
|
244
|
+
"many_to_one" if related.primary_key == (rc,) else "one_to_many"
|
|
245
|
+
)
|
|
246
|
+
else:
|
|
247
|
+
fks = [fk for fk in schema.relationships_between(root.name, related.name)
|
|
248
|
+
if not fk.composite]
|
|
249
|
+
if not fks:
|
|
250
|
+
raise MappingError(
|
|
251
|
+
f"no single-column foreign key links {root.name!r} and {related.name!r}; "
|
|
252
|
+
"declare one in the database or pass Include(..., via=(root_col, related_col))"
|
|
253
|
+
)
|
|
254
|
+
if len(fks) > 1:
|
|
255
|
+
listed = "; ".join(str(fk) for fk in fks)
|
|
256
|
+
raise MappingError(f"ambiguous relationship between {root.name!r} and "
|
|
257
|
+
f"{related.name!r} ({listed}); pass Include(..., via=...)")
|
|
258
|
+
fk = fks[0]
|
|
259
|
+
if fk.table == root.name:
|
|
260
|
+
kind, root_col, rel_col = "many_to_one", fk.columns[0], fk.ref_columns[0]
|
|
261
|
+
else:
|
|
262
|
+
kind, root_col, rel_col = "one_to_many", fk.ref_columns[0], fk.columns[0]
|
|
263
|
+
if not related.primary_key:
|
|
264
|
+
raise MappingError(f"included table {related.name!r} has no primary key; it is needed "
|
|
265
|
+
"for deterministic ordering and source references")
|
|
266
|
+
if not text and not meta:
|
|
267
|
+
raise MappingError(f"included table {related.name!r} contributes no text or metadata "
|
|
268
|
+
"fields")
|
|
269
|
+
max_items = inc.max_items if inc.max_items is not None else default_max
|
|
270
|
+
if max_items < 1:
|
|
271
|
+
raise MappingError("max_items must be >= 1")
|
|
272
|
+
return Relation(
|
|
273
|
+
table=related.name, kind=kind, root_column=root_col, related_column=rel_col,
|
|
274
|
+
primary_key=related.primary_key, text_columns=tuple(text),
|
|
275
|
+
metadata_columns=tuple(meta), max_items=max_items,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# --- deterministic serialisation ------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _template_fields(template: str) -> list[str]:
|
|
283
|
+
names: list[str] = []
|
|
284
|
+
for _, name, spec, conv in string.Formatter().parse(template):
|
|
285
|
+
if name is None:
|
|
286
|
+
continue
|
|
287
|
+
if not name.isidentifier() or spec or conv:
|
|
288
|
+
raise MappingError(f"template placeholder {{{name}}} must be a plain column name "
|
|
289
|
+
"(no attributes, indexes, conversions or format specs)")
|
|
290
|
+
names.append(name)
|
|
291
|
+
return names
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def jsonable(value: Any) -> Any:
|
|
295
|
+
if value is None or isinstance(value, bool | int | str):
|
|
296
|
+
return value
|
|
297
|
+
if isinstance(value, float):
|
|
298
|
+
return None if math.isnan(value) else value # NaN is not valid JSON
|
|
299
|
+
if isinstance(value, Decimal):
|
|
300
|
+
return str(value)
|
|
301
|
+
if isinstance(value, _dt.date | _dt.time):
|
|
302
|
+
return value.isoformat()
|
|
303
|
+
if isinstance(value, bytes | bytearray | memoryview):
|
|
304
|
+
return f"<{len(bytes(value))} bytes>"
|
|
305
|
+
return str(value)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _text(value: Any) -> str:
|
|
309
|
+
if value is None:
|
|
310
|
+
return NULL_TEXT
|
|
311
|
+
if isinstance(value, bool):
|
|
312
|
+
return "yes" if value else "no"
|
|
313
|
+
if isinstance(value, bytes | bytearray | memoryview):
|
|
314
|
+
return "[binary value omitted]"
|
|
315
|
+
return " ".join(str(jsonable(value)).split())
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _humanize(column: str) -> str:
|
|
319
|
+
return column.replace("_", " ").strip().capitalize()
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _singular(table: str) -> str:
|
|
323
|
+
word = table.replace("_", " ")
|
|
324
|
+
if word.endswith("ies"):
|
|
325
|
+
word = word[:-3] + "y"
|
|
326
|
+
elif word.endswith("s") and not word.endswith("ss"):
|
|
327
|
+
word = word[:-1]
|
|
328
|
+
return word.capitalize()
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _key_text(row: Mapping[str, Any], pk: Sequence[str]) -> str:
|
|
332
|
+
return "/".join(_text(row[c]) for c in pk)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _render(row: Mapping[str, Any], table: str, pk: Sequence[str], columns: Sequence[str],
|
|
336
|
+
spec: EntityMapping) -> str:
|
|
337
|
+
template = spec.templates.get(table)
|
|
338
|
+
if template is not None:
|
|
339
|
+
names = _template_fields(template)
|
|
340
|
+
# A null would make a template read as a false statement; fall back to explicit text.
|
|
341
|
+
if all(row[n] is not None for n in names):
|
|
342
|
+
return " ".join(template.format(**{n: _text(row[n]) for n in names}).split())
|
|
343
|
+
label = spec.labels.get(table, _singular(table))
|
|
344
|
+
parts = [f"{label} {_key_text(row, pk)}."]
|
|
345
|
+
parts.extend(f"{_humanize(c)}: {_text(row[c])}." for c in columns)
|
|
346
|
+
return " ".join(parts)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def build_record(
|
|
350
|
+
resolved: ResolvedMapping,
|
|
351
|
+
row: Mapping[str, Any],
|
|
352
|
+
related: Mapping[str, tuple[list[Mapping[str, Any]], int]],
|
|
353
|
+
) -> SemanticRecord:
|
|
354
|
+
"""Serialise one root row plus its related rows. ``related[table] = (rows, total_count)``."""
|
|
355
|
+
spec = resolved.spec
|
|
356
|
+
pk = {c: jsonable(row[c]) for c in resolved.primary_key}
|
|
357
|
+
sentences = [_render(row, spec.root_table, resolved.primary_key,
|
|
358
|
+
resolved.text_columns, spec)]
|
|
359
|
+
metadata: dict[str, Any] = {f"{spec.root_table}.{c}": jsonable(row[c])
|
|
360
|
+
for c in resolved.metadata_columns}
|
|
361
|
+
references: list[dict[str, Any]] = []
|
|
362
|
+
truncated: dict[str, int] = {}
|
|
363
|
+
|
|
364
|
+
for rel in resolved.relations:
|
|
365
|
+
rows, total = related.get(rel.table, ([], 0))
|
|
366
|
+
noun = spec.labels.get(rel.table, _singular(rel.table)).lower()
|
|
367
|
+
if rel.kind == "many_to_one":
|
|
368
|
+
if not rows:
|
|
369
|
+
key = row[rel.root_column]
|
|
370
|
+
sentences.append(f"No related {noun} is recorded." if key is None else
|
|
371
|
+
f"Related {noun} {_text(key)} was not found.")
|
|
372
|
+
for r in rows:
|
|
373
|
+
sentences.append(_render(r, rel.table, rel.primary_key, rel.text_columns, spec))
|
|
374
|
+
else:
|
|
375
|
+
plural = rel.table.replace("_", " ")
|
|
376
|
+
if total == 0:
|
|
377
|
+
sentences.append(f"No related {plural}.")
|
|
378
|
+
elif total > len(rows):
|
|
379
|
+
sentences.append(f"Related {plural}: {total} in total, showing the first "
|
|
380
|
+
f"{len(rows)}.")
|
|
381
|
+
truncated[rel.table] = total - len(rows)
|
|
382
|
+
sentences.extend(_render(r, rel.table, rel.primary_key, rel.text_columns, spec)
|
|
383
|
+
for r in rows)
|
|
384
|
+
for c in rel.metadata_columns:
|
|
385
|
+
values = [jsonable(r[c]) for r in rows]
|
|
386
|
+
metadata[f"{rel.table}.{c}"] = (values[0] if values else None) \
|
|
387
|
+
if rel.kind == "many_to_one" else values
|
|
388
|
+
references.extend(
|
|
389
|
+
{"table": rel.table, "relation": rel.kind,
|
|
390
|
+
"pk": {c: jsonable(r[c]) for c in rel.primary_key}}
|
|
391
|
+
for r in rows
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
text = " ".join(sentences)
|
|
395
|
+
vector_id = f"{spec.entity}:" + json.dumps(list(pk.values()), separators=(",", ":"))
|
|
396
|
+
content_hash = hashlib.sha256(json.dumps(
|
|
397
|
+
{"text": text, "metadata": metadata, "refs": references, "truncated": truncated,
|
|
398
|
+
"mapping": resolved.fingerprint},
|
|
399
|
+
sort_keys=True, separators=(",", ":"),
|
|
400
|
+
).encode()).hexdigest()
|
|
401
|
+
return SemanticRecord(
|
|
402
|
+
vector_id=vector_id, entity=spec.entity, source_table=spec.root_table, source_pk=pk,
|
|
403
|
+
text=text, metadata=metadata, references=references, truncated=truncated,
|
|
404
|
+
mapping_version=spec.version, content_hash=content_hash,
|
|
405
|
+
)
|
relvector/py.typed
ADDED
|
File without changes
|