Files
life-echo/api/app/features/memory/service.py
Kevin ac436b87a2 feat(api): 收敛对话与记忆流程边界,引入 LLM 网关与专用服务
- MemoryService 异步路径委托 MemoryIngestService / MemoryRetrievalService;富化派发经 MemoryEnrichmentScheduler
- WebSocket pipeline 经 ChatTurnService 与显式 DTO 编排单轮对话;回忆录片段入队由 MemoirIngestScheduler 封装
- 新增 LlmGateway(LlmUseCase),各 agent、任务与适配器对齐 ports
- 补充 memory 提示适配、runtime 类型、memory-retrieval 文档、ai-touchpoints 说明与扫描脚本及配套测试

Made-with: Cursor
2026-04-30 09:17:01 +08:00

365 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
MemoryService — conversation / memoir 的统一门面。
- ingest_transcript: transcript -> memory_sources, chunks, embedding
- ingest 成功后:向 ``memory_idle`` 队列派发 LLM 富化(见 ``schedule_memory_enrichment``),不阻塞请求
- 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.conversation.lineage_schemas import (
primary_user_message_id_from_lineage,
)
from app.features.memory.enrichment_scheduler import MemoryEnrichmentScheduler
from app.features.memory.ingest_service import MemoryIngestService
from app.features.memory.repo import (
create_curation_action,
set_chunk_excluded,
set_memory_fact_status,
)
from app.features.memory.retrieval_service import MemoryRetrievalService
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.
"""
service = MemoryIngestService(self._db, embedding_provider=self._embedding)
return await service.ingest_transcript(
user_id,
conversation_id,
transcript,
lineage_json=lineage_json,
)
async def retrieve(
self, user_id: str, query: str, *, top_k: int = 10
) -> EvidenceBundle:
"""Retrieve relevant evidence. 委托 HybridRetriever。"""
service = MemoryRetrievalService(self._db, embedding_provider=self._embedding)
return await service.retrieve(user_id, query, top_k=top_k)
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
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 主体仍可由外层提交。
# LLM enrichment 在 commit 后由 schedule_memory_enrichment 入 memory_idle 队列。
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, strict=False
):
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()
enrichment_task_id: str | None = None
if settings.memory_enrichment_enabled:
try:
from app.tasks.memory_enrichment_tasks import schedule_memory_enrichment
enrichment_task_id = schedule_memory_enrichment(
user_id, source.id, memoir_correlation_id=None
)
except Exception as e:
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_task_id={} sync=1",
user_id,
conversation_id,
source.id,
len(chunk_records),
vectors_written,
embedding_available,
settings.memory_enrichment_enabled,
enrichment_task_id,
)
return source.id
def ingest_transcripts_batch_sync(
session,
user_id: str,
items: list[tuple[str, str, dict | None]],
) -> list[str]:
"""
Phase1 批量:多段 transcript 在同一会话内建 source/chunks并单次 embed_texts_sync在适配器 batch 限制内)。
不 commit不派发 enrichment由调用方 commit 后 ``schedule_enrichment_for_sources``)。
items: (conversation_id, transcript, lineage_json)
返回与有效 items 顺序一致的 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,
)
source_ids: list[str] = []
all_chunk_records: list[tuple[str, str]] = []
for conversation_id, transcript, lineage_json in items:
text = (transcript or "").strip()
if not text:
continue
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=text,
conversation_id=conversation_id or None,
lineage_json=lineage_json,
primary_user_message_id=primary_mid,
)
session.flush()
chunks_text = chunk_transcript(text)
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()
all_chunk_records.append((chunk.id, content))
source_ids.append(source.id)
embedding_provider = None
try:
embedding_provider = get_embedding_provider()
except Exception as e:
logger.warning(
"memory embedding provider 不可用(batch sync): {} exc_type={}",
e,
type(e).__name__,
)
vectors_written = 0
try:
with session.begin_nested():
if all_chunk_records and embedding_provider is not None:
texts = [content for _, content in all_chunk_records]
embeddings = embedding_provider.embed_texts_sync(texts)
for (chunk_id, _), emb in zip(
all_chunk_records, embeddings, strict=False
):
if emb:
vectors_written += 1
update_chunk_embedding_sync(session, chunk_id, emb)
except Exception as e:
logger.warning(
"memory embedding 跳过(batch sync): {} exc_type={}",
e,
type(e).__name__,
)
emb_ok = (
embedding_provider.is_available() if embedding_provider is not None else False
)
logger.info(
"event=memory_ingest_batch_done user_id={} sources={} chunks={} "
"vectors_written={} embedding_available={}",
user_id,
len(source_ids),
len(all_chunk_records),
vectors_written,
emb_ok,
)
return source_ids
def schedule_enrichment_for_sources(
user_id: str,
source_ids: list[str],
*,
memoir_correlation_id: str | None = None,
) -> None:
"""After successful ingest commit, enqueue LLM enrichment for each source (memory_idle queue)."""
MemoryEnrichmentScheduler().schedule_many(
user_id,
source_ids,
memoir_correlation_id=memoir_correlation_id,
)