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.
- amu_pgvector-0.1.1/.gitignore +24 -0
- amu_pgvector-0.1.1/PKG-INFO +38 -0
- amu_pgvector-0.1.1/README.md +8 -0
- amu_pgvector-0.1.1/pyproject.toml +44 -0
- amu_pgvector-0.1.1/src/amu_pgvector/__init__.py +14 -0
- amu_pgvector-0.1.1/src/amu_pgvector/embeddings.py +62 -0
- amu_pgvector-0.1.1/src/amu_pgvector/mcp_server.py +150 -0
- amu_pgvector-0.1.1/src/amu_pgvector/models.py +36 -0
- amu_pgvector-0.1.1/src/amu_pgvector/store.py +269 -0
- amu_pgvector-0.1.1/tests/conftest.py +310 -0
- amu_pgvector-0.1.1/tests/test_closure.py +145 -0
- amu_pgvector-0.1.1/tests/test_convenience_mode.py +139 -0
- amu_pgvector-0.1.1/tests/test_embeddings.py +18 -0
- amu_pgvector-0.1.1/tests/test_mcp_server.py +93 -0
- amu_pgvector-0.1.1/tests/test_normalize_parity.py +36 -0
- amu_pgvector-0.1.1/tests/test_parity.py +138 -0
- amu_pgvector-0.1.1/tests/test_rls_security.py +222 -0
- amu_pgvector-0.1.1/tests/test_store.py +224 -0
|
@@ -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()
|