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/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