"""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