2026-03-18 17:18:23 +08:00
|
|
|
|
"""
|
|
|
|
|
|
MemoryService — conversation / memoir 的统一门面。
|
2026-03-20 10:30:07 +08:00
|
|
|
|
|
2026-04-03 11:43:16 +08:00
|
|
|
|
- ingest_transcript: transcript -> memory_sources, chunks, embedding
|
2026-03-27 16:01:28 +08:00
|
|
|
|
- ingest 后可选:LLM 富化(session/rolling 摘要、事实、时间线)
|
2026-04-03 11:43:16 +08:00
|
|
|
|
- retrieve: 委托 HybridRetriever 返回 evidence bundle(向量 chunks)
|
2026-03-26 12:13:36 +08:00
|
|
|
|
|
2026-04-03 11:43:16 +08:00
|
|
|
|
Celery 侧使用 `ingest_transcript_sync` + `retrieve_evidence_sync`,与异步路径对齐见
|
2026-03-26 12:13:36 +08:00
|
|
|
|
`api/docs/memory-retrieval.md`。
|
2026-03-18 17:18:23 +08:00
|
|
|
|
"""
|
2026-03-19 14:36:14 +08:00
|
|
|
|
|
2026-03-18 17:18:23 +08:00
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
2026-03-27 16:01:28 +08:00
|
|
|
|
from app.core.logging import get_logger
|
2026-03-20 10:30:07 +08:00
|
|
|
|
from app.features.memory.chunker import chunk_transcript
|
|
|
|
|
|
from app.features.memory.repo import (
|
|
|
|
|
|
create_chunk,
|
2026-03-27 16:01:28 +08:00
|
|
|
|
create_curation_action,
|
2026-03-20 10:30:07 +08:00
|
|
|
|
create_source,
|
2026-03-27 16:01:28 +08:00
|
|
|
|
set_chunk_excluded,
|
|
|
|
|
|
set_memory_fact_status,
|
2026-03-20 10:30:07 +08:00
|
|
|
|
update_chunk_embedding,
|
|
|
|
|
|
)
|
2026-04-08 15:37:09 +08:00
|
|
|
|
from app.features.conversation.lineage_schemas import primary_user_message_id_from_lineage
|
|
|
|
|
|
from app.features.memory.schemas import EvidenceBundle
|
2026-03-20 10:30:07 +08:00
|
|
|
|
from app.ports.embedding import EmbeddingProvider
|
|
|
|
|
|
|
2026-03-27 16:01:28 +08:00
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
|
2026-03-18 17:18:23 +08:00
|
|
|
|
|
|
|
|
|
|
class MemoryService:
|
2026-03-20 10:30:07 +08:00
|
|
|
|
def __init__(
|
|
|
|
|
|
self,
|
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
|
*,
|
|
|
|
|
|
embedding_provider: EmbeddingProvider | None = None,
|
|
|
|
|
|
):
|
2026-03-18 17:18:23 +08:00
|
|
|
|
self._db = db
|
2026-03-20 10:30:07 +08:00
|
|
|
|
self._embedding = embedding_provider
|
2026-03-18 17:18:23 +08:00
|
|
|
|
|
2026-03-19 14:36:14 +08:00
|
|
|
|
async def ingest_transcript(
|
2026-04-08 15:37:09 +08:00
|
|
|
|
self,
|
|
|
|
|
|
user_id: str,
|
|
|
|
|
|
conversation_id: str,
|
|
|
|
|
|
transcript: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
lineage_json: dict | None = None,
|
2026-03-19 14:36:14 +08:00
|
|
|
|
) -> str:
|
2026-03-20 10:30:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
Ingest conversation transcript into memory.
|
2026-04-03 11:43:16 +08:00
|
|
|
|
Creates MemorySource, chunks, populates embedding.
|
2026-03-20 10:30:07 +08:00
|
|
|
|
Returns source_id.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not transcript or not transcript.strip():
|
|
|
|
|
|
raise ValueError("transcript cannot be empty")
|
|
|
|
|
|
|
2026-04-08 15:37:09 +08:00
|
|
|
|
primary_mid = (
|
|
|
|
|
|
primary_user_message_id_from_lineage(lineage_json)
|
|
|
|
|
|
if lineage_json
|
|
|
|
|
|
else None
|
|
|
|
|
|
)
|
2026-03-20 10:30:07 +08:00
|
|
|
|
source = await create_source(
|
|
|
|
|
|
self._db,
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
source_type="transcript",
|
|
|
|
|
|
raw_text=transcript.strip(),
|
|
|
|
|
|
conversation_id=conversation_id,
|
2026-04-08 15:37:09 +08:00
|
|
|
|
lineage_json=lineage_json,
|
|
|
|
|
|
primary_user_message_id=primary_mid,
|
2026-03-20 10:30:07 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
|
2026-04-03 13:49:24 +08:00
|
|
|
|
from app.core.config import settings
|
|
|
|
|
|
|
|
|
|
|
|
vectors_written = 0
|
2026-03-20 10:30:07 +08:00
|
|
|
|
# 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:
|
2026-04-03 13:49:24 +08:00
|
|
|
|
vectors_written += 1
|
2026-03-20 10:30:07 +08:00
|
|
|
|
await update_chunk_embedding(self._db, chunk_id, emb)
|
|
|
|
|
|
|
2026-04-03 13:49:24 +08:00
|
|
|
|
enrichment_ok: bool | None = None
|
2026-03-27 16:01:28 +08:00
|
|
|
|
try:
|
2026-04-02 12:00:00 +08:00
|
|
|
|
from app.core.dependencies import get_llm_provider_fast
|
2026-03-27 16:01:28 +08:00
|
|
|
|
from app.features.memory.enrichment import enrich_memory_after_ingest_async
|
|
|
|
|
|
|
|
|
|
|
|
if settings.memory_enrichment_enabled:
|
2026-04-02 12:00:00 +08:00
|
|
|
|
llm = get_llm_provider_fast().langchain_llm
|
2026-03-27 16:01:28 +08:00
|
|
|
|
await enrich_memory_after_ingest_async(
|
|
|
|
|
|
self._db, user_id, source.id, llm
|
|
|
|
|
|
)
|
2026-04-03 13:49:24 +08:00
|
|
|
|
enrichment_ok = True
|
2026-03-27 16:01:28 +08:00
|
|
|
|
except Exception as e:
|
2026-04-03 13:49:24 +08:00
|
|
|
|
if settings.memory_enrichment_enabled:
|
|
|
|
|
|
enrichment_ok = False
|
2026-03-27 16:01:28 +08:00
|
|
|
|
logger.warning(
|
|
|
|
|
|
"memory enrichment 跳过: {} exc_type={}", e, type(e).__name__
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-20 10:30:07 +08:00
|
|
|
|
await self._db.commit()
|
2026-04-03 13:49:24 +08:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
2026-03-20 10:30:07 +08:00
|
|
|
|
return source.id
|
2026-03-18 17:18:23 +08:00
|
|
|
|
|
2026-03-27 16:01:28 +08:00
|
|
|
|
async def retrieve(
|
|
|
|
|
|
self, user_id: str, query: str, *, top_k: int = 10
|
|
|
|
|
|
) -> EvidenceBundle:
|
2026-03-20 10:30:07 +08:00
|
|
|
|
"""Retrieve relevant evidence. 委托 HybridRetriever。"""
|
|
|
|
|
|
from app.features.memory.retriever import HybridRetriever
|
|
|
|
|
|
|
|
|
|
|
|
retriever = HybridRetriever(self._db, embedding_provider=self._embedding)
|
2026-03-27 16:01:28 +08:00
|
|
|
|
raw = await retriever.retrieve(user_id=user_id, query=query, top_k=top_k)
|
2026-04-03 13:49:24 +08:00
|
|
|
|
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
|
2026-03-27 16:01:28 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-03-20 10:30:07 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def ingest_transcript_sync(
|
|
|
|
|
|
session,
|
|
|
|
|
|
user_id: str,
|
|
|
|
|
|
conversation_id: str,
|
|
|
|
|
|
transcript: str,
|
2026-04-08 15:37:09 +08:00
|
|
|
|
*,
|
|
|
|
|
|
lineage_json: dict | None = None,
|
2026-03-20 10:30:07 +08:00
|
|
|
|
) -> str:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Sync transcript ingest for Celery tasks.
|
2026-04-03 11:43:16 +08:00
|
|
|
|
Creates source + chunks, and best-effort populates embeddings.
|
2026-03-20 10:30:07 +08:00
|
|
|
|
Returns source_id.
|
|
|
|
|
|
"""
|
2026-03-30 10:46:35 +08:00
|
|
|
|
from app.core.dependencies import get_embedding_provider
|
2026-03-20 10:30:07 +08:00
|
|
|
|
from app.features.memory.chunker import chunk_transcript
|
|
|
|
|
|
from app.features.memory.repo import (
|
|
|
|
|
|
create_chunk_sync,
|
|
|
|
|
|
create_source_sync,
|
2026-03-30 10:46:35 +08:00
|
|
|
|
update_chunk_embedding_sync,
|
2026-03-20 10:30:07 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if not transcript or not transcript.strip():
|
|
|
|
|
|
raise ValueError("transcript cannot be empty")
|
|
|
|
|
|
|
2026-04-08 15:37:09 +08:00
|
|
|
|
primary_mid = (
|
|
|
|
|
|
primary_user_message_id_from_lineage(lineage_json) if lineage_json else None
|
|
|
|
|
|
)
|
2026-03-20 10:30:07 +08:00
|
|
|
|
source = create_source_sync(
|
|
|
|
|
|
session,
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
source_type="transcript",
|
|
|
|
|
|
raw_text=transcript.strip(),
|
|
|
|
|
|
conversation_id=conversation_id,
|
2026-04-08 15:37:09 +08:00
|
|
|
|
lineage_json=lineage_json,
|
|
|
|
|
|
primary_user_message_id=primary_mid,
|
2026-03-20 10:30:07 +08:00
|
|
|
|
)
|
|
|
|
|
|
session.flush()
|
|
|
|
|
|
|
|
|
|
|
|
chunks_text = chunk_transcript(transcript.strip())
|
2026-03-30 10:46:35 +08:00
|
|
|
|
chunk_records: list[tuple[str, str]] = []
|
2026-03-20 10:30:07 +08:00
|
|
|
|
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()
|
2026-03-30 10:46:35 +08:00
|
|
|
|
chunk_records.append((chunk.id, content))
|
2026-03-20 10:30:07 +08:00
|
|
|
|
|
2026-04-03 13:49:24 +08:00
|
|
|
|
from app.core.config import settings
|
|
|
|
|
|
|
|
|
|
|
|
vectors_written = 0
|
|
|
|
|
|
embedding_available = False
|
2026-04-07 17:15:01 +08:00
|
|
|
|
enrichment_ok: bool | None = None
|
|
|
|
|
|
|
2026-03-30 10:46:35 +08:00
|
|
|
|
try:
|
|
|
|
|
|
embedding_provider = get_embedding_provider()
|
2026-04-03 13:49:24 +08:00
|
|
|
|
if embedding_provider is not None:
|
|
|
|
|
|
embedding_available = embedding_provider.is_available()
|
2026-03-30 10:46:35 +08:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(
|
2026-04-07 17:15:01 +08:00
|
|
|
|
"memory embedding provider 不可用(sync): {} exc_type={}",
|
|
|
|
|
|
e,
|
|
|
|
|
|
type(e).__name__,
|
2026-03-30 10:46:35 +08:00
|
|
|
|
)
|
2026-04-07 17:15:01 +08:00
|
|
|
|
embedding_provider = None
|
2026-03-30 10:46:35 +08:00
|
|
|
|
|
2026-04-08 21:36:12 +08:00
|
|
|
|
# 向量写入在 SAVEPOINT 内;失败仅回滚本段,source/chunks 主体仍可由外层提交。
|
|
|
|
|
|
# enrichment 已迁移到独立异步任务 (memory_enrichment_tasks.enrich_memory_source)。
|
2026-03-27 16:01:28 +08:00
|
|
|
|
try:
|
2026-04-07 17:15:01 +08:00
|
|
|
|
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)
|
2026-03-27 16:01:28 +08:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(
|
2026-04-08 21:36:12 +08:00
|
|
|
|
"memory embedding 跳过(sync): {} exc_type={}",
|
2026-04-07 17:15:01 +08:00
|
|
|
|
e,
|
|
|
|
|
|
type(e).__name__,
|
2026-03-27 16:01:28 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-20 10:30:07 +08:00
|
|
|
|
session.commit()
|
2026-04-08 21:36:12 +08:00
|
|
|
|
|
|
|
|
|
|
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__,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-03 13:49:24 +08:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
2026-03-20 10:30:07 +08:00
|
|
|
|
return source.id
|