""" MemoryService — conversation / memoir 的统一门面。 - ingest_transcript: transcript -> memory_sources, chunks, embedding - ingest 后可选:LLM 富化(session/rolling 摘要、事实、时间线) - retrieve: 委托 HybridRetriever 返回 evidence bundle(向量 chunks) Celery 侧使用 `ingest_transcript_sync` + `retrieve_evidence_sync`,与异步路径对齐见 `api/docs/memory-retrieval.md`。 """ from sqlalchemy.ext.asyncio import AsyncSession from app.core.logging import get_logger from app.features.memory.chunker import chunk_transcript from app.features.memory.repo import ( create_chunk, create_curation_action, create_source, set_chunk_excluded, set_memory_fact_status, update_chunk_embedding, ) from app.features.conversation.lineage_schemas import primary_user_message_id_from_lineage from app.features.memory.schemas import EvidenceBundle from app.ports.embedding import EmbeddingProvider logger = get_logger(__name__) class MemoryService: def __init__( self, db: AsyncSession, *, embedding_provider: EmbeddingProvider | None = None, ): self._db = db self._embedding = embedding_provider async def ingest_transcript( self, user_id: str, conversation_id: str, transcript: str, *, lineage_json: dict | None = None, ) -> str: """ Ingest conversation transcript into memory. Creates MemorySource, chunks, populates embedding. Returns source_id. """ if not transcript or not transcript.strip(): raise ValueError("transcript cannot be empty") primary_mid = ( primary_user_message_id_from_lineage(lineage_json) if lineage_json else None ) source = await create_source( self._db, user_id=user_id, source_type="transcript", raw_text=transcript.strip(), conversation_id=conversation_id, lineage_json=lineage_json, primary_user_message_id=primary_mid, ) chunks_text = chunk_transcript(transcript.strip()) chunk_records = [] for i, content in enumerate(chunks_text): chunk = await create_chunk( self._db, source_id=source.id, user_id=user_id, content=content, chunk_index=i, ) chunk_records.append((chunk.id, content)) await self._db.flush() from app.core.config import settings vectors_written = 0 # Embedding: 若有 provider 则写入 if self._embedding and chunk_records: texts = [c for _, c in chunk_records] embeddings = await self._embedding.embed_texts(texts) for (chunk_id, _), emb in zip(chunk_records, embeddings): if emb: vectors_written += 1 await update_chunk_embedding(self._db, chunk_id, emb) enrichment_ok: bool | None = None try: from app.core.dependencies import get_llm_provider_fast from app.features.memory.enrichment import enrich_memory_after_ingest_async if settings.memory_enrichment_enabled: llm = get_llm_provider_fast().langchain_llm await enrich_memory_after_ingest_async( self._db, user_id, source.id, llm ) enrichment_ok = True except Exception as e: if settings.memory_enrichment_enabled: enrichment_ok = False logger.warning( "memory enrichment 跳过: {} exc_type={}", e, type(e).__name__ ) await self._db.commit() emb_ok = self._embedding.is_available() if self._embedding else False logger.info( "event=memory_ingest_done user_id={} conversation_id={} source_id={} " "chunks={} vectors_written={} embedding_available={} enrichment_enabled={} enrichment_ok={}", user_id, conversation_id, source.id, len(chunk_records), vectors_written, emb_ok, settings.memory_enrichment_enabled, enrichment_ok, ) return source.id async def retrieve( self, user_id: str, query: str, *, top_k: int = 10 ) -> EvidenceBundle: """Retrieve relevant evidence. 委托 HybridRetriever。""" from app.features.memory.retriever import HybridRetriever retriever = HybridRetriever(self._db, embedding_provider=self._embedding) raw = await retriever.retrieve(user_id=user_id, query=query, top_k=top_k) bundle = EvidenceBundle.model_validate(raw) bd = bundle.model_dump() vec_ok = self._embedding.is_available() if self._embedding else False logger.info( "event=memory_retrieve_done user_id={} query_len={} top_k={} " "chunks={} facts={} summaries={} timeline={} stories={} vector_ok={}", user_id, len((query or "").strip()), top_k, len(bd.get("relevant_chunks") or []), len(bd.get("relevant_facts") or []), len(bd.get("relevant_summaries") or []), len(bd.get("timeline_hints") or []), len(bd.get("relevant_stories") or []), vec_ok, ) return bundle async def exclude_chunk( self, user_id: str, chunk_id: str, *, reason: str = "" ) -> bool: ok = await set_chunk_excluded(self._db, chunk_id, user_id, True) if not ok: return False await create_curation_action( self._db, user_id=user_id, action_type="exclude", target_type="chunk", target_id=chunk_id, details={"reason": reason} if reason else None, ) await self._db.commit() return True async def restore_chunk(self, user_id: str, chunk_id: str) -> bool: ok = await set_chunk_excluded(self._db, chunk_id, user_id, False) if not ok: return False await create_curation_action( self._db, user_id=user_id, action_type="restore", target_type="chunk", target_id=chunk_id, details=None, ) await self._db.commit() return True async def confirm_fact(self, user_id: str, fact_id: str) -> bool: ok = await set_memory_fact_status(self._db, fact_id, user_id, "confirmed") if not ok: return False await create_curation_action( self._db, user_id=user_id, action_type="confirm", target_type="fact", target_id=fact_id, details=None, ) await self._db.commit() return True async def reject_fact( self, user_id: str, fact_id: str, *, reason: str = "" ) -> bool: ok = await set_memory_fact_status(self._db, fact_id, user_id, "rejected") if not ok: return False await create_curation_action( self._db, user_id=user_id, action_type="reject", target_type="fact", target_id=fact_id, details={"reason": reason} if reason else None, ) await self._db.commit() return ok def ingest_transcript_sync( session, user_id: str, conversation_id: str, transcript: str, *, lineage_json: dict | None = None, ) -> str: """ Sync transcript ingest for Celery tasks. Creates source + chunks, and best-effort populates embeddings. Returns source_id. """ from app.core.dependencies import get_embedding_provider from app.features.memory.chunker import chunk_transcript from app.features.memory.repo import ( create_chunk_sync, create_source_sync, update_chunk_embedding_sync, ) if not transcript or not transcript.strip(): raise ValueError("transcript cannot be empty") primary_mid = ( primary_user_message_id_from_lineage(lineage_json) if lineage_json else None ) source = create_source_sync( session, user_id=user_id, source_type="transcript", raw_text=transcript.strip(), conversation_id=conversation_id, lineage_json=lineage_json, primary_user_message_id=primary_mid, ) session.flush() chunks_text = chunk_transcript(transcript.strip()) chunk_records: list[tuple[str, str]] = [] for i, content in enumerate(chunks_text): chunk = create_chunk_sync( session, source_id=source.id, user_id=user_id, content=content, chunk_index=i, ) session.flush() chunk_records.append((chunk.id, content)) from app.core.config import settings vectors_written = 0 embedding_available = False enrichment_ok: bool | None = None try: embedding_provider = get_embedding_provider() if embedding_provider is not None: embedding_available = embedding_provider.is_available() except Exception as e: logger.warning( "memory embedding provider 不可用(sync): {} exc_type={}", e, type(e).__name__, ) embedding_provider = None # 向量写入在 SAVEPOINT 内;失败仅回滚本段,source/chunks 主体仍可由外层提交。 # enrichment 已迁移到独立异步任务 (memory_enrichment_tasks.enrich_memory_source)。 try: with session.begin_nested(): if chunk_records and embedding_provider is not None: texts = [content for _, content in chunk_records] embeddings = embedding_provider.embed_texts_sync(texts) for (chunk_id, _), emb in zip(chunk_records, embeddings): if emb: vectors_written += 1 update_chunk_embedding_sync(session, chunk_id, emb) except Exception as e: logger.warning( "memory embedding 跳过(sync): {} exc_type={}", e, type(e).__name__, ) session.commit() if settings.memory_enrichment_enabled: try: from app.tasks.memory_enrichment_tasks import enrich_memory_source enrich_memory_source.delay(user_id, source.id) enrichment_ok = True except Exception as e: enrichment_ok = False logger.warning( "memory enrichment 任务派发失败: {} exc_type={}", e, type(e).__name__, ) logger.info( "event=memory_ingest_done user_id={} conversation_id={} source_id={} " "chunks={} vectors_written={} embedding_available={} enrichment_enabled={} enrichment_ok={} sync=1", user_id, conversation_id, source.id, len(chunk_records), vectors_written, embedding_available, settings.memory_enrichment_enabled, enrichment_ok, ) return source.id