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.
Files changed (41) hide show
  1. {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/PKG-INFO +1 -1
  2. astrocyte_postgres-0.13.1/astrocyte_postgres/__init__.py +18 -0
  3. astrocyte_postgres-0.13.1/astrocyte_postgres/mental_model_store.py +363 -0
  4. astrocyte_postgres-0.13.1/astrocyte_postgres/source_store.py +495 -0
  5. {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/astrocyte_postgres/store.py +251 -5
  6. astrocyte_postgres-0.13.1/migrations/012_mental_models.sql +62 -0
  7. astrocyte_postgres-0.13.1/migrations/013_bm25_materialized_view.sql +101 -0
  8. astrocyte_postgres-0.13.1/migrations/014_source_documents.sql +87 -0
  9. {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/pyproject.toml +15 -0
  10. astrocyte_postgres-0.13.1/scripts/migrate-wiki-piggyback-to-mental-models.sh +125 -0
  11. astrocyte_postgres-0.13.1/tests/test_postgres_bm25_idf.py +226 -0
  12. astrocyte_postgres-0.13.1/tests/test_postgres_mental_model_store.py +239 -0
  13. astrocyte_postgres-0.13.1/tests/test_postgres_mental_model_tenant_isolation.py +131 -0
  14. astrocyte_postgres-0.13.1/tests/test_postgres_source_aware_retain.py +136 -0
  15. astrocyte_postgres-0.13.1/tests/test_postgres_source_store.py +316 -0
  16. astrocyte_postgres-0.13.1/tests/test_postgres_source_tenant_isolation.py +187 -0
  17. astrocyte_postgres-0.12.2/astrocyte_postgres/__init__.py +0 -11
  18. {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/.gitignore +0 -0
  19. {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/README.md +0 -0
  20. {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/astrocyte_postgres/wiki_store.py +0 -0
  21. {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/migrations/001_extension.sql +0 -0
  22. {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/migrations/002_astrocytes_vectors.sql +0 -0
  23. {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/migrations/003_indexes.sql +0 -0
  24. {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/migrations/004_memory_layer.sql +0 -0
  25. {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/migrations/005_banks_access.sql +0 -0
  26. {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/migrations/006_lifecycle_indexes.sql +0 -0
  27. {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/migrations/007_wiki_tables.sql +0 -0
  28. {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/migrations/008_entities_temporal.sql +0 -0
  29. {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/migrations/009_entities_trigram_embedding.sql +0 -0
  30. {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/migrations/010_hybrid_recall_indexes.sql +0 -0
  31. {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/migrations/011_text_fts.sql +0 -0
  32. {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/scripts/migrate-all-tenants.sh +0 -0
  33. {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/scripts/migrate.sh +0 -0
  34. {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/scripts/test-integration.sh +0 -0
  35. {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/tests/__init__.py +0 -0
  36. {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/tests/conftest.py +0 -0
  37. {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/tests/test_postgres_fts.py +0 -0
  38. {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/tests/test_postgres_store.py +0 -0
  39. {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/tests/test_postgres_tenant_isolation.py +0 -0
  40. {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/tests/test_postgres_wiki_store.py +0 -0
  41. {astrocyte_postgres-0.12.2 → astrocyte_postgres-0.13.1}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: astrocyte-postgres
3
- Version: 0.12.2
3
+ Version: 0.13.1
4
4
  Summary: PostgreSQL adapter for Astrocyte (vector + document + wiki stores backed by pgvector and tsvector)
5
5
  License-Expression: Apache-2.0
6
6
  Requires-Python: >=3.11
@@ -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
+ )