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

@@ -15,15 +15,14 @@ from app.agents.stage_constants import (
normalize_chat_stage,
)
from app.agents.state_schema import (
InterviewControlState,
KnownFact,
MemoirStateSchema,
PersonaThread,
SlotData,
default_state,
narrative_coverage_state,
)
from app.core.config import settings
from app.features.memoir import _interview_meta_store as interview_meta
from app.features.memoir.models import MemoirState as MemoirStateModel
@@ -35,19 +34,31 @@ def _slots_snapshot_for_merge(raw: Dict[str, Dict] | None) -> Dict[str, Dict]:
def coerce_memoir_state(model: MemoirStateModel) -> MemoirStateSchema:
"""把 ORM 行投影成 MemoirStateSchema控制元数据的读法已隔离在 interview_meta 适配层"""
"""把 ORM 行投影成 MemoirStateSchema控制元数据来自独立列"""
raw_slots = model.slots if isinstance(model.slots, dict) else None
control = interview_meta.read(raw_slots)
clean_slots = interview_meta.strip(raw_slots) or dict(default_state().slots)
clean_slots = raw_slots or dict(default_state().slots)
known_raw = (
model.known_facts_json if isinstance(model.known_facts_json, list) else []
)
persona_raw = (
model.persona_threads_json
if isinstance(model.persona_threads_json, list)
else []
)
recent_raw = (
model.recent_questions_json
if isinstance(model.recent_questions_json, list)
else []
)
return MemoirStateSchema.model_validate(
{
"stage_order": model.stage_order or default_state().stage_order,
"current_stage": model.current_stage,
"covered_stages": model.covered_stages or [],
"slots": clean_slots,
"known_facts": control.known_facts,
"persona_threads": control.persona_threads,
"recent_questions": control.recent_questions,
"known_facts": [x for x in known_raw if isinstance(x, dict)],
"persona_threads": [x for x in persona_raw if isinstance(x, dict)],
"recent_questions": [str(x).strip() for x in recent_raw if str(x).strip()],
}
)
@@ -173,7 +184,7 @@ async def mark_stage_complete(
async def get_empty_slots(user_id: str, db: AsyncSession) -> List[str]:
state = await get_or_create_state(user_id, db)
return state.empty_slots_for_current_stage()
return narrative_coverage_state(state).empty_slots_for_current_stage()
async def switch_stage(
@@ -220,17 +231,9 @@ async def save_interview_state_meta(
result = await db.execute(stmt)
state = result.scalar_one()
slots = _slots_snapshot_for_merge(
state.slots if isinstance(state.slots, dict) else None
)
state.slots = interview_meta.write(
slots,
control=InterviewControlState(
known_facts=known_facts,
persona_threads=persona_threads,
recent_questions=recent_questions,
),
)
state.known_facts_json = [x.model_dump() for x in known_facts]
state.persona_threads_json = [x.model_dump() for x in persona_threads]
state.recent_questions_json = list(recent_questions)
await db.commit()
await db.refresh(state)
return coerce_memoir_state(state)