"""Phase2 路由低置信延迟管线:deferred 池 / 唤醒 / 重试上限。""" from __future__ import annotations import uuid from datetime import datetime, timedelta, timezone from types import SimpleNamespace from unittest.mock import DEFAULT, MagicMock, patch import pytest from sqlalchemy import create_engine, select from sqlalchemy.orm import sessionmaker # 与 alembic/env.py 一致:注册全部 ORM,避免 relationship 解析失败 from app.agents.memoir.story_route_agent import StoryRouteDecision from app.agents.state_schema import MemoirStateSchema from app.core.config import settings from app.core.db import Base 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.conversation.models import Conversation, Segment from app.features.memoir import models as _memoir_models # noqa: F401 from app.features.memoir.story_pipeline_sync import ( StoryPipelineResult, 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 from app.features.user.models import User from app.tasks.memoir_tasks import ( _persist_phase2_route_defer, _wake_deferred_segments_for_category, ) @pytest.fixture def sqlite_session_factory(): engine = create_engine("sqlite:///:memory:", future=True) Base.metadata.create_all( engine, tables=[ User.__table__, Conversation.__table__, Segment.__table__, ], ) yield sessionmaker(bind=engine, expire_on_commit=False, future=True) engine.dispose() def _seed_user_segment( db, *, user_id: str, conversation_id: str, segment_id: str, text: str = "我童年的事情很短暂", topic_category: str = "childhood", ) -> Segment: if not db.get(User, user_id): db.add( User( id=user_id, phone=f"p-{user_id[:8]}", password_hash="x", nickname="t", ) ) if not db.get(Conversation, conversation_id): db.add(Conversation(id=conversation_id, user_id=user_id)) seg = Segment( id=segment_id, conversation_id=conversation_id, user_input_text=text, topic_category=topic_category, narrated=False, skip_narrative=False, narrative_defer_count=0, ) db.add(seg) db.commit() return seg def _patch_pipeline(plan_return, decide_return): """统一 mock pipeline 内的 IO 与 LLM 依赖,便于聚焦路由分支。 返回 ``(context_manager, route_agent_mock)``;进入 context 后由 ``patch.multiple`` 生成的 mock dict 作为 ``mocks`` 提供给测试用例配置返回值与断言。 """ route_agent_mock = MagicMock() route_agent_mock.plan_batch.return_value = plan_return route_agent_mock.decide.return_value = decide_return return ( patch.multiple( "app.features.memoir.story_pipeline_sync", list_active_stories_for_user_sync=DEFAULT, StoryRouteAgent=DEFAULT, NarrativeAgent=DEFAULT, normalize_oral_for_memoir=DEFAULT, ensure_chapter_story_link_sync=DEFAULT, reorder_chapter_story_links_by_life_order_sync=DEFAULT, mark_chapter_dirty_sync=DEFAULT, chapter_needs_cover_enqueue=DEFAULT, MemoirImageSettings=DEFAULT, refresh_chapter_evidence_snapshot_with_retry_sync=DEFAULT, create_story_with_version_sync=DEFAULT, _ensure_chapter_record=DEFAULT, ), route_agent_mock, ) def _configure_pipeline_mocks(mocks: dict, route_agent_mock: MagicMock) -> None: mocks["list_active_stories_for_user_sync"].return_value = [] mocks["StoryRouteAgent"].return_value = route_agent_mock mocks["normalize_oral_for_memoir"].side_effect = lambda text, **_: text mocks["chapter_needs_cover_enqueue"].return_value = False mocks["MemoirImageSettings"].from_env.return_value = MagicMock(enabled=False) def _empty_state() -> MemoirStateSchema: return MemoirStateSchema( stage_order=["childhood"], current_stage="childhood", covered_stages=[], slots={}, ) @pytest.mark.parametrize("reason", ["no_llm", "parse_error", "invalid_target"]) def test_pipeline_defers_on_fallback_route_reason(reason: str) -> None: """单段路由 fallback 时不写 chapter/story,返回 deferred 结果。""" seg = SimpleNamespace(id="seg-defer-1", user_input_text="一句简短的口述") decide_return = StoryRouteDecision( decision="new_story", new_story_title=None, reason=reason, ) cm, route_agent_mock = _patch_pipeline( plan_return=None, decide_return=decide_return, ) with cm as mocks: _configure_pipeline_mocks(mocks, route_agent_mock) session = MagicMock() exec_result = MagicMock() exec_result.unique.return_value.scalar_one_or_none.return_value = None session.execute.return_value = exec_result result = run_story_pipeline_for_category_batch( session, user_id="user-defer", chapter_category="childhood", category_segments=[seg], state=_empty_state(), user_profile="", user_birth_year=None, llm=object(), memory_evidence={ "relevant_chunks": [], "relevant_summaries": [], "relevant_facts": [], "relevant_stories": [], }, ) assert isinstance(result, StoryPipelineResult) assert result.deferred is True assert result.chapter is None assert result.dispatch_ids == set() assert result.defer_reason == reason assert result.defer_segment_ids == ["seg-defer-1"] mocks["_ensure_chapter_record"].assert_not_called() mocks["create_story_with_version_sync"].assert_not_called() mocks["mark_chapter_dirty_sync"].assert_not_called() route_agent_mock.decide.assert_called_once() def test_pipeline_does_not_defer_when_disabled( monkeypatch: pytest.MonkeyPatch, ) -> None: """关闭开关后,旧行为:直接写 new_story(不再延迟)。""" monkeypatch.setattr(settings, "memoir_route_defer_enabled", False) seg = SimpleNamespace(id="seg-no-defer", user_input_text="一句简短的口述") decide_return = StoryRouteDecision( decision="new_story", new_story_title=None, reason="no_llm", ) cm, route_agent_mock = _patch_pipeline( plan_return=None, decide_return=decide_return, ) with cm as mocks: _configure_pipeline_mocks(mocks, route_agent_mock) chapter_stub = SimpleNamespace(id="chapter-1") mocks["_ensure_chapter_record"].return_value = chapter_stub story_stub = MagicMock() story_stub.id = "story-x" story_stub.current_version_id = None mocks["create_story_with_version_sync"].return_value = story_stub # NarrativeAgent.generate_narrative 必须返回有效 JSON nac_instance = mocks["NarrativeAgent"].return_value nac_instance.generate_narrative.return_value = ( '{"paragraphs": [{"content": "叙事正文段落足够长用于测试合并逻辑避免触发过短回退"}]}' ) session = MagicMock() exec_result = MagicMock() exec_result.unique.return_value.scalar_one_or_none.return_value = None session.execute.return_value = exec_result result = run_story_pipeline_for_category_batch( session, user_id="user-no-defer", chapter_category="childhood", category_segments=[seg], state=_empty_state(), user_profile="", user_birth_year=None, llm=object(), memory_evidence={ "relevant_chunks": [], "relevant_summaries": [], "relevant_facts": [], "relevant_stories": [], }, ) assert isinstance(result, StoryPipelineResult) assert result.deferred is False assert result.chapter is chapter_stub mocks["_ensure_chapter_record"].assert_called_once() def test_pipeline_returns_result_object_for_normal_path() -> None: """决策非 fallback 时,pipeline 仍按原路径执行并返回 StoryPipelineResult。""" seg = SimpleNamespace(id="seg-ok", user_input_text="一段足够长的童年口述用于测试正常写入路径") decide_return = StoryRouteDecision( decision="new_story", new_story_title="一个童年故事的新标题", reason="ok", ) cm, route_agent_mock = _patch_pipeline( plan_return=None, decide_return=decide_return, ) with cm as mocks: _configure_pipeline_mocks(mocks, route_agent_mock) chapter_stub = SimpleNamespace(id="chapter-ok") mocks["_ensure_chapter_record"].return_value = chapter_stub story_stub = MagicMock() story_stub.id = "story-ok" story_stub.current_version_id = None mocks["create_story_with_version_sync"].return_value = story_stub nac_instance = mocks["NarrativeAgent"].return_value nac_instance.generate_narrative.return_value = ( '{"paragraphs": [{"content": "叙事正文段落足够长用于测试合并逻辑避免触发过短回退"}]}' ) session = MagicMock() exec_result = MagicMock() exec_result.unique.return_value.scalar_one_or_none.return_value = None session.execute.return_value = exec_result result = run_story_pipeline_for_category_batch( session, user_id="user-ok", chapter_category="childhood", category_segments=[seg], state=_empty_state(), user_profile="", user_birth_year=None, llm=object(), memory_evidence={ "relevant_chunks": [], "relevant_summaries": [], "relevant_facts": [], "relevant_stories": [], }, ) assert isinstance(result, StoryPipelineResult) assert result.deferred is False assert result.chapter is chapter_stub def test_persist_phase2_route_defer_marks_segment_and_schedules_next( sqlite_session_factory, monkeypatch: pytest.MonkeyPatch, ) -> None: """首次延迟:写入 defer 元数据并安排下一次 timeout(未达上限)。""" monkeypatch.setattr(settings, "memoir_route_defer_seconds", 30.0) monkeypatch.setattr(settings, "memoir_route_defer_max_attempts", 3) db = sqlite_session_factory() seg = _seed_user_segment( db, user_id="u-defer-1", conversation_id=str(uuid.uuid4()), segment_id="seg-defer-x1", ) with patch( "app.tasks.memoir_tasks._schedule_phase2_timeout", return_value="task-id-next", ) as schedule_mock: out = _persist_phase2_route_defer( db, user_id="u-defer-1", chapter_category="childhood", task_id="task-id-current", memoir_correlation_id="cid-1", defer_segment_ids=[seg.id], defer_reason="no_llm", phase2_started=0.0, pipeline_elapsed=0.0, lock_elapsed=0.0, ) assert out["status"] == "deferred" assert out["segments"] == 1 assert out["saturated_count"] == 0 schedule_mock.assert_called_once_with("u-defer-1", "childhood", "cid-1") refreshed = db.execute(select(Segment).where(Segment.id == seg.id)).scalar_one() assert refreshed.narrative_defer_count == 1 assert refreshed.narrative_defer_reason == "no_llm" assert refreshed.narrative_deferred_until is not None assert refreshed.narrative_last_attempt_at is not None assert refreshed.narrated is False assert refreshed.processed is False def test_persist_phase2_route_defer_stops_scheduling_at_max_attempts( sqlite_session_factory, monkeypatch: pytest.MonkeyPatch, ) -> None: """达到 max_attempts 后不再继续派发 timeout,segment 仍保留 defer 元数据。""" monkeypatch.setattr(settings, "memoir_route_defer_seconds", 30.0) monkeypatch.setattr(settings, "memoir_route_defer_max_attempts", 2) db = sqlite_session_factory() seg = _seed_user_segment( db, user_id="u-defer-max", conversation_id=str(uuid.uuid4()), segment_id="seg-defer-max-1", ) seg.narrative_defer_count = 1 db.commit() with patch( "app.tasks.memoir_tasks._schedule_phase2_timeout", return_value="should-not-be-called", ) as schedule_mock: out = _persist_phase2_route_defer( db, user_id="u-defer-max", chapter_category="childhood", task_id="task-id-current", memoir_correlation_id="cid-2", defer_segment_ids=[seg.id], defer_reason="parse_error", phase2_started=0.0, pipeline_elapsed=0.0, lock_elapsed=0.0, ) assert out["status"] == "deferred" assert out["saturated_count"] == 1 schedule_mock.assert_not_called() refreshed = db.execute(select(Segment).where(Segment.id == seg.id)).scalar_one() assert refreshed.narrative_defer_count == 2 # 达上限后不设 deferred_until,需要等待新素材唤醒;此时 segment 仍可被下次 Phase2 消费 assert refreshed.narrative_deferred_until is None assert refreshed.narrative_defer_reason == "parse_error" def test_wake_deferred_segments_clears_defer_metadata( sqlite_session_factory, ) -> None: """新素材到达时清空同类目下既有 defer 元数据,并保留另一类目不变。""" db = sqlite_session_factory() user_id = "u-wake" conv_id = str(uuid.uuid4()) seg_a = _seed_user_segment( db, user_id=user_id, conversation_id=conv_id, segment_id="seg-wake-1", topic_category="childhood", ) seg_other = _seed_user_segment( db, user_id=user_id, conversation_id=conv_id, segment_id="seg-other", topic_category="education", ) seg_a.narrative_defer_count = 2 seg_a.narrative_defer_reason = "parse_error" seg_a.narrative_deferred_until = datetime.now(timezone.utc) + timedelta(minutes=5) seg_other.narrative_defer_count = 1 seg_other.narrative_defer_reason = "no_llm" seg_other.narrative_deferred_until = datetime.now(timezone.utc) + timedelta( minutes=5 ) db.commit() woke = _wake_deferred_segments_for_category(db, user_id, "childhood") db.commit() refreshed_a = db.execute( select(Segment).where(Segment.id == seg_a.id) ).scalar_one() refreshed_other = db.execute( select(Segment).where(Segment.id == seg_other.id) ).scalar_one() assert woke == 1 assert refreshed_a.narrative_deferred_until is None assert refreshed_a.narrative_defer_count == 0 assert refreshed_a.narrative_defer_reason is None # 其它类目不应被波及 assert refreshed_other.narrative_deferred_until is not None assert refreshed_other.narrative_defer_count == 1 assert refreshed_other.narrative_defer_reason == "no_llm"