feat(api): 访谈人格/回复长度策略、口述归一、背景语气与输入净稿全链路
Chat 访谈 - 新增 persona 系统(default / warm_listener / curious_guide)与 background_voice 语气层 - 回复长度由 compute_reply_plan 统一决策(brief / standard / expanded),融合信息密度启发式 - 输入净稿(input_normalize):编排层可选 rules/llm 归一用户口语后再喂模型与记忆检索 - 记忆证据注入:按用户话检索 memory evidence 并注入 prompt Memoir 回忆录 - 口述归一(oral_normalize):segment 原文保留,story 管线取派生净稿作叙事输入 - segment 入队批次门闸:累计字数 + 最长等待秒数,减少零碎提交 - fidelity_check / prompts / narrative_agent 微调 - Alembic 0005:清理跨章节 story 外键 Infra - Dockerfile 加入 ffmpeg - pyproject.toml 新增依赖并同步 uv.lock - .env.example / .env.production 补全新配置项 Tests - 新增 test_background_voice、test_chat_input_normalize、test_experience_regressions - 扩展 test_interview_prompts、test_interview_reply_length、test_story_route_oral_invariant Made-with: Cursor
This commit is contained in:
258
api/tests/test_story_route_oral_invariant.py
Normal file
258
api/tests/test_story_route_oral_invariant.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""Story 路由:batch_transcript 仅含本批口述,不含 evidence(与 story_pipeline_sync 行为一致)。"""
|
||||
|
||||
# 与 alembic/env.py 一致:注册全部 ORM,避免 relationship 解析 KeyError
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.agents.memoir.prompts import format_evidence_chunks_for_prompt
|
||||
from app.agents.memoir.story_route_agent import StoryRouteDecision
|
||||
from app.agents.state_schema import MemoirStateSchema
|
||||
from app.features.asset import models as _asset_models # noqa: F401
|
||||
from app.features.auth import models as _auth_models # noqa: F401
|
||||
from app.features.conversation import models as _conv_models # noqa: F401
|
||||
from app.features.memoir import models as _memoir_models # noqa: F401
|
||||
from app.features.memoir.story_pipeline_sync import (
|
||||
run_story_pipeline_for_category_batch,
|
||||
)
|
||||
from app.features.memory import models as _memory_models # noqa: F401
|
||||
from app.features.payment import models as _payment_models # noqa: F401
|
||||
from app.features.story import models as _story_models # noqa: F401
|
||||
from app.features.user import models as _user_models # noqa: F401
|
||||
|
||||
|
||||
def test_single_segment_decide_receives_only_combined_text_not_evidence() -> None:
|
||||
"""路由输入不变量:decide 的 batch_transcript 等于 oral combined_text,且不含证据标记。"""
|
||||
oral = "这是一条用于测试路由输入的用户口述内容足够长以避免叙事回退误判"
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
def decide_capture(
|
||||
*,
|
||||
batch_transcript: str,
|
||||
**kwargs: object,
|
||||
) -> StoryRouteDecision:
|
||||
captured["batch_transcript"] = batch_transcript
|
||||
return StoryRouteDecision(
|
||||
decision="new_story",
|
||||
new_story_title="测试新故事标题六个字以上",
|
||||
reason="测试",
|
||||
)
|
||||
|
||||
seg = SimpleNamespace(id="seg-route-test-1", user_input_text=oral)
|
||||
evidence_payload = {
|
||||
"relevant_chunks": [],
|
||||
"relevant_summaries": [
|
||||
{
|
||||
"content": "滚动摘要里的旧内容不应出现在路由输入",
|
||||
"summary_type": "rolling",
|
||||
}
|
||||
],
|
||||
"relevant_facts": [{"subject": "X", "predicate": "y", "object_json": {}}],
|
||||
"timeline_hints": [],
|
||||
"relevant_stories": [],
|
||||
}
|
||||
evidence_formatted = format_evidence_chunks_for_prompt(evidence_payload)
|
||||
assert "[摘要:rolling]" in evidence_formatted
|
||||
|
||||
route_agent_mock = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.features.memoir.story_pipeline_sync.retrieve_evidence_sync",
|
||||
return_value=evidence_payload,
|
||||
),
|
||||
patch(
|
||||
"app.features.memoir.story_pipeline_sync.list_active_stories_for_user_sync",
|
||||
return_value=[],
|
||||
),
|
||||
patch(
|
||||
"app.features.memoir.story_pipeline_sync.StoryRouteAgent",
|
||||
return_value=route_agent_mock,
|
||||
),
|
||||
patch(
|
||||
"app.features.memoir.story_pipeline_sync.NarrativeAgent",
|
||||
) as nac,
|
||||
patch(
|
||||
"app.features.memoir.story_pipeline_sync.create_story_with_version_sync",
|
||||
) as csw,
|
||||
patch(
|
||||
"app.features.memoir.story_pipeline_sync.ensure_chapter_story_link_sync",
|
||||
),
|
||||
patch(
|
||||
"app.features.memoir.story_pipeline_sync.reorder_chapter_story_links_by_life_order_sync",
|
||||
),
|
||||
patch(
|
||||
"app.features.memoir.story_pipeline_sync.mark_chapter_dirty_sync",
|
||||
),
|
||||
patch(
|
||||
"app.features.memoir.story_pipeline_sync.chapter_needs_cover_enqueue",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"app.features.memoir.story_pipeline_sync.MemoirImageSettings",
|
||||
) as mis,
|
||||
):
|
||||
route_agent_mock.plan_batch.return_value = None
|
||||
route_agent_mock.decide.side_effect = decide_capture
|
||||
|
||||
na = MagicMock()
|
||||
nac.return_value = na
|
||||
na.generate_title.return_value = "章节标题"
|
||||
na.generate_narrative.return_value = '{"paragraphs": [{"content": "叙事正文段落足够长用于测试合并逻辑避免触发过短回退"}]}'
|
||||
|
||||
mock_story = MagicMock()
|
||||
mock_story.id = "11111111-1111-1111-1111-111111111111"
|
||||
csw.return_value = mock_story
|
||||
|
||||
mis.from_env.return_value = MagicMock(enabled=False)
|
||||
|
||||
session = MagicMock()
|
||||
exec_result = MagicMock()
|
||||
exec_result.unique.return_value.scalar_one_or_none.return_value = None
|
||||
session.execute.return_value = exec_result
|
||||
|
||||
state = MemoirStateSchema(
|
||||
stage_order=["childhood"],
|
||||
current_stage="childhood",
|
||||
covered_stages=[],
|
||||
slots={},
|
||||
)
|
||||
run_story_pipeline_for_category_batch(
|
||||
session,
|
||||
user_id="user-1",
|
||||
chapter_category="summary",
|
||||
category_segments=[seg],
|
||||
state=state,
|
||||
user_profile="",
|
||||
user_birth_year=None,
|
||||
llm=object(),
|
||||
)
|
||||
|
||||
assert captured["batch_transcript"] == oral
|
||||
assert "[摘要:rolling]" not in captured["batch_transcript"]
|
||||
assert "滚动摘要" not in captured["batch_transcript"]
|
||||
route_agent_mock.decide.assert_called_once()
|
||||
assert route_agent_mock.decide.call_args.kwargs["batch_transcript"] == oral
|
||||
|
||||
|
||||
def test_decide_receives_only_same_stage_story_candidates() -> None:
|
||||
"""路由候选仅含 story.stage == chapter_category,禁止跨章节 append 导致多章内容相同。"""
|
||||
oral = "这是一条用于测试路由候选过滤的用户口述内容足够长以避免叙事回退误判"
|
||||
captured: dict[str, list] = {}
|
||||
|
||||
def decide_capture(
|
||||
*, candidate_stories: object, **kwargs: object
|
||||
) -> StoryRouteDecision:
|
||||
captured["candidates"] = list(candidate_stories)
|
||||
return StoryRouteDecision(
|
||||
decision="new_story",
|
||||
new_story_title="测试新故事标题六个字以上",
|
||||
reason="测试",
|
||||
)
|
||||
|
||||
seg = SimpleNamespace(id="seg-route-stage-filter-1", user_input_text=oral)
|
||||
childhood_story = SimpleNamespace(
|
||||
id="s-child", stage="childhood", canonical_markdown=""
|
||||
)
|
||||
education_story = SimpleNamespace(
|
||||
id="s-edu", stage="education", canonical_markdown=""
|
||||
)
|
||||
|
||||
route_agent_mock = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.features.memoir.story_pipeline_sync.retrieve_evidence_sync",
|
||||
return_value={
|
||||
"relevant_chunks": [],
|
||||
"relevant_summaries": [],
|
||||
"relevant_facts": [],
|
||||
"timeline_hints": [],
|
||||
"relevant_stories": [],
|
||||
},
|
||||
),
|
||||
patch(
|
||||
"app.features.memoir.story_pipeline_sync.list_active_stories_for_user_sync",
|
||||
return_value=[childhood_story, education_story],
|
||||
),
|
||||
patch(
|
||||
"app.features.memoir.story_pipeline_sync.StoryRouteAgent",
|
||||
return_value=route_agent_mock,
|
||||
),
|
||||
patch(
|
||||
"app.features.memoir.story_pipeline_sync.NarrativeAgent",
|
||||
) as nac,
|
||||
patch(
|
||||
"app.features.memoir.story_pipeline_sync.create_story_with_version_sync",
|
||||
) as csw,
|
||||
patch(
|
||||
"app.features.memoir.story_pipeline_sync.ensure_chapter_story_link_sync",
|
||||
),
|
||||
patch(
|
||||
"app.features.memoir.story_pipeline_sync.reorder_chapter_story_links_by_life_order_sync",
|
||||
),
|
||||
patch(
|
||||
"app.features.memoir.story_pipeline_sync.mark_chapter_dirty_sync",
|
||||
),
|
||||
patch(
|
||||
"app.features.memoir.story_pipeline_sync.chapter_needs_cover_enqueue",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"app.features.memoir.story_pipeline_sync.MemoirImageSettings",
|
||||
) as mis,
|
||||
):
|
||||
route_agent_mock.plan_batch.return_value = None
|
||||
route_agent_mock.decide.side_effect = decide_capture
|
||||
|
||||
na = MagicMock()
|
||||
nac.return_value = na
|
||||
na.generate_title.return_value = "章节标题"
|
||||
na.generate_narrative.return_value = '{"paragraphs": [{"content": "叙事正文段落足够长用于测试合并逻辑避免触发过短回退"}]}'
|
||||
|
||||
mock_story = MagicMock()
|
||||
mock_story.id = "22222222-2222-2222-2222-222222222222"
|
||||
csw.return_value = mock_story
|
||||
|
||||
mis.from_env.return_value = MagicMock(enabled=False)
|
||||
|
||||
session = MagicMock()
|
||||
exec_result = MagicMock()
|
||||
exec_result.unique.return_value.scalar_one_or_none.return_value = None
|
||||
session.execute.return_value = exec_result
|
||||
|
||||
state = MemoirStateSchema(
|
||||
stage_order=["education"],
|
||||
current_stage="education",
|
||||
covered_stages=[],
|
||||
slots={},
|
||||
)
|
||||
run_story_pipeline_for_category_batch(
|
||||
session,
|
||||
user_id="user-1",
|
||||
chapter_category="education",
|
||||
category_segments=[seg],
|
||||
state=state,
|
||||
user_profile="",
|
||||
user_birth_year=None,
|
||||
llm=object(),
|
||||
)
|
||||
|
||||
assert len(captured["candidates"]) == 1
|
||||
assert captured["candidates"][0].id == "s-edu"
|
||||
assert captured["candidates"][0].stage == "education"
|
||||
route_agent_mock.decide.assert_called_once()
|
||||
va = route_agent_mock.decide.call_args.kwargs["valid_story_ids"]
|
||||
assert va == {"s-edu"}
|
||||
|
||||
|
||||
def test_get_story_route_prompt_includes_route_boundary_rule() -> None:
|
||||
from app.agents.memoir.prompts import get_story_route_prompt
|
||||
|
||||
out = get_story_route_prompt(
|
||||
chapter_category="summary",
|
||||
chapter_title="标题",
|
||||
batch_transcript="仅口述",
|
||||
candidate_stories_json="[]",
|
||||
)
|
||||
assert "路由边界" in out
|
||||
assert "仅根据" in out or "本批口述" in out
|
||||
Reference in New Issue
Block a user