from __future__ import annotations from datetime import timedelta import pytest import pytest_asyncio from sqlalchemy import JSON from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.models.base import Base from app.models.feishu_reply_outbox import FeishuReplyOutbox from app.models.ingest_event import IngestEvent from app.models.qa_record import QARecord from app.models.qa_session import QASession from app.models.qa_session_state import QASessionState from app.services.adaptive_rag_service.mixin_core_intent import AdaptiveRAGCoreIntentMixin from app.services.adaptive_rag_service.mixin_followup_anchor import ( AdaptiveRAGFollowupAnchorMixin, FollowupAnchorResolution, ) from app.services.adaptive_rag_service.mixin_qa_session_core import AdaptiveRAGQASessionCoreMixin from app.services.adaptive_rag_service.mixin_session_state import AdaptiveRAGSessionStateMixin from app.utils.timezone import now_cn_naive def _use_sqlite_json_types() -> None: for table in (QARecord.__table__, QASessionState.__table__, IngestEvent.__table__): for column in table.columns: if column.name in { "sources", "domains_used", "citations", "sources_used", "evidence_pack", "tool_trace", "retrieval_metrics", "last_entity_ids", "rewrite_history", "pending_clarification", "routing_snapshot", }: column.type = JSON() if table.name == "ingest_events" and column.name == "payload": column.type = JSON() @pytest_asyncio.fixture async def db_session(): engine = create_async_engine("sqlite+aiosqlite:///:memory:") _use_sqlite_json_types() async with engine.begin() as conn: await conn.run_sync( lambda sync_conn: Base.metadata.create_all( sync_conn, tables=[ QASession.__table__, QASessionState.__table__, QARecord.__table__, FeishuReplyOutbox.__table__, IngestEvent.__table__, ], ) ) maker = async_sessionmaker(engine, expire_on_commit=False) async with maker() as session: yield session await engine.dispose() class _FakeContextCache: async def get_session_messages(self, *, tenant_id: int, session_id: int | None): _ = (tenant_id, session_id) return [] async def append_session_messages(self, *, tenant_id: int, session_id: int | None, question: str, answer: str): _ = (tenant_id, session_id, question, answer) return None @staticmethod def messages_to_turns(messages): _ = messages return [] class _Harness( AdaptiveRAGCoreIntentMixin, AdaptiveRAGQASessionCoreMixin, AdaptiveRAGSessionStateMixin, AdaptiveRAGFollowupAnchorMixin, ): def __init__(self, db) -> None: super().__init__(db, tenant_id=1) self.context_cache = _FakeContextCache() @pytest.mark.asyncio async def test_external_conversation_key_creates_and_reuses_real_qa_session(db_session) -> None: service = _Harness(db_session) session_id, turn_index = await service._touch_session_for_new_record( asked_by=None, asked_via="feishu", project_id=None, question="帮我解释下系统架构", external_conversation_key="feishu:chat-1:root-1", ) assert session_id is not None assert turn_index == 1 reused_session_id, reused_turn_index = await service._touch_session_for_new_record( asked_by=None, asked_via="feishu", project_id=None, question="系统有什么模型?", external_conversation_key="feishu:chat-1:root-1", ) assert reused_session_id == session_id assert reused_turn_index == 2 row = await db_session.get(QASession, int(session_id)) assert row is not None assert row.user_id is None assert row.external_conversation_key == "feishu:chat-1:root-1" @pytest.mark.asyncio async def test_meta_followup_prefers_single_recent_reply_candidate(db_session) -> None: service = _Harness(db_session) session = QASession( tenant_id=1, user_id=None, title="系统架构", external_conversation_key="feishu:chat-1:root-1", started_at=now_cn_naive(), last_message_at=now_cn_naive(), message_count=1, ) db_session.add(session) await db_session.flush() db_session.add( QARecord( tenant_id=1, question="帮我解释下系统的架构", answer="PMFlow 系统采用前后端分离架构。", sources=[], intent="question", domains_used=[], response_time_ms=10, asked_by=None, asked_via="feishu", project_id=None, session_id=int(session.id), turn_index=1, ) ) await db_session.commit() state = await service._ensure_session_state(session_id=int(session.id)) resolution = await service._resolve_followup_anchor( question="我问你这个是哪个模型执行回复的?", session_id=int(session.id), context_session_id=int(session.id), session_state=state, ) assert resolution.anchor_mode == "implicit_recent_reply" assert resolution.anchor_reply_id is not None assert resolution.is_meta_followup is True assert resolution.candidate_replies == [] @pytest.mark.asyncio async def test_ambiguous_recent_reply_returns_numbered_candidates(db_session) -> None: service = _Harness(db_session) now = now_cn_naive() session = QASession( tenant_id=1, user_id=None, title="新会话", external_conversation_key="feishu:chat-1:root-1", started_at=now, last_message_at=now, message_count=2, ) db_session.add(session) await db_session.flush() db_session.add_all( [ QARecord( tenant_id=1, question="帮我解释下系统架构", answer="PMFlow 系统采用前后端分离架构。", sources=[], intent="question", domains_used=[], response_time_ms=10, asked_by=None, asked_via="feishu", project_id=None, session_id=int(session.id), turn_index=1, created_at=now - timedelta(minutes=1), ), QARecord( tenant_id=1, question="系统有什么模型?", answer="当前有 low/high 两层模型路由。", sources=[], intent="question", domains_used=[], response_time_ms=12, asked_by=None, asked_via="feishu", project_id=None, session_id=int(session.id), turn_index=2, created_at=now, ), ] ) await db_session.commit() state = await service._ensure_session_state(session_id=int(session.id)) resolution = await service._resolve_followup_anchor( question="那你刚才那条是哪个模型回复的?", session_id=int(session.id), context_session_id=int(session.id), session_state=state, ) assert resolution.anchor_mode == "ambiguous_candidates" assert resolution.anchor_reply_id is None assert len(resolution.candidate_replies) == 2 assert resolution.candidate_replies[0]["index"] == 1 assert resolution.candidate_replies[1]["index"] == 2 @pytest.mark.asyncio async def test_pending_clarification_selection_restores_original_question(db_session) -> None: service = _Harness(db_session) session = QASession( tenant_id=1, user_id=None, title="新会话", external_conversation_key="feishu:chat-1:root-1", started_at=now_cn_naive(), last_message_at=now_cn_naive(), message_count=1, ) db_session.add(session) await db_session.flush() state = await service._ensure_session_state(session_id=int(session.id)) state.pending_clarification = { "kind": "followup_anchor_selection", "original_question": "我问你这个是哪个模型执行回复的?", "candidates": [ {"index": 1, "qa_id": 101, "label": "系统架构那条"}, {"index": 2, "qa_id": 102, "label": "系统模型那条"}, ], } await db_session.commit() resolved = service._consume_pending_clarification_selection( question="第二条", session_state=state, ) assert resolved is not None assert resolved["selected_qa_id"] == 102 assert resolved["effective_question"] == "我问你这个是哪个模型执行回复的?" @pytest.mark.asyncio async def test_explicit_reply_to_message_id_resolves_anchor_without_session_history(db_session) -> None: service = _Harness(db_session) now = now_cn_naive() db_session.add( IngestEvent( tenant_id=1, channel="feishu_message", event_key="om_user_message_1", status="done", payload={"message_text": "帮我解释下系统的架构"}, ) ) db_session.add( FeishuReplyOutbox( tenant_id=1, trace_id="trace-1", source_message_id="om_user_message_1", sent_message_id="om_bot_reply_1", chat_id="oc-chat-1", message_text='林宇航 PMFlow 系统采用前后端分离架构。', status="sent", created_at=now - timedelta(minutes=1), sent_at=now - timedelta(minutes=1), ) ) await db_session.commit() resolution = await service._resolve_followup_anchor( question="这个是哪个模型去回复我问题的?", session_id=None, context_session_id=None, session_state=None, thread_context={"reply_to_message_id": "om_bot_reply_1"}, ) assert resolution.anchor_mode == "explicit" assert resolution.is_meta_followup is True assert resolution.anchor_reply is not None assert resolution.anchor_reply["question"] == "帮我解释下系统的架构" assert "前后端分离架构" in resolution.anchor_reply["anchor_reply_preview"] @pytest.mark.asyncio async def test_explicit_reply_falls_back_to_current_ingest_closure_turns_when_outbox_mapping_missing(db_session) -> None: service = _Harness(db_session) db_session.add( IngestEvent( tenant_id=1, channel="feishu_message", event_key="om_current_message_1", status="done", payload={ "message_text": "这个是哪个模型去回复我问题的?", "closure_turns": [ {"role": "user", "text": "帮我解释下系统的架构"}, {"role": "assistant", "text": "PMFlow 系统采用前后端分离架构。"}, {"role": "user", "text": "这个是哪个模型去回复我问题的?"}, ], }, ) ) await db_session.commit() resolution = await service._resolve_followup_anchor( question="这个是哪个模型去回复我问题的?", session_id=None, context_session_id=None, session_state=None, source_message_id="om_current_message_1", thread_context={"reply_to_message_id": "om_unmapped_reply"}, ) assert resolution.anchor_mode == "explicit" assert resolution.is_meta_followup is True assert resolution.anchor_reply is not None assert resolution.anchor_reply["question"] == "帮我解释下系统的架构" assert "前后端分离架构" in resolution.anchor_reply["anchor_reply_preview"] @pytest.mark.asyncio async def test_short_reply_to_explanation_is_treated_as_meta_followup(db_session) -> None: service = _Harness(db_session) now = now_cn_naive() db_session.add( IngestEvent( tenant_id=1, channel="feishu_message", event_key="om_user_message_explain_1", status="done", payload={"message_text": "你刚才说 adaptive_rag_answer_normal 调用失败。"}, ) ) db_session.add( FeishuReplyOutbox( tenant_id=1, trace_id="trace-explain-1", source_message_id="om_user_message_explain_1", sent_message_id="om_bot_reply_explain_1", chat_id="oc-chat-1", message_text="系统处理失败:adaptive_rag_answer_normal LLM failed after retries: openai_chat_completion returned empty content after retries。" "本次未执行入库。", status="sent", created_at=now - timedelta(minutes=1), sent_at=now - timedelta(minutes=1), ) ) await db_session.commit() resolution = await service._resolve_followup_anchor( question="这是什么情况,详细说下", session_id=None, context_session_id=None, session_state=None, thread_context={"reply_to_message_id": "om_bot_reply_explain_1"}, ) assert resolution.anchor_mode == "explicit" assert resolution.is_meta_followup is True assert resolution.meta_followup_kind == "reply_error_reason" assert resolution.anchor_reply is not None assert "你刚才说 adaptive_rag_answer_normal 调用失败" in resolution.anchor_reply["question"] @pytest.mark.asyncio async def test_single_recent_reply_allows_short_explanation_followup_without_explicit_reply_to(db_session) -> None: service = _Harness(db_session) now = now_cn_naive() session = QASession( tenant_id=1, user_id=None, title="错误追问", external_conversation_key="feishu:chat-1:ou-1", started_at=now, last_message_at=now, message_count=1, ) db_session.add(session) await db_session.flush() db_session.add( QARecord( tenant_id=1, question="你刚才说 adaptive_rag_answer_normal 调用失败", answer="系统处理失败:adaptive_rag_answer_normal LLM failed after retries: openai_chat_completion returned empty content after retries。本次未执行入库。", sources=[], intent="question", domains_used=[], response_time_ms=1200, asked_by=None, asked_via="feishu", project_id=None, session_id=int(session.id), turn_index=1, conversation_scope_key="feishu:chat-1:ou-1", source_chat_id="chat-1", source_sender_open_id="ou-1", source_message_id="om_user_error_1", created_at=now, ) ) await db_session.commit() state = await service._ensure_session_state(session_id=int(session.id)) resolution = await service._resolve_followup_anchor( question="这是什么情况,详细说下", session_id=int(session.id), context_session_id=int(session.id), session_state=state, conversation_scope_key="feishu:chat-1:ou-1", source_chat_id="chat-1", source_sender_open_id="ou-1", ) assert resolution.anchor_mode == "implicit_recent_reply" assert resolution.is_meta_followup is True assert resolution.meta_followup_kind == "reply_error_reason" assert resolution.anchor_reply is not None assert resolution.anchor_reply["question"] == "你刚才说 adaptive_rag_answer_normal 调用失败" @pytest.mark.asyncio async def test_recent_followup_candidates_can_cross_sessions_by_conversation_scope_key(db_session) -> None: service = _Harness(db_session) now = now_cn_naive() previous_session = QASession( tenant_id=1, user_id=None, title="旧会话", external_conversation_key="feishu:msg-old-1", started_at=now - timedelta(minutes=2), last_message_at=now - timedelta(minutes=1), message_count=1, ) current_session = QASession( tenant_id=1, user_id=None, title="新会话", external_conversation_key="feishu:msg-new-1", started_at=now, last_message_at=now, message_count=1, ) db_session.add_all([previous_session, current_session]) await db_session.flush() db_session.add( QARecord( tenant_id=1, question="帮我解释下系统架构", answer="PMFlow 系统采用前后端分离架构。", sources=[], intent="question", domains_used=[], response_time_ms=10, asked_by=None, asked_via="feishu", project_id=None, session_id=int(previous_session.id), turn_index=1, conversation_scope_key="feishu:chat-1:ou-1", source_chat_id="chat-1", source_sender_open_id="ou-1", source_message_id="om_user_1", created_at=now - timedelta(minutes=1), ) ) await db_session.commit() state = await service._ensure_session_state(session_id=int(current_session.id)) resolution = await service._resolve_followup_anchor( question="这个是哪个模型回复的?", session_id=int(current_session.id), context_session_id=int(current_session.id), session_state=state, conversation_scope_key="feishu:chat-1:ou-1", source_chat_id="chat-1", source_sender_open_id="ou-1", ) assert resolution.anchor_mode == "implicit_recent_reply" assert resolution.anchor_reply_id is not None assert resolution.anchor_kind == "meta_recent_reply" assert resolution.anchor_reply is not None assert resolution.anchor_reply["question"] == "帮我解释下系统架构" @pytest.mark.asyncio async def test_standalone_project_lookup_is_not_misclassified_as_followup_anchor(db_session) -> None: service = _Harness(db_session) now = now_cn_naive() session = QASession( tenant_id=1, user_id=None, title="旧问题", external_conversation_key="feishu:chat-1:ou-1", started_at=now, last_message_at=now, message_count=1, ) db_session.add(session) await db_session.flush() db_session.add( QARecord( tenant_id=1, question="帮我解释下系统架构", answer="PMFlow 系统采用前后端分离架构。", sources=[], intent="question", domains_used=[], response_time_ms=12, asked_by=None, asked_via="feishu", project_id=None, session_id=int(session.id), turn_index=1, conversation_scope_key="feishu:chat-1:ou-1", source_chat_id="chat-1", source_sender_open_id="ou-1", source_message_id="om_user_1", created_at=now, ) ) await db_session.commit() state = await service._ensure_session_state(session_id=int(session.id)) resolution = await service._resolve_followup_anchor( question="一个 AI 客服工具,这个项目有吗", session_id=int(session.id), context_session_id=int(session.id), session_state=state, conversation_scope_key="feishu:chat-1:ou-1", source_chat_id="chat-1", source_sender_open_id="ou-1", ) assert resolution.anchor_mode == "no_anchor" assert resolution.anchor_reply_id is None assert resolution.candidate_replies == [] @pytest.mark.asyncio async def test_meta_followup_model_identity_reads_routing_snapshot_and_hides_real_model(db_session) -> None: service = _Harness(db_session) now = now_cn_naive() session = QASession( tenant_id=1, user_id=None, title="系统架构", external_conversation_key="feishu:chat-1:ou-1", started_at=now, last_message_at=now, message_count=1, ) db_session.add(session) await db_session.flush() qa = QARecord( tenant_id=1, question="帮我解释下系统架构", answer="PMFlow 系统采用前后端分离架构。", sources=[], intent="question", domains_used=[], response_time_ms=12, asked_by=None, asked_via="feishu", project_id=None, session_id=int(session.id), turn_index=1, conversation_scope_key="feishu:chat-1:ou-1", source_chat_id="chat-1", source_sender_open_id="ou-1", source_message_id="om_user_1", routing_snapshot={ "planned_tier": "low", "executed_tier": "high", "route_source": "default", "route_rule_name": "engine_high_reasoning_glm5", "routing_decision": "low_to_high_escalated", "provider": "openai", "model": "GLM-5", }, ) db_session.add(qa) await db_session.commit() payload = await service._build_meta_followup_payload( question="这个是哪个模型回复的?", anchor_resolution=FollowupAnchorResolution( anchor_mode="implicit_recent_reply", anchor_reply_id=int(qa.id), anchor_session_id=int(session.id), anchor_kind="meta_recent_reply", is_meta_followup=True, anchor_reply={ "qa_id": int(qa.id), "question": "帮我解释下系统架构", "anchor_reply_preview": "PMFlow 系统采用前后端分离架构。", "source_message_id": "om_user_1", }, ), conversation_scope_key="feishu:chat-1:ou-1", ) assert payload is not None assert payload["meta_followup_kind"] == "reply_model_identity" assert payload["reason_code"] == "meta_followup_answered" assert "planned_tier=low" in payload["answer"] assert "executed_tier=high" in payload["answer"] assert "engine_high_reasoning_glm5" in payload["answer"] assert "glm-5" not in payload["answer"].lower() @pytest.mark.asyncio async def test_meta_followup_error_reason_humanizes_llm_empty_content_failure(db_session) -> None: service = _Harness(db_session) now = now_cn_naive() session = QASession( tenant_id=1, user_id=None, title="错误追问", external_conversation_key="feishu:chat-1:ou-1", started_at=now, last_message_at=now, message_count=1, ) db_session.add(session) await db_session.flush() qa = QARecord( tenant_id=1, question="这是什么情况,详细说下", answer="系统处理失败:adaptive_rag_answer_normal LLM failed after retries: openai_chat_completion returned empty content after retries。本次未执行入库。", sources=[], intent="question", domains_used=[], response_time_ms=1200, asked_by=None, asked_via="feishu", project_id=None, session_id=int(session.id), turn_index=1, conversation_scope_key="feishu:chat-1:ou-1", source_chat_id="chat-1", source_sender_open_id="ou-1", source_message_id="om_user_error_1", ) db_session.add(qa) await db_session.commit() payload = await service._build_meta_followup_payload( question="这是什么情况,详细说下", anchor_resolution=FollowupAnchorResolution( anchor_mode="implicit_recent_reply", anchor_reply_id=int(qa.id), anchor_session_id=int(session.id), anchor_kind="meta_recent_reply", is_meta_followup=True, meta_followup_kind="reply_error_reason", anchor_reply={ "qa_id": int(qa.id), "question": "你刚才说 adaptive_rag_answer_normal 调用失败", "anchor_reply_preview": qa.answer, "source_message_id": "om_user_error_1", }, ), conversation_scope_key="feishu:chat-1:ou-1", ) assert payload is not None assert payload["meta_followup_kind"] == "reply_error_reason" assert payload["reason_code"] == "meta_followup_answered" assert "模型在生成答案时连续返回空正文" in payload["answer"] assert "所以系统没有拿到可发出的有效回复" in payload["answer"]