astrocyte-postgres 0.12.2__tar.gz → 0.13.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.
- {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/PKG-INFO +1 -1
- astrocyte_postgres-0.13.1/astrocyte_postgres/__init__.py +18 -0
- astrocyte_postgres-0.13.1/astrocyte_postgres/mental_model_store.py +363 -0
- astrocyte_postgres-0.13.1/astrocyte_postgres/source_store.py +495 -0
- {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/astrocyte_postgres/store.py +251 -5
- astrocyte_postgres-0.13.1/migrations/012_mental_models.sql +62 -0
- astrocyte_postgres-0.13.1/migrations/013_bm25_materialized_view.sql +101 -0
- astrocyte_postgres-0.13.1/migrations/014_source_documents.sql +87 -0
- {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/pyproject.toml +15 -0
- astrocyte_postgres-0.13.1/scripts/migrate-wiki-piggyback-to-mental-models.sh +125 -0
- astrocyte_postgres-0.13.1/tests/test_postgres_bm25_idf.py +226 -0
- astrocyte_postgres-0.13.1/tests/test_postgres_mental_model_store.py +239 -0
- astrocyte_postgres-0.13.1/tests/test_postgres_mental_model_tenant_isolation.py +131 -0
- astrocyte_postgres-0.13.1/tests/test_postgres_source_aware_retain.py +136 -0
- astrocyte_postgres-0.13.1/tests/test_postgres_source_store.py +316 -0
- astrocyte_postgres-0.13.1/tests/test_postgres_source_tenant_isolation.py +187 -0
- astrocyte_postgres-0.12.2/astrocyte_postgres/__init__.py +0 -11
- {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/.gitignore +0 -0
- {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/README.md +0 -0
- {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/astrocyte_postgres/wiki_store.py +0 -0
- {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/migrations/001_extension.sql +0 -0
- {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/migrations/002_astrocytes_vectors.sql +0 -0
- {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/migrations/003_indexes.sql +0 -0
- {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/migrations/004_memory_layer.sql +0 -0
- {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/migrations/005_banks_access.sql +0 -0
- {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/migrations/006_lifecycle_indexes.sql +0 -0
- {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/migrations/007_wiki_tables.sql +0 -0
- {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/migrations/008_entities_temporal.sql +0 -0
- {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/migrations/009_entities_trigram_embedding.sql +0 -0
- {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/migrations/010_hybrid_recall_indexes.sql +0 -0
- {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/migrations/011_text_fts.sql +0 -0
- {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/scripts/migrate-all-tenants.sh +0 -0
- {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/scripts/migrate.sh +0 -0
- {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/scripts/test-integration.sh +0 -0
- {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/tests/__init__.py +0 -0
- {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/tests/conftest.py +0 -0
- {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/tests/test_postgres_fts.py +0 -0
- {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/tests/test_postgres_store.py +0 -0
- {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/tests/test_postgres_tenant_isolation.py +0 -0
- {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/tests/test_postgres_wiki_store.py +0 -0
- {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/uv.lock +0 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""PostgreSQL adapter for Astrocyte Tier 1.
|
|
2
|
+
|
|
3
|
+
Backed by pgvector (HNSW vector index) and tsvector (BM25 keyword search).
|
|
4
|
+
Provides VectorStore, DocumentStore, WikiStore, MentalModelStore, and
|
|
5
|
+
SourceStore implementations against a single Postgres database.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from astrocyte_postgres.mental_model_store import PostgresMentalModelStore
|
|
9
|
+
from astrocyte_postgres.source_store import PostgresSourceStore
|
|
10
|
+
from astrocyte_postgres.store import PostgresStore
|
|
11
|
+
from astrocyte_postgres.wiki_store import PostgresWikiStore
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"PostgresMentalModelStore",
|
|
15
|
+
"PostgresSourceStore",
|
|
16
|
+
"PostgresStore",
|
|
17
|
+
"PostgresWikiStore",
|
|
18
|
+
]
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""PostgreSQL-backed first-class :class:`MentalModelStore` (M9).
|
|
2
|
+
|
|
3
|
+
Implements the dedicated :class:`astrocyte.provider.MentalModelStore`
|
|
4
|
+
SPI. Mental models live in their own ``astrocyte_mental_models`` table
|
|
5
|
+
with per-revision snapshots in ``astrocyte_mental_model_versions``,
|
|
6
|
+
replacing the prior wiki-piggyback design (``WikiPage`` rows with
|
|
7
|
+
``kind="concept"`` and ``metadata["_mental_model"] = True``).
|
|
8
|
+
|
|
9
|
+
Schema is defined by ``migrations/012_mental_models.sql``; the bootstrap
|
|
10
|
+
path mirrors it so per-test ``table_name`` setups work without running
|
|
11
|
+
migrate.sh first.
|
|
12
|
+
|
|
13
|
+
Per-tenant aware: every SQL statement routes through ``self._fq()`` so a
|
|
14
|
+
single store instance serves multiple tenants when the gateway sets
|
|
15
|
+
``_current_schema`` per request.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import os
|
|
22
|
+
from datetime import UTC, datetime
|
|
23
|
+
from typing import Any, ClassVar
|
|
24
|
+
|
|
25
|
+
import psycopg
|
|
26
|
+
from astrocyte.tenancy import fq_table, get_current_schema
|
|
27
|
+
from astrocyte.types import HealthStatus, MentalModel
|
|
28
|
+
from psycopg.rows import dict_row
|
|
29
|
+
from psycopg.types.json import Json
|
|
30
|
+
from psycopg_pool import AsyncConnectionPool
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PostgresMentalModelStore:
|
|
34
|
+
"""First-class mental-model store backed by PostgreSQL.
|
|
35
|
+
|
|
36
|
+
Matches the lifecycle invariants of :class:`MentalModelStore`:
|
|
37
|
+
|
|
38
|
+
- ``upsert`` increments revision on each call when the model exists,
|
|
39
|
+
and snapshots the prior revision into ``astrocyte_mental_model_versions``
|
|
40
|
+
so callers can reconstruct history.
|
|
41
|
+
- ``delete`` is a soft-delete (sets ``deleted_at`` on the current row);
|
|
42
|
+
``get`` and ``list`` filter it out.
|
|
43
|
+
- All queries are tenant-schema-aware via :func:`fq_table`.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
SPI_VERSION: ClassVar[int] = 1
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
dsn: str | None = None,
|
|
51
|
+
*,
|
|
52
|
+
bootstrap_schema: bool = True,
|
|
53
|
+
**kwargs: Any,
|
|
54
|
+
) -> None:
|
|
55
|
+
self._dsn = dsn or os.environ.get("DATABASE_URL") or os.environ.get("ASTROCYTE_PG_DSN")
|
|
56
|
+
if not self._dsn:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
"PostgresMentalModelStore requires `dsn` in mental_model_store_config "
|
|
59
|
+
"or DATABASE_URL / ASTROCYTE_PG_DSN",
|
|
60
|
+
)
|
|
61
|
+
self._bootstrap_schema = bool(bootstrap_schema)
|
|
62
|
+
self._pool: AsyncConnectionPool | None = None
|
|
63
|
+
self._pool_lock = asyncio.Lock()
|
|
64
|
+
# Per-tenant-schema bootstrap tracking — same pattern as PostgresStore.
|
|
65
|
+
self._bootstrapped_schemas: set[str] = set()
|
|
66
|
+
self._schema_lock = asyncio.Lock()
|
|
67
|
+
|
|
68
|
+
# ------------------------------------------------------------------
|
|
69
|
+
# Per-tenant naming helpers
|
|
70
|
+
# ------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def _fq(self, table: str) -> str:
|
|
73
|
+
"""Schema-qualify ``table`` using the active tenant context."""
|
|
74
|
+
return fq_table(table)
|
|
75
|
+
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
# Connection pool
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
async def _ensure_pool(self) -> AsyncConnectionPool:
|
|
81
|
+
async with self._pool_lock:
|
|
82
|
+
if self._pool is None:
|
|
83
|
+
async def configure(conn: psycopg.AsyncConnection) -> None:
|
|
84
|
+
# Match the wiki/vector pool: pin search_path to ``public``
|
|
85
|
+
# first so any unqualified type lookups (jsonb, timestamptz)
|
|
86
|
+
# resolve correctly.
|
|
87
|
+
await conn.execute('SET search_path = public, "$user"')
|
|
88
|
+
await conn.commit()
|
|
89
|
+
|
|
90
|
+
self._pool = AsyncConnectionPool(
|
|
91
|
+
conninfo=self._dsn,
|
|
92
|
+
configure=configure,
|
|
93
|
+
open=False,
|
|
94
|
+
min_size=2,
|
|
95
|
+
max_size=20,
|
|
96
|
+
kwargs={"connect_timeout": 10},
|
|
97
|
+
)
|
|
98
|
+
await self._pool.open()
|
|
99
|
+
return self._pool
|
|
100
|
+
|
|
101
|
+
# ------------------------------------------------------------------
|
|
102
|
+
# Schema bootstrap
|
|
103
|
+
# ------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
async def _ensure_schema(self, pool: AsyncConnectionPool) -> None:
|
|
106
|
+
"""Per-tenant-aware bootstrap. Mirrors 012_mental_models.sql.
|
|
107
|
+
|
|
108
|
+
See :class:`PostgresStore._ensure_schema` for the rationale on
|
|
109
|
+
per-(schema, store) tracking and the bootstrap-vs-migrations
|
|
110
|
+
invariant.
|
|
111
|
+
"""
|
|
112
|
+
if not self._bootstrap_schema:
|
|
113
|
+
return
|
|
114
|
+
active_schema = get_current_schema()
|
|
115
|
+
if active_schema in self._bootstrapped_schemas:
|
|
116
|
+
return
|
|
117
|
+
async with self._schema_lock:
|
|
118
|
+
if active_schema in self._bootstrapped_schemas:
|
|
119
|
+
return
|
|
120
|
+
models = self._fq("astrocyte_mental_models")
|
|
121
|
+
versions = self._fq("astrocyte_mental_model_versions")
|
|
122
|
+
async with pool.connection() as conn:
|
|
123
|
+
await conn.execute(f'CREATE SCHEMA IF NOT EXISTS "{active_schema}"')
|
|
124
|
+
# Mirrors 012_mental_models.sql.
|
|
125
|
+
await conn.execute(
|
|
126
|
+
f"""
|
|
127
|
+
CREATE TABLE IF NOT EXISTS {models} (
|
|
128
|
+
bank_id TEXT NOT NULL,
|
|
129
|
+
model_id TEXT NOT NULL,
|
|
130
|
+
title TEXT NOT NULL,
|
|
131
|
+
content TEXT NOT NULL,
|
|
132
|
+
scope TEXT NOT NULL DEFAULT 'bank',
|
|
133
|
+
source_ids TEXT[] NOT NULL DEFAULT '{{}}'::text[],
|
|
134
|
+
revision INTEGER NOT NULL DEFAULT 1,
|
|
135
|
+
metadata JSONB NOT NULL DEFAULT '{{}}'::jsonb,
|
|
136
|
+
refreshed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
137
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
138
|
+
deleted_at TIMESTAMPTZ,
|
|
139
|
+
PRIMARY KEY (bank_id, model_id)
|
|
140
|
+
)
|
|
141
|
+
"""
|
|
142
|
+
)
|
|
143
|
+
await conn.execute(
|
|
144
|
+
f"""
|
|
145
|
+
CREATE INDEX IF NOT EXISTS astrocyte_mental_models_bank_scope_idx
|
|
146
|
+
ON {models} (bank_id, scope)
|
|
147
|
+
WHERE deleted_at IS NULL
|
|
148
|
+
"""
|
|
149
|
+
)
|
|
150
|
+
await conn.execute(
|
|
151
|
+
f"""
|
|
152
|
+
CREATE INDEX IF NOT EXISTS astrocyte_mental_models_bank_refreshed_idx
|
|
153
|
+
ON {models} (bank_id, refreshed_at DESC)
|
|
154
|
+
WHERE deleted_at IS NULL
|
|
155
|
+
"""
|
|
156
|
+
)
|
|
157
|
+
await conn.execute(
|
|
158
|
+
f"""
|
|
159
|
+
CREATE TABLE IF NOT EXISTS {versions} (
|
|
160
|
+
id BIGSERIAL PRIMARY KEY,
|
|
161
|
+
bank_id TEXT NOT NULL,
|
|
162
|
+
model_id TEXT NOT NULL,
|
|
163
|
+
revision INTEGER NOT NULL,
|
|
164
|
+
title TEXT NOT NULL,
|
|
165
|
+
content TEXT NOT NULL,
|
|
166
|
+
source_ids TEXT[] NOT NULL DEFAULT '{{}}'::text[],
|
|
167
|
+
metadata JSONB NOT NULL DEFAULT '{{}}'::jsonb,
|
|
168
|
+
archived_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
169
|
+
UNIQUE (bank_id, model_id, revision),
|
|
170
|
+
FOREIGN KEY (bank_id, model_id)
|
|
171
|
+
REFERENCES {models}(bank_id, model_id) ON DELETE CASCADE
|
|
172
|
+
)
|
|
173
|
+
"""
|
|
174
|
+
)
|
|
175
|
+
await conn.execute(
|
|
176
|
+
f"""
|
|
177
|
+
CREATE INDEX IF NOT EXISTS astrocyte_mental_model_versions_lookup_idx
|
|
178
|
+
ON {versions} (bank_id, model_id, revision DESC)
|
|
179
|
+
"""
|
|
180
|
+
)
|
|
181
|
+
await conn.commit()
|
|
182
|
+
self._bootstrapped_schemas.add(active_schema)
|
|
183
|
+
|
|
184
|
+
# ------------------------------------------------------------------
|
|
185
|
+
# MentalModelStore SPI
|
|
186
|
+
# ------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
async def upsert(self, model: MentalModel, bank_id: str) -> int:
|
|
189
|
+
"""Create-or-refresh — bumps revision + archives prior to versions."""
|
|
190
|
+
pool = await self._ensure_pool()
|
|
191
|
+
await self._ensure_schema(pool)
|
|
192
|
+
models = self._fq("astrocyte_mental_models")
|
|
193
|
+
versions = self._fq("astrocyte_mental_model_versions")
|
|
194
|
+
now = datetime.now(UTC)
|
|
195
|
+
async with pool.connection() as conn:
|
|
196
|
+
async with conn.cursor(row_factory=dict_row) as cur:
|
|
197
|
+
# Look up the current row (if any) to (a) determine the new
|
|
198
|
+
# revision number and (b) archive its prior values into
|
|
199
|
+
# the versions table BEFORE we overwrite. We do this in a
|
|
200
|
+
# single transaction so a concurrent upsert can't observe
|
|
201
|
+
# an intermediate state where the current row has been
|
|
202
|
+
# updated but the prior revision hasn't been archived.
|
|
203
|
+
await cur.execute(
|
|
204
|
+
f"""
|
|
205
|
+
SELECT revision, title, content, source_ids, metadata
|
|
206
|
+
FROM {models}
|
|
207
|
+
WHERE bank_id = %s AND model_id = %s AND deleted_at IS NULL
|
|
208
|
+
""",
|
|
209
|
+
[bank_id, model.model_id],
|
|
210
|
+
)
|
|
211
|
+
existing = await cur.fetchone()
|
|
212
|
+
if existing is None:
|
|
213
|
+
new_revision = 1
|
|
214
|
+
else:
|
|
215
|
+
new_revision = int(existing["revision"]) + 1
|
|
216
|
+
# Archive the row we're about to overwrite.
|
|
217
|
+
await cur.execute(
|
|
218
|
+
f"""
|
|
219
|
+
INSERT INTO {versions}
|
|
220
|
+
(bank_id, model_id, revision, title, content, source_ids, metadata)
|
|
221
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
222
|
+
ON CONFLICT (bank_id, model_id, revision) DO NOTHING
|
|
223
|
+
""",
|
|
224
|
+
[
|
|
225
|
+
bank_id,
|
|
226
|
+
model.model_id,
|
|
227
|
+
existing["revision"],
|
|
228
|
+
existing["title"],
|
|
229
|
+
existing["content"],
|
|
230
|
+
list(existing["source_ids"] or []),
|
|
231
|
+
Json(existing["metadata"] or {}),
|
|
232
|
+
],
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# Upsert the current row. ON CONFLICT branches handle both
|
|
236
|
+
# the create and update cases without a second SELECT.
|
|
237
|
+
await cur.execute(
|
|
238
|
+
f"""
|
|
239
|
+
INSERT INTO {models}
|
|
240
|
+
(bank_id, model_id, title, content, scope, source_ids,
|
|
241
|
+
revision, metadata, refreshed_at, deleted_at)
|
|
242
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, NULL)
|
|
243
|
+
ON CONFLICT (bank_id, model_id) DO UPDATE SET
|
|
244
|
+
title = EXCLUDED.title,
|
|
245
|
+
content = EXCLUDED.content,
|
|
246
|
+
scope = EXCLUDED.scope,
|
|
247
|
+
source_ids = EXCLUDED.source_ids,
|
|
248
|
+
revision = EXCLUDED.revision,
|
|
249
|
+
metadata = EXCLUDED.metadata,
|
|
250
|
+
refreshed_at = EXCLUDED.refreshed_at,
|
|
251
|
+
deleted_at = NULL
|
|
252
|
+
""",
|
|
253
|
+
[
|
|
254
|
+
bank_id,
|
|
255
|
+
model.model_id,
|
|
256
|
+
model.title,
|
|
257
|
+
model.content,
|
|
258
|
+
model.scope,
|
|
259
|
+
list(model.source_ids),
|
|
260
|
+
new_revision,
|
|
261
|
+
Json({}),
|
|
262
|
+
now,
|
|
263
|
+
],
|
|
264
|
+
)
|
|
265
|
+
await conn.commit()
|
|
266
|
+
return new_revision
|
|
267
|
+
|
|
268
|
+
async def get(self, model_id: str, bank_id: str) -> MentalModel | None:
|
|
269
|
+
pool = await self._ensure_pool()
|
|
270
|
+
await self._ensure_schema(pool)
|
|
271
|
+
models = self._fq("astrocyte_mental_models")
|
|
272
|
+
async with pool.connection() as conn:
|
|
273
|
+
async with conn.cursor(row_factory=dict_row) as cur:
|
|
274
|
+
await cur.execute(
|
|
275
|
+
f"""
|
|
276
|
+
SELECT model_id, bank_id, title, content, scope,
|
|
277
|
+
source_ids, revision, refreshed_at
|
|
278
|
+
FROM {models}
|
|
279
|
+
WHERE bank_id = %s AND model_id = %s AND deleted_at IS NULL
|
|
280
|
+
""",
|
|
281
|
+
[bank_id, model_id],
|
|
282
|
+
)
|
|
283
|
+
row = await cur.fetchone()
|
|
284
|
+
return _row_to_model(row) if row else None
|
|
285
|
+
|
|
286
|
+
async def list(
|
|
287
|
+
self,
|
|
288
|
+
bank_id: str,
|
|
289
|
+
*,
|
|
290
|
+
scope: str | None = None,
|
|
291
|
+
) -> list[MentalModel]:
|
|
292
|
+
pool = await self._ensure_pool()
|
|
293
|
+
await self._ensure_schema(pool)
|
|
294
|
+
models = self._fq("astrocyte_mental_models")
|
|
295
|
+
params: list[Any] = [bank_id]
|
|
296
|
+
where = ["bank_id = %s", "deleted_at IS NULL"]
|
|
297
|
+
if scope is not None:
|
|
298
|
+
where.append("scope = %s")
|
|
299
|
+
params.append(scope)
|
|
300
|
+
async with pool.connection() as conn:
|
|
301
|
+
async with conn.cursor(row_factory=dict_row) as cur:
|
|
302
|
+
await cur.execute(
|
|
303
|
+
f"""
|
|
304
|
+
SELECT model_id, bank_id, title, content, scope,
|
|
305
|
+
source_ids, revision, refreshed_at
|
|
306
|
+
FROM {models}
|
|
307
|
+
WHERE {" AND ".join(where)}
|
|
308
|
+
ORDER BY refreshed_at DESC, model_id
|
|
309
|
+
""",
|
|
310
|
+
params,
|
|
311
|
+
)
|
|
312
|
+
rows = await cur.fetchall()
|
|
313
|
+
return [_row_to_model(row) for row in rows]
|
|
314
|
+
|
|
315
|
+
async def delete(self, model_id: str, bank_id: str) -> bool:
|
|
316
|
+
pool = await self._ensure_pool()
|
|
317
|
+
await self._ensure_schema(pool)
|
|
318
|
+
models = self._fq("astrocyte_mental_models")
|
|
319
|
+
async with pool.connection() as conn:
|
|
320
|
+
async with conn.cursor() as cur:
|
|
321
|
+
await cur.execute(
|
|
322
|
+
f"""
|
|
323
|
+
UPDATE {models}
|
|
324
|
+
SET deleted_at = NOW()
|
|
325
|
+
WHERE bank_id = %s AND model_id = %s AND deleted_at IS NULL
|
|
326
|
+
""",
|
|
327
|
+
[bank_id, model_id],
|
|
328
|
+
)
|
|
329
|
+
deleted = bool(cur.rowcount)
|
|
330
|
+
await conn.commit()
|
|
331
|
+
return deleted
|
|
332
|
+
|
|
333
|
+
async def health(self) -> HealthStatus:
|
|
334
|
+
try:
|
|
335
|
+
pool = await self._ensure_pool()
|
|
336
|
+
async with pool.connection() as conn:
|
|
337
|
+
async with conn.cursor() as cur:
|
|
338
|
+
await cur.execute("SELECT 1")
|
|
339
|
+
return HealthStatus(healthy=True, message="postgres mental model store connected")
|
|
340
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
341
|
+
return HealthStatus(healthy=False, message=f"postgres mental model store unhealthy: {exc!s}")
|
|
342
|
+
|
|
343
|
+
async def close(self) -> None:
|
|
344
|
+
async with self._pool_lock:
|
|
345
|
+
if self._pool is not None:
|
|
346
|
+
await self._pool.close()
|
|
347
|
+
self._pool = None
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _row_to_model(row: dict[str, Any]) -> MentalModel:
|
|
351
|
+
refreshed_at = row["refreshed_at"]
|
|
352
|
+
if refreshed_at.tzinfo is None:
|
|
353
|
+
refreshed_at = refreshed_at.replace(tzinfo=UTC)
|
|
354
|
+
return MentalModel(
|
|
355
|
+
model_id=row["model_id"],
|
|
356
|
+
bank_id=row["bank_id"],
|
|
357
|
+
title=row["title"],
|
|
358
|
+
content=row["content"],
|
|
359
|
+
scope=row["scope"],
|
|
360
|
+
source_ids=list(row["source_ids"] or []),
|
|
361
|
+
revision=int(row["revision"]),
|
|
362
|
+
refreshed_at=refreshed_at,
|
|
363
|
+
)
|