访谈与阶段 - 新增 app/agents/stage_constants.py:集中 CHAT_STAGES、章节分类/顺序、阶段到默认 memoir 类别等,与 MemoirState 默认槽位顺序对齐;减少散落在 prompts 内的重复常量。 - 新增 app/agents/chat/prompt_context.py:以 ChatPromptContext 汇总 guided 系统提示所需字段(阶段、槽位、轮次、人设、记忆证据、回复长度模式、背景声线、职业等),统一走 get_guided_conversation_prompt。 - 大幅收敛 app/agents/chat/prompts_conversation.py;调整 prompts.py、stage_prompts.py、stage_detection.py;同步 interview_agent、profile_agent、helpers 与 state_schema,使对话侧构造提示的方式一致、可测。 回忆录流水线 - memoir/prompts.py 删除已迁至 stage_constants / 独立模板的大段常量与图片占位相关逻辑;classification / extraction / fidelity / narrative agents 与 orchest(全量历史仍可用于计数,注入模型时按轮次与字符上限截断)、image_prompt_fallback_disabled。 - dependencies 增加 get_llm_provider_fast(LRU 缓存,可与默认共用密钥与 base_url)。 任务与编排 - memoir_tasks:prepare_batches 注入 llm_fast;开启独立快档模型时打结构化日志。 - chapter_cover_tasks、story_image_tasks:与图片 prompt / JSON 工具路径或策略变更对齐(import 与行为一致)。 - story_pipeline_sync 等小处同步。 其它核心 - langchain_llm、text_normalize 随上述调用链微调。 开发者体验 - .cursor/settings.json:启用 redis-development、postman 插件。 测试 - 新增 test_image_prompt_policy:覆盖「禁止回退」等图片 prompt 策略。 - 更新 test_interview_prompts、test_interview_reply_length、test_experience_regressions、test_json_and_memory_utils,匹配新常量位置、json_utils 与对话/长度行为。
209 lines
8.6 KiB
Python
209 lines
8.6 KiB
Python
"""面向体验的回归测试:保护"聊得下去"与"回忆录有文笔"两个核心目标。
|
||
|
||
与 test_interview_prompts / test_interview_reply_length 不同,这组测试不验证字面规则,
|
||
而是验证体验目标的必要条件是否成立。改 agent 后如果这里挂了,说明体验方向可能在退步。
|
||
"""
|
||
|
||
from types import SimpleNamespace
|
||
|
||
import pytest
|
||
|
||
from app.agents.chat.interview_reply_length import (
|
||
ReplyLengthMode,
|
||
compute_reply_plan,
|
||
heuristic_likely_emotional,
|
||
heuristic_likely_new_detail,
|
||
)
|
||
from app.agents.chat.prompts_conversation import (
|
||
get_guided_conversation_prompt,
|
||
get_opening_prompt,
|
||
)
|
||
from app.agents.memoir.prompts import (
|
||
get_creative_title_json_prompt,
|
||
get_narrative_editor_system_prompt,
|
||
get_narrative_json_prompt,
|
||
)
|
||
from app.features.memoir import story_pipeline_sync as sps
|
||
|
||
|
||
def _fake_settings(**overrides: object) -> SimpleNamespace:
|
||
base = {
|
||
"chat_interview_max_tokens": 380,
|
||
"chat_interview_max_segments": 2,
|
||
"chat_interview_max_chars_per_segment": 260,
|
||
"chat_interview_brief_max_tokens": 260,
|
||
"chat_interview_brief_max_chars_per_segment": 200,
|
||
"chat_interview_expanded_max_tokens": 520,
|
||
"chat_interview_expanded_max_chars_per_segment": 380,
|
||
}
|
||
base.update(overrides)
|
||
return SimpleNamespace(**base)
|
||
|
||
|
||
# ── 聊天体验回归 ──────────────────────────────────────────────────
|
||
|
||
|
||
class TestChatExperienceRegressions:
|
||
"""保护"聊得下去"体验。"""
|
||
|
||
def test_emotional_short_message_not_brief(self) -> None:
|
||
"""用户表达强情绪时不应压成 brief,要给模型足够空间承接情绪。"""
|
||
p = compute_reply_plan(
|
||
"我妈走了以后,我真的很难过",
|
||
background_voice=None,
|
||
settings=_fake_settings(),
|
||
)
|
||
assert p.mode != ReplyLengthMode.brief
|
||
assert heuristic_likely_emotional("我妈走了以后,我真的很难过") is True
|
||
|
||
def test_emotional_medium_message_gets_expanded(self) -> None:
|
||
"""中等长度且有情绪的消息应该给 expanded 档位,让模型有空间好好共情。"""
|
||
msg = "那年我奶奶去世的时候,我在外地上学,没来得及见最后一面,到现在想起来还是特别难过"
|
||
assert len(msg) >= 40
|
||
p = compute_reply_plan(msg, background_voice=None, settings=_fake_settings())
|
||
assert p.mode == ReplyLengthMode.expanded
|
||
|
||
def test_new_detail_triggers_followup_hint_in_prompt(self) -> None:
|
||
"""用户提到新人名/新关系时,prompt 应明确要求追问(而不是只感慨)。"""
|
||
p = get_guided_conversation_prompt(
|
||
current_stage="childhood",
|
||
empty_slots=["place", "people"],
|
||
filled_slots={},
|
||
user_message="那个女生叫小芳,是我同桌",
|
||
conversation_turn_total=2,
|
||
same_topic_turns=2,
|
||
all_stages_coverage=None,
|
||
detected_user_stage="childhood",
|
||
user_profile_context="",
|
||
persona="default",
|
||
)
|
||
assert "本轮判定" in p
|
||
assert "追问" in p
|
||
|
||
def test_emotional_prompt_prioritizes_empathy(self) -> None:
|
||
"""用户情绪浓时 prompt 应出现情绪承接优先的提示。"""
|
||
p = get_guided_conversation_prompt(
|
||
current_stage="family",
|
||
empty_slots=["relationship"],
|
||
filled_slots={},
|
||
user_message="想起我妈,心酸",
|
||
conversation_turn_total=3,
|
||
same_topic_turns=1,
|
||
all_stages_coverage=None,
|
||
detected_user_stage="family",
|
||
user_profile_context="",
|
||
persona="default",
|
||
)
|
||
assert "情绪" in p
|
||
|
||
def test_chit_chat_does_not_force_memoir_question(self) -> None:
|
||
"""闲聊时 prompt 不应强行追问回忆录问题。"""
|
||
p = get_guided_conversation_prompt(
|
||
current_stage="childhood",
|
||
empty_slots=["place"],
|
||
filled_slots={},
|
||
user_message="今天天气真好哈哈",
|
||
conversation_turn_total=0,
|
||
same_topic_turns=0,
|
||
all_stages_coverage=None,
|
||
detected_user_stage="childhood",
|
||
user_profile_context="",
|
||
persona="default",
|
||
)
|
||
assert "偏闲聊" in p
|
||
assert "陪聊" in p
|
||
|
||
def test_topic_switch_not_triggered_at_3_turns(self) -> None:
|
||
"""聊了 3 轮同话题不应该就要换——用户可能还想继续。"""
|
||
p = get_guided_conversation_prompt(
|
||
current_stage="childhood",
|
||
empty_slots=["place", "people", "emotion"],
|
||
filled_slots={"daily_life": "放学后去河边玩"},
|
||
user_message="对啊,那条河特别浅",
|
||
conversation_turn_total=4,
|
||
same_topic_turns=3,
|
||
all_stages_coverage=None,
|
||
detected_user_stage="childhood",
|
||
user_profile_context="",
|
||
persona="default",
|
||
)
|
||
assert "聊得差不多了" not in p
|
||
|
||
def test_prompt_intro_mentions_empathy_first(self) -> None:
|
||
"""prompt 开头应强调"先接住对方"而不是"控制字数"。"""
|
||
p = get_guided_conversation_prompt(
|
||
current_stage="childhood",
|
||
empty_slots=["place"],
|
||
filled_slots={},
|
||
user_message="小时候家里穷",
|
||
conversation_turn_total=0,
|
||
same_topic_turns=0,
|
||
all_stages_coverage=None,
|
||
detected_user_stage="childhood",
|
||
user_profile_context="",
|
||
persona="default",
|
||
)
|
||
assert "接住" in p
|
||
|
||
|
||
# ── 回忆录文风回归 ──────────────────────────────────────────────────
|
||
|
||
|
||
class TestMemoirStyleRegressions:
|
||
"""保护"回忆录有文笔"体验。"""
|
||
|
||
def test_title_prompt_allows_literary_expression(self) -> None:
|
||
"""标题 prompt 不应禁止一切文学性表达——只禁止虚构。"""
|
||
prompt = get_creative_title_json_prompt(
|
||
stage="childhood",
|
||
emotion="warm",
|
||
slots={"place": "湖南老家", "turning_event": "爷爷背我过河"},
|
||
)
|
||
assert "禁止虚构" in prompt
|
||
assert "平实" not in prompt.lower()
|
||
|
||
def test_title_prompt_uses_facts_only_not_plain(self) -> None:
|
||
"""标题 prompt 应该走 facts_only(允许文采),而不是 plain(要求平实)。"""
|
||
prompt = get_creative_title_json_prompt(
|
||
stage="childhood",
|
||
emotion="warm",
|
||
slots={"place": "老家"},
|
||
)
|
||
assert "优雅" in prompt or "书面语" in prompt or "文采" in prompt
|
||
|
||
def test_narrative_prompt_encourages_literary_quality(self) -> None:
|
||
"""叙事 prompt 应该鼓励"有温度"的书面语,不只是"清楚记事"。"""
|
||
sys_prompt = get_narrative_editor_system_prompt()
|
||
assert "温度" in sys_prompt or "优雅" in sys_prompt
|
||
assert "画面感" in sys_prompt or "生动" in sys_prompt
|
||
|
||
def test_narrative_json_prompt_allows_emotion_rendering(self) -> None:
|
||
"""叙事 JSON prompt 应允许情感渲染(不新增事实前提下)。"""
|
||
prompt = get_narrative_json_prompt(
|
||
stage="childhood",
|
||
slots={"turning_event": "爷爷背我过河"},
|
||
new_content="【本段用户口述】\n那年下大雨,爷爷背我过河,鞋全湿了,他一直笑。",
|
||
)
|
||
assert "文采服务于真实" in prompt or "虚构描写" in prompt
|
||
|
||
def test_fallback_ratio_is_lenient(self) -> None:
|
||
"""fallback 阈值应该宽松——只有极端压缩才触发,正常书面化改写不触发。"""
|
||
oral = "我一九九九年出生在上海,后来搬到苏州。小学时爷爷常带我去河边散步。"
|
||
half_length_md = oral[: len(oral) // 2 + 5]
|
||
assert not sps._should_fallback_to_transcript(half_length_md, oral)
|
||
|
||
def test_merge_shrink_only_on_extreme_loss(self) -> None:
|
||
"""合并场景只有在极端缩水时才触发 fallback,不因正常重组而退回。"""
|
||
existing = "这是一段已有的故事正文,讲述了童年在河边的回忆。" * 20
|
||
assert len(existing) > 400
|
||
half_content = existing[: len(existing) // 2]
|
||
import json
|
||
|
||
raw = json.dumps(
|
||
{"paragraphs": [{"content": half_content}]}, ensure_ascii=False
|
||
)
|
||
out, ft = sps._apply_narrative_fallbacks(
|
||
raw, "新的口述补充", existing, chapter_category="childhood"
|
||
)
|
||
assert ft == "none"
|