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

@@ -6,7 +6,7 @@ from types import SimpleNamespace
import pytest
from app.features.memory.enrichment import enrich_memory_after_ingest_sync
from app.features.memory.enrichment import enrich_memory_after_ingest_async
from app.features.memory.llm_schemas import EnrichmentPayload, parse_json_payload
from app.features.memory.models import MemorySource
from app.features.user.models import User
@@ -26,7 +26,8 @@ def test_enrichment_payload_roundtrip() -> None:
assert p.facts[0].subject == "王伟"
def test_enrich_memory_after_ingest_sync_single_llm_call(
@pytest.mark.asyncio
async def test_enrich_memory_after_ingest_async_single_llm_call(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from app.features.memory import enrichment as mod
@@ -35,43 +36,56 @@ def test_enrich_memory_after_ingest_sync_single_llm_call(
invoke_count = {"n": 0}
def fake_invoke(llm, prompt, max_tokens, agent):
async def fake_invoke(llm, prompt, max_tokens, agent):
invoke_count["n"] += 1
assert agent == "memory.enrichment_sync"
assert agent == "memory.enrichment"
return (
'{"summary":"本轮要点",'
'"facts":[{"fact_type":"event","subject":"王伟","predicate":"",'
'"object_json":{"value":"上海"},"confidence":0.8,"source_chunk_id":"ch1"}]}'
)
monkeypatch.setattr(mod, "invoke_json_object", fake_invoke)
monkeypatch.setattr(
mod,
"list_chunks_for_source_sync",
lambda s, sid: [SimpleNamespace(id="ch1", content="王伟住在上海。")],
)
monkeypatch.setattr(mod, "ainvoke_json_object", fake_invoke)
summaries: list[dict] = []
facts: list[dict] = []
def capture_summary(session, **kwargs):
async def capture_summary(session, **kwargs):
summaries.append(kwargs)
def capture_fact(session, **kwargs):
async def capture_fact(session, **kwargs):
facts.append(kwargs)
monkeypatch.setattr(mod, "create_memory_summary_sync", capture_summary)
monkeypatch.setattr(mod, "create_memory_fact_sync", capture_fact)
monkeypatch.setattr(mod, "create_memory_summary", capture_summary)
monkeypatch.setattr(mod, "create_memory_fact", capture_fact)
class FakeResult:
def unique(self):
return self
def scalars(self):
return self
def all(self):
return [SimpleNamespace(id="ch1", content="王伟住在上海。")]
class FakeSession:
def get(self, model, key):
async def get(self, model, key):
if model is User and key == "u1":
return SimpleNamespace(nickname="老王")
if model is MemorySource and key == "src-1":
return SimpleNamespace(lineage_json=None)
return None
enrich_memory_after_ingest_sync(FakeSession(), "u1", "src-1", llm=object())
async def execute(self, _stmt):
return FakeResult()
await enrich_memory_after_ingest_async(
FakeSession(), # type: ignore[arg-type]
"u1",
"src-1",
llm=object(),
)
assert invoke_count["n"] == 1
assert len(summaries) == 1
@@ -83,38 +97,54 @@ def test_enrich_memory_after_ingest_sync_single_llm_call(
assert facts[0]["status"] == "confirmed"
def test_enrich_memory_skips_when_parse_returns_none(
@pytest.mark.asyncio
async def test_enrich_memory_skips_when_parse_returns_none(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from app.features.memory import enrichment as mod
monkeypatch.setattr("app.core.config.settings.memory_enrichment_enabled", True)
monkeypatch.setattr(mod, "invoke_json_object", lambda *a, **k: "{not json")
monkeypatch.setattr(
mod,
"list_chunks_for_source_sync",
lambda s, sid: [SimpleNamespace(id="c1", content="x")],
)
async def fake_invoke(*_args, **_kwargs):
return "{not json"
monkeypatch.setattr(mod, "ainvoke_json_object", fake_invoke)
called = {"summary": False, "fact": False}
monkeypatch.setattr(
mod,
"create_memory_summary_sync",
lambda *a, **k: called.update(summary=True),
)
monkeypatch.setattr(
mod,
"create_memory_fact_sync",
lambda *a, **k: called.update(fact=True),
)
async def capture_summary(*_args, **_kwargs):
called.update(summary=True)
async def capture_fact(*_args, **_kwargs):
called.update(fact=True)
monkeypatch.setattr(mod, "create_memory_summary", capture_summary)
monkeypatch.setattr(mod, "create_memory_fact", capture_fact)
class FakeResult:
def unique(self):
return self
def scalars(self):
return self
def all(self):
return [SimpleNamespace(id="c1", content="x")]
class FakeSession:
def get(self, model, key):
async def get(self, model, key):
if model is User and key == "u":
return None
if model is MemorySource and key == "s":
return SimpleNamespace(lineage_json=None)
return None
enrich_memory_after_ingest_sync(FakeSession(), "u", "s", llm=object())
async def execute(self, _stmt):
return FakeResult()
await enrich_memory_after_ingest_async(
FakeSession(), # type: ignore[arg-type]
"u",
"s",
llm=object(),
)
assert called == {"summary": False, "fact": False}