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",
]

View File

@@ -5,9 +5,7 @@ Option B 的 schema 分层:
- `NarrativeCoverageState`:叙述覆盖视图 —— 阶段推进、叙述槽、每阶段的完成情况;**不掺控制元信息**。
- `InterviewControlState`:访谈控制视图 —— 已确认事实、人物主线、最近已问;用于访谈控场,**不参与成稿槽位**。
- `MemoirStateSchema`以 facade 形式聚合两个视图,保留旧字段以兼容已持久化数据
消费方**应**通过 `state.narrative()` / `state.control()` 视图取值;顶层字段会逐步按阶段迁出。
- `MemoirStateSchema`持久化 DTO只承载字段消费方显式投影到两个视图
"""
from __future__ import annotations
@@ -154,19 +152,25 @@ class InterviewControlState(BaseModel):
if fact.slot_name and (not fact.stage or fact.stage == stage)
}
def prompt_empty_slots_for_stage(
self, narrative: NarrativeCoverageState, stage: str
) -> List[str]:
"""生成 prompt 时可追问的槽位,排除已被 known_facts 覆盖的方向。"""
blocked = self.blocked_slot_names_for_stage(stage)
return [
key
for key in narrative.empty_slots_for_stage(stage)
if key not in blocked
]
# =============================================================================
# FacadeMemoirStateSchema 兼容旧字段形态,方法委托到两个视图
# 持久化 DTO不承载视图委托逻辑
# =============================================================================
class MemoirStateSchema(BaseModel):
"""回忆录状态Facade
为兼容既有持久化与旧调用方,顶层字段保持不变;内部将其投影成
`NarrativeCoverageState` 与 `InterviewControlState` 两个视图,后续新代码应直接使用
`state.narrative()` / `state.control()` 表达意图。
"""
"""回忆录状态持久化 DTO。视图逻辑由显式 state projector 提供。"""
stage_order: List[str]
current_stage: str
@@ -176,62 +180,33 @@ class MemoirStateSchema(BaseModel):
persona_threads: List[PersonaThread] = Field(default_factory=list)
recent_questions: List[str] = Field(default_factory=list)
# ---- 视图投影 ----
def narrative(self) -> NarrativeCoverageState:
return NarrativeCoverageState(
stage_order=self.stage_order,
current_stage=self.current_stage,
covered_stages=self.covered_stages,
slots=self.slots,
)
def control(self) -> InterviewControlState:
return InterviewControlState(
known_facts=self.known_facts,
persona_threads=self.persona_threads,
recent_questions=self.recent_questions,
)
# ---- 兼容层:委托到 narrative / control 视图 ----
def empty_slots_for_current_stage(self) -> List[str]:
return self.narrative().empty_slots_for_current_stage()
def empty_slots_for_stage(self, stage: str) -> List[str]:
return self.narrative().empty_slots_for_stage(stage)
def filled_slots_for_stage(self, stage: str) -> Dict[str, str]:
return self.narrative().filled_slots_for_stage(stage)
def all_stages_coverage(self) -> Dict[str, Dict]:
return self.narrative().all_stages_coverage()
def prompt_empty_slots_for_stage(self, stage: str) -> List[str]:
"""生成 prompt 时可追问的槽位,排除已被 known_facts 覆盖的方向。"""
blocked = self.control().blocked_slot_names_for_stage(stage)
return [
key
for key in self.narrative().empty_slots_for_stage(stage)
if key not in blocked
]
def prompt_empty_slots_for_current_stage(self) -> List[str]:
return self.prompt_empty_slots_for_stage(self.current_stage)
def prompt_known_fact_lines(self, *, limit: int = 10) -> List[str]:
return self.control().prompt_known_fact_lines(limit=limit)
def prompt_persona_thread_lines(self, *, limit: int = 6) -> List[str]:
return self.control().prompt_persona_thread_lines(limit=limit)
def prompt_recent_question_lines(self, *, limit: int = 4) -> List[str]:
return self.control().prompt_recent_question_lines(limit=limit)
DEFAULT_STAGE_ORDER: list[str] = list(CHAT_STAGES)
def narrative_coverage_state(state: MemoirStateSchema) -> NarrativeCoverageState:
return NarrativeCoverageState(
stage_order=state.stage_order,
current_stage=state.current_stage,
covered_stages=state.covered_stages,
slots=state.slots,
)
def interview_control_state(state: MemoirStateSchema) -> InterviewControlState:
return InterviewControlState(
known_facts=state.known_facts,
persona_threads=state.persona_threads,
recent_questions=state.recent_questions,
)
def prompt_empty_slots_for_current_stage(state: MemoirStateSchema) -> List[str]:
narrative = narrative_coverage_state(state)
control = interview_control_state(state)
return control.prompt_empty_slots_for_stage(narrative, state.current_stage)
def default_slots() -> Dict[str, Dict[str, SlotData]]:
return {
"childhood": {
@@ -290,4 +265,7 @@ __all__ = [
"SlotData",
"default_slots",
"default_state",
"interview_control_state",
"narrative_coverage_state",
"prompt_empty_slots_for_current_stage",
]