from contextlib import asynccontextmanager from datetime import datetime, timedelta, timezone from unittest.mock import AsyncMock, patch import pytest from nanoid import generate as generate_nanoid from sqlalchemy import func, update from sqlalchemy.ext.asyncio import AsyncSession from src import models from src.crud.representation import RepresentationManager from src.schemas.configuration import ( ResolvedConfiguration, ResolvedDreamConfiguration, ResolvedPeerCardConfiguration, ResolvedReasoningConfiguration, ResolvedSummaryConfiguration, ) from src.utils.representation import ( DeductiveObservation, ExplicitObservation, Representation, ) def _resolved_config(*, dream_enabled: bool = False) -> ResolvedConfiguration: """Build a minimal ResolvedConfiguration for tests that only care about dream.enabled.""" return ResolvedConfiguration( reasoning=ResolvedReasoningConfiguration(enabled=False), peer_card=ResolvedPeerCardConfiguration(use=False, create=False), summary=ResolvedSummaryConfiguration( enabled=False, messages_per_short_summary=20, messages_per_long_summary=60, ), dream=ResolvedDreamConfiguration(enabled=dream_enabled), ) @asynccontextmanager async def _fake_tracked_db(_name: str): yield object() def _saved_observations(mock_save: AsyncMock): call = mock_save.await_args assert call is not None, "mock_save was never awaited" if "all_observations" in call.kwargs: return call.kwargs["all_observations"] if len(call.args) > 1: return call.args[1] raise AssertionError("missing all_observations in await args") class TestRepresentationManagerSoftDelete: """Tests that RepresentationManager query methods exclude soft-deleted documents.""" async def _setup( self, db_session: AsyncSession, test_workspace: models.Workspace, test_peer: models.Peer, ) -> tuple[models.Peer, models.Session, models.Collection, RepresentationManager]: """Create peers, session, collection, and a RepresentationManager.""" test_peer2 = models.Peer( name=str(generate_nanoid()), workspace_name=test_workspace.name ) db_session.add(test_peer2) await db_session.flush() test_session = models.Session( name=str(generate_nanoid()), workspace_name=test_workspace.name ) db_session.add(test_session) await db_session.flush() collection = models.Collection( workspace_name=test_workspace.name, observer=test_peer.name, observed=test_peer2.name, ) db_session.add(collection) await db_session.flush() manager = RepresentationManager( test_workspace.name, observer=test_peer.name, observed=test_peer2.name, ) return test_peer2, test_session, collection, manager @pytest.mark.asyncio async def test_query_documents_recent_excludes_soft_deleted( self, db_session: AsyncSession, sample_data: tuple[models.Workspace, models.Peer], ): """Soft-deleted documents must not appear in the recent-documents query.""" test_workspace, test_peer = sample_data test_peer2, test_session, _, manager = await self._setup( db_session, test_workspace, test_peer ) # Create two documents doc_live = models.Document( workspace_name=test_workspace.name, observer=test_peer.name, observed=test_peer2.name, content="Live observation", session_name=test_session.name, ) doc_deleted = models.Document( workspace_name=test_workspace.name, observer=test_peer.name, observed=test_peer2.name, content="Deleted observation", session_name=test_session.name, ) db_session.add_all([doc_live, doc_deleted]) await db_session.flush() # Soft-delete one await db_session.execute( update(models.Document) .where(models.Document.id == doc_deleted.id) .values(deleted_at=func.now()) ) await db_session.commit() results = await manager._query_documents_recent(db_session, top_k=10) # pyright: ignore[reportPrivateUsage] result_ids = [doc.id for doc in results] assert doc_live.id in result_ids assert doc_deleted.id not in result_ids @pytest.mark.asyncio async def test_query_documents_most_derived_excludes_soft_deleted( self, db_session: AsyncSession, sample_data: tuple[models.Workspace, models.Peer], ): """Soft-deleted documents must not appear in the most-derived query.""" test_workspace, test_peer = sample_data test_peer2, test_session, _, manager = await self._setup( db_session, test_workspace, test_peer ) # Create two documents with different times_derived doc_live = models.Document( workspace_name=test_workspace.name, observer=test_peer.name, observed=test_peer2.name, content="Live observation", session_name=test_session.name, times_derived=5, ) doc_deleted = models.Document( workspace_name=test_workspace.name, observer=test_peer.name, observed=test_peer2.name, content="Deleted high-derived observation", session_name=test_session.name, times_derived=100, ) db_session.add_all([doc_live, doc_deleted]) await db_session.flush() # Soft-delete the high-derived one await db_session.execute( update(models.Document) .where(models.Document.id == doc_deleted.id) .values(deleted_at=func.now()) ) await db_session.commit() results = await manager._query_documents_most_derived(db_session, top_k=10) # pyright: ignore[reportPrivateUsage] result_ids = [doc.id for doc in results] assert doc_live.id in result_ids assert doc_deleted.id not in result_ids @pytest.mark.asyncio async def test_query_documents_most_derived_ties_break_by_recency( self, db_session: AsyncSession, sample_data: tuple[models.Workspace, models.Peer], ): """Regression: when times_derived ties, the manager's most-derived query must fall back to recency, not insertion order. Mirrors the equivalent test on crud.query_documents_most_derived -- the query is duplicated in both modules and must not drift.""" test_workspace, test_peer = sample_data test_peer2, test_session, _, manager = await self._setup( db_session, test_workspace, test_peer ) base = datetime(2026, 1, 1, tzinfo=timezone.utc) # Three conclusions, all reinforced once, inserted oldest-first. for i in range(3): db_session.add( models.Document( workspace_name=test_workspace.name, observer=test_peer.name, observed=test_peer2.name, content=f"tie {i}", session_name=test_session.name, times_derived=1, created_at=base + timedelta(days=i), ) ) # A genuinely reinforced conclusion that is also the oldest of all. db_session.add( models.Document( workspace_name=test_workspace.name, observer=test_peer.name, observed=test_peer2.name, content="hot", session_name=test_session.name, times_derived=5, created_at=base - timedelta(days=10), ) ) await db_session.flush() results = await manager._query_documents_most_derived(db_session, top_k=10) # pyright: ignore[reportPrivateUsage] contents = [doc.content for doc in results] # Primary sort still wins: the actually-reinforced conclusion leads. assert contents[0] == "hot" # Ties break toward most-recent, not oldest-inserted. assert contents[1:] == ["tie 2", "tie 1", "tie 0"] class TestRepresentationManagerSave: @pytest.mark.asyncio async def test_save_representation_filters_blank_observations_before_embedding( self, ): manager = RepresentationManager( "workspace", observer="observer", observed="observed", ) representation = Representation( explicit=[ ExplicitObservation( content=" ", created_at=datetime.now(timezone.utc), message_ids=[1], session_name="session", ), ExplicitObservation( content=" useful observation ", created_at=datetime.now(timezone.utc), message_ids=[1], session_name="session", ), ] ) with ( patch("src.crud.representation.tracked_db", _fake_tracked_db), patch( "src.crud.representation.embedding_client.simple_batch_embed", new=AsyncMock(return_value=[[0.1]]), ) as mock_embed, patch.object( manager, "_save_representation_internal", new=AsyncMock(return_value=1), ) as mock_save, ): saved = await manager.save_representation( representation, message_ids=[1], session_name="session", message_created_at=datetime.now(timezone.utc), message_level_configuration=_resolved_config(), ) assert saved == 1 mock_embed.assert_awaited_once_with(["useful observation"]) saved_observations = _saved_observations(mock_save) assert len(saved_observations) == 1 assert saved_observations[0].content == "useful observation" @pytest.mark.asyncio async def test_save_representation_filters_blank_deductive_observations(self): manager = RepresentationManager( "workspace", observer="observer", observed="observed", ) representation = Representation( deductive=[ DeductiveObservation( conclusion=" ", premises=["premise a"], source_ids=["doc-a"], created_at=datetime.now(timezone.utc), message_ids=[1], session_name="session", ), DeductiveObservation( conclusion=" inferred conclusion ", premises=["premise b"], source_ids=["doc-b"], created_at=datetime.now(timezone.utc), message_ids=[1], session_name="session", ), ] ) with ( patch("src.crud.representation.tracked_db", _fake_tracked_db), patch( "src.crud.representation.embedding_client.simple_batch_embed", new=AsyncMock(return_value=[[0.2]]), ) as mock_embed, patch.object( manager, "_save_representation_internal", new=AsyncMock(return_value=1), ) as mock_save, ): saved = await manager.save_representation( representation, message_ids=[1], session_name="session", message_created_at=datetime.now(timezone.utc), message_level_configuration=_resolved_config(), ) assert saved == 1 mock_embed.assert_awaited_once_with(["inferred conclusion"]) saved_observations = _saved_observations(mock_save) assert len(saved_observations) == 1 assert isinstance(saved_observations[0], DeductiveObservation) assert saved_observations[0].conclusion == "inferred conclusion" @pytest.mark.asyncio async def test_save_representation_skips_all_blank_observations(self): manager = RepresentationManager( "workspace", observer="observer", observed="observed", ) representation = Representation( explicit=[ ExplicitObservation( content="", created_at=datetime.now(timezone.utc), message_ids=[1], session_name="session", ), ExplicitObservation( content="\n\t ", created_at=datetime.now(timezone.utc), message_ids=[1], session_name="session", ), ] ) with ( patch("src.crud.representation.tracked_db", _fake_tracked_db), patch( "src.crud.representation.embedding_client.simple_batch_embed", new=AsyncMock(), ) as mock_embed, patch.object( manager, "_save_representation_internal", new=AsyncMock(), ) as mock_save, ): saved = await manager.save_representation( representation, message_ids=[1], session_name="session", message_created_at=datetime.now(timezone.utc), message_level_configuration=_resolved_config(), ) assert saved == 0 mock_embed.assert_not_awaited() mock_save.assert_not_awaited()