amu-pgvector 0.1.1__tar.gz

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.
@@ -0,0 +1,24 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ build/
6
+ dist/
7
+ .venv/
8
+ venv/
9
+ .uv/
10
+ .uv-cache/
11
+ .pytest_cache/
12
+ .mypy_cache/
13
+ .ruff_cache/
14
+ .coverage
15
+ htmlcov/
16
+ .env
17
+ .env.*
18
+ !.env.example
19
+ *.log
20
+ .DS_Store
21
+ .claude/
22
+ node_modules/
23
+ .idea/
24
+ .vscode/
@@ -0,0 +1,38 @@
1
+ Metadata-Version: 2.5
2
+ Name: amu-pgvector
3
+ Version: 0.1.1
4
+ Summary: Reference implementation of Lineage-Aware Memory Governance on PostgreSQL + pgvector: Postgres RLS enforces S(a) subset P(d), not application code.
5
+ Project-URL: Homepage, https://github.com/sangaraju1988/amu-pgvector
6
+ Project-URL: Repository, https://github.com/sangaraju1988/amu-pgvector
7
+ Project-URL: Paper, https://doi.org/10.1109/ACCESS.2026.3730363
8
+ Author: Venkata Sangaraju
9
+ License: MIT
10
+ Keywords: agent-memory,governance,lineage,pgvector,postgres,rag,rls,row-level-security
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Database
18
+ Classifier: Topic :: Security
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: amu-governance==0.1.2
21
+ Requires-Dist: pgvector>=0.3
22
+ Requires-Dist: psycopg[binary]>=3.1
23
+ Provides-Extra: async
24
+ Requires-Dist: psycopg[binary,pool]>=3.1; extra == 'async'
25
+ Provides-Extra: mcp
26
+ Requires-Dist: mcp>=2.0; extra == 'mcp'
27
+ Provides-Extra: st
28
+ Requires-Dist: sentence-transformers>=3.0; extra == 'st'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # amu-pgvector
32
+
33
+ Python client for [amu-pgvector](https://github.com/sangaraju1988/amu-pgvector) — see the repository root README for the full quickstart, security model, and benchmarks.
34
+
35
+ The `[mcp]` extra (`pip install amu-pgvector[mcp]`) adds an `amu-pgvector-mcp` console script: an MCP server exposing `amu_search`, `amu_record` and `amu_check_conflict` as lineage-gated tools. See `submissions/mcp-registry.md` in the repository for registry publishing details.
36
+
37
+ <!-- mcp-name: io.github.sangaraju1988/amu-pgvector -->
38
+
@@ -0,0 +1,8 @@
1
+ # amu-pgvector
2
+
3
+ Python client for [amu-pgvector](https://github.com/sangaraju1988/amu-pgvector) — see the repository root README for the full quickstart, security model, and benchmarks.
4
+
5
+ The `[mcp]` extra (`pip install amu-pgvector[mcp]`) adds an `amu-pgvector-mcp` console script: an MCP server exposing `amu_search`, `amu_record` and `amu_check_conflict` as lineage-gated tools. See `submissions/mcp-registry.md` in the repository for registry publishing details.
6
+
7
+ <!-- mcp-name: io.github.sangaraju1988/amu-pgvector -->
8
+
@@ -0,0 +1,44 @@
1
+ [project]
2
+ name = "amu-pgvector"
3
+ version = "0.1.1"
4
+ description = "Reference implementation of Lineage-Aware Memory Governance on PostgreSQL + pgvector: Postgres RLS enforces S(a) subset P(d), not application code."
5
+ readme = "README.md"
6
+ license = { text = "MIT" }
7
+ requires-python = ">=3.10"
8
+ authors = [{ name = "Venkata Sangaraju" }]
9
+ keywords = ["postgres", "pgvector", "rls", "row-level-security", "agent-memory", "lineage", "governance", "rag"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Programming Language :: Python :: 3.10",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Topic :: Database",
18
+ "Topic :: Security",
19
+ ]
20
+ dependencies = [
21
+ "amu-governance==0.1.2",
22
+ "psycopg[binary]>=3.1",
23
+ "pgvector>=0.3",
24
+ ]
25
+
26
+ [project.optional-dependencies]
27
+ mcp = ["mcp>=2.0"]
28
+ st = ["sentence-transformers>=3.0"]
29
+ async = ["psycopg[binary,pool]>=3.1"]
30
+
31
+ [project.scripts]
32
+ amu-pgvector-mcp = "amu_pgvector.mcp_server:main"
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/sangaraju1988/amu-pgvector"
36
+ Repository = "https://github.com/sangaraju1988/amu-pgvector"
37
+ Paper = "https://doi.org/10.1109/ACCESS.2026.3730363"
38
+
39
+ [build-system]
40
+ requires = ["hatchling"]
41
+ build-backend = "hatchling.build"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["src/amu_pgvector"]
@@ -0,0 +1,14 @@
1
+ """amu-pgvector: Lineage-Aware Memory Governance on PostgreSQL + pgvector.
2
+
3
+ Postgres row-level security enforces S(a) subset P(d) at the row level, so
4
+ that reuse of a cached analytical result (an Analytical Memory Unit, AMU) is
5
+ gated on its derivation lineage. See sql/amu_pgvector.sql for the enforced
6
+ schema and docs/design.md for how each paper concept maps to a SQL object.
7
+ """
8
+
9
+ from amu_pgvector.models import Conflict, RecordResult, SearchResult
10
+ from amu_pgvector.store import AMUStore
11
+
12
+ __version__ = "0.1.1"
13
+
14
+ __all__ = ["AMUStore", "Conflict", "RecordResult", "SearchResult"]
@@ -0,0 +1,62 @@
1
+ """Pluggable embedding functions.
2
+
3
+ An embed_fn is just Callable[[str], Sequence[float]]. `fake_embedder` is
4
+ deterministic and needs no network or model download -- it's what the test
5
+ suite and the quickstart use by default. `sentence_transformer_embedder`
6
+ needs the optional `[st]` extra; the import is deferred so the base package
7
+ never requires sentence-transformers/torch.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import struct
14
+ from collections.abc import Callable
15
+
16
+
17
+ def fake_embedder(dim: int = 1536) -> Callable[[str], list[float]]:
18
+ """A deterministic, hash-based embedder: same text always maps to the
19
+ same vector, semantically meaningless but stable and dependency-free.
20
+ Two different strings produce different vectors; that's all the test
21
+ suite (and a quickstart with nothing else installed) needs."""
22
+
23
+ def _embed(text: str) -> list[float]:
24
+ values: list[float] = []
25
+ counter = 0
26
+ while len(values) < dim:
27
+ digest = hashlib.sha256(f"{text}\0{counter}".encode()).digest()
28
+ for i in range(0, len(digest) - 3, 4):
29
+ if len(values) >= dim:
30
+ break
31
+ (as_uint,) = struct.unpack(">I", digest[i : i + 4])
32
+ # Map to [-1, 1].
33
+ values.append((as_uint / 0xFFFFFFFF) * 2 - 1)
34
+ counter += 1
35
+ return values
36
+
37
+ return _embed
38
+
39
+
40
+ def sentence_transformer_embedder(
41
+ model_name: str = "all-MiniLM-L6-v2",
42
+ ) -> Callable[[str], list[float]]:
43
+ """Requires the `[st]` extra (sentence-transformers). Real semantic
44
+ embeddings, for examples and production use -- not for the test suite,
45
+ which must never need a model download."""
46
+ try:
47
+ from sentence_transformers import SentenceTransformer
48
+ except ImportError as exc: # pragma: no cover - exercised only without [st]
49
+ raise ImportError(
50
+ "sentence_transformer_embedder requires the 'st' extra: "
51
+ "pip install amu-pgvector[st]"
52
+ ) from exc
53
+
54
+ model = SentenceTransformer(model_name)
55
+
56
+ def _embed(text: str) -> list[float]:
57
+ return model.encode(text, normalize_embeddings=True).tolist()
58
+
59
+ return _embed
60
+
61
+
62
+ __all__ = ["fake_embedder", "sentence_transformer_embedder"]
@@ -0,0 +1,150 @@
1
+ """amu-pgvector-mcp: an MCP server exposing lineage-gated AMU search,
2
+ record, and conflict-check as tools.
3
+
4
+ Connects with the DSN of the Postgres role it's launched as (the
5
+ AMU_PGVECTOR_DSN env var) -- results are gated by Postgres row-level
6
+ security under THAT role, exactly as everywhere else in this project. If
7
+ launched as an amu_agent_base-only role, amu_record will fail with a
8
+ Postgres permission error rather than silently degrading -- writes need an
9
+ amu_writer-member role. This server never re-implements or bypasses the
10
+ gate; it just runs SQL as whichever role it was given.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ from typing import Any
17
+
18
+ from mcp.server.mcpserver import MCPServer
19
+
20
+ from amu_pgvector import AMUStore
21
+ from amu_pgvector.embeddings import fake_embedder
22
+
23
+ DSN_ENV_VAR = "AMU_PGVECTOR_DSN"
24
+ EMBEDDING_DIM_ENV_VAR = "AMU_PGVECTOR_EMBEDDING_DIM"
25
+
26
+ _GATING_NOTE = (
27
+ "Results are lineage-gated by Postgres row-level security under this "
28
+ "server's own Postgres role: an Analytical Memory Unit (AMU) is only "
29
+ "ever returned if every sensitive column touched by its derivation is "
30
+ "in this role's permitted set. This tool cannot bypass that gate."
31
+ )
32
+
33
+ mcp = MCPServer(
34
+ name="amu-pgvector",
35
+ instructions=(
36
+ "Lineage-gated agent memory on PostgreSQL + pgvector (Lineage-Aware "
37
+ "Memory Governance, IEEE Access DOI 10.1109/ACCESS.2026.3730363). "
38
+ + _GATING_NOTE
39
+ ),
40
+ )
41
+
42
+ _store: AMUStore | None = None
43
+ _embed_fn = None
44
+
45
+
46
+ def _get_store() -> AMUStore:
47
+ global _store
48
+ if _store is None:
49
+ dsn = os.environ.get(DSN_ENV_VAR)
50
+ if not dsn:
51
+ raise RuntimeError(
52
+ f"{DSN_ENV_VAR} must be set to the DSN of the Postgres role "
53
+ "this server should run as."
54
+ )
55
+ _store = AMUStore(dsn)
56
+ return _store
57
+
58
+
59
+ def _get_embed_fn():
60
+ global _embed_fn
61
+ if _embed_fn is None:
62
+ try:
63
+ from amu_pgvector.embeddings import sentence_transformer_embedder
64
+
65
+ _embed_fn = sentence_transformer_embedder()
66
+ except ImportError:
67
+ dim = int(os.environ.get(EMBEDDING_DIM_ENV_VAR, "1536"))
68
+ _embed_fn = fake_embedder(dim=dim)
69
+ return _embed_fn
70
+
71
+
72
+ @mcp.tool(
73
+ description=(
74
+ "Search lineage-gated Analytical Memory Units (AMUs) by semantic "
75
+ "similarity to a natural-language query. " + _GATING_NOTE
76
+ )
77
+ )
78
+ def amu_search(query: str, k: int = 5, metric_name: str | None = None) -> list[dict[str, Any]]:
79
+ results = _get_store().search(query, k, embed_fn=_get_embed_fn(), metric_name=metric_name)
80
+ return [
81
+ {
82
+ "id": str(r.id),
83
+ "metric_name": r.metric_name,
84
+ "description": r.description,
85
+ "value": r.value,
86
+ "owner_department": r.owner_department,
87
+ "definition_hash": r.definition_hash,
88
+ "distance": r.distance,
89
+ }
90
+ for r in results
91
+ ]
92
+
93
+
94
+ @mcp.tool(
95
+ description=(
96
+ "Record a new Analytical Memory Unit (AMU). Lineage is extracted "
97
+ "from the executed `sql` (ground truth), never self-reported. "
98
+ "Requires an amu_writer-member Postgres role -- other roles get a "
99
+ "Postgres permission error, not a silent no-op."
100
+ )
101
+ )
102
+ def amu_record(
103
+ sql: str,
104
+ value: dict[str, Any],
105
+ metric_name: str,
106
+ description: str,
107
+ owner_department: str,
108
+ ) -> dict[str, Any]:
109
+ result = _get_store().record(
110
+ sql,
111
+ value,
112
+ metric_name=metric_name,
113
+ description=description,
114
+ owner_department=owner_department,
115
+ embed_fn=_get_embed_fn(),
116
+ )
117
+ return {
118
+ "amu_id": str(result.amu_id),
119
+ "lineage_status": result.lineage_status,
120
+ "conflicts": [
121
+ {"department": c.department, "definition_hash": c.definition_hash}
122
+ for c in result.conflicts
123
+ ],
124
+ }
125
+
126
+
127
+ @mcp.tool(
128
+ description=(
129
+ "Check whether another department has already recorded a "
130
+ "conflicting definition of the same metric_name (same name, "
131
+ "different derivation / definition_hash). Returns only department "
132
+ "names and hashes -- never values or lineage -- so this cannot "
133
+ "become a side channel around the gate."
134
+ )
135
+ )
136
+ def amu_check_conflict(
137
+ metric_name: str, definition_hash: str, owner_department: str
138
+ ) -> list[dict[str, str]]:
139
+ conflicts = _get_store().check_conflict(metric_name, definition_hash, owner_department)
140
+ return [
141
+ {"department": c.department, "definition_hash": c.definition_hash} for c in conflicts
142
+ ]
143
+
144
+
145
+ def main() -> None:
146
+ mcp.run()
147
+
148
+
149
+ if __name__ == "__main__":
150
+ main()
@@ -0,0 +1,36 @@
1
+ """Small result types returned by AMUStore. Not the governance model itself
2
+ -- that's amu_governance.AMU/Lineage/LineageStep, reused as-is."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import uuid
7
+ from dataclasses import dataclass
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Conflict:
12
+ """A pre-existing AMU for the same metric_name with a different
13
+ definition_hash, owned by a different department. Carries no values or
14
+ lineage -- amu.check_conflict() never returns them."""
15
+
16
+ department: str
17
+ definition_hash: str
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class RecordResult:
22
+ amu_id: uuid.UUID
23
+ lineage_status: str
24
+ conflicts: list[Conflict]
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class SearchResult:
29
+ id: uuid.UUID
30
+ metric_name: str
31
+ description: str
32
+ value: dict
33
+ owner_department: str
34
+ definition_hash: str
35
+ distance: float
36
+ external_id: str | None = None
@@ -0,0 +1,269 @@
1
+ """AMUStore: the Python client for amu-pgvector.
2
+
3
+ A thin wrapper around one Postgres DSN. Which methods actually work depends
4
+ entirely on what Postgres privileges that DSN's role has -- record() needs
5
+ an amu_writer-member role, search() just needs amu_agent_base, and the
6
+ admin_* helpers need the schema owner. Nothing here re-implements or
7
+ weakens the gate: every security-relevant decision (S(a) subset P(d),
8
+ lineage closure, sensitivity tagging) happens in sql/amu_pgvector.sql, not
9
+ in this client.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import time
15
+ from collections.abc import Callable, Sequence
16
+
17
+ import psycopg
18
+ from amu_governance.sql_lineage import lineage_from_sql
19
+ from pgvector.psycopg import register_vector
20
+ from psycopg.types.json import Json
21
+
22
+ from amu_pgvector.models import Conflict, RecordResult, SearchResult
23
+
24
+ EmbedFn = Callable[[str], Sequence[float]]
25
+
26
+
27
+ def _lineage_json(lineage) -> dict:
28
+ return {
29
+ "steps": [
30
+ {"table": step.table, "columns_used": list(step.columns_used)}
31
+ for step in lineage.steps
32
+ ],
33
+ "filter_logic": lineage.filter_logic,
34
+ }
35
+
36
+
37
+ def _row_to_search_result(row: tuple) -> SearchResult:
38
+ id_, external_id, metric_name, description, value, owner_department, definition_hash, distance = row
39
+ return SearchResult(
40
+ id=id_,
41
+ external_id=external_id,
42
+ metric_name=metric_name,
43
+ description=description,
44
+ value=value,
45
+ owner_department=owner_department,
46
+ definition_hash=definition_hash,
47
+ distance=distance,
48
+ )
49
+
50
+
51
+ class AMUStore:
52
+ def __init__(self, dsn: str, *, sql_dialect: str = "postgres") -> None:
53
+ self._dsn = dsn
54
+ self._sql_dialect = sql_dialect
55
+
56
+ def _connect(self) -> psycopg.Connection:
57
+ conn = psycopg.connect(self._dsn)
58
+ register_vector(conn)
59
+ return conn
60
+
61
+ def check_conflict(
62
+ self, metric_name: str, definition_hash: str, owner_department: str
63
+ ) -> list[Conflict]:
64
+ """Departments with a different definition_hash for the same
65
+ metric_name -- department/hash only, never values or lineage (see
66
+ amu.check_conflict() in sql/amu_pgvector.sql)."""
67
+ with self._connect() as conn, conn.cursor() as cur:
68
+ cur.execute(
69
+ "SELECT conflicting_department, other_hash FROM amu.check_conflict(%s, %s, %s)",
70
+ (metric_name, definition_hash, owner_department),
71
+ )
72
+ return [Conflict(dept, other_hash) for dept, other_hash in cur.fetchall()]
73
+
74
+ # -- writer path -----------------------------------------------------
75
+
76
+ def record(
77
+ self,
78
+ sql: str,
79
+ result,
80
+ *,
81
+ metric_name: str,
82
+ description: str,
83
+ owner_department: str,
84
+ embed_fn: EmbedFn,
85
+ epoch: int | None = None,
86
+ external_id: str | None = None,
87
+ ) -> RecordResult:
88
+ """Extract lineage from the executed `sql` (ground truth, not
89
+ self-reported by an agent), compute its definition_hash, check for
90
+ conflicting definitions of the same metric, insert the AMU, and
91
+ return the new id plus lineage_status plus any conflicts found.
92
+
93
+ `result` is the analytical value being cached (must be JSON-
94
+ serializable); `description` is the natural-language text that gets
95
+ embedded for retrieval. `external_id`, if given, upserts (an AMU
96
+ already recorded under that external_id is updated in place rather
97
+ than duplicated) -- used by langchain_amu.AMUVectorStore to give
98
+ add_documents(..., ids=[...]) idempotent/mutable semantics; leave it
99
+ None for a plain new AMU.
100
+ """
101
+ lineage = lineage_from_sql(sql, dialect=self._sql_dialect)
102
+ definition_hash = lineage.definition_hash()
103
+ embedding = list(embed_fn(description))
104
+ if epoch is None:
105
+ epoch = int(time.time())
106
+
107
+ conflicts = self.check_conflict(metric_name, definition_hash, owner_department)
108
+
109
+ with self._connect() as conn, conn.cursor() as cur:
110
+ params = (
111
+ metric_name,
112
+ description,
113
+ Json(result),
114
+ owner_department,
115
+ epoch,
116
+ Json(_lineage_json(lineage)),
117
+ definition_hash,
118
+ embedding,
119
+ external_id,
120
+ )
121
+ if external_id is None:
122
+ cur.execute(
123
+ """
124
+ INSERT INTO amu.memory_units
125
+ (metric_name, description, value, owner_department, epoch,
126
+ lineage, definition_hash, embedding, external_id)
127
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
128
+ RETURNING id, lineage_status
129
+ """,
130
+ params,
131
+ )
132
+ else:
133
+ cur.execute(
134
+ """
135
+ INSERT INTO amu.memory_units
136
+ (metric_name, description, value, owner_department, epoch,
137
+ lineage, definition_hash, embedding, external_id)
138
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
139
+ ON CONFLICT (external_id) WHERE external_id IS NOT NULL DO UPDATE SET
140
+ metric_name = EXCLUDED.metric_name,
141
+ description = EXCLUDED.description,
142
+ value = EXCLUDED.value,
143
+ owner_department = EXCLUDED.owner_department,
144
+ epoch = EXCLUDED.epoch,
145
+ lineage = EXCLUDED.lineage,
146
+ definition_hash = EXCLUDED.definition_hash,
147
+ embedding = EXCLUDED.embedding
148
+ RETURNING id, lineage_status
149
+ """,
150
+ params,
151
+ )
152
+ amu_id, lineage_status = cur.fetchone()
153
+ conn.commit()
154
+
155
+ return RecordResult(amu_id=amu_id, lineage_status=lineage_status, conflicts=conflicts)
156
+
157
+ def delete_by_external_ids(self, external_ids: Sequence[str]) -> None:
158
+ with self._connect() as conn, conn.cursor() as cur:
159
+ cur.execute(
160
+ "DELETE FROM amu.memory_units WHERE external_id = ANY(%s)",
161
+ (list(external_ids),),
162
+ )
163
+ conn.commit()
164
+
165
+ def get_by_external_ids(self, external_ids: Sequence[str]) -> list[SearchResult]:
166
+ with self._connect() as conn, conn.cursor() as cur:
167
+ cur.execute(
168
+ "SELECT id, external_id, metric_name, description, value, "
169
+ "owner_department, definition_hash, 0.0 "
170
+ "FROM amu.memory_units WHERE external_id = ANY(%s)",
171
+ (list(external_ids),),
172
+ )
173
+ return [_row_to_search_result(row) for row in cur.fetchall()]
174
+
175
+ # -- agent path (RLS applies under this DSN's own role) --------------
176
+
177
+ def search(
178
+ self,
179
+ query: str,
180
+ k: int,
181
+ embed_fn: EmbedFn,
182
+ *,
183
+ metric_name: str | None = None,
184
+ ) -> list[SearchResult]:
185
+ embedding = list(embed_fn(query))
186
+ with self._connect() as conn, conn.cursor() as cur:
187
+ cur.execute(
188
+ "SELECT id, external_id, metric_name, description, value, "
189
+ "owner_department, definition_hash, distance "
190
+ "FROM amu.search(%s::vector, %s, %s)",
191
+ (embedding, k, metric_name),
192
+ )
193
+ return [_row_to_search_result(row) for row in cur.fetchall()]
194
+
195
+ def search_with_vectors(
196
+ self,
197
+ embedding: Sequence[float],
198
+ fetch_k: int,
199
+ *,
200
+ metric_name: str | None = None,
201
+ ) -> list[tuple[SearchResult, list[float]]]:
202
+ """Like search(), but also returns each candidate's raw embedding --
203
+ for max_marginal_relevance re-ranking (langchain_amu). Not part of
204
+ amu.search()'s own return shape (kept lean for the common case);
205
+ this is a plain SELECT on amu.memory_units instead, so it's still
206
+ fully RLS-gated under the caller's own role."""
207
+ embedding = list(embedding)
208
+ with self._connect() as conn, conn.cursor() as cur:
209
+ cur.execute(
210
+ "SELECT id, external_id, metric_name, description, value, owner_department, "
211
+ "definition_hash, embedding <=> %s::vector AS distance, embedding "
212
+ "FROM amu.memory_units "
213
+ "WHERE %s::text IS NULL OR metric_name = %s "
214
+ "ORDER BY embedding <=> %s::vector LIMIT %s",
215
+ (embedding, metric_name, metric_name, embedding, fetch_k),
216
+ )
217
+ rows = cur.fetchall()
218
+ return [(_row_to_search_result(row[:-1]), row[-1].to_list()) for row in rows]
219
+
220
+ # -- admin helpers (need schema-owner privilege) ----------------------
221
+
222
+ def register_sensitive_column(self, column_id: str) -> None:
223
+ with self._connect() as conn, conn.cursor() as cur:
224
+ cur.execute(
225
+ "INSERT INTO amu.sensitive_columns (column_id) VALUES (%s) "
226
+ "ON CONFLICT DO NOTHING",
227
+ (column_id,),
228
+ )
229
+ conn.commit()
230
+
231
+ def grant_department_permission(self, department: str, column_id: str) -> None:
232
+ with self._connect() as conn, conn.cursor() as cur:
233
+ cur.execute(
234
+ "INSERT INTO amu.department_permissions (department, column_id) "
235
+ "VALUES (%s, %s) ON CONFLICT DO NOTHING",
236
+ (department, column_id),
237
+ )
238
+ conn.commit()
239
+
240
+ def create_agent_role(self, role_name: str, department: str, password: str) -> None:
241
+ """Create a LOGIN role that's a member of amu_agent_base and mapped
242
+ to `department` in amu.role_departments -- the identity mechanism
243
+ strong mode relies on (session_user, not anything the client sets).
244
+ """
245
+ from psycopg import sql as pg_sql
246
+
247
+ with self._connect() as conn, conn.cursor() as cur:
248
+ cur.execute(
249
+ pg_sql.SQL("CREATE ROLE {} LOGIN PASSWORD {} IN ROLE amu_agent_base").format(
250
+ pg_sql.Identifier(role_name), pg_sql.Literal(password)
251
+ )
252
+ )
253
+ cur.execute(
254
+ "INSERT INTO amu.role_departments (role_name, department) VALUES (%s, %s)",
255
+ (role_name, department),
256
+ )
257
+ conn.commit()
258
+
259
+ def register_materialization_edge(
260
+ self, derived_table: str, source_table: str, column_map: dict[str, str | list[str]]
261
+ ) -> None:
262
+ with self._connect() as conn, conn.cursor() as cur:
263
+ cur.execute(
264
+ "INSERT INTO amu.materialization_edges "
265
+ "(derived_table, source_table, column_map) VALUES (%s, %s, %s) "
266
+ "ON CONFLICT (derived_table, source_table) DO UPDATE SET column_map = EXCLUDED.column_map",
267
+ (derived_table, source_table, Json(column_map)),
268
+ )
269
+ conn.commit()