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

@@ -1,7 +1,7 @@
"""
Memory compaction增量 chunk 近重复检测软排除is_excluded + MemoryCurationAction
仅依赖 repo / settingsCelery 同步任务调用。
仅依赖 repo / settingsasync MemoryService 调用。
"""
from __future__ import annotations
@@ -11,19 +11,19 @@ import time
from datetime import datetime, timezone
from typing import Any
from sqlalchemy.orm import Session
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.logging import get_logger
from app.features.memory.models import MemoryChunk, MemorySource
from app.features.memory.repo import (
create_curation_action_sync,
get_first_chunk_after_cursor_sync,
get_memory_chunk_sync,
list_incremental_chunks_for_compaction_sync,
mark_facts_stale_for_excluded_chunk_sync,
search_nearest_chunks_for_compaction_sync,
set_chunk_excluded_sync,
create_curation_action,
get_first_chunk_after_cursor,
get_memory_chunk_for_user,
list_incremental_chunks_for_compaction,
mark_facts_stale_for_excluded_chunk,
search_nearest_chunks_for_compaction,
set_chunk_excluded,
)
logger = get_logger(__name__)
@@ -90,8 +90,8 @@ def canonical_score(
return score + bonus
def _source_type_for_chunk(session: Session, chunk: MemoryChunk) -> str | None:
src = session.get(MemorySource, chunk.source_id)
async def _source_type_for_chunk(db: AsyncSession, chunk: MemoryChunk) -> str | None:
src = await db.get(MemorySource, chunk.source_id)
return src.source_type if src else None
@@ -149,8 +149,8 @@ def count_duplicate_layers(
return n
def _advance_cursor_past_excluded_only_sync(
session: Session,
async def _advance_cursor_past_excluded_only(
db: AsyncSession,
user_id: str,
cursor_ts: datetime,
cursor_id: str,
@@ -166,8 +166,8 @@ def _advance_cursor_past_excluded_only_sync(
advanced = False
ts, cid = cursor_ts, cursor_id
for _ in range(max_steps):
nxt = get_first_chunk_after_cursor_sync(
session,
nxt = await get_first_chunk_after_cursor(
db,
user_id=user_id,
after_cursor_ts=ts,
after_chunk_id=cid,
@@ -200,8 +200,8 @@ def _advance_cursor_past_excluded_only_sync(
return (ts, cid) if advanced else None
def run_memory_compaction_sync(
session: Session, user_id: str, context: dict[str, Any] | None
async def run_memory_compaction(
db: AsyncSession, user_id: str, context: dict[str, Any] | None
) -> dict[str, Any]:
"""
对增量 chunk 做近重复软排除;调用方负责 commit。
@@ -228,8 +228,8 @@ def run_memory_compaction_sync(
has_candidate_filter = "candidate_chunk_ids" in ctx or "candidate_source_ids" in ctx
max_chunks = settings.memory_compaction_max_chunks_per_run
incremental = list_incremental_chunks_for_compaction_sync(
session,
incremental = await list_incremental_chunks_for_compaction(
db,
user_id=user_id,
after_cursor_ts=cursor_ts,
after_chunk_id=cursor_id,
@@ -242,8 +242,8 @@ def run_memory_compaction_sync(
ms = (time.perf_counter() - t0) * 1000
# 候选 id/source 收窄时,空集可能仅表示「交集为空」,不能盲目前进全局游标
if not has_candidate_filter:
tail = _advance_cursor_past_excluded_only_sync(
session,
tail = await _advance_cursor_past_excluded_only(
db,
user_id,
cursor_ts,
cursor_id,
@@ -313,7 +313,7 @@ def run_memory_compaction_sync(
if chunk.id in local_excluded:
last_cursor_chunk = chunk
continue
row = get_memory_chunk_sync(session, chunk.id, user_id)
row = await get_memory_chunk_for_user(db, chunk.id, user_id)
if row is None or row.is_excluded:
last_cursor_chunk = chunk
continue
@@ -348,9 +348,9 @@ def run_memory_compaction_sync(
}
chunks_scanned_this_run += 1
st_c = _source_type_for_chunk(session, row)
neighbors = search_nearest_chunks_for_compaction_sync(
session,
st_c = await _source_type_for_chunk(db, row)
neighbors = await search_nearest_chunks_for_compaction(
db,
user_id=user_id,
chunk_id=row.id,
query_embedding=list(emb),
@@ -363,7 +363,7 @@ def run_memory_compaction_sync(
nid = nb["id"]
if nid == row.id or nid in local_excluded:
continue
other = get_memory_chunk_sync(session, nid, user_id)
other = await get_memory_chunk_for_user(db, nid, user_id)
if other is None or other.is_excluded:
continue
@@ -409,15 +409,15 @@ def run_memory_compaction_sync(
else:
loser_id, winner_id = row.id, nid
loser = get_memory_chunk_sync(session, loser_id, user_id)
loser = await get_memory_chunk_for_user(db, loser_id, user_id)
if loser is None or loser.is_excluded:
continue
ok = set_chunk_excluded_sync(session, loser_id, user_id, True)
ok = await set_chunk_excluded(db, loser_id, user_id, True)
if not ok:
continue
stale_n = mark_facts_stale_for_excluded_chunk_sync(
session, user_id=user_id, chunk_id=loser_id
stale_n = await mark_facts_stale_for_excluded_chunk(
db, user_id=user_id, chunk_id=loser_id
)
if stale_n:
logger.info(
@@ -426,8 +426,8 @@ def run_memory_compaction_sync(
loser_id,
stale_n,
)
create_curation_action_sync(
session,
await create_curation_action(
db,
user_id=user_id,
action_type="exclude",
target_type="chunk",