From da148d418caf3944c0ffaf33c0584cb8b825eda7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Mon, 10 Aug 2026 13:16:26 +0200 Subject: [PATCH] feat(transfer): carry Knowledge Pages tree and regenerate mental-model search state on import (#3308, #3323) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Whole-bank export/import previously dropped the Knowledge Pages tree (knowledge_pages was in _SKIP_TABLES because its self-referential parent_id FK needs a topological restore) and restored mental models without an embedding or lexical search state — leaving imported knowledge pages disconnected and unsearchable, on every text-search backend. Export: - Add a typed TransferKnowledgePage model (no raw dicts across phases) and carry the folder/page tree in knowledge_pages.json, parent-first, preserving id, parent_id, mental_model_id, managed, sort_order, name and timestamps. - Remove knowledge_pages from _SKIP_TABLES; classify it under a new KNOWLEDGE_TABLES bucket (coverage guard updated). Import: - Regenerate each restored mental model's embedding with the TARGET model (same "{name} {content}" text create_mental_model embeds), off-connection so no DB conn is held across the embedding call. - Rebuild backend-specific lexical state via the shared pg_search_vector_expr (vchord's bm25vector column; native's is GENERATED and repopulates on insert; pg_search/pg_textsearch/pgroonga index base columns). - Restore the tree after its backing mental models exist and parents-first (topological order tolerant of cycles/dangling parents), ON CONFLICT DO NOTHING. Tests: whole-bank roundtrip asserts the nested tree restores exactly (ids, parents, mm refs, managed) and pages are searchable after import with no NULL mental-model embeddings; plus non-DB unit tests for the topological ordering. --- hindsight-api-slim/hindsight_api/admin/cli.py | 3 +- .../hindsight_api/engine/transfer/export.py | 61 +++++++- .../hindsight_api/engine/transfer/importer.py | 135 +++++++++++++++++- .../hindsight_api/engine/transfer/schema.py | 27 ++++ .../tests/test_document_transfer.py | 106 +++++++++++++- 5 files changed, 320 insertions(+), 12 deletions(-) diff --git a/hindsight-api-slim/hindsight_api/admin/cli.py b/hindsight-api-slim/hindsight_api/admin/cli.py index 38e5f8148f..66d244bbd3 100644 --- a/hindsight-api-slim/hindsight_api/admin/cli.py +++ b/hindsight-api-slim/hindsight_api/admin/cli.py @@ -847,7 +847,8 @@ def import_bank_command( f"Imported bank '{result.bank_id}': {result.documents_imported} doc(s), " f"{result.facts_imported} fact(s), {result.observations_imported} observation(s), " f"{result.mental_models_imported} mental model(s), " - f"{result.mental_model_history_imported} mm-history row(s), {result.directives_imported} directive(s), " + f"{result.mental_model_history_imported} mm-history row(s), " + f"{result.knowledge_pages_imported} knowledge page(s), {result.directives_imported} directive(s), " f"{result.webhooks_imported} webhook(s), {result.history_rows_imported} history row(s)" ) diff --git a/hindsight-api-slim/hindsight_api/engine/transfer/export.py b/hindsight-api-slim/hindsight_api/engine/transfer/export.py index 5e551fad29..73cb16b9f3 100644 --- a/hindsight-api-slim/hindsight_api/engine/transfer/export.py +++ b/hindsight-api-slim/hindsight_api/engine/transfer/export.py @@ -31,6 +31,7 @@ TransferChunk, TransferDocument, TransferFact, + TransferKnowledgePage, TransferManifest, TransferObservation, TransferObservationSource, @@ -88,12 +89,6 @@ # to fresh ids, so carrying them would only produce dangling associations. # Revert anything worth keeping on the source before migrating. "invalidated_memory_units", - # Knowledge-base folder/page tree (client-managed metadata over the carried - # mental models). Not carried yet: its self-referential parent_id FK needs a - # parents-first (topological) restore order, which the generic per-row - # _restore_rows doesn't provide — a follow-up. The mental models themselves - # ARE carried, so the target can recreate the tree. - "knowledge_pages", } ) # Derived columns dropped from carried rows so the target regenerates them with @@ -268,6 +263,47 @@ async def _dump_bank_rows(conn: Any, table: str, bank_id: str) -> list[dict]: return [{k: v for k, v in dict(row).items() if k not in _DERIVED_COLUMNS} for row in rows] +async def _load_knowledge_pages(conn: Any, bank_id: str) -> list[TransferKnowledgePage]: + """Load the knowledge-base tree (folders + pages) as typed rows. + + Ordered parents-before-children (root folders first) via a recursive walk of + ``parent_id`` so the archive is deterministic and import can insert in list + order; import re-derives a safe order regardless. IDs, ``parent_id``, + ``mental_model_id``, ``managed`` and ``sort_order`` are all preserved. + """ + rows = await conn.fetch( + f""" + WITH RECURSIVE tree AS ( + SELECT kp.*, 0 AS depth + FROM {fq_table("knowledge_pages")} kp + WHERE kp.bank_id = $1 AND kp.parent_id IS NULL + UNION ALL + SELECT kp.*, t.depth + 1 + FROM {fq_table("knowledge_pages")} kp + JOIN tree t ON kp.parent_id = t.id AND kp.bank_id = $1 + ) + SELECT id, parent_id, kind, name, mental_model_id, sort_order, managed, created_at, updated_at + FROM tree + ORDER BY depth, sort_order, id + """, + bank_id, + ) + return [ + TransferKnowledgePage( + id=row["id"], + parent_id=row["parent_id"], + kind=row["kind"], + name=row["name"], + mental_model_id=row["mental_model_id"], + sort_order=row["sort_order"], + managed=row["managed"], + created_at=row["created_at"], + updated_at=row["updated_at"], + ) + for row in rows + ] + + async def _dump_history_rows(conn: Any, table: str, bank_id: str) -> list[dict]: """Dump a bank-scoped child-history table for carrying across instances. @@ -314,6 +350,7 @@ async def export_bank( bank_rows = {table: await _dump_bank_rows(conn, table, bank_id) for table in _BANK_ROW_TABLES} for table in CARRIED_HISTORY_TABLES: bank_rows[table] = await _dump_history_rows(conn, table, bank_id) + knowledge_pages = await _load_knowledge_pages(conn, bank_id) history_rows: dict[str, list[dict]] = {} if include_history: history_rows = {table: await _dump_bank_rows(conn, table, bank_id) for table in HISTORY_TABLES} @@ -331,6 +368,14 @@ async def export_bank( for table, rows in bank_rows.items(): zf.writestr(f"{table}.json", json.dumps(rows, indent=2, default=_row_json_default)) + # Typed knowledge-page tree (parent-first). Written even when empty so the + # importer can distinguish "no pages" from a pre-tree archive. + zf.writestr( + "knowledge_pages.json", + "[\n" + ",\n".join(p.model_dump_json(indent=2) for p in knowledge_pages) + "\n]\n" + if knowledge_pages + else "[]\n", + ) for table, rows in history_rows.items(): zf.writestr(f"history/{table}.json", json.dumps(rows, indent=2, default=_row_json_default)) @@ -343,6 +388,7 @@ async def export_bank( observation_count=len(observations), archive_type="bank", mental_model_count=len(bank_rows.get("mental_models", [])), + knowledge_page_count=len(knowledge_pages), directive_count=len(bank_rows.get("directives", [])), webhook_count=len(bank_rows.get("webhooks", [])), includes_history=include_history, @@ -352,12 +398,13 @@ async def export_bank( logger.info( "[transfer] Exported bank %s: %d document(s), %d fact(s), %d observation(s), " - "%d mental model(s), %d directive(s), %d webhook(s)%s", + "%d mental model(s), %d knowledge page(s), %d directive(s), %d webhook(s)%s", bank_id, len(documents), fact_total, len(observations), len(bank_rows.get("mental_models", [])), + len(knowledge_pages), len(bank_rows.get("directives", [])), len(bank_rows.get("webhooks", [])), " (with history)" if include_history else "", diff --git a/hindsight-api-slim/hindsight_api/engine/transfer/importer.py b/hindsight-api-slim/hindsight_api/engine/transfer/importer.py index a20784bb47..d56d6190a8 100644 --- a/hindsight-api-slim/hindsight_api/engine/transfer/importer.py +++ b/hindsight-api-slim/hindsight_api/engine/transfer/importer.py @@ -20,6 +20,7 @@ from typing import Any, Literal from ..causal_links import CANONICAL_CAUSAL_LINK_TYPE, LEGACY_CAUSAL_LINK_TYPES +from ..db.ops_postgresql import pg_search_vector_expr from ..db_utils import acquire_with_retry from ..retain import bank_utils, chunk_storage, embedding_processing, fact_storage, link_utils, orchestrator from ..retain.types import ( @@ -37,6 +38,7 @@ BankRowsJSONEncoding, TransferDocument, TransferFact, + TransferKnowledgePage, TransferManifest, TransferObservation, ) @@ -246,6 +248,7 @@ class BankImportResult: observations_imported: int = 0 mental_models_imported: int = 0 mental_model_history_imported: int = 0 + knowledge_pages_imported: int = 0 directives_imported: int = 0 webhooks_imported: int = 0 history_rows_imported: int = 0 @@ -258,6 +261,8 @@ class ParsedBankArchive: manifest: TransferManifest # table name -> list of verbatim row dicts (banks, mental_models, directives, webhooks) bank_rows: dict[str, list[dict]] = field(default_factory=dict) + # Typed knowledge-base tree (folders + pages), restored parent-first. + knowledge_pages: list[TransferKnowledgePage] = field(default_factory=list) # table name -> rows (audit_log, llm_requests), present only with --include-history history_rows: dict[str, list[dict]] = field(default_factory=dict) @@ -277,12 +282,20 @@ def parse_bank_archive(archive_bytes: bytes) -> ParsedBankArchive: for table in ("banks", *_BANK_CHILD_TABLES, *CARRIED_HISTORY_TABLES): fname = f"{table}.json" bank_rows[table] = json.loads(zf.read(fname)) if fname in names else [] + # Typed tree — absent on pre-tree archives, which restore with no pages. + knowledge_pages: list[TransferKnowledgePage] = [] + if "knowledge_pages.json" in names: + knowledge_pages = [ + TransferKnowledgePage.model_validate(p) for p in json.loads(zf.read("knowledge_pages.json")) + ] history_rows: dict[str, list[dict]] = {} for table in HISTORY_TABLES: fname = f"history/{table}.json" if fname in names: history_rows[table] = json.loads(zf.read(fname)) - return ParsedBankArchive(manifest=manifest, bank_rows=bank_rows, history_rows=history_rows) + return ParsedBankArchive( + manifest=manifest, bank_rows=bank_rows, knowledge_pages=knowledge_pages, history_rows=history_rows + ) def _resolve_bank_rows_json_encoding(manifest: TransferManifest) -> BankRowsJSONEncoding: @@ -349,6 +362,108 @@ async def _restore_rows( return inserted +async def _regenerate_mental_model_embeddings(embeddings_model: Any, mm_rows: list[dict]) -> dict[str, str]: + """Re-embed each restored mental model with the *target* model. + + Export strips the source embedding (target-derived). Embeds the same + ``"{name} {content}"`` text ``create_mental_model`` embeds so a restored model + ranks identically to a freshly written one. Runs off-connection (no DB conn is + held across the embedding call — see the retain path); returns id -> vector + literal for the caller to apply in the restore transaction. + """ + if not mm_rows: + return {} + texts = [f"{(r.get('name') or '')} {(r.get('content') or '')}" for r in mm_rows] + vectors = await embedding_processing.generate_embeddings_batch(embeddings_model, texts) + return {r["id"]: str(v) for r, v in zip(mm_rows, vectors, strict=True)} + + +async def _apply_mental_model_derived_state( + conn: Any, + bank_id: str, + mm_embeddings: dict[str, str], + config: Any, +) -> None: + """Write the regenerated embedding (and vchord lexical state) onto restored models. + + ``search_vector`` is rebuilt only for vchord: native's column is GENERATED and + already repopulated when the row was inserted, and pg_search / pg_textsearch / + pgroonga index the base ``name`` / ``content`` columns directly. Same + per-backend expression the live mental-model writes use (``pg_search_vector_expr``). + """ + if not mm_embeddings: + return + sv_expr = pg_search_vector_expr( + config, text_col="name", context_col="content", signals_col=None, native_inline=False + ) + sv_clause = f", search_vector = {sv_expr}" if sv_expr else "" + for mm_id, vector in mm_embeddings.items(): + await conn.execute( + f"UPDATE {fq_table('mental_models')} SET embedding = $3::vector{sv_clause} WHERE bank_id = $1 AND id = $2", + bank_id, + mm_id, + vector, + ) + + +def _topological_page_order(pages: list[TransferKnowledgePage]) -> list[TransferKnowledgePage]: + """Order nodes parents-before-children so the self-referential ``parent_id`` FK + always resolves on insert. A node whose parent is absent from the archive (only + possible in a corrupt export) or part of a cycle is emitted last so the FK, not + a silent drop, surfaces it.""" + by_id = {p.id: p for p in pages} + ordered: list[TransferKnowledgePage] = [] + placed: set[str] = set() + remaining = list(pages) + while remaining: + ready = [p for p in remaining if p.parent_id is None or p.parent_id not in by_id or p.parent_id in placed] + if not ready: + # Unresolvable parents (cycle / dangling) — emit the rest as-is. + ordered.extend(remaining) + break + for p in ready: + ordered.append(p) + placed.add(p.id) + ready_ids = {p.id for p in ready} + remaining = [p for p in remaining if p.id not in ready_ids] + return ordered + + +async def _restore_knowledge_pages(conn: Any, bank_id: str, pages: list[TransferKnowledgePage]) -> int: + """Restore the knowledge-base tree into ``bank_id`` parents-first. + + IDs, ``parent_id``, ``mental_model_id``, ``managed``, ``sort_order``, name and + timestamps are preserved; ``bank_id`` is applied to the target. Pages are + restored after their backing mental models (the caller sequences that), and + folders before their children (topological order here). ``ON CONFLICT DO + NOTHING`` keeps the import idempotent. + """ + if not pages: + return 0 + inserted = 0 + for page in _topological_page_order(pages): + await conn.execute( + f""" + INSERT INTO {fq_table("knowledge_pages")} + (id, bank_id, parent_id, kind, name, mental_model_id, sort_order, managed, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, COALESCE($9, now()), COALESCE($10, now())) + ON CONFLICT DO NOTHING + """, + page.id, + bank_id, + page.parent_id, + page.kind, + page.name, + page.mental_model_id, + page.sort_order, + page.managed, + page.created_at, + page.updated_at, + ) + inserted += 1 + return inserted + + async def import_bank( *, backend: Any, @@ -451,13 +566,22 @@ async def import_bank( facts_imported=doc_result.facts_imported, observations_imported=doc_result.observations_imported, ) + + # Re-embed restored mental models off-connection (the source embedding was + # stripped on export), so no DB connection is held across the embedding call. + mm_rows = parsed.bank_rows.get("mental_models", []) + mm_embeddings = await _regenerate_mental_model_embeddings(embeddings_model, mm_rows) + async with acquire_with_retry(backend) as conn: result.mental_models_imported = await _restore_rows( conn, "mental_models", - parsed.bank_rows.get("mental_models", []), + mm_rows, bank_rows_json_encoding=bank_rows_json_encoding, ) + # Apply the regenerated embedding + backend-specific lexical state onto the + # restored rows (native search_vector already repopulated on insert). + await _apply_mental_model_derived_state(conn, bank_id, mm_embeddings, config) # Restored after mental_models so the (mental_model_id, bank_id) FK resolves. result.mental_model_history_imported = await _restore_rows( conn, @@ -465,6 +589,9 @@ async def import_bank( parsed.bank_rows.get("mental_model_history", []), bank_rows_json_encoding=bank_rows_json_encoding, ) + # Knowledge-base tree after its backing mental models exist (page FK) and + # parents-first (self-referential parent_id FK). + result.knowledge_pages_imported = await _restore_knowledge_pages(conn, bank_id, parsed.knowledge_pages) result.directives_imported = await _restore_rows( conn, "directives", @@ -488,13 +615,15 @@ async def import_bank( logger.info( "[transfer] Imported bank %s: %d doc(s), %d fact(s), %d observation(s), " - "%d mental model(s), %d mm-history row(s), %d directive(s), %d webhook(s), %d history row(s)", + "%d mental model(s), %d mm-history row(s), %d knowledge page(s), %d directive(s), " + "%d webhook(s), %d history row(s)", bank_id, result.documents_imported, result.facts_imported, result.observations_imported, result.mental_models_imported, result.mental_model_history_imported, + result.knowledge_pages_imported, result.directives_imported, result.webhooks_imported, result.history_rows_imported, diff --git a/hindsight-api-slim/hindsight_api/engine/transfer/schema.py b/hindsight-api-slim/hindsight_api/engine/transfer/schema.py index ddf4f60e51..55ea9e6b2f 100644 --- a/hindsight-api-slim/hindsight_api/engine/transfer/schema.py +++ b/hindsight-api-slim/hindsight_api/engine/transfer/schema.py @@ -27,6 +27,9 @@ # history is optional and included only when the caller requests it. CARRIED_HISTORY_TABLES = ("mental_model_history",) HISTORY_TABLES = ("audit_log", "llm_requests") +# Logical tree carried as typed rows (not raw dicts) and restored parent-first +# after its backing mental models exist. +KNOWLEDGE_TABLES = ("knowledge_pages",) ObservationScopes = Literal["per_tag", "combined", "all_combinations", "shared"] | list[list[str]] BankRowsJSONEncoding = Literal["decoded", "serialized"] @@ -130,6 +133,29 @@ class TransferDocument(BaseModel): facts: list[TransferFact] = Field(default_factory=list) +class TransferKnowledgePage(BaseModel): + """One node of the knowledge-base tree (a folder or a page). + + Carried verbatim across a whole-bank transfer so the folder/page hierarchy, + ``managed`` flags, and ordering survive. IDs are preserved (a page's + ``mental_model_id`` and a node's ``parent_id`` must still resolve on the + target), and ``bank_id`` is re-applied to the target bank on import. A folder + has ``kind='folder'`` and ``mental_model_id=None``; a page has ``kind='page'`` + and points at its backing mental model. No derived search state lives here — + that is on ``mental_models`` and regenerated on the target. + """ + + id: str + parent_id: str | None = None + kind: Literal["folder", "page"] + name: str + mental_model_id: str | None = None + sort_order: int = 0 + managed: bool = False + created_at: datetime | None = None + updated_at: datetime | None = None + + class TransferManifest(BaseModel): """Top-level archive descriptor (``manifest.json``). @@ -148,6 +174,7 @@ class TransferManifest(BaseModel): # (also carries bank config, mental models, directives, webhooks). archive_type: Literal["documents", "bank"] = "documents" mental_model_count: int = 0 + knowledge_page_count: int = 0 directive_count: int = 0 webhook_count: int = 0 # True when --include-history carried audit_log / llm_requests. diff --git a/hindsight-api-slim/tests/test_document_transfer.py b/hindsight-api-slim/tests/test_document_transfer.py index b4bddfe21b..536e75d037 100644 --- a/hindsight-api-slim/tests/test_document_transfer.py +++ b/hindsight-api-slim/tests/test_document_transfer.py @@ -191,12 +191,13 @@ def test_export_bank_covers_schema(): history, or explicitly skipped — so a future migration can't silently drop one.""" from hindsight_api.admin.cli import BACKUP_TABLES from hindsight_api.engine.transfer.export import _BANK_ROW_TABLES, _REPLAYED_TABLES, _SKIP_TABLES - from hindsight_api.engine.transfer.schema import CARRIED_HISTORY_TABLES, HISTORY_TABLES + from hindsight_api.engine.transfer.schema import CARRIED_HISTORY_TABLES, HISTORY_TABLES, KNOWLEDGE_TABLES buckets = [ set(_REPLAYED_TABLES), set(_BANK_ROW_TABLES), set(CARRIED_HISTORY_TABLES), + set(KNOWLEDGE_TABLES), set(HISTORY_TABLES), set(_SKIP_TABLES), ] @@ -209,6 +210,38 @@ def test_export_bank_covers_schema(): assert sum(len(b) for b in buckets) == len(classified), "a table is classified in more than one bucket" +def test_topological_page_order_is_parent_first(): + """Nodes always sort so a parent precedes its children (self-FK safe).""" + from hindsight_api.engine.transfer.importer import _topological_page_order + from hindsight_api.engine.transfer.schema import TransferKnowledgePage + + def _page(pid, parent): + kind = "page" if pid.startswith("p") else "folder" + return TransferKnowledgePage(id=pid, parent_id=parent, kind=kind, name=pid) + + # Deliberately shuffled: child before parent, grandchild before both. + pages = [_page("pC", "fB"), _page("fB", "fA"), _page("fA", None), _page("pRoot", None)] + ordered = [p.id for p in _topological_page_order(pages)] + assert ordered.index("fA") < ordered.index("fB") < ordered.index("pC") + assert ordered.index("fA") < ordered.index("pC") + assert set(ordered) == {"pC", "fB", "fA", "pRoot"} + + +def test_topological_page_order_tolerates_cycles_and_dangling_parents(): + """A cycle or missing parent (only possible in a corrupt export) is emitted + rather than dropped, so the DB FK — not a silent loss — surfaces it.""" + from hindsight_api.engine.transfer.importer import _topological_page_order + from hindsight_api.engine.transfer.schema import TransferKnowledgePage + + cycle = [ + TransferKnowledgePage(id="a", parent_id="b", kind="folder", name="a"), + TransferKnowledgePage(id="b", parent_id="a", kind="folder", name="b"), + ] + assert {p.id for p in _topological_page_order(cycle)} == {"a", "b"} + dangling = [TransferKnowledgePage(id="x", parent_id="missing", kind="page", name="x")] + assert [p.id for p in _topological_page_order(dangling)] == ["x"] + + def test_export_jsonb_coercion_preserves_decoded_scalar_string(): """Admin connections decode JSONB before the transfer exporter sees it.""" from hindsight_api.engine.transfer.export import _as_jsonb @@ -667,6 +700,77 @@ async def test_bank_roundtrip_carries_mental_model_history(memory, request_conte await memory.delete_bank(bank, request_context=request_context) +@pytest.mark.asyncio +async def test_bank_roundtrip_carries_knowledge_pages(memory, request_context): + """A whole-bank archive restores the Knowledge Pages tree — nested folders + + pages, parent_id / mental_model_id / managed / sort_order preserved — and + regenerates each backing mental model's embedding + lexical state on the + target, so pages stay searchable after import (#3308, #3323).""" + bank = _unique_bank("bank_kb") + try: + await memory.get_bank_profile(bank, request_context=request_context) + root = await memory.create_knowledge_folder(bank, "Runbooks", managed=True, request_context=request_context) + sub = await memory.create_knowledge_folder( + bank, "Billing", parent_id=root["id"], request_context=request_context + ) + page = await memory.create_knowledge_page( + bank, + name="Net-30 policy", + source_query="what is our billing policy", + content="Invoices are due Net-30. Late payments accrue interest.", + parent_id=sub["id"], + request_context=request_context, + ) + # A root-level page (NULL parent) exercises the non-nested path too. + await memory.create_knowledge_page( + bank, + name="Overview", + source_query="overview", + content="Company overview and mission statement.", + request_context=request_context, + ) + + def _tree(nodes): + return sorted( + (n["id"], n["kind"], n["parent_id"], n["mental_model_id"], n["managed"], n["name"]) for n in nodes + ) + + before = _tree(await memory.list_knowledge_nodes(bank, request_context=request_context)) + before_search = await memory.search_knowledge_pages( + bank, "net-30 billing", limit=5, request_context=request_context + ) + assert any(r["id"] == page["id"] for r in before_search), "page should be searchable before export" + + from hindsight_api.engine.transfer import export_bank + + backend = await memory._get_backend() + async with acquire_with_retry(backend) as conn: + archive = await export_bank(conn, bank) + # Delete then restore into the same id — exact round-trip, no PK collisions. + await memory.delete_bank(bank, request_context=request_context) + result = await memory.import_bank_async(archive, request_context) + assert result.knowledge_pages_imported == 4 # 2 folders + 2 pages + + # Tree restored exactly: ids, parents, backing mental models, managed flag. + after = _tree(await memory.list_knowledge_nodes(bank, request_context=request_context)) + assert after == before + + # Backing mental models re-embedded on the target (no NULL vectors), so both + # the vector and lexical arms of knowledge search work again. + async with acquire_with_retry(backend) as conn: + null_embeddings = await conn.fetchval( + f"SELECT count(*) FROM {fq_table('mental_models')} WHERE bank_id = $1 AND embedding IS NULL", + bank, + ) + assert null_embeddings == 0, "restored mental models must be re-embedded" + after_search = await memory.search_knowledge_pages( + bank, "net-30 billing", limit=5, request_context=request_context + ) + assert any(r["id"] == page["id"] for r in after_search), "page must be searchable after import" + finally: + await memory.delete_bank(bank, request_context=request_context) + + @pytest.mark.asyncio async def test_import_bank_rejects_documents_archive(memory, request_context): """A documents-only archive must be rejected by the bank importer."""