Files
life-echo/api/app/features/memory/service.py
Kevin 309a051038 feat: 回忆录证据血缘与内部评测可追溯,顺带对齐本地评测台与 CI
数据库与模型:新增多版迁移(章节证据快照、对话血缘、记忆事实/时间线 lineage 等),把「成稿 ↔ 对话/记忆」的溯源信息落到表结构里。
业务链路:会话与 WS、回忆录/故事流水线、记忆写入与 enrichment 等跟着接上线索与快照;新增章节证据快照与评测侧 EvalTraceService 等模块,方便组评审用的证据包。
内部评测:自动化 run 与手工 memoir 评审共用可追溯证据;rubric/ judge 相关脚本与文档有配套调整。
app-eval-web:Memoir/实验详情里能展开看证据摘要与 evidence_trace(含对话轮次 id);Vite 代理与 development.sh 注入的 API 端口与当前默认内部评测端口一致,避免改端口后页面连错服务。
工程杂项:GitHub Actions / 仓库说明有更新;各适配器与支付/配额/plan 等多处为小改动或跟随主改动的收尾;新增/扩充了?
2026-04-08 15:37:09 +08:00

337 lines
11 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 后可选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
# 向量写入与 enrichment 任一失败时,此前若在**同一事务内**已开始失败的 SQL
# 会导致 session 进入「必须 rollback」状态进而让后续 commit 抛出
# PendingRollbackError污染 Celery 里共用的 `db`。
# 用 SAVEPOINT 包一层失败仅回滚本段source/chunks 主体仍可由外层提交。
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)
if settings.memory_enrichment_enabled:
from app.features.memory.enrichment import (
enrich_memory_after_ingest_sync,
)
enrich_memory_after_ingest_sync(
session, user_id, source.id, llm=None
)
enrichment_ok = True
except Exception as e:
logger.warning(
"memory embedding/enrichment 跳过(sync): {} exc_type={}",
e,
type(e).__name__,
)
if settings.memory_enrichment_enabled:
enrichment_ok = False
session.commit()
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