PMFlow/backend/tests/test_rag_route_contract.py

368 lines
13 KiB
Python

import json
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",
consultant_mode_preference="ADVise",
)
assert req.asked_via == "/ai-pmo/process-text"
assert req.message_intent == "mixed"
assert req.mixed_signal_part == "新增任务确认流"
assert req.action_mode == "read"
assert req.consultant_mode_preference == "advise"
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="新增任务确认流",
consultant_mode_preference="challenge",
)
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": "新增任务确认流",
"consultant_mode_preference": "challenge",
}
def test_build_kernel_ingress_kwargs_assigns_unique_source_message_id_for_rag() -> None:
request = RAGAskRequest(question="现在进展怎么样")
kwargs_a = rag_ask_contract.build_kernel_ingress_kwargs(
request=request,
current_user=SimpleNamespace(id=1, tenant_id=1, username="admin"),
)
kwargs_b = rag_ask_contract.build_kernel_ingress_kwargs(
request=request,
current_user=SimpleNamespace(id=1, tenant_id=1, username="admin"),
)
source_message_id_a = str(kwargs_a["source_message_id"] or "")
source_message_id_b = str(kwargs_b["source_message_id"] or "")
assert source_message_id_a.startswith("rag-ask-")
assert source_message_id_b.startswith("rag-ask-")
assert source_message_id_a != source_message_id_b
@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="新增任务确认流",
consultant_mode_preference="clarify",
)
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"] == "新增任务确认流"
assert rag_request_context["consultant_mode_preference"] == "clarify"
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 len(captured_kwargs) == 1
forwarded = captured_kwargs[0]
assert str(forwarded["source_message_id"]).startswith("rag-ask-")
assert forwarded == {
"source_system": "pmflow",
"source_message_id": forwarded["source_message_id"],
"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"}],
"answer_mode": "retrieval_deep",
"grounding_status": "grounded",
"policy_version": "gr-v2.1|routing-v3|prompt:feishu_direct",
"citations": [{"source_type": "doc", "source_id": "doc-1"}],
"sources_used": [{"source_type": "doc", "source_id": "doc-1"}],
"evidence_pack": {
"summary": {
"conclusion": "final answer",
"evidence": ["doc-1"],
"uncertainties": [],
},
"tool_trace": [{"tool": "vector_search"}],
},
"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"
assert payload["answer_mode"] == "retrieval_deep"
assert payload["grounding_status"] == "grounded"
assert payload["policy_version"] == "gr-v2.1|routing-v3|prompt:feishu_direct"
assert payload["citations"] == [{"source_type": "doc", "source_id": "doc-1"}]
assert payload["sources_used"] == [{"source_type": "doc", "source_id": "doc-1"}]
assert payload["evidence_pack"]["summary"]["conclusion"] == "final answer"
assert payload["evidence_pack"]["tool_trace"] == [{"tool": "vector_search"}]
def test_build_rag_response_payload_adds_grounding_contract_defaults() -> None:
payload = rag_ask_contract.build_rag_response_payload(
ingress_result={
"status": "answered",
"intent": "query.read_only",
"direct_reply_text": "fallback",
"rag_result": {
"answer": "final answer",
},
}
)
assert payload["answer_mode"] == "retrieval_normal"
assert payload["grounding_status"] == "partially_grounded"
assert payload["policy_version"] == "retrieval-v2.1|routing:unknown|prompt:unknown"
assert payload["citations"] == []
assert payload["sources_used"] == []
assert payload["evidence_pack"] == {
"summary": {
"conclusion": "final answer",
"evidence": [],
"uncertainties": [],
},
"tool_trace": [],
}
def test_build_rag_response_payload_falls_back_to_top_level_consultant_dispatch() -> None:
consultant_dispatch = {
"domain": "project",
"mode": "challenge",
"risk_level": "high",
"missing_fields": [],
"allow_execute": False,
"next_action": "请先拆分范围,再确认是否继续推进高风险动作。",
}
payload = rag_ask_contract.build_rag_response_payload(
ingress_result={
"status": "answered",
"intent": "project.create",
"direct_reply_text": "当前建议先拆分范围。",
"consultant_dispatch": consultant_dispatch,
"rag_result": {
"answer": "当前建议先拆分范围。",
"processing_stage": "qa",
},
}
)
assert payload["consultant_dispatch"] == consultant_dispatch
def test_build_rag_response_payload_does_not_inject_fake_answer_text() -> None:
payload = rag_ask_contract.build_rag_response_payload(
ingress_result={
"status": "answered",
"intent": "query.read_only",
"direct_reply_text": "",
"rag_result": {
"processing_stage": "qa",
},
}
)
assert payload["answer"] == ""
@pytest.mark.asyncio
async def test_rag_ask_route_rejects_empty_rag_answer(monkeypatch) -> None:
class FakeKernel:
def __init__(self, db, tenant_id: int):
_ = db
_ = tenant_id
async def process_ingress(self, **kwargs):
_ = kwargs
return {
"handled": True,
"status": "answered",
"intent": "query.read_only",
"direct_reply_text": "",
"rag_result": {
"processing_stage": "qa",
},
}
monkeypatch.setattr(routes_query, "AIPMOKernelService", FakeKernel)
response = await routes_query.rag_ask(
request=RAGAskRequest(question="空回答测试"),
current_user=SimpleNamespace(id=1, tenant_id=1),
db=object(),
tenant_id=1,
)
assert getattr(response, "status_code", None) == 500
body = json.loads(response.body.decode("utf-8"))
assert body == {
"code": 500,
"message": "RAG 返回空回答,未生成有效响应。",
"data": None,
}