feat(api)!: memory single chain — async MemoryService, strict eval closure

Route all memory ingest/retrieve/enrichment/compaction through async MemoryService.
Remove legacy sync memory implementations (ingest/retrieve/compaction); Celery and
memoir Phase2 call asyncio.run into MemoryService-backed helpers.

Memoir Phase1 batch ingest uses MemoryService.ingest_transcripts_batch; drop chapters.
evidence_bundle_json mirror (Alembic 0015). Evaluation uses snapshot/link-only bundles;
raise EvidenceClosureMissing instead of partial/fallback lineage tiers.

Split memoir state into NarrativeCoverageState and InterviewControlState; delete the
_interview_meta_store adapter layer. Remove rolling-query and recent-fact fallback
settings from config and evidence assembly.

Update judges, docs, tests, and PlaygroundPage alignment.

Made-with: Cursor
This commit is contained in:
Kevin
2026-04-30 14:11:46 +08:00
parent ac436b87a2
commit 71fbd39e32
53 changed files with 953 additions and 2448 deletions

View File

@@ -11,7 +11,6 @@ from app.features.memoir.models import Chapter, ChapterStoryLink
from app.features.memory.models import (
MemoryChunk,
MemoryFact,
MemorySource,
MemorySummary,
TimelineEvent,
)
@@ -171,77 +170,6 @@ async def fetch_ai_messages_for_segments(
return out
async def fetch_memory_closure_for_conversations(
db: AsyncSession, *, user_id: str, conversation_ids: list[str]
) -> tuple[list[str], list[str], list[str], list[str]]:
"""
返回 (chunk_ids, fact_ids, timeline_event_ids, summary_ids),均限定 user_id。
路径MemorySource(conversation_id) -> chunksfacts by source_chunk_id
timeline by memory_source_idsummaries 仅 rolling + 与会话 chunk 有交集的(轻量近似)。
"""
if not conversation_ids:
return [], [], [], []
conv_set = list({c for c in conversation_ids if c})
src_stmt = select(MemorySource).where(
MemorySource.user_id == user_id,
MemorySource.conversation_id.in_(conv_set),
)
src_result = await db.execute(src_stmt)
sources = list(src_result.scalars().all())
source_ids = [s.id for s in sources]
if not source_ids:
return [], [], [], []
ch_stmt = select(MemoryChunk).where(
MemoryChunk.user_id == user_id,
MemoryChunk.source_id.in_(source_ids),
MemoryChunk.is_excluded.is_(False),
)
ch_result = await db.execute(ch_stmt)
chunks = list(ch_result.scalars().all())
chunk_ids = [c.id for c in chunks]
if not chunk_ids:
fact_rows: list[MemoryFact] = []
else:
f_stmt = select(MemoryFact).where(
MemoryFact.user_id == user_id,
MemoryFact.source_chunk_id.in_(chunk_ids),
or_(MemoryFact.status.is_(None), MemoryFact.status != "stale"),
)
f_result = await db.execute(f_stmt)
fact_rows = list(f_result.scalars().all())
fact_ids = [f.id for f in fact_rows]
te_stmt = select(TimelineEvent).where(
TimelineEvent.user_id == user_id,
TimelineEvent.memory_source_id.in_(source_ids),
)
te_result = await db.execute(te_stmt)
ev_rows = list(te_result.scalars().all())
timeline_ids = [e.id for e in ev_rows]
sum_stmt = (
select(MemorySummary)
.where(MemorySummary.user_id == user_id)
.order_by(MemorySummary.updated_at.desc())
.limit(12)
)
sum_result = await db.execute(sum_stmt)
summaries = list(sum_result.scalars().all())
chunk_set = set(chunk_ids)
summary_ids: list[str] = []
for sm in summaries:
if sm.summary_type == "rolling":
summary_ids.append(sm.id)
continue
scids = sm.source_chunk_ids or []
if isinstance(scids, list) and chunk_set.intersection({str(x) for x in scids}):
summary_ids.append(sm.id)
return chunk_ids, fact_ids, timeline_ids, summary_ids
async def load_chunks_by_ids(
db: AsyncSession, *, user_id: str, chunk_ids: list[str]
) -> list[MemoryChunk]: