from __future__ import annotations import datetime import logging import time from contextlib import suppress from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from src import crud, exceptions, models, schemas from src.config import settings from src.dependencies import tracked_db from src.dreamer.dream_scheduler import check_and_schedule_dream from src.embedding_client import embedding_client from src.schemas import ResolvedConfiguration from src.telemetry.logging import accumulate_metric from src.utils.formatting import format_datetime_utc from src.utils.representation import ( DeductiveObservation, ExplicitObservation, Representation, ) logger = logging.getLogger(__name__) def _observation_text(obs: ExplicitObservation | DeductiveObservation) -> str: """Return the canonical text payload for an explicit or deductive observation.""" return obs.conclusion if isinstance(obs, DeductiveObservation) else obs.content def _normalized_observation( obs: ExplicitObservation | DeductiveObservation, ) -> ExplicitObservation | DeductiveObservation: """Return an observation with its persisted/embed text normalized.""" text = _observation_text(obs).strip() if isinstance(obs, DeductiveObservation): return obs.model_copy(update={"conclusion": text}) return obs.model_copy(update={"content": text}) class RepresentationManager: """Unified manager for representation and document queries.""" def __init__( self, workspace_name: str, *, observer: str, observed: str, ) -> None: self.workspace_name: str = workspace_name self.observer: str = observer self.observed: str = observed async def save_representation( self, representation: Representation, message_ids: list[int], session_name: str, message_created_at: datetime.datetime, message_level_configuration: ResolvedConfiguration, ) -> int: """ Save Representation objects to the collection as a set of documents. Args: representation: Representation object message_ids: Message ID range to link with observations session_name: Session name to link with existing summary context message_created_at: Timestamp when the message was created Returns: The number of *new documents saved* """ new_documents = 0 if not representation.deductive and not representation.explicit: logger.debug("No observations to save") return new_documents all_observations = [ _normalized_observation(obs) for obs in representation.deductive + representation.explicit if _observation_text(obs).strip() ] if not all_observations: logger.debug("No non-empty observations to save") return new_documents # Batch embed all observations batch_embed_start = time.perf_counter() observation_texts = [_observation_text(obs) for obs in all_observations] try: embeddings = await embedding_client.simple_batch_embed(observation_texts) except ValueError as e: raise exceptions.ValidationException( "Observation content exceeds maximum token limit of " + f"{settings.EMBEDDING.MAX_INPUT_TOKENS}." ) from e batch_embed_duration = (time.perf_counter() - batch_embed_start) * 1000 accumulate_metric( f"deriver_{message_ids[-1]}_{self.observer}", "embed_new_observations", batch_embed_duration, "ms", ) # Batch create document objects create_document_start = time.perf_counter() async with tracked_db("representation_manager.save_representation") as db: new_documents = await self._save_representation_internal( db, all_observations, embeddings, message_ids, session_name, message_created_at, message_level_configuration, ) create_document_duration = (time.perf_counter() - create_document_start) * 1000 accumulate_metric( f"deriver_{message_ids[-1]}_{self.observer}", "save_new_observations", create_document_duration, "ms", ) return new_documents async def _save_representation_internal( self, db: AsyncSession, all_observations: list[ExplicitObservation | DeductiveObservation], embeddings: list[list[float]], message_ids: list[int], session_name: str, message_created_at: datetime.datetime, message_level_configuration: ResolvedConfiguration, ) -> int: # get_or_create_collection already handles IntegrityError with rollback and a retry collection = await crud.get_or_create_collection( db, self.workspace_name, observer=self.observer, observed=self.observed, ) # Prepare all documents for bulk creation documents_to_create: list[schemas.DocumentCreate] = [] for obs, embedding in zip(all_observations, embeddings, strict=True): # NOTE: will add additional levels of reasoning in the future if isinstance(obs, DeductiveObservation): obs_level = "deductive" obs_content = obs.conclusion obs_premises = obs.premises else: obs_level = "explicit" obs_content = obs.content obs_premises = None metadata: schemas.DocumentMetadata = schemas.DocumentMetadata( message_ids=message_ids, premises=obs_premises, message_created_at=format_datetime_utc(message_created_at), ) documents_to_create.append( schemas.DocumentCreate( content=obs_content, session_name=session_name, level=obs_level, metadata=metadata, embedding=embedding, ) ) # Use bulk creation with optional duplicate detection accepted_documents = await crud.create_documents( db, documents_to_create, self.workspace_name, observer=self.observer, observed=self.observed, deduplicate=settings.DERIVER.DEDUPLICATE, ) if message_level_configuration.dream.enabled: try: await check_and_schedule_dream(db, collection) except Exception as e: logger.warning(f"Failed to check dream scheduling: {e}") return len(accepted_documents) async def get_working_representation( self, *, db: AsyncSession | None = None, session_name: str | None = None, include_semantic_query: str | None = None, embedding: list[float] | None = None, semantic_search_top_k: int | None = None, semantic_search_max_distance: float | None = None, include_most_derived: bool = False, max_observations: int = settings.DERIVER.WORKING_REPRESENTATION_MAX_OBSERVATIONS, ) -> Representation: """ Get working representation with flexible query options. Args: db: Optional database session. If provided, uses it directly; otherwise creates a new session via tracked_db. session_name: Optional session to filter by include_semantic_query: Query for semantic search embedding: Pre-computed embedding for the semantic query. semantic_search_top_k: Number of semantic results semantic_search_max_distance: Maximum distance for semantic search include_most_derived: Include most derived observations max_observations: Maximum total observations to return Returns: Representation combining various query strategies """ if include_semantic_query and embedding is None: with suppress(Exception): # Best-effort precompute embedding = await embedding_client.embed(include_semantic_query) if db is not None: return await self._get_working_representation_internal( db, session_name=session_name, include_semantic_query=include_semantic_query, embedding=embedding, semantic_search_top_k=semantic_search_top_k, semantic_search_max_distance=semantic_search_max_distance, include_most_derived=include_most_derived, max_observations=max_observations, ) async with tracked_db( "representation_manager.get_working_representation" ) as new_db: return await self._get_working_representation_internal( new_db, session_name=session_name, include_semantic_query=include_semantic_query, embedding=embedding, semantic_search_top_k=semantic_search_top_k, semantic_search_max_distance=semantic_search_max_distance, include_most_derived=include_most_derived, max_observations=max_observations, ) # Private helper methods async def _get_working_representation_internal( self, db: AsyncSession, *, session_name: str | None = None, include_semantic_query: str | None = None, embedding: list[float] | None = None, semantic_search_top_k: int | None = None, semantic_search_max_distance: float | None = None, include_most_derived: bool = False, max_observations: int = settings.DERIVER.WORKING_REPRESENTATION_MAX_OBSERVATIONS, ) -> Representation: """Internal implementation of get_working_representation.""" total = max_observations # Calculate how many observations to get from each source semantic_observations = ( min( max( 0, semantic_search_top_k if semantic_search_top_k is not None else total // 3, ), total, ) if include_semantic_query else 0 ) if include_semantic_query and include_most_derived: # three-way blend: both semantic and derived requested top_observations = min(max(0, total // 3), total - semantic_observations) elif include_most_derived: # two-way blend: only derived requested top_observations = min(max(0, total // 2), total - semantic_observations) else: # no derived observations requested top_observations = 0 # remaining observations are recent recent_observations = total - semantic_observations - top_observations representation = Representation() # Get semantic observations if requested if include_semantic_query: semantic_docs = await self._query_documents_semantic( db, query=include_semantic_query, top_k=semantic_observations, max_distance=semantic_search_max_distance, embedding=embedding, ) representation.merge_representation( Representation.from_documents(semantic_docs) ) # Get most derived observations if requested if include_most_derived: derived_docs = await self._query_documents_most_derived( db, top_k=top_observations ) representation.merge_representation( Representation.from_documents(derived_docs) ) # Get recent observations recent_docs = await self._query_documents_recent( db, top_k=recent_observations, session_name=session_name ) representation.merge_representation(Representation.from_documents(recent_docs)) return representation async def _query_documents_semantic( self, db: AsyncSession, query: str, top_k: int, max_distance: float | None = None, level: str | None = None, embedding: list[float] | None = None, ) -> list[models.Document]: """Query documents by semantic similarity.""" try: if level: return await self._query_documents_for_level( db, query, level, top_k, max_distance, embedding=embedding, ) else: documents = await crud.query_documents( db, workspace_name=self.workspace_name, observer=self.observer, observed=self.observed, query=query, max_distance=max_distance, top_k=top_k, embedding=embedding, ) db.expunge_all() return list(documents) except Exception as e: logger.error(f"Error getting relevant observations: {e}") return [] async def _query_documents_recent( self, db: AsyncSession, top_k: int, session_name: str | None = None ) -> list[models.Document]: """Query most recent documents.""" stmt = ( select(models.Document) .limit(top_k) .where( models.Document.workspace_name == self.workspace_name, models.Document.observer == self.observer, models.Document.observed == self.observed, models.Document.deleted_at.is_(None), *( [models.Document.session_name == session_name] if session_name is not None else [] ), ) .order_by(models.Document.created_at.desc()) ) result = await db.execute(stmt) documents = result.scalars().all() db.expunge_all() return list(documents) async def _query_documents_most_derived( self, db: AsyncSession, top_k: int ) -> list[models.Document]: """Query most derived documents.""" stmt = ( select(models.Document) .limit(top_k) .where( models.Document.workspace_name == self.workspace_name, models.Document.observer == self.observer, models.Document.observed == self.observed, models.Document.deleted_at.is_(None), ) .order_by(models.Document.times_derived.desc()) ) result = await db.execute(stmt) documents = result.scalars().all() db.expunge_all() return list(documents) async def _get_observations_internal( self, db: AsyncSession, query: str, top_k: int, max_distance: float, level: str | None, ) -> list[models.Document]: """Internal method that does the actual observation retrieval.""" return await self._query_documents_semantic( db, query, top_k, max_distance, level ) async def _query_documents_for_level( self, db: AsyncSession, query: str, level: str, count: int, max_distance: float | None = None, embedding: list[float] | None = None, ) -> list[models.Document]: """Query documents for a specific level.""" documents = await crud.query_documents( db, workspace_name=self.workspace_name, observer=self.observer, observed=self.observed, query=query, max_distance=max_distance, top_k=count, filters=self._build_filter_conditions(level), embedding=embedding, ) # Sort by creation time docs_sorted: list[models.Document] = sorted( list(documents), key=lambda x: x.created_at, reverse=True ) return docs_sorted def _build_filter_conditions( self, level: str | None = None, ) -> dict[str, Any]: """ Build filter conditions for document queries. Returns a flat dict of key-value pairs for vector store filtering. """ filters: dict[str, Any] = {} if level: filters["level"] = level return filters # Module-level functions for backward compatibility and convenience async def get_working_representation( workspace_name: str, *, db: AsyncSession | None = None, observer: str, observed: str, session_name: str | None = None, include_semantic_query: str | None = None, embedding: list[float] | None = None, semantic_search_top_k: int | None = None, semantic_search_max_distance: float | None = None, include_most_derived: bool = False, max_observations: int = settings.DERIVER.WORKING_REPRESENTATION_MAX_OBSERVATIONS, ) -> Representation: """ Get raw working representation data from the relevant document collection. This is a convenience function that creates a RepresentationManager and calls get_working_representation on it. Args: db: Optional database session. If provided, uses it directly; otherwise creates a new session via tracked_db. embedding: Pre-computed embedding for the semantic query. """ manager = RepresentationManager( workspace_name=workspace_name, observer=observer, observed=observed, ) return await manager.get_working_representation( db=db, session_name=session_name, include_semantic_query=include_semantic_query, embedding=embedding, semantic_search_top_k=semantic_search_top_k, semantic_search_max_distance=semantic_search_max_distance, include_most_derived=include_most_derived, max_observations=max_observations, )