Newer
Older
navi-1 / navi / memory / _chunks.py
@Eugene Sukhodolskiy Eugene Sukhodolskiy 1 day ago 19 KB memory: fix review findings in unified search
"""Raw conversation chunks — the append-only record of what compression dropped.

Long-term memory used to have three shapes: curated facts, per-session sketches,
and the live context. When the context was compressed, everything that did not
fit the summary was simply gone — the message rows stay in the database, but
nothing searched them by meaning, and after a reload the session carried only the
summary. "We discussed this earlier" was unanswerable.

This table is the missing store: every message that compression demotes is
written here once, verbatim (a very large single message is elided in the
middle), with the turn range and timestamps it came from and the breadcrumb keys
extracted for it. Three properties are load-bearing:

* **Append-only and immutable.** A row is never updated. A correction is a new
  chunk written later; `UNIQUE(session_id, content_hash)` only prevents the same
  dropped run from being indexed twice, which is what makes a compression replay
  idempotent.
* **Read together with facts and sketches**, through `UnifiedMemorySearch`, so an
  agent has one place to ask. Writing stays separate: chunks are written only by
  the compressor, never by a tool.
* **Provenance is part of the record** — session, turn range, timestamps — because
  an answer built on a raw excerpt must say when it was said, and a curated fact
  from last week must win over a raw line from last month.
"""

from __future__ import annotations

import hashlib
import re
import uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING

import structlog

from ._embeddings import _vector_to_str
from ._facts import STOPWORDS, _normalize_query

if TYPE_CHECKING:
    from navi.llm.base import Message

log = structlog.get_logger()

# One chunk is one searchable unit. 4000 chars is roughly a long tool result or
# a dozen chat messages — small enough that a hit is about one topic, large
# enough that the answer's context is not split across three rows.
CHUNK_MAX_CHARS = 4000
# A single message can be megabytes (a full file read). Keep its head and tail:
# the middle of a dump is the part nobody asks for later, while the command line
# at the top and the error at the bottom are usually the breadcrumbs.
CHUNK_MESSAGE_MAX_CHARS = 12_000

# Cosine distance cutoff for a chunk match. Facts use 0.3 because a fact value is
# one short sentence; a 4 KB passage against a short query naturally sits farther
# away, and 0.45 is where the pilot's paraphrased queries still landed. Anything
# past it goes to the lexical fallback instead — a wrong vector hit is worse than
# a keyword hit for a long passage.
VECTOR_CUTOFF_DISTANCE = 0.45

# Recall builds its query from a window of recent turns (up to 1200 chars), and
# every term used to become its own ILIKE pattern and its own `terms_hit` point.
# A dozen content words is what a query is actually about; the rest is prose.
_MAX_LEXICAL_TERMS = 12

_WORD = re.compile(r"\w+", re.UNICODE)

_ROLE_PREFIX = {
    "user": "User",
    "assistant": "Assistant",
    "tool": "Tool result",
}


def build_chunks(
    messages: list[Message],
    breadcrumbs: list[str] | None = None,
    max_chars: int = CHUNK_MAX_CHARS,
) -> list[dict]:
    """Group dropped messages into immutable chunks.

    Consecutive messages are packed together until `max_chars`, so a turn's user
    message, tool calls and the assistant reply land in one chunk. Summary and
    compression marker messages are skipped — a summary is not raw history, and
    indexing old summaries again would make each compression index its own past.
    """
    chunks: list[dict] = []
    current: list[str] = []
    current_len = 0
    start_seq: int | None = None
    end_seq: int | None = None
    started_at: datetime | None = None
    ended_at: datetime | None = None
    keys = list(breadcrumbs or [])

    def flush() -> None:
        nonlocal current, current_len, start_seq, end_seq, started_at, ended_at
        if not current:
            return
        text = "\n\n".join(current).strip()
        if text:
            chunks.append({
                "content": text,
                "content_hash": hashlib.sha256(text.encode("utf-8", "replace")).hexdigest(),
                "start_seq": start_seq if start_seq is not None else -1,
                "end_seq": end_seq if end_seq is not None else -1,
                "started_at": started_at,
                "ended_at": ended_at,
                "breadcrumbs": keys,
            })
        current, current_len = [], 0
        start_seq = end_seq = None
        started_at = ended_at = None

    for msg in messages:
        if msg.role == "system" or msg.is_summary or msg.is_compression:
            continue
        text = _format_message(msg)
        if not text:
            continue
        if current and current_len + len(text) > max_chars:
            flush()
        current.append(text)
        current_len += len(text) + 2
        if start_seq is None:
            start_seq = msg.sequence_number
            started_at = msg.created_at
        if msg.sequence_number >= 0:
            end_seq = msg.sequence_number
        if msg.created_at is not None:
            ended_at = msg.created_at
    flush()
    return chunks


def _format_message(msg: Message) -> str:
    content = (msg.content or "").strip()
    if not content:
        # An assistant message that only requested tool calls still records what
        # was asked for; the result follows in its own message.
        if msg.tool_calls:
            names = ", ".join(tc.name for tc in msg.tool_calls if getattr(tc, "name", None))
            return f"Assistant requested tools: {names}" if names else ""
        return ""
    if len(content) > CHUNK_MESSAGE_MAX_CHARS:
        head = CHUNK_MESSAGE_MAX_CHARS * 3 // 4
        tail = CHUNK_MESSAGE_MAX_CHARS - head - 40
        content = f"{content[:head]}\n…[middle elided]…\n{content[-tail:]}"
    prefix = _ROLE_PREFIX.get(msg.role, msg.role.title())
    if msg.role == "tool" and msg.name:
        prefix = f"Tool result ({msg.name})"
    return f"{prefix}: {content}"


class ChunkMixin:
    """Storage and search for dropped-raw-history chunks.

    Expected on the composite class:
        _get_pool() -> asyncpg.Pool
        _generate_embedding(text) -> list[float] | None
        _generate_embeddings(texts) -> list[list[float] | None]
        _has_pgvector() -> bool
    """

    async def add_chunks(
        self,
        chunks: list[dict],
        user_id: str | None = None,
        session_id: str | None = None,
    ) -> int:
        """Write chunks, skipping ones already indexed for this session."""
        if not chunks or not session_id:
            return 0
        # The embedding column exists only where pgvector does; on a plain Postgres
        # the `::vector` cast in the INSERT would fail the whole write, so the row
        # is written text-only and found through FTS.
        use_vectors = await self._has_pgvector()
        embeddings = (
            await self._generate_embeddings([c["content"] for c in chunks])
            if use_vectors else [None] * len(chunks)
        )
        now = datetime.now(timezone.utc)
        pool = await self._get_pool()
        written = 0
        async with pool.acquire() as conn:
            for chunk, embedding in zip(chunks, embeddings):
                vec_str = _vector_to_str(embedding) if embedding else None
                if use_vectors:
                    result = await conn.execute(
                        """INSERT INTO memory_chunks
                            (id, user_id, session_id, start_seq, end_seq, started_at, ended_at,
                             content, breadcrumbs, content_hash, created_at, embedding)
                           VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::vector)
                           ON CONFLICT (session_id, content_hash) DO NOTHING""",
                        str(uuid.uuid4()), user_id, session_id,
                        chunk["start_seq"], chunk["end_seq"],
                        chunk.get("started_at"), chunk.get("ended_at"),
                        chunk["content"], list(chunk.get("breadcrumbs") or []),
                        chunk["content_hash"], now, vec_str,
                    )
                else:
                    result = await conn.execute(
                        """INSERT INTO memory_chunks
                            (id, user_id, session_id, start_seq, end_seq, started_at, ended_at,
                             content, breadcrumbs, content_hash, created_at)
                           VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
                           ON CONFLICT (session_id, content_hash) DO NOTHING""",
                        str(uuid.uuid4()), user_id, session_id,
                        chunk["start_seq"], chunk["end_seq"],
                        chunk.get("started_at"), chunk.get("ended_at"),
                        chunk["content"], list(chunk.get("breadcrumbs") or []),
                        chunk["content_hash"], now,
                    )
                if result == "INSERT 0 1":
                    written += 1
        if written:
            log.info(
                "memory.chunks_written",
                session_id=session_id,
                written=written,
                skipped=len(chunks) - written,
            )
        return written

    async def search_chunks(
        self,
        query: str,
        user_id: str | None = None,
        session_id: str | None = None,
        limit: int = 8,
    ) -> list[dict]:
        """Search raw chunks: vector first, lexical fallback when that misses.

        `session_id` narrows to one conversation; omitting it searches every
        session of the user. Anonymous rows (`user_id IS NULL`) are their own
        scope, exactly like facts.
        """
        if limit <= 0:
            return []
        rows: list[dict] = []
        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:
                    rows = await self._vector_chunks(
                        vec_str, user_id, session_id, limit,
                    )
                except Exception:
                    log.warning("memory.chunk_vector_failed", query_len=len(query), exc_info=True)
        if len(rows) >= limit:
            log.debug("memory.chunk_vector_search", hits=len(rows))
            return rows
        if rows and not _lexical_terms(query):
            # Nothing lexical to top up with; the empty-term fallback returns
            # *recent* chunks, which are not matches and must not ride along
            # behind a vector hit.
            log.debug("memory.chunk_vector_search", hits=len(rows))
            return rows
        # Vector-first, but not vector-only: a chunk whose embedding is NULL (a
        # failed embedding batch, or a row written while pgvector was absent) is
        # invisible to `ORDER BY embedding <=>` and would never come back once
        # any vector hit exists. Top up the short result with the lexical
        # matches, which see every row.
        lexical = await self._lexical_chunks(query, user_id, session_id, limit)
        if not rows:
            if lexical:
                log.debug("memory.chunk_lexical_search", hits=len(lexical))
            return lexical
        seen = {row["id"] for row in rows}
        rows.extend(row for row in lexical if row["id"] not in seen)
        log.debug("memory.chunk_vector_search", hits=len(rows))
        return rows[:limit]

    async def _vector_chunks(
        self,
        vec_str: str,
        user_id: str | None,
        session_id: str | None,
        limit: int,
    ) -> list[dict]:
        where, params = _chunk_filter(user_id, session_id)
        params.append(vec_str)
        vec_idx = len(params)
        params.append(limit * 3)
        lim_idx = len(params)
        pool = await self._get_pool()
        async with pool.acquire() as conn:
            rows = await conn.fetch(
                f"""SELECT id, session_id, start_seq, end_seq, started_at, ended_at,
                           content, breadcrumbs, created_at,
                           embedding <=> ${vec_idx}::vector AS distance
                    FROM memory_chunks
                    WHERE {where}
                    ORDER BY embedding <=> ${vec_idx}::vector
                    LIMIT ${lim_idx}""",
                *params,
            )
        hits: list[dict] = []
        for row in rows:
            distance = row["distance"]
            if distance is None or distance >= VECTOR_CUTOFF_DISTANCE:
                continue
            hit = _row_to_chunk(row)
            hit["score"] = max(0.0, 1.0 - float(distance))
            hits.append(hit)
            if len(hits) >= limit:
                break
        return hits

    async def _lexical_chunks(
        self,
        query: str,
        user_id: str | None,
        session_id: str | None,
        limit: int,
    ) -> list[dict]:
        terms = _lexical_terms(query)
        where, params = _chunk_filter(user_id, session_id)
        if not terms:
            return await self._recent_chunks(where, params, limit)
        ts_query = " ".join(terms)
        like_patterns = [f"%{term}%" for term in terms]
        params.append(ts_query)
        ts_idx = len(params)
        params.append(like_patterns)
        like_idx = len(params)
        params.append(limit * 4)
        lim_idx = len(params)
        match_clause = (
            f"(to_tsvector('simple', content) @@ plainto_tsquery('simple', ${ts_idx}) "
            f"OR content ILIKE ANY(${like_idx}::text[]) "
            f"OR array_to_string(breadcrumbs, ' ') ILIKE ANY(${like_idx}::text[]))"
        )
        pool = await self._get_pool()
        async with pool.acquire() as conn:
            rows = await conn.fetch(
                f"""SELECT id, session_id, start_seq, end_seq, started_at, ended_at,
                           content, breadcrumbs, created_at,
                           ts_rank(to_tsvector('simple', content),
                                   plainto_tsquery('simple', ${ts_idx})) AS rank
                    FROM memory_chunks
                    WHERE {where} AND {match_clause}
                    ORDER BY rank DESC, ended_at DESC NULLS LAST
                    LIMIT ${lim_idx}""",
                *params,
            )
        hits = []
        normalized_terms = {t for t in terms if len(t) > 2}
        for row in rows:
            hit = _row_to_chunk(row)
            haystack = f"{hit['content']} {' '.join(hit['breadcrumbs'])}"
            hit["terms_hit"] = _matched_terms(haystack, normalized_terms)
            hit["score"] = None
            hits.append(hit)
        # A row matched one long term by accident; a row matching several query
        # terms is the one the caller meant. Rank on that before the DB order.
        hits.sort(key=lambda h: (-h["terms_hit"], -_ts(h.get("rank"))))
        return hits[:limit]
    async def _recent_chunks(self, where: str, params: list, limit: int) -> list[dict]:
        pool = await self._get_pool()
        async with pool.acquire() as conn:
            rows = await conn.fetch(
                f"""SELECT id, session_id, start_seq, end_seq, started_at, ended_at,
                           content, breadcrumbs, created_at
                    FROM memory_chunks
                    WHERE {where}
                    ORDER BY ended_at DESC NULLS LAST
                    LIMIT ${len(params) + 1}""",
                *params, limit,
            )
        hits = []
        for row in rows:
            hit = _row_to_chunk(row)
            # No relevance was computed here — "most recent" is not "most
            # similar". A fabricated score (1.0, 0.95, …) used to sail past
            # recall's similarity threshold and inject unrelated turns.
            hit["score"] = None
            hit["terms_hit"] = 0
            hits.append(hit)
        return hits

    async def latest_summary_at(self, session_id: str) -> datetime | None:
        """When this session was last compressed — raw chunks before it are verbatim
        history that the current summary has already curated."""
        pool = await self._get_pool()
        async with pool.acquire() as conn:
            try:
                hot = await conn.fetchval(
                    "SELECT max(created_at) FROM session_messages "
                    "WHERE session_id = $1 AND is_summary = TRUE",
                    session_id,
                )
            except Exception:
                log.debug("memory.latest_summary_failed", session_id=session_id, exc_info=True)
                return None
            # Long sessions archive old rows, summaries included (the archive
            # move is by sequence number, not by kind). Looking only at the hot
            # table made the newest summary invisible there and the
            # `before_summary` label wrong.
            try:
                archived = await conn.fetchval(
                    "SELECT max(created_at) FROM session_messages_archive "
                    "WHERE session_id = $1 AND is_summary = TRUE",
                    session_id,
                )
            except Exception:
                archived = None
        values = [value for value in (hot, archived) if value is not None]
        return max(values) if values else None

    async def chunk_count(self, session_id: str | None = None, user_id: str | None = None) -> int:
        where, params = _chunk_filter(user_id, session_id)
        pool = await self._get_pool()
        async with pool.acquire() as conn:
            return int(await conn.fetchval(
                f"SELECT count(*) FROM memory_chunks WHERE {where}", *params
            ) or 0)


def _lexical_terms(query: str) -> list[str]:
    """Content words of the query, first occurrence wins, capped.

    `_normalize_query` deliberately keeps function words — the fact and sketch
    lookups use them too and their ILIKE paths are cheap — but a chunk query is
    *scored* on term overlap, and "the" inside "other" must not count. The cap
    matters because recall passes a window of recent conversation (up to 1200
    chars); without it one query becomes hundreds of ILIKE patterns and matches
    everything.
    """
    seen: set[str] = set()
    terms: list[str] = []
    for term in _normalize_query(query):
        if term in STOPWORDS or term in seen:
            continue
        seen.add(term)
        terms.append(term)
        if len(terms) >= _MAX_LEXICAL_TERMS:
            break
    return terms


def _matched_terms(text: str, terms: set[str]) -> int:
    """How many of `terms` occur as whole words in `text`."""
    return len(terms & set(_WORD.findall(text.lower())))


def _chunk_filter(user_id: str | None, session_id: str | None) -> tuple[str, list]:
    """WHERE clause for the (user, session) scope. Anonymous rows are NULL-owned."""
    clauses = []
    params: list = []
    if user_id is None:
        clauses.append("user_id IS NULL")
    else:
        params.append(user_id)
        clauses.append(f"user_id = ${len(params)}")
    if session_id is not None:
        params.append(session_id)
        clauses.append(f"session_id = ${len(params)}")
    return " AND ".join(clauses) or "TRUE", params


def _row_to_chunk(row) -> dict:
    return {
        "rank": row["rank"] if "rank" in row.keys() else None,
        "id": row["id"],
        "session_id": row["session_id"],
        "start_seq": row["start_seq"],
        "end_seq": row["end_seq"],
        "started_at": row["started_at"],
        "ended_at": row["ended_at"],
        "content": row["content"],
        "breadcrumbs": list(row["breadcrumbs"] or []),
        "created_at": row["created_at"],
        "terms_hit": 0,
    }


def _ts(rank) -> float:
    try:
        return float(rank or 0.0)
    except (TypeError, ValueError):
        return 0.0