from types import SimpleNamespace import pytest from pydantic import ValidationError from app.api import rag_ask_contract from app.api.router import api_router from app.api.ai_steward_api import router as ai_steward_router from app.api.rag_api import routes_query from app.schemas.rag import RAGAskRequest def test_rag_ask_request_normalizes_contract_fields() -> None: assert RAGAskRequest(question="状态怎么样").thinking_mode == "deep" req = RAGAskRequest( question="状态怎么样", asked_via="ai-pmo-process-text", message_intent="MIXED", mixed_signal_part="新增任务确认流", action_mode="read", ) assert req.asked_via == "/ai-pmo/process-text" assert req.message_intent == "mixed" assert req.mixed_signal_part == "新增任务确认流" assert req.action_mode == "read" def test_rag_ask_request_rejects_unknown_asked_via() -> None: with pytest.raises(ValidationError): RAGAskRequest( question="hello", asked_via="email", ) def test_build_answer_question_kwargs_maps_fields() -> None: request = RAGAskRequest( question="请确认任务", asked_via="pmflow_manual", session_id=33, new_session=1, thinking_mode="deep", action_mode="read_write", sync_requirement=0, message_intent="mixed", mixed_signal_part="新增任务确认流", ) kwargs = rag_ask_contract.build_answer_question_kwargs( request=request, current_user=SimpleNamespace(id="77"), ) assert kwargs == { "question": "请确认任务", "asked_by": 77, "asked_via": "pmflow_manual", "project_id": None, "source_chat_id": None, "session_id": 33, "force_new_session": True, "thinking_mode": "deep", "action_mode": "read_write", "sync_requirement": False, "message_intent": "mixed", "mixed_signal_part": "新增任务确认流", } @pytest.mark.asyncio async def test_rag_ask_route_forwards_contract_fields(monkeypatch) -> None: captured: dict[str, object] = {} class FakeKernel: def __init__(self, db, tenant_id: int): _ = db captured["tenant_id"] = tenant_id async def process_ingress(self, **kwargs): captured["ingress_kwargs"] = kwargs return { "handled": True, "status": "answered", "intent": "query.read_only", "direct_reply_required": True, "direct_reply_text": "ok", "rag_result": {"answer": "ok", "session_id": 12, "processing_stage": "qa"}, } monkeypatch.setattr(routes_query, "AIPMOKernelService", FakeKernel) request = RAGAskRequest( question="现在进展怎么样", asked_via="pmflow_manual", action_mode="read", message_intent="mixed", mixed_signal_part="新增任务确认流", ) current_user = SimpleNamespace(id=101, tenant_id=9) response = await routes_query.rag_ask( request=request, current_user=current_user, db=object(), tenant_id=9, ) assert response["code"] == 0 data = response["data"] assert data["answer"] == "ok" assert data["session_id"] == 12 ingress_kwargs = captured["ingress_kwargs"] assert isinstance(ingress_kwargs, dict) assert ingress_kwargs["source_system"] == "pmflow_manual" assert ingress_kwargs["message_text"] == "现在进展怎么样" rag_request_context = ingress_kwargs["ingress_options"]["rag_request_context"] assert rag_request_context["asked_via"] == "pmflow_manual" assert rag_request_context["action_mode"] == "read" assert rag_request_context["message_intent"] == "mixed" assert rag_request_context["mixed_signal_part"] == "新增任务确认流" def test_ai_steward_router_does_not_expose_legacy_rag_ask_route() -> None: route_paths = {str(getattr(route, "path", "")).strip() for route in ai_steward_router.routes} assert "/ai-steward/rag/ask" not in route_paths def test_api_router_uses_single_prefix_for_rag_and_ai_steward() -> None: route_paths = {str(getattr(route, "path", "")).strip() for route in api_router.routes} assert "/rag/ask" in route_paths assert "/rag/rag/ask" not in route_paths assert "/ai-steward/feedback-center/summary" in route_paths assert "/ai-steward/ai-steward/feedback-center/summary" not in route_paths @pytest.mark.asyncio async def test_rag_ask_route_uses_shared_contract_builder(monkeypatch) -> None: captured_kwargs: list[dict[str, object]] = [] captured_tenants: list[int] = [] def fake_build_answer_kwargs(request: RAGAskRequest, current_user) -> dict[str, object]: return { "question": request.question, "asked_by": int(current_user.id), "asked_via": "shared-builder", } monkeypatch.setattr(rag_ask_contract, "build_answer_question_kwargs", fake_build_answer_kwargs) monkeypatch.setattr(rag_ask_contract, "resolve_effective_tenant_id", lambda *_args, **_kwargs: 88) class FakeKernel: def __init__(self, db, tenant_id: int): _ = db captured_tenants.append(int(tenant_id)) async def process_ingress(self, **kwargs): captured_kwargs.append(dict(kwargs)) return {"handled": True, "status": "answered", "direct_reply_text": "ok", "rag_result": {"answer": "ok"}} monkeypatch.setattr(routes_query, "AIPMOKernelService", FakeKernel) response_rag = await routes_query.rag_ask( request=RAGAskRequest(question="q1"), current_user=SimpleNamespace(id=1, tenant_id=1), db=object(), tenant_id=1, ) assert response_rag["code"] == 0 assert captured_tenants == [88] assert captured_kwargs == [ { "source_system": "pmflow", "source_message_id": None, "message_text": "q1", "sender": "PMFlow用户", "channel_ref": None, "source_sender_open_id": None, "captured_at": None, "chat_type": "manual", "project_hint_id": None, "ingress_options": { "rag_request_context": { "question": "q1", "asked_by": 1, "asked_via": "shared-builder", } }, } ] def test_build_rag_response_payload_preserves_rag_fields() -> None: payload = rag_ask_contract.build_rag_response_payload( ingress_result={ "status": "answered", "intent": "query.read_only", "work_item_id": 321, "ai_trace_id": "ai-pmo-321", "direct_reply_required": True, "direct_reply_text": "fallback", "rag_result": { "answer": "final answer", "session_id": 18, "sources": [{"source": "snapshot"}], "processing_stage": "qa", }, } ) assert payload["answer"] == "final answer" assert payload["session_id"] == 18 assert payload["work_item_id"] == 321 assert payload["trace_id"] == "ai-pmo-321" assert payload["reply_required"] is True assert payload["reply_text"] == "fallback"