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

@@ -31,7 +31,11 @@ from app.agents.chat.reply_limits import (
)
from app.agents.chat.reply_planner import maybe_refine_turn_plan_with_llm
from app.agents.chat.stage_detection import keyword_fallback_primary_stage
from app.agents.state_schema import MemoirStateSchema
from app.agents.state_schema import (
MemoirStateSchema,
interview_control_state,
narrative_coverage_state,
)
from app.core.agent_logging import (
agent_span,
log_agent_payload,
@@ -154,14 +158,14 @@ class InterviewAgent:
text_for_model = self._resolve_text_for_model(
user_message, normalized_user_message
)
empty_slots = memoir_state.prompt_empty_slots_for_current_stage()
filled_slots = {
key: value.snippet
for key, value in memoir_state.slots.get(
memoir_state.current_stage, {}
).items()
if value.snippet
}
narrative_state = narrative_coverage_state(memoir_state)
control_state = interview_control_state(memoir_state)
empty_slots = control_state.prompt_empty_slots_for_stage(
narrative_state, memoir_state.current_stage
)
filled_slots = narrative_state.filled_slots_for_stage(
memoir_state.current_stage
)
if detected_user_stage is not None:
du = detected_user_stage
else:
@@ -173,7 +177,7 @@ class InterviewAgent:
)
recent_questions = extract_recent_questions(hw.window)
conversation_turn_total = hw.turn_total
all_stages_coverage = memoir_state.all_stages_coverage()
all_stages_coverage = narrative_state.all_stages_coverage()
persona = normalize_interview_persona(settings.chat_interview_persona)
max_segments = int(settings.chat_interview_max_segments)
max_tokens = int(settings.chat_interview_max_tokens)
@@ -406,7 +410,11 @@ class InterviewAgent:
if not self.llm:
return ["你好呀~ 又见面了。今天想从人生里哪一小段回忆开始聊聊?"]
try:
empty_slots = memoir_state.prompt_empty_slots_for_current_stage()
narrative_state = narrative_coverage_state(memoir_state)
control_state = interview_control_state(memoir_state)
empty_slots = control_state.prompt_empty_slots_for_stage(
narrative_state, memoir_state.current_stage
)
empty_slots_readable = [SLOT_NAME_MAP.get(s, s) for s in empty_slots]
persona = normalize_interview_persona(settings.chat_interview_persona)
prompt = get_opening_prompt(

View File

@@ -5,13 +5,18 @@ from __future__ import annotations
import re
from collections.abc import Iterable
# 与 `apply_duplicate_question_guard` 中整段替换句一致;用于判定是否需触发二次生成。
DUPLICATE_QUESTION_GUARD_FALLBACK_ZH = "这一段我记住了。"
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
from langchain_core.messages import AIMessage, BaseMessage
from app.agents.stage_constants import STAGE_DISPLAY_ZH, STAGE_SLOT_KEYS
from app.agents.state_schema import KnownFact, MemoirStateSchema, PersonaThread
from app.agents.state_schema import (
KnownFact,
MemoirStateSchema,
PersonaThread,
narrative_coverage_state,
)
# 与 `apply_duplicate_question_guard` 中整段替换句一致;用于判定是否需触发二次生成。
DUPLICATE_QUESTION_GUARD_FALLBACK_ZH = "这一段我记住了。"
_QUESTION_SPLIT_RE = re.compile(r"[?]+")
_SENTENCE_SPLIT_RE = re.compile(r"(?<=[。!?!?])")
@@ -220,10 +225,11 @@ def build_runtime_interview_state(
)
persona_additions: list[PersonaThread] = []
narrative_state = narrative_coverage_state(state)
haystack = " ".join(
[msg]
+ [fact.value for fact in state.known_facts[-8:]]
+ list(state.filled_slots_for_stage(active_stage).values())[:4]
+ list(narrative_state.filled_slots_for_stage(active_stage).values())[:4]
)
for trait, markers in _TRAIT_HINTS:
for marker in markers:

View File

@@ -48,13 +48,3 @@ def get_interview_persona_tone_hint(persona: str) -> str:
"短句像微信,一次最多一个具体问题,不重复上文已清楚的事;底色仍要**随和、像聊天伙伴**,别像考官盘问。"
"允许用一两句场景感的短描写承接对方画面,不要只用干巴巴的确认句;情绪重时承接要有半句并肩,勿只回嗯。"
)
def get_interview_persona_block(persona: str) -> str:
"""兼容旧名:返回空串,请改用 get_interview_persona_tone_hint。"""
return ""
def get_opening_persona_line(persona: str) -> str:
"""兼容旧名:与访谈轮次共用一句性格提示。"""
return get_interview_persona_tone_hint(persona)

View File

@@ -4,7 +4,7 @@
from __future__ import annotations
from typing import Any, Optional
from typing import Any
from app.agents.chat.schemas import StageDetectionOutput
from app.agents.chat.stage_prompts import (
@@ -23,12 +23,6 @@ from app.core.logging import get_logger
logger = get_logger(__name__)
def normalize_life_stage(raw: Optional[str], fallback: str) -> str:
"""兼容旧名:统一走 normalize_chat_stage。"""
return normalize_chat_stage(raw, fallback)
def keyword_fallback_primary_stage(user_message: str) -> str:
"""多阶段打分,取最高分;平局按 CHAT_STAGES 逆序优先(与历史 tie_order 派生一致,可能有小幅行为差异)。"""
if not (user_message or "").strip():
@@ -100,5 +94,4 @@ __all__ = [
"detect_primary_life_stage",
"keyword_fallback_primary_stage",
"life_stage_display_name",
"normalize_life_stage",
]