"""Compose source evidence and the two memory scopes without conflating them.""" from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable from app.observability.operation import Operation, observe_operation, observe_stage from app.rag.models import EvidenceBundle, EvidenceRequest, MemoryContextItem from app.rag.project_memory import ProjectMemoryStore from app.rag.retrieval_service import PaperEvidenceService StudentMemoryReader = Callable[[str], Awaitable[str]] class ContextComposer: """Single context-transfer boundary for chat, wiki, pair buddy, and agents.""" def __init__( self, evidence: PaperEvidenceService | None = None, project_memory: ProjectMemoryStore | None = None, student_memory_reader: StudentMemoryReader | None = None, ) -> None: self.evidence = evidence or PaperEvidenceService() self.project_memory = project_memory or ProjectMemoryStore() self._student_memory_reader = student_memory_reader async def compose(self, request: EvidenceRequest) -> EvidenceBundle: consumer = request.consumer.value with observe_operation("context.compose", subsystem="context", consumer=consumer) as op: with observe_stage(op, "source_context", subsystem="context", consumer=consumer) as stage: source = self.evidence.retrieve(request) stage.add_count("source_evidence_count", len(source.evidence)) stage.add_count("source_context_chars", source.diagnostics.source_context_chars) stage.add_count("context_truncated", int(source.diagnostics.context_truncated)) project_items, student_items = await self.recall_memory(request, observer=op) with observe_stage( op, "context_composition", subsystem="context", consumer=consumer, ): diagnostics = source.diagnostics.model_copy(deep=True) diagnostics.project_memory_chars = sum(len(item.statement) for item in project_items) diagnostics.student_memory_chars = sum(len(item.statement) for item in student_items) bundle = EvidenceBundle( source_evidence=source.evidence, project_memory=project_items, student_memory=student_items, citations=source.citations, diagnostics=diagnostics, ) op.add_count("source_evidence_count", len(source.evidence)) op.add_count("project_memory_count", len(project_items)) op.add_count("student_memory_count", len(student_items)) op.add_count("source_context_chars", diagnostics.source_context_chars) op.add_count("project_memory_chars", diagnostics.project_memory_chars) op.add_count("student_memory_chars", diagnostics.student_memory_chars) op.add_count("context_truncated", int(diagnostics.context_truncated)) op.set("project_memory_policy", request.project_memory_policy) op.set("student_memory_policy", request.student_memory_policy) return bundle async def recall_memory( self, request: EvidenceRequest, *, observer: Operation | None = None, ) -> tuple[list[MemoryContextItem], list[MemoryContextItem]]: if observer is None: consumer = request.consumer.value with observe_operation( "context.memory_recall", subsystem="context", consumer=consumer, ) as operation: return await self._recall_memory(request, operation) return await self._recall_memory(request, observer) async def _recall_memory( self, request: EvidenceRequest, observer: Operation, ) -> tuple[list[MemoryContextItem], list[MemoryContextItem]]: consumer = request.consumer.value project_items: list[MemoryContextItem] = [] with observe_stage( observer, "project_memory_recall", subsystem="memory", consumer=consumer, ) as stage: if request.project_memory_policy == "relevant": try: project_items = await asyncio.to_thread( self.project_memory.search, request.project_id, request.query, 6, consumer=consumer, ) except Exception as exc: # project memory must not block source RAG stage.record_exception( exc, escaped=False, stage="project_memory_recall", category="project_memory_recall_fallback", ) stage.mark_terminal("degraded") observer.mark_terminal("degraded") stage.add_count("project_memory_count", len(project_items)) stage.add_count( "project_memory_chars", sum(len(item.statement) for item in project_items), ) stage.set("memory_policy", request.project_memory_policy) student_items: list[MemoryContextItem] = [] with observe_stage( observer, "student_memory_recall", subsystem="memory", consumer=consumer, ) as stage: if request.student_memory_policy != "none": try: statement = await self._read_student_memory( request.query, policy=request.student_memory_policy, ) except Exception as exc: # defensive boundary for custom readers stage.record_exception( exc, escaped=False, stage="student_memory_recall", category="student_memory_recall_fallback", ) stage.mark_terminal("degraded") observer.mark_terminal("degraded") statement = "" if statement: student_items.append( MemoryContextItem( memory_id="student_profile_recall", source="student_memory", statement=statement, kind="student_profile", metadata={"policy": request.student_memory_policy}, ) ) stage.add_count("student_memory_count", len(student_items)) stage.add_count( "student_memory_chars", sum(len(item.statement) for item in student_items), ) stage.set("memory_policy", request.student_memory_policy) return project_items, student_items async def _read_student_memory(self, query: str, *, policy: str) -> str: if self._student_memory_reader is not None: return await self._student_memory_reader(query) from app.services.student_memory import StudentMemoryService # Cognee owns cross-project student identity/preferences only. Project # research state is intentionally retrieved from Chroma above. memory = StudentMemoryService() if policy == "cached": return await memory.query_prior_knowledge( query, mode="profile", cache_only=True, ) return await memory.query_prior_knowledge( query, mode="profile", bypass_cache=True, ) def format_evidence_bundle(bundle: EvidenceBundle) -> str: """Render explicit prompt channels; never merge memory into source facts.""" source_lines = [] for index, item in enumerate(bundle.source_evidence, 1): unit = item.evidence location = f"p.{unit.page_start}" if unit.page_start == unit.page_end else f"pp.{unit.page_start}-{unit.page_end}" section = " > ".join(unit.section_path) source_lines.append( f"[E{index} | {location} | {unit.element_type.value}" f"{f' | {section}' if section else ''}]\n{unit.index_text}" ) project_lines = [f"- {item.statement}" for item in bundle.project_memory] student_lines = [f"- {item.statement}" for item in bundle.student_memory] return ( "\n" + ("\n\n".join(source_lines) or "No source evidence retrieved.") + "\n\n\n" + "\n" + ("\n".join(project_lines) or "No relevant project memory.") + "\n\n\n" + "\n" + ("\n".join(student_lines) or "No relevant student memory.") + "\n" )