Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions hindsight-api-slim/hindsight_api/admin/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,7 @@ 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.knowledge_pages_imported} Knowledge Page node(s), "
f"{result.mental_model_history_imported} mm-history row(s), {result.directives_imported} directive(s), "
f"{result.webhooks_imported} webhook(s), {result.history_rows_imported} history row(s)"
)
Expand Down
14 changes: 14 additions & 0 deletions hindsight-api-slim/hindsight_api/engine/db/ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,20 @@ def uses_observation_sources_table(self) -> bool:
"""
return True # Default: use junction table (Oracle)

def mental_model_search_vector_expr(
self,
config: Any,
*,
name_col: str = "name",
content_col: str = "content",
) -> str | None:
"""Stored lexical projection for a mental-model document, if any.

Oracle and PostgreSQL base-column search backends maintain no stored
projection here. PostgreSQL VChord overrides this capability.
"""
return None

# -- Bulk insert operations ------------------------------------------

@abstractmethod
Expand Down
32 changes: 32 additions & 0 deletions hindsight-api-slim/hindsight_api/engine/db/ops_postgresql.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,41 @@ def pg_search_vector_expr(
return None


def pg_mental_model_search_vector_expr(
config,
*,
name_col: str = "name",
content_col: str = "content",
) -> str | None:
"""Build the stored lexical projection for a mental-model document.

VChord is the only backend whose mental-model projection must be written by
the application. Native PostgreSQL uses a generated ``tsvector`` column,
while pg_textsearch, PGroonga, and pg_search index ``name``/``content``
directly and retain only a dummy ``search_vector`` column.
"""
if config.text_search_extension != "vchord":
return None
combined = f"COALESCE({name_col}, '') || ' ' || COALESCE({content_col}, '')"
return f"tokenize({combined}, 'llmlingua2')::bm25_catalog.bm25vector"


class PostgreSQLOps(DataAccessOps):
"""PostgreSQL-specific data access operations using unnest and LATERAL."""

def mental_model_search_vector_expr(
self,
config,
*,
name_col: str = "name",
content_col: str = "content",
) -> str | None:
return pg_mental_model_search_vector_expr(
config,
name_col=name_col,
content_col=content_col,
)

@property
def uses_observation_sources_table(self) -> bool:
return False # PG uses native array ops on source_memory_ids
Expand Down
190 changes: 147 additions & 43 deletions hindsight-api-slim/hindsight_api/engine/memory_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -4418,10 +4418,11 @@ async def import_bank_async(
) -> "BankImportResult":
"""Restore a whole bank from an :func:`transfer.export_bank` archive.

Re-embeds facts with this instance's embedding model and rebuilds links and
indexes; restores bank config, mental models, directives and webhooks as
exported (no consolidation/webhooks — a migration restores exact state). The
target bank must not already exist (import restores a whole bank, not a merge).
Re-embeds facts and mental models with this instance's embedding model and
rebuilds links/search projections; restores bank config, Knowledge Pages,
directives and webhooks as exported (no consolidation/webhooks — a migration
restores exact state). The target bank must not already exist (import restores
a whole bank, not a merge).
"""
from .transfer import import_bank
from .transfer.importer import parse_bank_archive
Expand Down Expand Up @@ -11818,6 +11819,8 @@ async def create_mental_model(
# Convert embedding to string for asyncpg vector type
embedding_str = str(embedding[0]) if embedding else None

config = get_config()

if not mental_model_id:
mental_model_id = f"mm-{uuid.uuid4().hex}"

Expand All @@ -11833,11 +11836,18 @@ async def create_mental_model(
conn=conn,
)
if mental_model_id:
search_vector_expr = backend.ops.mental_model_search_vector_expr(
config,
name_col="$3",
content_col="$5",
)
search_vector_column = ", search_vector" if search_vector_expr else ""
search_vector_value = f", {search_vector_expr}" if search_vector_expr else ""
row = await conn.fetchrow(
f"""
INSERT INTO {fq_table("mental_models")}
(id, bank_id, subtype, name, description, source_query, content, embedding, tags, max_tokens, trigger)
VALUES ($1, $2, 'pinned', $3, ' ', $4, $5, $6, $7, COALESCE($8, 2048), COALESCE($9, '{{"refresh_after_consolidation": false}}'::jsonb))
(id, bank_id, subtype, name, description, source_query, content, embedding, tags, max_tokens, trigger{search_vector_column})
VALUES ($1, $2, 'pinned', $3, ' ', $4, $5, $6, $7, COALESCE($8, 2048), COALESCE($9, '{{"refresh_after_consolidation": false}}'::jsonb){search_vector_value})
RETURNING id, bank_id, name, source_query, content, tags,
last_refreshed_at, created_at, reflect_response,
max_tokens, trigger, structured_content
Expand All @@ -11853,11 +11863,18 @@ async def create_mental_model(
json.dumps(trigger) if trigger else None,
)
else:
search_vector_expr = backend.ops.mental_model_search_vector_expr(
config,
name_col="$2",
content_col="$4",
)
search_vector_column = ", search_vector" if search_vector_expr else ""
search_vector_value = f", {search_vector_expr}" if search_vector_expr else ""
row = await conn.fetchrow(
f"""
INSERT INTO {fq_table("mental_models")}
(bank_id, subtype, name, description, source_query, content, embedding, tags, max_tokens, trigger)
VALUES ($1, 'pinned', $2, ' ', $3, $4, $5, $6, COALESCE($7, 2048), COALESCE($8, '{{"refresh_after_consolidation": false}}'::jsonb))
(bank_id, subtype, name, description, source_query, content, embedding, tags, max_tokens, trigger{search_vector_column})
VALUES ($1, 'pinned', $2, ' ', $3, $4, $5, $6, COALESCE($7, 2048), COALESCE($8, '{{"refresh_after_consolidation": false}}'::jsonb){search_vector_value})
RETURNING id, bank_id, name, source_query, content, tags,
last_refreshed_at, created_at, reflect_response,
max_tokens, trigger, structured_content
Expand Down Expand Up @@ -12761,34 +12778,42 @@ async def update_mental_model(
await self._validate_operation(self._operation_validator.validate_bank_write(ctx))
backend = await self._get_backend()

# Compute the new embedding BEFORE acquiring a pooled connection: a slow
# embedder must never pin a DB connection. The embedding text depends only
# on the incoming name/content, never on DB state, so it can be done here.
# Resolve the canonical document before embedding it. Name-only and
# content-only writes must include the other stored half; otherwise the
# embedding and lexical projection silently describe different text.
previous_content: str | None = None
previous_reflect_response: dict[str, Any] | None = None
effective_name: str | None = None
effective_content: str | None = None
new_embedding_str: str | None = None
if content is not None:
embedding_text = f"{name or ''} {content}"
embedding = await embedding_utils.generate_embeddings_batch(self.embeddings, [embedding_text])
if embedding:
new_embedding_str = str(embedding[0])

async with acquire_with_retry(backend) as conn:
# If content is changing, fetch current content + reflect_response to record history
previous_content: str | None = None
previous_reflect_response: dict[str, Any] | None = None
if content is not None:
document_changed = name is not None or content is not None
if document_changed:
async with acquire_with_retry(backend) as conn:
current_row = await conn.fetchrow(
f"SELECT content, reflect_response FROM {fq_table('mental_models')} WHERE bank_id = $1 AND id = $2",
f"SELECT name, content, reflect_response FROM {fq_table('mental_models')} "
"WHERE bank_id = $1 AND id = $2",
bank_id,
mental_model_id,
)
if current_row:
previous_content = current_row["content"]
raw_rr = current_row["reflect_response"]
if isinstance(raw_rr, str):
previous_reflect_response = json.loads(raw_rr) if raw_rr else None
else:
previous_reflect_response = raw_rr
if current_row is None:
return None
effective_name = name if name is not None else current_row["name"]
effective_content = content if content is not None else current_row["content"]
if content is not None:
previous_content = current_row["content"]
raw_rr = current_row["reflect_response"]
if isinstance(raw_rr, str):
previous_reflect_response = json.loads(raw_rr) if raw_rr else None
else:
previous_reflect_response = raw_rr

# Embedding remains outside pooled connections: providers can be slow.
embedding_text = f"{effective_name} {effective_content}"
embedding = await embedding_utils.generate_embeddings_batch(self.embeddings, [embedding_text])
if embedding and embedding[0]:
new_embedding_str = str(embedding[0])

async with acquire_with_retry(backend) as conn:
# Build dynamic update
updates = []
params: list[Any] = [bank_id, mental_model_id]
Expand Down Expand Up @@ -12833,11 +12858,6 @@ async def update_mental_model(
slim["trace"] = previous_trace
slim_reflect_response = slim or None
record_mm_history = True
# Apply the embedding computed above (off-connection).
if new_embedding_str is not None:
updates.append(f"embedding = ${param_idx}")
params.append(new_embedding_str)
param_idx += 1
elif refresh_watermark is not None:
# A successful delta refresh can find no topic-relevant facts even though
# the coarse staleness query found new rows. Advance the watermark to the
Expand All @@ -12848,6 +12868,23 @@ async def update_mental_model(
params.append(refresh_watermark)
param_idx += 1

if document_changed:
# Setting NULL when the provider returns no embedding is safer
# than retaining a vector for the previous document.
updates.append(f"embedding = ${param_idx}")
params.append(new_embedding_str)
param_idx += 1

search_vector_expr = backend.ops.mental_model_search_vector_expr(
get_config(),
name_col=f"${param_idx}",
content_col=f"${param_idx + 1}",
)
if search_vector_expr:
updates.append(f"search_vector = {search_vector_expr}")
params.extend([effective_name, effective_content])
param_idx += 2

if reflect_response is not None:
updates.append(f"reflect_response = ${param_idx}")
params.append(json.dumps(reflect_response))
Expand Down Expand Up @@ -12987,20 +13024,43 @@ async def clear_mental_model(
await self._validate_operation(self._operation_validator.validate_bank_write(ctx))
backend = await self._get_backend()

async with acquire_with_retry(backend) as conn:
current_name = await conn.fetchval(
f"SELECT name FROM {fq_table('mental_models')} WHERE bank_id = $1 AND id = $2",
bank_id,
mental_model_id,
)
if current_name is None:
return None

embedding = await embedding_utils.generate_embeddings_batch(self.embeddings, [f"{current_name} "])
embedding_str = str(embedding[0]) if embedding and embedding[0] else None

search_vector_expr = backend.ops.mental_model_search_vector_expr(
get_config(),
name_col="name",
content_col="''",
)
search_vector_clause = (
f",\n search_vector = {search_vector_expr}" if search_vector_expr else ""
)

async with acquire_with_retry(backend) as conn:
row = await conn.fetchrow(
f"""
UPDATE {fq_table("mental_models")}
SET content = '',
structured_content = NULL,
last_refreshed_source_query = NULL
last_refreshed_source_query = NULL,
embedding = $3{search_vector_clause}
WHERE bank_id = $1 AND id = $2
RETURNING id, bank_id, name, source_query, content, tags,
last_refreshed_at, created_at, reflect_response,
max_tokens, trigger, structured_content
""",
bank_id,
mental_model_id,
embedding_str,
)

return self._row_to_mental_model(row) if row else None
Expand Down Expand Up @@ -13425,21 +13485,65 @@ async def search_knowledge_pages(
async def rename_knowledge_node(
self, bank_id: str, node_id: str, name: str, *, request_context: "RequestContext"
) -> dict[str, Any] | None:
"""Rename a folder or page node."""
"""Rename a folder or a page and its backing mental-model document."""
await self._authenticate_tenant(request_context)
backend = await self._get_backend()

async with acquire_with_retry(backend) as conn:
row = await conn.fetchrow(
current = await conn.fetchrow(
f"""
UPDATE {fq_table("knowledge_pages")}
SET name = $3, updated_at = now()
WHERE bank_id = $1 AND id = $2
RETURNING {self._KP_COLUMNS}
SELECT kp.kind, kp.mental_model_id, mm.content AS mm_content
FROM {fq_table("knowledge_pages")} kp
LEFT JOIN {fq_table("mental_models")} mm
ON mm.id = kp.mental_model_id AND mm.bank_id = kp.bank_id
WHERE kp.bank_id = $1 AND kp.id = $2
""",
bank_id,
node_id,
name,
)
if current is None:
return None

embedding_str: str | None = None
if current["kind"] == "page" and current["mental_model_id"] is not None:
embedding = await embedding_utils.generate_embeddings_batch(
self.embeddings,
[f"{name} {current['mm_content'] or ''}"],
)
embedding_str = str(embedding[0]) if embedding and embedding[0] else None

search_vector_expr = backend.ops.mental_model_search_vector_expr(
get_config(),
name_col="$3",
content_col="content",
)
search_vector_clause = f", search_vector = {search_vector_expr}" if search_vector_expr else ""

async with acquire_with_retry(backend) as conn:
async with conn.transaction():
row = await conn.fetchrow(
f"""
UPDATE {fq_table("knowledge_pages")}
SET name = $3, updated_at = now()
WHERE bank_id = $1 AND id = $2
RETURNING {self._KP_COLUMNS}
""",
bank_id,
node_id,
name,
)
if current["kind"] == "page" and current["mental_model_id"] is not None:
await conn.execute(
f"""
UPDATE {fq_table("mental_models")}
SET name = $3, embedding = $4{search_vector_clause}
WHERE bank_id = $1 AND id = $2
""",
bank_id,
current["mental_model_id"],
name,
embedding_str,
)
return self._row_to_knowledge_node(row) if row else None

async def update_knowledge_page(
Expand Down
Loading