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:
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Facade:MemoirStateSchema 兼容旧字段形态,方法委托到两个视图
|
||||
# 持久化 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",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user