WIP: memory system improvements (in progress)

Interview/chat prompt layers, reply planner, style profiles, memory
injection, interview meta store, and related tests. Work not finished.

Made-with: Cursor
This commit is contained in:
Kevin
2026-04-22 16:56:28 +08:00
parent e848f26354
commit 3121d1384d
28 changed files with 2790 additions and 452 deletions

View File

@@ -4,6 +4,8 @@
访谈侧仅验证 prompt 仍包含关键行为指引。
"""
from app.agents.chat.interview_turn_plan import plan_interview_turn
from app.agents.chat.output_rules import chat_output_rules
from app.agents.chat.prompts_conversation import (
get_guided_conversation_prompt,
get_opening_prompt,
@@ -54,6 +56,23 @@ class TestChatExperienceRegressions:
)
assert "接住" in p
def test_guided_prompt_bans_assistant_autobiographical_claims(self) -> None:
"""避免助手把用户经历说成自己的童年/暗恋等(身份越界)。"""
p = get_guided_conversation_prompt(
current_stage="education",
empty_slots=["school"],
filled_slots={},
detected_user_stage="education",
user_profile_context="",
persona="default",
)
assert "身份边界" in p
assert "暗恋" in p
assert "共同回忆" in p
rules = chat_output_rules()
assert "助手本人" in rules
assert "共同回忆" in rules
def test_opening_prompt_stays_short_task_shape(self) -> None:
p = get_opening_prompt(
current_stage="childhood",
@@ -64,6 +83,19 @@ class TestChatExperienceRegressions:
assert "问候" in p
assert "任务" in p or "具体问题" in p
def test_followup_her_story_turn_plan_stays_user_perspective_shape(self) -> None:
"""追问第三方故事时:编排仍锁用户视角,并倾向「承接+一问」形状(防助手自传漂移)。"""
p = plan_interview_turn(
current_stage="education",
empty_slots=["school", "city"],
normalized_user_message="讲讲她的故事吧,后来怎么样了?",
memory_evidence_text="",
stage_switched_this_turn=False,
)
assert p.subject_owner == "user_only"
assert p.forbid_first_person_experience is True
assert p.reply_shape == "ack_then_question"
class TestMemoirStyleRegressions:
"""保护「回忆录有文笔」体验。"""

View File

@@ -3,7 +3,9 @@
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from app.agents.chat.interview_state_hints import (
AUTOBIOGRAPHICAL_BOUNDARY_FALLBACK_ZH,
DUPLICATE_QUESTION_GUARD_FALLBACK_ZH,
apply_autobiographical_boundary_guard,
apply_duplicate_question_guard,
extract_scene_cues,
segments_are_only_duplicate_guard_fallback,
@@ -16,6 +18,7 @@ from app.agents.state_schema import (
)
from app.agents.chat.helpers import format_history_string
from app.agents.chat.personas import normalize_interview_persona
from app.agents.chat.output_rules import chat_output_rules
from app.agents.chat.prompts_conversation import (
get_guided_conversation_prompt,
get_opening_prompt,
@@ -217,8 +220,7 @@ def test_guided_prompt_contains_memory_section_when_evidence():
persona="default",
memory_evidence_text="[摘要:rolling] 1990年生于上海。",
)
assert "相关记忆摘录" in p
assert "过往口述" in p
assert "记忆线索" in p or "追问角度" in p
assert "1990年生于上海" in p
@@ -368,3 +370,92 @@ def test_format_history_string_omit_system_body() -> None:
assert "System: <omitted total_len=16" in s
assert "sha12=" in s
assert "Human: hi" in s
def test_guided_prompt_includes_identity_boundary_hard_rules() -> None:
p = get_guided_conversation_prompt(
current_stage="childhood",
empty_slots=["place"],
filled_slots={},
detected_user_stage="childhood",
user_profile_context="",
persona="default",
)
assert "身份边界" in p
assert "真实人生传记" in p
assert "共同回忆" in p
assert "泛指" in p
def test_guided_prompt_blocks_using_user_context_as_assistant_identity() -> None:
p = get_guided_conversation_prompt(
current_stage="childhood",
empty_slots=["place"],
filled_slots={},
detected_user_stage="childhood",
user_profile_context="成长地:上海",
persona="default",
)
assert "我是上海人" in p
assert "不能把用户的成长地答成" in p
assert "你刚提到上海" in p or "你之前说过那段童年" in p
def test_chat_output_rules_bans_assistant_autobiography() -> None:
rules = chat_output_rules()
assert "禁止" in rules
assert "声称助手本人" in rules or "助手本人" in rules
assert "共同回忆" in rules
assert "你是哪里人" in rules
assert "你刚提到" in rules
def test_autobiographical_boundary_guard_replaces_crush_claim() -> None:
out, touched = apply_autobiographical_boundary_guard(
["是啊,朱丽叶就是我当时暗恋的女生。"]
)
assert touched is True
assert out == [AUTOBIOGRAPHICAL_BOUNDARY_FALLBACK_ZH]
def test_autobiographical_boundary_guard_replaces_childhood_claim() -> None:
out, touched = apply_autobiographical_boundary_guard(
["我小时候也演过这个,还挺紧张的。"]
)
assert touched is True
assert out == [AUTOBIOGRAPHICAL_BOUNDARY_FALLBACK_ZH]
def test_autobiographical_boundary_guard_allows_generic_empathy() -> None:
safe = [
"我能想象那会儿站在台上,手心里全是汗。",
"换作很多人可能也会记很久。",
]
out, touched = apply_autobiographical_boundary_guard(safe)
assert touched is False
assert out == safe
def test_autobiographical_boundary_guard_mixed_segments() -> None:
out, touched = apply_autobiographical_boundary_guard(
["嗯,你刚才那段我接住了。", "我小时候也演过。"]
)
assert touched is True
assert out[0] == "嗯,你刚才那段我接住了。"
assert out[1] == AUTOBIOGRAPHICAL_BOUNDARY_FALLBACK_ZH
def test_autobiographical_boundary_guard_catches_iyan_role_without_demo() -> None:
out, touched = apply_autobiographical_boundary_guard(
["那次话剧里我演罗密欧,对手戏挺难的。"]
)
assert touched is True
assert out == [AUTOBIOGRAPHICAL_BOUNDARY_FALLBACK_ZH]
def test_autobiographical_boundary_guard_allows_wo_yanshi_demo() -> None:
out, touched = apply_autobiographical_boundary_guard(
["我演示一下这个按钮怎么点。"]
)
assert touched is False
assert out == ["我演示一下这个按钮怎么点。"]

View File

@@ -1,7 +1,10 @@
"""interview_turn_plan轮次模式与主槽选择服务端硬编排"""
from app.agents.chat.interview_turn_plan import (
InterviewTurnPlan,
apply_safe_mode_override,
extract_anchor_snippet,
format_interview_turn_directive_block,
plan_interview_turn,
primary_empty_slot,
)
@@ -12,11 +15,177 @@ def test_primary_empty_slot_order():
assert primary_empty_slot("childhood", ["emotion"]) == "emotion"
def test_extract_anchor_snippet_prefers_memory():
def test_extract_anchor_snippet_prefers_user_when_long_enough():
mem = "摘录的一段记忆\n\n[场景氛围提示"
assert "摘录的一段记忆" in extract_anchor_snippet(
memory_evidence_text=mem, user_message="用户说很长一句" * 3
um = "用户说很长一句" * 5
sn = extract_anchor_snippet(memory_evidence_text=mem, user_message=um)
assert sn.startswith("用户说")
assert "摘录" not in sn
def test_extract_anchor_snippet_prefers_first_m_line():
mem = (
"【相关记忆摘录·聊天专用】\n"
"说明行……\n"
"[M1] 你在校园演出里饰演罗密欧。\n"
)
sn = extract_anchor_snippet(memory_evidence_text=mem, user_message="")
assert "校园演出" in sn
assert "【相关" not in sn
def test_plan_sets_memory_usage_when_evidence_present():
p = plan_interview_turn(
current_stage="childhood",
empty_slots=["place"],
normalized_user_message="嗯。",
memory_evidence_text="【头】\n[M1] 你提过河边。",
stage_switched_this_turn=False,
)
assert p.memory_usage == "allowed_with_attribution"
def test_plan_marks_assistant_identity_question() -> None:
p = plan_interview_turn(
current_stage="childhood",
empty_slots=["place"],
normalized_user_message="你是哪里人,你的童年是什么样的?",
memory_evidence_text="用户曾说:「我小时候在上海长大。」",
stage_switched_this_turn=False,
)
assert p.assistant_identity_question is True
def test_plan_reply_shape_ack_then_question_on_her_story_followup():
p = plan_interview_turn(
current_stage="education",
empty_slots=["school"],
normalized_user_message="那你讲讲她的故事吧。",
memory_evidence_text="",
stage_switched_this_turn=False,
)
assert p.reply_shape == "ack_then_question"
def test_directive_includes_attribution_when_memory_allowed():
plan = InterviewTurnPlan(
mode="memoir_push",
anchor_slot_key="place",
anchor_slot_readable="成长的地方",
anchor_snippet="挂钩",
memory_usage="allowed_with_attribution",
memory_reference_style="你之前提过",
reply_shape="ack_then_question",
)
block = format_interview_turn_directive_block(plan)
assert "线索" in block
assert "你之前提过" in block
assert "真实人生传记" in block
assert "ack" not in block.lower()
assert "承接" in block
assert (
"本轮追问" in block
or "承接角度" in block
or "本轮承接重点" in block
)
def test_directive_includes_focus_summary_when_set():
plan = InterviewTurnPlan(
mode="memoir_push",
anchor_slot_key="place",
anchor_slot_readable="成长的地方",
anchor_snippet="挂钩",
focus_summary="先接住「芳芳」与怕丢脸这条线",
focus_source="llm",
primary_focus="relationship",
)
block = format_interview_turn_directive_block(plan)
assert "芳芳" in block
assert "本轮追问" in block or "承接角度" in block
def test_directive_marks_user_message_anchor_source_correctly():
plan = InterviewTurnPlan(
mode="memoir_push",
anchor_slot_key="place",
anchor_slot_readable="成长的地方",
anchor_snippet="那年夏天我总往河边跑",
anchor_source_kind="user_message",
)
block = format_interview_turn_directive_block(plan)
assert "来自用户本轮原话摘录" in block
assert "不是用户本轮新说的内容" not in block
def test_directive_marks_memory_anchor_source_correctly():
plan = InterviewTurnPlan(
mode="memoir_push",
anchor_slot_key="place",
anchor_slot_readable="成长的地方",
anchor_snippet="你小时候常在河边玩",
anchor_source_kind="memory",
)
block = format_interview_turn_directive_block(plan)
assert "来自检索到的用户过往口述/摘要" in block
assert "不是用户本轮新说的内容" in block
def test_directive_adds_boundary_for_assistant_identity_question():
plan = InterviewTurnPlan(
mode="memoir_push",
anchor_slot_key="place",
anchor_slot_readable="成长的地方",
anchor_snippet="用户曾说:「我小时候在上海长大。」",
assistant_identity_question=True,
)
block = format_interview_turn_directive_block(plan)
assert "本轮用户在问助手本人" in block
assert "你刚提到上海" in block
def test_apply_safe_mode_override_blocks_emotion_downgrade():
assert apply_safe_mode_override("emotion_first", "memoir_push", primary_focus="emotion") is None
def test_apply_safe_mode_override_allows_memoir_to_emotion():
assert (
apply_safe_mode_override("memoir_push", "emotion_first", primary_focus="relationship")
== "emotion_first"
)
def test_apply_safe_mode_override_clarify_blocks_memoir():
assert apply_safe_mode_override("clarify_first", "memoir_push", primary_focus="emotion") is None
def test_apply_safe_mode_override_clarify_to_emotion():
assert (
apply_safe_mode_override("clarify_first", "emotion_first", primary_focus="emotion")
== "emotion_first"
)
def test_plan_clarify_first_when_ambiguous():
p = plan_interview_turn(
current_stage="childhood",
empty_slots=["place", "people"],
normalized_user_message="那种喜欢我也说不清,好像有又好像没有。",
memory_evidence_text="",
stage_switched_this_turn=False,
)
assert p.mode == "clarify_first"
def test_plan_clarify_first_when_very_short():
p = plan_interview_turn(
current_stage="childhood",
empty_slots=["place"],
normalized_user_message="还好吧",
memory_evidence_text="",
stage_switched_this_turn=False,
)
assert p.mode == "clarify_first"
def test_plan_memoir_push():

View File

@@ -3,6 +3,7 @@
import pytest
from app.features.memory import evidence as evidence_mod
from app.features.memory.evidence_format import format_evidence_chunks_for_chat_prompt
from app.features.memory.evidence import (
EMPTY_EVIDENCE_BUNDLE,
_facts_to_dicts,
@@ -85,3 +86,107 @@ def test_format_helpers_empty() -> None:
assert _facts_to_dicts([]) == []
assert _timeline_to_dicts([]) == []
assert _stories_to_dicts([]) == []
def test_format_evidence_chunks_for_chat_prompt_reframes_and_labels() -> None:
evidence = {
"relevant_chunks": [
{"id": "chunk-1", "content": "我小时候在河边长大,夏天常去玩水。"},
],
"relevant_summaries": [],
"relevant_facts": [],
"timeline_hints": [],
"relevant_stories": [],
}
text = format_evidence_chunks_for_chat_prompt(evidence)
assert "聊天专用" in text
assert "归因" in text
assert "[M1]" in text
assert "用户曾说" in text
assert "我小时候在河边长大" in text
def test_slice_interview_memory_empty_bundle():
from app.features.memory.chat_memory_injection import slice_interview_memory
s = slice_interview_memory(None, "你好")
assert s.prompt_excerpt == ""
assert s.anchor_source == ""
assert s.planner_preview == ""
assert s.had_retrieval is False
def test_slice_interview_memory_retrieval_not_equal_inject_dismissive():
"""有检索预览但 gating 后不进主 prompt / anchor。"""
from app.features.memory.chat_memory_injection import slice_interview_memory
evidence = {
"relevant_chunks": [
{"id": "c1", "content": "很久以前在校园礼堂排练到很晚。"},
],
"relevant_summaries": [],
"relevant_facts": [],
"timeline_hints": [],
"relevant_stories": [],
}
s = slice_interview_memory(evidence, "哈哈,早就不会了")
assert s.prompt_excerpt == ""
assert s.anchor_source == ""
assert s.planner_preview.strip() != ""
assert s.had_retrieval is True
def test_slice_interview_memory_minimal_inject_when_aligned():
from app.features.memory.chat_memory_injection import slice_interview_memory
evidence = {
"relevant_chunks": [
{"id": "c1", "content": "你在校园演出里饰演罗密欧。"},
],
"relevant_summaries": [],
"relevant_facts": [],
"timeline_hints": [],
"relevant_stories": [],
}
s = slice_interview_memory(evidence, "那次排练其实挺紧张的,灯光一打我就忘词。")
assert "记忆线索" in s.prompt_excerpt
assert "校园演出" in s.prompt_excerpt or "罗密欧" in s.prompt_excerpt
assert s.anchor_source
assert s.had_retrieval is True
def test_slice_interview_memory_keeps_first_person_but_marks_ownership():
from app.features.memory.chat_memory_injection import slice_interview_memory
evidence = {
"relevant_chunks": [
{"id": "c1", "content": "我小时候在河边长大,夏天常去玩水。"},
],
"relevant_summaries": [],
"relevant_facts": [],
"timeline_hints": [],
"relevant_stories": [],
}
s = slice_interview_memory(evidence, "那条河一到夏天就特别热闹,我现在都记得。")
assert "用户曾说" in s.prompt_excerpt
assert "我小时候在河边长大" in s.prompt_excerpt
assert s.anchor_source.startswith("用户曾说")
def test_slice_interview_memory_suppresses_long_new_topic():
from app.features.memory.chat_memory_injection import slice_interview_memory
evidence = {
"relevant_chunks": [
{"id": "c1", "content": "旧记忆关于河边。"},
],
"relevant_summaries": [],
"relevant_facts": [],
"timeline_hints": [],
"relevant_stories": [],
}
long_msg = "我今天想随便聊聊工作里的事,项目压力很大。" * 6
assert len(long_msg) > 72
s = slice_interview_memory(evidence, long_msg)
assert s.prompt_excerpt == ""
assert s.anchor_source == ""

View File

@@ -0,0 +1,112 @@
"""reply_plannerJSON 合并与安全边界。"""
import json
from app.agents.chat.interview_turn_plan import InterviewTurnPlan
from app.agents.chat.reply_planner import merge_reply_planner_json_into_turn_plan
def _base_plan(**kwargs) -> InterviewTurnPlan:
defaults = dict(
mode="memoir_push",
anchor_slot_key="place",
anchor_slot_readable="成长的地方",
anchor_snippet="河边",
memory_usage="allowed_with_attribution",
reply_shape="flexible",
memory_reference_style="你之前提过",
)
defaults.update(kwargs)
return InterviewTurnPlan(**defaults)
def test_merge_does_not_upgrade_memory_from_none_to_allowed():
plan = _base_plan(memory_usage="none")
raw = json.dumps(
{
"memory_usage": "allowed_with_attribution",
"reply_shape": "ack_then_question",
"memory_reference_style": "你说过",
"forbid_first_person_experience": True,
}
)
merged = merge_reply_planner_json_into_turn_plan(plan, raw)
assert merged.memory_usage == "none"
assert merged.reply_shape == "ack_then_question"
assert merged.memory_reference_style == "你说过"
def test_merge_allows_downgrade_memory_usage_to_none():
plan = _base_plan(memory_usage="allowed_with_attribution")
raw = json.dumps({"memory_usage": "none"})
merged = merge_reply_planner_json_into_turn_plan(plan, raw)
assert merged.memory_usage == "none"
def test_merge_invalid_json_returns_original():
plan = _base_plan()
merged = merge_reply_planner_json_into_turn_plan(plan, "not json")
assert merged == plan
def test_merge_ignores_non_dict_json():
plan = _base_plan()
merged = merge_reply_planner_json_into_turn_plan(plan, "[1,2]")
assert merged == plan
def test_merge_trims_memory_reference_style():
plan = _base_plan()
raw = json.dumps({"memory_reference_style": " 你刚讲到 "})
merged = merge_reply_planner_json_into_turn_plan(plan, raw)
assert merged.memory_reference_style == "你刚讲到"
def test_merge_sets_focus_and_summary():
plan = _base_plan()
raw = json.dumps(
{
"primary_focus": "relationship",
"focus_summary": "先接住用户提到的在场关系与面子压力",
"forbid_first_person_experience": True,
}
)
merged = merge_reply_planner_json_into_turn_plan(plan, raw)
assert merged.primary_focus == "relationship"
assert "关系" in merged.focus_summary
assert merged.focus_source == "llm"
def test_merge_mode_override_memoir_to_emotion_when_focus_supports():
plan = _base_plan(mode="memoir_push")
raw = json.dumps(
{
"mode_override": "emotion_first",
"primary_focus": "identity",
"forbid_first_person_experience": True,
}
)
merged = merge_reply_planner_json_into_turn_plan(plan, raw)
assert merged.mode == "emotion_first"
assert merged.focus_source == "llm"
def test_merge_rejects_unsafe_mode_override_emotion_to_memoir():
plan = _base_plan(mode="emotion_first")
raw = json.dumps(
{
"mode_override": "memoir_push",
"primary_focus": "memoir_gap",
"forbid_first_person_experience": True,
}
)
merged = merge_reply_planner_json_into_turn_plan(plan, raw)
assert merged.mode == "emotion_first"
def test_merge_omitted_secondary_focus_unchanged():
plan = _base_plan()
raw = json.dumps({"reply_shape": "ack_only", "forbid_first_person_experience": True})
merged = merge_reply_planner_json_into_turn_plan(plan, raw)
assert merged.reply_shape == "ack_only"
assert merged.focus_source == "rule"