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
479 lines
15 KiB
Python
479 lines
15 KiB
Python
"""访谈提示词:精简结构与人格/语气融合回归。"""
|
||
|
||
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
||
|
||
from app.agents.chat.helpers import format_history_string
|
||
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,
|
||
)
|
||
from app.agents.chat.output_rules import chat_output_rules
|
||
from app.agents.chat.personas import normalize_interview_persona
|
||
from app.agents.chat.prompts_conversation import (
|
||
get_guided_conversation_prompt,
|
||
get_opening_prompt,
|
||
)
|
||
from app.agents.chat.slot_question_bank import SLOT_QUESTION_OUTLINES
|
||
from app.agents.state_schema import (
|
||
KnownFact,
|
||
MemoirStateSchema,
|
||
PersonaThread,
|
||
default_slots,
|
||
prompt_empty_slots_for_current_stage,
|
||
)
|
||
|
||
|
||
def test_guided_prompt_does_not_embed_raw_user_message_in_system_text():
|
||
p = get_guided_conversation_prompt(
|
||
current_stage="childhood",
|
||
empty_slots=["place"],
|
||
filled_slots={},
|
||
detected_user_stage="childhood",
|
||
user_profile_context="",
|
||
persona="default",
|
||
)
|
||
assert "__USER_SECRET_PHRASE_XYZ__" not in p
|
||
# Signature no longer takes user_message; secret would only leak via profile
|
||
p2 = get_guided_conversation_prompt(
|
||
current_stage="childhood",
|
||
empty_slots=["place"],
|
||
filled_slots={},
|
||
detected_user_stage="childhood",
|
||
user_profile_context="__USER_SECRET_PROFILE__",
|
||
persona="default",
|
||
)
|
||
assert "__USER_SECRET_PROFILE__" in p2
|
||
|
||
|
||
def test_guided_prompt_injects_slot_question_outline_for_empty_slots():
|
||
p = get_guided_conversation_prompt(
|
||
current_stage="childhood",
|
||
empty_slots=["place", "people"],
|
||
filled_slots={"emotion": "挺开心的"},
|
||
detected_user_stage="childhood",
|
||
user_profile_context="",
|
||
persona="default",
|
||
)
|
||
assert "本阶段问题大纲" in p
|
||
assert "叙述槽" in p or "采集目标" in p
|
||
assert "成长的地方" in p
|
||
assert "重要的人" in p
|
||
|
||
|
||
def test_guided_prompt_emotion_priority_over_outline():
|
||
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 and "大纲" in p
|
||
|
||
|
||
def test_guided_prompt_includes_memoir_quality_rubric_hints():
|
||
p = get_guided_conversation_prompt(
|
||
current_stage="career",
|
||
empty_slots=["growth"],
|
||
filled_slots={"job": "做过工程师"},
|
||
detected_user_stage="career",
|
||
user_profile_context="",
|
||
persona="default",
|
||
)
|
||
assert "成稿质量导向" in p
|
||
assert "真实性与覆盖" in p
|
||
assert "出版就绪度" in p
|
||
|
||
|
||
def test_guided_prompt_follow_immersion_whitespace_style_examples():
|
||
p = get_guided_conversation_prompt(
|
||
current_stage="belief",
|
||
empty_slots=["value"],
|
||
filled_slots={},
|
||
detected_user_stage="belief",
|
||
user_profile_context="",
|
||
persona="default",
|
||
)
|
||
assert "跟随" in p and "沉浸" in p
|
||
assert "留白" in p
|
||
assert "风格参考" in p
|
||
assert "这让我想起" in p
|
||
|
||
|
||
def test_guided_prompt_narrative_weave_and_transition():
|
||
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 or "内在线" in p
|
||
assert "通感" in p or "比喻" in p
|
||
assert "下面我们聊聊" in p
|
||
|
||
|
||
def test_guided_prompt_host_tone_and_context_forward():
|
||
p = get_guided_conversation_prompt(
|
||
current_stage="career",
|
||
empty_slots=["job"],
|
||
filled_slots={},
|
||
detected_user_stage="career",
|
||
user_profile_context="",
|
||
persona="default",
|
||
)
|
||
assert "主持" in p or "播报" in p
|
||
assert "上下文" in p or "既定事实" in p
|
||
assert "行为" in p and "影响" in p
|
||
|
||
|
||
def test_guided_prompt_leaves_turn_level_question_contract_to_turn_plan() -> None:
|
||
p = get_guided_conversation_prompt(
|
||
current_stage="career",
|
||
empty_slots=["job"],
|
||
filled_slots={},
|
||
detected_user_stage="career",
|
||
user_profile_context="",
|
||
persona="default",
|
||
)
|
||
assert "随后**必须**用**一条**" not in p
|
||
assert "短承接后须带回一条" not in p
|
||
assert "仍须**勾回回忆叙事**" not in p
|
||
assert "具体问几问、是否必须追问,见顶部" in p
|
||
|
||
|
||
def test_education_and_family_change_outlines_differ():
|
||
edu = SLOT_QUESTION_OUTLINES[("education", "change")]
|
||
fam = SLOT_QUESTION_OUTLINES[("family", "change")]
|
||
assert edu[0] != fam[0]
|
||
|
||
|
||
def test_guided_prompt_mentions_empathy_and_scene_strategy():
|
||
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 or "细节" in p
|
||
assert "深挖" in p
|
||
assert "串联" in p
|
||
|
||
|
||
def test_guided_prompt_era_popculture_open_questions_when_birth_year():
|
||
p = get_guided_conversation_prompt(
|
||
current_stage="childhood",
|
||
empty_slots=["place"],
|
||
filled_slots={},
|
||
detected_user_stage="childhood",
|
||
user_profile_context="",
|
||
persona="default",
|
||
profile_birth_year=1985,
|
||
profile_era_place="潍坊",
|
||
)
|
||
assert "时代与氛围参考" in p
|
||
assert "流行文化" in p
|
||
assert "开放式" in p
|
||
|
||
|
||
def test_opening_prompt_includes_era_task_when_birth_year_configured():
|
||
p = get_opening_prompt(
|
||
current_stage="childhood",
|
||
empty_slots_readable=["成长的地方"],
|
||
user_profile_context="出生年份:1985年",
|
||
persona="default",
|
||
profile_birth_year=1985,
|
||
profile_era_place="潍坊",
|
||
)
|
||
assert "年代氛围" in p
|
||
|
||
|
||
def test_guided_prompt_persona_tone_warm_listener():
|
||
p = get_guided_conversation_prompt(
|
||
current_stage="education",
|
||
empty_slots=["school"],
|
||
filled_slots={},
|
||
detected_user_stage="education",
|
||
user_profile_context="",
|
||
persona="warm_listener",
|
||
)
|
||
assert "倾听" in p or "柔和" in p
|
||
|
||
|
||
def test_guided_prompt_persona_curious_guide():
|
||
p = get_guided_conversation_prompt(
|
||
current_stage="education",
|
||
empty_slots=["school"],
|
||
filled_slots={},
|
||
detected_user_stage="education",
|
||
user_profile_context="",
|
||
persona="curious_guide",
|
||
)
|
||
assert "细节" in p
|
||
|
||
|
||
def test_normalize_interview_persona_unknown_falls_back():
|
||
assert normalize_interview_persona("not_a_real_persona") == "default"
|
||
assert normalize_interview_persona("") == "default"
|
||
|
||
|
||
def test_guided_prompt_contains_memory_section_when_evidence():
|
||
p = get_guided_conversation_prompt(
|
||
current_stage="childhood",
|
||
empty_slots=["place"],
|
||
filled_slots={},
|
||
detected_user_stage="childhood",
|
||
user_profile_context="",
|
||
persona="default",
|
||
memory_evidence_text="[摘要:rolling] 1990年生于上海。",
|
||
)
|
||
assert "记忆线索" in p or "追问角度" in p
|
||
assert "1990年生于上海" in p
|
||
|
||
|
||
def test_guided_prompt_military_tone_in_system():
|
||
p = get_guided_conversation_prompt(
|
||
current_stage="childhood",
|
||
empty_slots=["place"],
|
||
filled_slots={},
|
||
detected_user_stage="childhood",
|
||
user_profile_context="",
|
||
persona="default",
|
||
background_voice="military",
|
||
)
|
||
assert "简洁" in p or "利落" in p or "得体" in p
|
||
|
||
|
||
def test_guided_prompt_includes_known_facts_persona_threads_and_recent_questions():
|
||
p = get_guided_conversation_prompt(
|
||
current_stage="career",
|
||
empty_slots=["job", "decision"],
|
||
filled_slots={"growth": "越做越确定自己适合产品"},
|
||
detected_user_stage="career",
|
||
user_profile_context="",
|
||
persona="default",
|
||
known_facts=[
|
||
KnownFact(
|
||
label="本轮新信息", value="我后来去了瑞士读书", stage="education"
|
||
),
|
||
],
|
||
persona_threads=[
|
||
PersonaThread(trait="执着坚持", evidence="为了训练咬牙坚持了很多年"),
|
||
],
|
||
recent_questions=["你当时为什么会想去瑞士?"],
|
||
)
|
||
assert "已确认事实" in p
|
||
assert "我后来去了瑞士读书" in p
|
||
assert "人物主线" in p
|
||
assert "执着坚持" in p
|
||
assert "最近已经问过的问题" in p
|
||
assert "为什么会想去瑞士" in p
|
||
|
||
|
||
def test_prompt_empty_slots_excludes_slots_already_covered_by_known_facts():
|
||
state = MemoirStateSchema(
|
||
stage_order=["education"],
|
||
current_stage="education",
|
||
covered_stages=[],
|
||
slots={"education": default_slots()["education"]},
|
||
known_facts=[
|
||
KnownFact(
|
||
label="求学城市",
|
||
value="后来在瑞士读书",
|
||
stage="education",
|
||
slot_name="city",
|
||
)
|
||
],
|
||
)
|
||
prompt_empty = prompt_empty_slots_for_current_stage(state)
|
||
assert "city" not in prompt_empty
|
||
assert "school" in prompt_empty
|
||
|
||
|
||
def test_duplicate_question_guard_downgrades_recent_repeat_question():
|
||
state = MemoirStateSchema(
|
||
stage_order=["education"],
|
||
current_stage="education",
|
||
covered_stages=[],
|
||
slots={"education": default_slots()["education"]},
|
||
known_facts=[
|
||
KnownFact(label="本轮新信息", value="我后来去了瑞士读书", stage="education")
|
||
],
|
||
)
|
||
cleaned, touched = apply_duplicate_question_guard(
|
||
["我记住了。你后来去了瑞士读书吗?"],
|
||
state=state,
|
||
recent_questions=["你后来去了瑞士读书吗?"],
|
||
)
|
||
assert touched is True
|
||
assert cleaned == ["我记住了。"]
|
||
|
||
|
||
def test_segments_are_only_duplicate_guard_fallback_single_stub():
|
||
assert segments_are_only_duplicate_guard_fallback(
|
||
[DUPLICATE_QUESTION_GUARD_FALLBACK_ZH]
|
||
)
|
||
assert not segments_are_only_duplicate_guard_fallback(["承接。后来呢?"])
|
||
|
||
|
||
def test_segments_are_only_duplicate_guard_fallback_requires_exact_stub():
|
||
assert not segments_are_only_duplicate_guard_fallback(["我记住了。"])
|
||
assert not segments_are_only_duplicate_guard_fallback(
|
||
[DUPLICATE_QUESTION_GUARD_FALLBACK_ZH, "第二泡"]
|
||
)
|
||
|
||
|
||
def test_extract_scene_cues_picks_up_sensory_keywords():
|
||
cues = extract_scene_cues("我们小时候在河里游泳,冬天溜冰")
|
||
assert any("凉" in c or "水" in c for c in cues)
|
||
assert any("冰" in c or "咔嚓" in c for c in cues)
|
||
|
||
|
||
def test_extract_scene_cues_empty_for_abstract_text():
|
||
assert extract_scene_cues("我觉得人生需要坚持") == []
|
||
|
||
|
||
def test_default_persona_now_has_tone_hint():
|
||
from app.agents.chat.personas import get_interview_persona_tone_hint
|
||
|
||
hint = get_interview_persona_tone_hint("default")
|
||
assert hint
|
||
assert "画面" in hint or "细节" in hint
|
||
|
||
|
||
def test_opening_prompt_military_style_rules_not_dialogue_samples() -> None:
|
||
p = get_opening_prompt(
|
||
current_stage="childhood",
|
||
empty_slots_readable=["成长的地方"],
|
||
user_profile_context="",
|
||
persona="default",
|
||
background_voice="military",
|
||
)
|
||
assert "军队相关" in p
|
||
assert "示例" not in p
|
||
|
||
|
||
def test_format_history_string_includes_system_for_debug_logs() -> None:
|
||
s = format_history_string(
|
||
[
|
||
SystemMessage(content="SYS_INSTRUCTIONS"),
|
||
HumanMessage(content="hi"),
|
||
AIMessage(content="hello"),
|
||
]
|
||
)
|
||
assert "System: SYS_INSTRUCTIONS" in s
|
||
assert "Human: hi" in s
|
||
assert "Assistant: hello" in s
|
||
|
||
|
||
def test_format_history_string_omit_system_body() -> None:
|
||
s = format_history_string(
|
||
[
|
||
SystemMessage(content="SYS_INSTRUCTIONS"),
|
||
HumanMessage(content="hi"),
|
||
],
|
||
omit_system_body=True,
|
||
)
|
||
assert "SYS_INSTRUCTIONS" not in s
|
||
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 == ["我演示一下这个按钮怎么点。"]
|