Files
life-echo/api/tests/test_story_route_oral_invariant.py

261 lines
9.6 KiB
Python
Raw Normal View History

"""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.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
2026-04-30 16:22:55 +08:00
from app.features.memory.evidence_format import format_evidence_chunks_for_prompt
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": {}}],
"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.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,
patch(
"app.tasks.story_title_tasks.generate_story_title_after_create.delay",
),
patch(
"app.features.memoir.story_pipeline_sync.refresh_chapter_evidence_snapshot_with_retry_sync",
),
):
route_agent_mock.plan_batch.return_value = None
route_agent_mock.decide.side_effect = decide_capture
na = MagicMock()
nac.return_value = na
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(),
memory_evidence=evidence_payload,
)
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.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,
patch(
"app.tasks.story_title_tasks.generate_story_title_after_create.delay",
),
patch(
"app.features.memoir.story_pipeline_sync.refresh_chapter_evidence_snapshot_with_retry_sync",
),
):
route_agent_mock.plan_batch.return_value = None
route_agent_mock.decide.side_effect = decide_capture
na = MagicMock()
nac.return_value = na
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(),
memory_evidence={
"relevant_chunks": [],
"relevant_summaries": [],
"relevant_facts": [],
"relevant_stories": [],
},
)
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