"""Fact CRUD and search — vector + ILIKE fallback."""
import re
import uuid
from datetime import datetime, timezone
import asyncpg
import structlog
from navi.config import settings
from ._embeddings import _vector_to_str
log = structlog.get_logger()
_SEPARATORS = re.compile(r"[-_/\\.]")
_NOISE = re.compile(r"[^\w\s]", re.UNICODE)
_KEY_SPLIT = re.compile(r"[-_/.\s]+")
_AUTO_DUMP_THRESHOLD = 60
_VECTOR_CUTOFF_DISTANCE = 0.3
# Leading tokens that carry no meaning of their own: `preferred_language`,
# `ui_language` and `language` are one key, and the extractor writes all three.
_MERGE_PREFIX_TOKENS = frozenset({"preferred", "preference", "pref", "user", "my", "ui", "response"})
def _normalize_query(query: str) -> list[str]:
q = _SEPARATORS.sub(" ", query)
q = _NOISE.sub(" ", q)
return [t for t in q.lower().split() if len(t) > 1]
def _key_tokens(key: str) -> frozenset[str]:
"""The tokens of a fact key, with *leading* noise words dropped.
Leading only, and that is load-bearing: `ui_language` is the language key with a
noise prefix, but `dark_ui` is a name of its own and must not collapse into
`dark`. A rule that strips `ui` wherever it stands cannot tell those apart.
"""
tokens = [t for t in _KEY_SPLIT.split(key.strip().lower()) if len(t) > 1]
while tokens and tokens[0] in _MERGE_PREFIX_TOKENS:
tokens.pop(0)
return frozenset(tokens)
class FactMixin:
"""Fact storage operations.
Expected on the composite class:
_get_pool() -> asyncpg.Pool
_generate_embedding(text) -> list[float] | None
_has_pgvector() -> bool
"""
async def upsert_fact(
self,
category: str,
key: str,
value: str,
user_id: str | None = None,
source_session_id: str | None = None,
source: str = "conversation",
confidence: int = 70,
expires_at: datetime | None = None,
source_context: str = "",
dedupe: bool = False,
) -> None:
"""Write a fact, keyed on `(user_id, category, key)`.
With `dedupe`, the same fact already stored under a different spelling of
this key is hidden behind the row that survives — see `_merge_key_aliases`.
Off by default, so callers that own their keys (the `memory` tool) write
exactly the row they asked for.
"""
now = datetime.now(timezone.utc)
embedding = await self._generate_embedding(value)
vec_str = _vector_to_str(embedding) if embedding else None
pool = await self._get_pool()
fact_id = str(uuid.uuid4())
async with pool.acquire() as conn, conn.transaction():
# The dedupe path marks losers before the row they point at exists, so a
# failed insert would leave them hidden behind nothing. The plain path
# needs no boundary of its own, and taking one anyway costs nothing.
if dedupe:
await self._merge_key_aliases(conn, category, key, user_id, fact_id)
if vec_str:
await conn.execute(
"""INSERT INTO memory_facts
(id, user_id, category, key, value, created_at, updated_at, source_session_id,
embedding, source, confidence, expires_at, source_context)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::vector, $10, $11, $12, $13)
ON CONFLICT(user_id, category, key) DO UPDATE SET
value = EXCLUDED.value,
updated_at = EXCLUDED.updated_at,
source_session_id = EXCLUDED.source_session_id,
embedding = EXCLUDED.embedding,
source = EXCLUDED.source,
confidence = EXCLUDED.confidence,
expires_at = COALESCE(EXCLUDED.expires_at, memory_facts.expires_at),
source_context = EXCLUDED.source_context""",
fact_id, user_id, category, key, value, now, now,
source_session_id, vec_str, source, confidence, expires_at, source_context,
)
else:
await conn.execute(
"""INSERT INTO memory_facts
(id, user_id, category, key, value, created_at, updated_at, source_session_id,
source, confidence, expires_at, source_context)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT(user_id, category, key) DO UPDATE SET
value = EXCLUDED.value,
updated_at = EXCLUDED.updated_at,
source_session_id = EXCLUDED.source_session_id,
source = EXCLUDED.source,
confidence = EXCLUDED.confidence,
expires_at = COALESCE(EXCLUDED.expires_at, memory_facts.expires_at),
source_context = EXCLUDED.source_context""",
fact_id, user_id, category, key, value, now, now,
source_session_id, source, confidence, expires_at, source_context,
)
async def _merge_key_aliases(
self, conn, category: str, key: str, user_id: str | None, new_id: str
) -> None:
"""Hide the same fact already stored under a different spelling of this key.
The exact-key upsert cannot see `preferred_language` when `language` is what
is already there, which is how the base grew 11 spellings of the same handful
of keys. Compared on key tokens and **not** on value: `ui_language` and
`language` are one key while `dark` and `dark_ui` are two, and that is set
equality, not overlap. Values are deliberately left out of the decision: the
survivor is the row that already holds the key the writer used (else the
incoming one), and since the upsert writes the fresh value into it, the
fresher value is what remains readable either way.
Only *leading* noise tokens are dropped (see `_key_tokens`), so a trailing
one survives: `dark_ui` keeps its `ui` and stays apart from `dark`, while
`ui_dark` loses it and does not. That asymmetry is the price of reading `ui`
as noise in `ui_language` and as part of the name in `dark_ui`; no rule reads
it both ways.
Marked, never deleted: a loser keeps its own key and its own value, so the
merge is reversible with `UPDATE ... SET merged_into = NULL`.
"""
tokens = _key_tokens(key)
if not tokens:
return
# Hidden rows are read too: one of them may hold this exact key, and then the
# upsert below will land on it through ON CONFLICT rather than inserting the
# row we are about to point everything at.
rows = await conn.fetch(
"SELECT id, key, merged_into FROM memory_facts "
"WHERE user_id IS NOT DISTINCT FROM $1 AND category = $2 AND id <> $3",
user_id, category, new_id,
)
exact = [r["id"] for r in rows if r["key"] == key]
# Only live rows are worth hiding; the rest are already behind something.
aliases = [
r["id"] for r in rows
if r["key"] != key and r["merged_into"] is None and _key_tokens(r["key"]) == tokens
]
winner = exact[0] if exact else new_id
if exact:
# A row with this exact key is what the ON CONFLICT will update, so it
# becomes live again — and it has to be un-hidden for that, or the write
# lands in a row no read can see and the losers get pointed at a row that
# was never inserted.
await conn.execute(
"UPDATE memory_facts SET merged_into = NULL WHERE id = ANY($1::text[])", exact
)
if not aliases:
return
# The second clause re-points the losers of an earlier merge, so pointers
# never form a chain through a row that is itself hidden.
await conn.execute(
"UPDATE memory_facts SET merged_into = $2 "
"WHERE (id = ANY($1::text[]) OR merged_into = ANY($1::text[])) AND id <> $2",
aliases, winner,
)
log.info(
"memory.key_alias_merged",
category=category, key=key[:60], merged=len(aliases), winner=winner,
)
async def search_facts(self, query: str, user_id: str | None = None, limit: int = 15) -> list[dict]:
# 1. Try vector search if pgvector + embedding backend are available
if self._embedding_backend and await self._has_pgvector():
query_embedding = await self._generate_embedding(query)
vec_str = _vector_to_str(query_embedding) if query_embedding else None
if vec_str:
try:
pool = await self._get_pool()
async with pool.acquire() as conn:
if user_id is None:
rows = await conn.fetch(
"""SELECT id, category, key, value, updated_at,
source, confidence, expires_at, source_context, merged_into,
embedding <=> $1::vector AS distance
FROM memory_facts
WHERE (expires_at IS NULL OR expires_at > now())
AND merged_into IS NULL
AND user_id IS NULL
ORDER BY embedding <=> $1::vector
LIMIT $2""",
vec_str, limit,
)
else:
rows = await conn.fetch(
"""SELECT id, category, key, value, updated_at,
source, confidence, expires_at, source_context, merged_into,
embedding <=> $1::vector AS distance
FROM memory_facts
WHERE (expires_at IS NULL OR expires_at > now())
AND merged_into IS NULL
AND user_id = $3
ORDER BY embedding <=> $1::vector
LIMIT $2""",
vec_str, limit, user_id,
)
results = [
_row_to_dict(r)
for r in rows
if r["distance"] < _VECTOR_CUTOFF_DISTANCE
]
if results:
log.debug("memory.vector_search", hits=len(results), query=query[:40])
return results
except Exception:
log.warning("memory.vector_search_failed", query=query[:40], exc_info=True)
else:
log.warning("memory.vector_fallback_to_ilike", reason="embed_failed", query=query[:40])
else:
reason = "no_embedding_backend" if not self._embedding_backend else "no_pgvector"
log.warning("memory.vector_fallback_to_ilike", reason=reason, query=query[:40])
# 2. Text fallback (original ILIKE logic)
terms = _normalize_query(query)
if not terms:
return await self.get_all_facts(user_id=user_id, limit=limit)
if await self.fact_count(user_id=user_id) <= _AUTO_DUMP_THRESHOLD:
return await self.get_all_facts(user_id=user_id, limit=limit)
base_params: list = []
term_conds: list[str] = []
for term in terms:
like = f"%{term}%"
i = len(base_params) + 1
term_conds.append(
f"(category ILIKE ${i} OR key ILIKE ${i + 1} OR value ILIKE ${i + 2})"
)
base_params.extend([like, like, like])
user_filter = "user_id IS NULL" if user_id is None else f"user_id = ${len(base_params) + 1}"
if user_id is not None:
base_params.append(user_id)
# This branch used to be the only read path that served expired facts, and
# hidden merge losers used to be served by all of them.
user_filter += " AND merged_into IS NULL AND (expires_at IS NULL OR expires_at > now())"
limit_idx = len(base_params) + 1
and_where = " AND ".join(term_conds)
or_where = " OR ".join(term_conds)
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
f"SELECT id, category, key, value, updated_at, source, confidence, "
f"expires_at, source_context, merged_into FROM memory_facts "
f"WHERE {user_filter} AND {and_where} ORDER BY updated_at DESC LIMIT ${limit_idx}",
*base_params, limit,
)
if rows:
return [_row_to_dict(r) for r in rows]
score_expr = " + ".join(
f"CASE WHEN {c} THEN 1 ELSE 0 END" for c in term_conds
)
rows = await conn.fetch(
f"SELECT id, category, key, value, updated_at, source, confidence, "
f"expires_at, source_context, merged_into, ({score_expr}) AS score "
f"FROM memory_facts WHERE {user_filter} AND ({or_where}) "
f"ORDER BY score DESC, updated_at DESC LIMIT ${limit_idx}",
*base_params, limit,
)
return [_row_to_dict(r) for r in rows]
async def delete_fact(self, key: str, category: str | None = None, user_id: str | None = None) -> int:
pool = await self._get_pool()
async with pool.acquire() as conn, conn.transaction():
await self._delete_merged_dependents(conn, key, category, user_id)
if category:
if user_id is None:
result = await conn.execute(
"DELETE FROM memory_facts WHERE LOWER(key)=LOWER($1) AND LOWER(category)=LOWER($2) AND user_id IS NULL",
key, category,
)
else:
result = await conn.execute(
"DELETE FROM memory_facts WHERE LOWER(key)=LOWER($1) AND LOWER(category)=LOWER($2) AND user_id = $3",
key, category, user_id,
)
else:
if user_id is None:
result = await conn.execute(
"DELETE FROM memory_facts WHERE LOWER(key)=LOWER($1) AND user_id IS NULL", key
)
else:
result = await conn.execute(
"DELETE FROM memory_facts WHERE LOWER(key)=LOWER($1) AND user_id = $2", key, user_id
)
return int(result.split()[1])
async def _delete_merged_dependents(
self, conn, key: str, category: str | None, user_id: str | None
) -> None:
"""Forget drops the hidden aliases of the keys it is about to delete.
A merge loser points at its winner; deleting the winner on its own would
leave those rows hidden with nothing left to point at and no way back to
them. They are deleted, not un-hidden — the user asked for the key gone.
"""
conditions = ["LOWER(key) = LOWER($1)"]
args: list = [key]
if category:
args.append(category)
conditions.append(f"LOWER(category) = LOWER(${len(args)})")
if user_id is None:
conditions.append("user_id IS NULL")
else:
args.append(user_id)
conditions.append(f"user_id = ${len(args)}")
await conn.execute(
"DELETE FROM memory_facts WHERE merged_into IN "
f"(SELECT id FROM memory_facts WHERE {' AND '.join(conditions)})",
*args,
)
async def get_all_facts(
self,
user_id: str | None = None,
limit: int | None = None,
offset: int = 0,
search: str | None = None,
sort_by: str = "category",
sort_order: str = "desc",
all_users: bool = False,
include_merged: bool = False,
) -> list[dict]:
q = (
"SELECT id, category, key, value, updated_at, source, confidence, "
"expires_at, source_context, merged_into FROM memory_facts "
)
params: list = []
param_idx = 0
def add_param(value):
nonlocal param_idx
param_idx += 1
params.append(value)
return f"${param_idx}"
conditions = []
if not all_users:
if user_id is None:
conditions.append("user_id IS NULL")
else:
conditions.append(f"user_id = {add_param(user_id)}")
if not include_merged:
conditions.append("merged_into IS NULL")
if search:
like = f"%{search}%"
conditions.append(
f"(key ILIKE {add_param(like)} OR value ILIKE {add_param(like)} OR category ILIKE {add_param(like)})"
)
if conditions:
q += "WHERE " + " AND ".join(conditions) + " "
allowed_cols = {"category", "key", "updated_at", "confidence", "source"}
col = sort_by if sort_by in allowed_cols else "category"
order = "DESC" if sort_order == "desc" else "ASC"
if col == "category":
q += f"ORDER BY {col} {order}, updated_at DESC"
else:
q += f"ORDER BY {col} {order}"
if limit is not None:
q += f" LIMIT {add_param(limit)}"
if offset:
q += f" OFFSET {add_param(offset)}"
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(q, *params)
return [_row_to_dict(r) for r in rows]
async def fact_count(
self,
user_id: str | None = None,
all_users: bool = False,
search: str | None = None,
include_merged: bool = False,
) -> int:
q = "SELECT COUNT(*) FROM memory_facts"
params: list = []
conditions = []
if not all_users:
if user_id is None:
conditions.append("user_id IS NULL")
else:
conditions.append(f"user_id = ${len(params) + 1}")
params.append(user_id)
if not include_merged:
conditions.append("merged_into IS NULL")
if search:
like = f"%{search}%"
conditions.append(
f"(key ILIKE ${len(params) + 1} OR value ILIKE ${len(params) + 2} OR category ILIKE ${len(params) + 3})"
)
params.extend([like, like, like])
if conditions:
q += " WHERE " + " AND ".join(conditions)
pool = await self._get_pool()
async with pool.acquire() as conn:
return await conn.fetchval(q, *params) or 0
async def get_categories(self, user_id: str | None = None) -> list[str]:
pool = await self._get_pool()
async with pool.acquire() as conn:
if user_id is None:
rows = await conn.fetch(
"SELECT DISTINCT category FROM memory_facts "
"WHERE user_id IS NULL AND merged_into IS NULL ORDER BY category"
)
else:
rows = await conn.fetch(
"SELECT DISTINCT category FROM memory_facts "
"WHERE user_id = $1 AND merged_into IS NULL ORDER BY category",
user_id,
)
return [r["category"] for r in rows]
def _row_to_dict(row: asyncpg.Record) -> dict:
val = row["updated_at"]
updated_at = val.isoformat() if hasattr(val, "isoformat") else str(val)
expires_at = row.get("expires_at")
expires_str = expires_at.isoformat() if hasattr(expires_at, "isoformat") else None
return {
"id": row["id"],
"category": row["category"],
"key": row["key"],
"value": row["value"],
"updated_at": updated_at,
"source": row.get("source", "conversation"),
"confidence": row.get("confidence", 70),
"expires_at": expires_str,
"source_context": row.get("source_context", ""),
"merged_into": row.get("merged_into"),
}