from __future__ import annotations from datetime import timedelta from types import SimpleNamespace 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", "active_workspace", "context_capsules", "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() async def _resolve_followup_anchor_semantics( self, *, message: str, has_explicit_reply_anchor: bool, recent_candidates: list[dict[str, Any]], ) -> dict[str, Any]: text = str(message or "").strip() normalized = text.replace(" ", "") if "项目有吗" in normalized or "有没有" in normalized: return { "followup_mode": "none", "meta_followup_kind": "none", "anchor_strategy": "none", "candidate_index": 0, "confidence": 0.9, } meta_kind = "none" if any(token in normalized for token in ("哪个模型", "什么模型", "谁回复", "谁回答")): meta_kind = "reply_model_identity" elif any(token in normalized for token in ("什么情况", "怎么回事", "为什么失败", "为什么报错", "详细说", "详细解释")): meta_kind = "reply_error_reason" elif any(token in normalized for token in ("来源", "根据什么", "什么依据")): meta_kind = "reply_source" if meta_kind != "none": followup_mode = "meta" elif has_explicit_reply_anchor or recent_candidates: followup_mode = "referential" else: followup_mode = "none" if has_explicit_reply_anchor: anchor_strategy = "explicit" elif meta_kind != "none" and len(recent_candidates) > 1: anchor_strategy = "clarify" elif len(recent_candidates) == 1: anchor_strategy = "implicit_recent" elif len(recent_candidates) > 1: anchor_strategy = "clarify" else: anchor_strategy = "none" return { "followup_mode": followup_mode, "meta_followup_kind": meta_kind, "anchor_strategy": anchor_strategy, "candidate_index": 1 if anchor_strategy == "implicit_recent" and recent_candidates else 0, "confidence": 0.9, } async def _resolve_pending_clarification_selection_semantics( self, *, question: str, payload: dict[str, Any], ) -> dict[str, Any]: text = str(question or "").strip() normalized = text.replace(" ", "") kind = str(payload.get("kind") or "").strip().lower() if kind == "query_route_selection": selected_key = "" if "外部" in normalized or normalized.upper() == "A": selected_key = "A" elif "内部" in normalized or normalized.upper() == "B": selected_key = "B" return { "selection_status": "resolved" if selected_key else "unresolved", "selected_key": selected_key, "selected_index": 0, "confidence": 0.9 if selected_key else 0.0, "reason": "test_double", } selected_index = 0 if any(token in normalized for token in ("第二", "第2", "2")): selected_index = 2 elif any(token in normalized for token in ("第一", "第1", "1")): selected_index = 1 return { "selection_status": "resolved" if selected_index else "unresolved", "selected_key": "", "selected_index": selected_index, "confidence": 0.9 if selected_index else 0.0, "reason": "test_double", } @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_followup_anchor_prefers_structured_semantics_without_keyword_hints( db_session, monkeypatch: pytest.MonkeyPatch, ) -> 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=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, conversation_scope_key="feishu:chat-1:root-1", created_at=now, ) ) await db_session.commit() async def _fake_semantics(**kwargs): _ = kwargs return { "followup_mode": "meta", "meta_followup_kind": "reply_explanation", "anchor_strategy": "implicit_recent", "candidate_index": 1, "confidence": 0.93, } monkeypatch.setattr( service, "_resolve_followup_anchor_semantics", _fake_semantics, raising=False, ) 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:root-1", ) assert resolution.anchor_mode == "implicit_recent_reply" assert resolution.is_meta_followup is True assert resolution.meta_followup_kind == "reply_explanation" assert resolution.anchor_reply is not None assert resolution.anchor_reply["question"] == "帮我解释下系统架构" @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 = await 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_selected_followup_clarification_anchor_binds_exact_qa_record(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, selected_anchor_reply_id=2, ) assert resolution.anchor_mode == "clarification_selection" assert resolution.anchor_reply_id == 2 assert resolution.anchor_reply is not None assert resolution.anchor_reply["question"] == "系统有什么模型?" @pytest.mark.asyncio async def test_no_anchor_clarification_prefers_reply_or_object_id_guidance(db_session) -> None: service = _Harness(db_session) payload = service._build_followup_clarification_payload( question="沿着刚才那个继续说", anchor_resolution=FollowupAnchorResolution(clarification_code="no_anchor_found"), session_state=None, ) assert payload is not None assert "引用那条消息" in payload["answer"] assert "对象 ID" in payload["answer"] @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_anchor_rewrite_takes_priority_over_session_state_subject(db_session) -> None: service = _Harness(db_session) session_state = QASessionState( tenant_id=1, session_id=999, last_subject="_sync_pmflow_rag_async->backend/app/api/sstc.py", last_entity_ids=[], last_intent="question", ambiguity="clear", rewrite_history=[], pending_clarification=None, pending_clarification_expires_at=None, ) rewritten, rewrite_meta = service._rewrite_question_with_state( question="B", conversation=[], session_state=session_state, anchor_resolution=FollowupAnchorResolution( followup_mode="referential", anchor_mode="explicit", anchor_kind="feishu_reply_outbox", anchor_reply={ "qa_id": 321, "question": "你提到的是给运营公司打造「主播推流」的新需求", "label": "主播推流新需求", "anchor_reply_preview": "这是个典型的宽泛 IT 项目概念,我先给你拆成 A/B/C 三类。", "source_message_id": "om_user_message_1", }, ), ) assert rewritten == "围绕上一轮对话“主播推流新需求”继续回答:B" assert rewrite_meta["mode"] == "followup_anchor_recent_reply" assert rewrite_meta["qa_id"] == 321 @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_project_followup_state_prefers_answer_project_name_over_raw_question(db_session) -> None: service = _Harness(db_session) state = SimpleNamespace( last_subject=None, last_entity_ids=[], last_intent="question", ambiguity="clear", rewrite_history=[], pending_clarification=None, pending_clarification_expires_at=None, ) await service._update_session_state_after_turn( session_state=state, question="帮我看下采购经理那个需求是否存在系统?", effective_question="帮我看下采购经理那个需求是否存在系统?", answer=( "有,命中1个项目:项目ID 123,项目名“采购经理每日实时库存上报服务”," "状态 active,负责人 系统管理员。" ), query_type="existence", ambiguity="clear", rewrite_meta={"mode": "direct"}, sources=[ { "source": "db", "label": "Project database", "score": 0.99, "metadata": {"table": "projects", "matched_project_ids": [123]}, } ], ) assert state.last_subject == "采购经理每日实时库存上报服务" assert state.last_entity_ids == [123] rewritten, rewrite_meta = service._rewrite_question_with_state( question="这个项目怎么样?", conversation=[], session_state=state, anchor_resolution=FollowupAnchorResolution( followup_mode="referential", anchor_mode="implicit_recent", ), ) assert rewritten == "围绕“采购经理每日实时库存上报服务”回答:这个项目怎么样?" assert rewrite_meta["mode"] == "state_subject" @pytest.mark.asyncio async def test_project_followup_state_extracts_unquoted_project_name_from_answer(db_session) -> None: service = _Harness(db_session) state = SimpleNamespace( last_subject=None, last_entity_ids=[], last_intent="question", ambiguity="clear", rewrite_history=[], pending_clarification=None, pending_clarification_expires_at=None, ) await service._update_session_state_after_turn( session_state=state, question="帮我看下采购经理那个需求是否存在系统?", effective_question="帮我看下采购经理那个需求是否存在系统?", answer=( "有,命中 1 个项目:项目ID 123、项目名 采购经理每日实时库存上报服务、" "状态 active、负责人 系统管理员。" ), query_type="existence", ambiguity="clear", rewrite_meta={"mode": "direct"}, sources=[ { "source": "db", "label": "Project database", "score": 0.99, "metadata": {"table": "projects", "matched_project_ids": [123]}, } ], ) assert state.last_subject == "采购经理每日实时库存上报服务" @pytest.mark.asyncio async def test_project_followup_state_prefers_customer_visible_project_focus_over_kg_entities(db_session) -> None: service = _Harness(db_session) state = SimpleNamespace( last_subject=None, last_entity_ids=[], last_intent="question", ambiguity="clear", rewrite_history=[], pending_clarification=None, pending_clarification_expires_at=None, ) await service._update_session_state_after_turn( session_state=state, question="现在有什么项目", effective_question="现在有什么项目", answer="你当前有 1 个项目:童析-学龄前儿童行为特质AI评估助手。", query_type="project_inventory", ambiguity="clear", rewrite_meta={"mode": "direct"}, sources=[ { "source": "graph", "label": "Knowledge Graph", "score": 0.8, "metadata": { "subject_entity_id": 14308, "object_entity_id": 14311, "subject_name": "个人空间", "object_name": "/pmflow-os/projects/{project_id}/truth-state", }, }, { "source": "pmflow", "label": "项目数据", "score": 1.0, "metadata": { "object_ref": "project:141", "project_id": 141, "source_type": "customer_visible_object", "subject_name": "童析-学龄前儿童行为特质AI评估助手", "query_keyword": "童析-学龄前儿童行为特质AI评估助手", }, }, ], ) assert state.last_subject == "童析-学龄前儿童行为特质AI评估助手" assert state.last_entity_ids == [141] @pytest.mark.asyncio async def test_external_research_workspace_persists_candidate_set_and_survives_followup_rewrite(db_session) -> None: service = _Harness(db_session) state = SimpleNamespace( last_subject=None, last_entity_ids=[], last_intent="question", ambiguity="clear", rewrite_history=[], active_workspace=None, context_capsules=[], pending_clarification=None, pending_clarification_expires_at=None, ) await service._update_session_state_after_turn( session_state=state, question="这个项目的竞品有哪些", effective_question="这个项目的竞品有哪些", answer="我先给你一组候选竞品。", query_type="compare", ambiguity="clear", rewrite_meta={"mode": "direct"}, sources=[ { "source": "pmflow", "label": "项目数据", "score": 1.0, "metadata": { "object_ref": "project:141", "project_id": 141, "source_type": "customer_visible_object", "subject_name": "童析-学龄前儿童行为特质AI评估助手", "query_keyword": "童析-学龄前儿童行为特质AI评估助手", }, } ], route_decision={"effective_source_policy": "external"}, response_payload={ "research_workspace": { "workspace_id": "ws_competitor_141", "workspace_kind": "hybrid_research", "topic": "竞品研究", "primary_object_ref": "project:141", "primary_object_id": 141, "primary_object_title": "童析-学龄前儿童行为特质AI评估助手", "candidate_set_binding": "cset_competitors_141", "candidate_items": [ {"candidate_ref": "candidate:goally", "display_name": "Goally"}, {"candidate_ref": "candidate:abcmouse", "display_name": "ABCmouse"}, ], } }, ) rewritten, meta = service._rewrite_question_with_state( question="详细对比下", conversation=[], session_state=state, anchor_resolution=SimpleNamespace(followup_mode="referential", anchor_mode="none"), ) assert state.active_workspace["candidate_set_binding"] == "cset_competitors_141" assert state.context_capsules[0]["workspace_id"] == "ws_competitor_141" assert rewritten.startswith("围绕当前研究工作区") assert meta["mode"] == "active_workspace" @pytest.mark.asyncio async def test_state_scope_from_qa_record_prefers_customer_focus_object(db_session) -> None: service = _Harness(db_session) qa = QARecord( tenant_id=1, question="现在有什么项目", answer="你当前有 1 个项目:童析-学龄前儿童行为特质AI评估助手。", sources=[], intent="question", domains_used=["pmflow"], response_time_ms=100, asked_by=1, asked_via="pmflow", session_id=1, turn_index=1, routing_snapshot={ "response_payload": { "customer_interaction_context_packet": { "current_focus_object": { "object_ref": "project:141", "object_id": 141, "object_type": "project", "title": "童析-学龄前儿童行为特质AI评估助手", } } } }, ) scope = service._state_scope_from_qa_record(qa) assert scope["project_id"] == 141 assert scope["target_ref"] == "project:141" assert scope["target_entity_type"] == "project" @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_state_rewrite_governance_skips_rewrite_when_current_scope_switches_project( db_session, monkeypatch: pytest.MonkeyPatch, ) -> 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="测试产能警告这个项目,如果只考虑外部信号源,你会优先推荐哪 3 类?", answer="先看测试执行结果、发布记录、工单反馈。", sources=[], intent="question", domains_used=[], response_time_ms=12, asked_by=None, asked_via="feishu", project_id=81, session_id=int(session.id), turn_index=1, routing_snapshot={ "query_semantics": { "query_profile": {"referenced_objects": ["project_object"]}, "target_resolution": { "target_entity_type": "project", "target_ref": "project:81", "resolved_label": "测试产能警告", }, } }, created_at=now, ) ) await db_session.commit() state = await service._ensure_session_state(session_id=int(session.id)) state.last_subject = "围绕“测试产能警告这个项目,如果只考虑外部信号源...”回答" await db_session.flush() async def _fake_query_semantics(**kwargs): _ = kwargs return { "query_profile": {"referenced_objects": ["project_object"]}, "source_policy": "internal", "effective_source_policy": "internal", } async def _fake_resolve_project_scope_for_question(**kwargs): _ = kwargs return (101, "AI客服", "AI客服") monkeypatch.setattr(service, "_resolve_query_semantics", _fake_query_semantics, raising=False) monkeypatch.setattr( service, "_resolve_project_scope_for_question", _fake_resolve_project_scope_for_question, raising=False, ) rewrite_governance = await service._build_state_rewrite_governance( question="AI客服这个项目里,用户洞察数据源现在主要是拿来解决什么问题?", project_id=None, session_id=int(session.id), source_policy_hint=None, query_semantics=None, session_state=state, anchor_resolution=FollowupAnchorResolution(followup_mode="referential"), ) assert rewrite_governance["allow_state_rewrite"] is False assert rewrite_governance["reason"] == "new_project_scope" assert rewrite_governance["current_scope"]["project_id"] == 101 assert rewrite_governance["previous_scope"]["project_id"] == 81 rewritten, rewrite_meta = service._rewrite_question_with_state( question="AI客服这个项目里,用户洞察数据源现在主要是拿来解决什么问题?", conversation=[], session_state=state, anchor_resolution=FollowupAnchorResolution(followup_mode="referential"), rewrite_governance=rewrite_governance, ) assert rewritten == "AI客服这个项目里,用户洞察数据源现在主要是拿来解决什么问题?" assert rewrite_meta["mode"] == "scope_reset" assert rewrite_meta["reason"] == "new_project_scope" @pytest.mark.asyncio async def test_state_rewrite_governance_skips_rewrite_when_current_scope_switches_business_target( db_session, monkeypatch: pytest.MonkeyPatch, ) -> 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="当前还没定义明确接收人。", sources=[], intent="question", domains_used=[], response_time_ms=12, asked_by=None, asked_via="feishu", project_id=81, session_id=int(session.id), turn_index=1, routing_snapshot={ "query_semantics": { "query_profile": {"referenced_objects": ["project_object", "signal_object"]}, "target_resolution": { "target_entity_type": "signal", "target_ref": "signal:alert_receiver", "resolved_label": "告警接收人", }, } }, created_at=now, ) ) await db_session.commit() state = await service._ensure_session_state(session_id=int(session.id)) state.last_subject = "测试产能警告这个项目里,告警接收人现在是谁?" await db_session.flush() async def _fake_resolve_project_scope_for_question(**kwargs): _ = kwargs return (81, "测试产能警告", "测试产能警告") monkeypatch.setattr( service, "_resolve_project_scope_for_question", _fake_resolve_project_scope_for_question, raising=False, ) rewrite_governance = await service._build_state_rewrite_governance( question="测试产能警告这个项目里,用户洞察数据源现在主要是拿来解决什么问题?", project_id=None, session_id=int(session.id), source_policy_hint=None, query_semantics={ "query_profile": {"referenced_objects": ["project_object", "signal_object"]}, "target_resolution": { "target_entity_type": "signal", "target_ref": "signal:user_insight_source", "resolved_label": "用户洞察数据源", }, }, session_state=state, anchor_resolution=FollowupAnchorResolution(followup_mode="referential"), ) assert rewrite_governance["allow_state_rewrite"] is False assert rewrite_governance["reason"] == "new_business_scope" assert rewrite_governance["current_scope"]["project_id"] == 81 assert rewrite_governance["current_scope"]["target_ref"] == "signal:user_insight_source" assert rewrite_governance["previous_scope"]["target_ref"] == "signal:alert_receiver" @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( followup_mode="meta", 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_model_identity", 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"]