PMFlow/backend/tests/test_rag_route_contract.py

679 lines
26 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.core.config import settings
from app.schemas.rag import RAGAskRequest
from app.services.adaptive_rag_service.mixin_answer_payload import AdaptiveRAGAnswerPayloadMixin
def _direct_route_plan(*, goal_kind: str, answer_contract: str) -> dict:
return {
"source_strategy": "direct",
"goal_kind": goal_kind,
"answer_contract": answer_contract,
"allowed_executors": ["direct_answer"],
}
def test_rag_ask_request_normalizes_contract_fields() -> None:
assert RAGAskRequest(question="状态怎么样").thinking_mode == "auto"
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,
"tenant_id": None,
"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",
"surface_context": {},
}
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
def test_build_kernel_ingress_kwargs_preserves_explicit_anchor_fields() -> None:
request = RAGAskRequest(
question="这个项目怎么样",
project_id=17,
source_chat_id="pmflow-project-17",
)
kwargs = rag_ask_contract.build_kernel_ingress_kwargs(
request=request,
current_user=SimpleNamespace(id=1, tenant_id=1, username="admin"),
)
assert kwargs["project_hint_id"] == 17
assert kwargs["channel_ref"] == "pmflow-project-17"
assert kwargs["ingress_options"]["rag_request_context"]["project_id"] == 17
assert kwargs["ingress_options"]["rag_request_context"]["source_chat_id"] == "pmflow-project-17"
def test_build_kernel_ingress_kwargs_uses_conversation_context_project_binding() -> None:
request = RAGAskRequest(
question="把我们的项目产品和宝宝树对比下",
session_id=524,
)
kwargs = rag_ask_contract.build_kernel_ingress_kwargs(
request=request,
current_user=SimpleNamespace(id=1, tenant_id=1, username="admin"),
conversation_context={
"last_entity_ids": [141],
"active_workspace": {
"primary_object_ref": "project:141",
"primary_object_id": 141,
"primary_object_title": "童析—学龄前儿童行为特质AI评估助手",
},
},
)
assert kwargs["ingress_options"]["rag_request_context"]["project_id"] == 141
assert kwargs["ingress_options"]["rag_request_context"]["active_workspace"]["primary_object_ref"] == "project:141"
assert kwargs["ingress_options"]["tenant_project_context"]["current_project_id"] == 141
assert kwargs["ingress_options"]["project_operating_context"]["current_project_id"] == 141
def test_build_kernel_ingress_kwargs_attaches_project_operating_context() -> None:
request = RAGAskRequest(
question="OpenAI Docs 这种能力适不适合我们当前项目?",
project_id=17,
source_chat_id="pmflow-project-17",
asked_via="feishu",
)
kwargs = rag_ask_contract.build_kernel_ingress_kwargs(
request=request,
current_user=SimpleNamespace(id=1, tenant_id=1, username="admin"),
)
packet = kwargs["ingress_options"]["project_operating_context"]
assert packet["current_project_id"] == 17
assert packet["current_repo_scope"]
assert packet["page_anchor"]["project_id"] == 17
assert packet["fit_scope"] == "current_project_and_repo"
assert packet["decision_strategy"] == "answer_then_offer_plan"
def test_build_kernel_ingress_kwargs_attaches_surface_and_tenant_project_context() -> None:
request = RAGAskRequest(
question="我今天应该先处理什么?",
surface_context={
"surface": "personal_space",
"workbench": {
"counts": {"nextActions": 2, "pendingConfirmations": 1, "aiProgress": 1},
"top_next_actions": [
{
"title": "确认项目草案",
"project": "AI 周报助手",
"priority": "高",
"href": "/change-review?projectId=17&changeSetId=cs-1&itemId=ci-1",
"object_ref": "change_item:ci-1",
"object_anchor": {
"object_ref": "change_item:ci-1",
"object_type": "change_item",
"object_id": "ci-1",
"surface": "change_review",
"project_id": "17",
"project_title": "AI 周报助手",
"href": "/change-review?projectId=17&changeSetId=cs-1&itemId=ci-1",
"parent_object_ref": "project:17",
},
}
],
},
"ai_readiness": {"status": "ready", "can_generate_project_draft": True, "can_manage": False},
},
)
kwargs = rag_ask_contract.build_kernel_ingress_kwargs(
request=request,
current_user=SimpleNamespace(id=7, tenant_id=3, username="owner"),
)
options = kwargs["ingress_options"]
assert options["surface_context"]["surface"] == "personal_space"
assert options["tenant_project_context"]["tenant_id"] == 3
assert options["tenant_project_context"]["surface_context"]["workbench"]["counts"]["nextActions"] == 2
action = options["tenant_project_context"]["surface_context"]["workbench"]["top_next_actions"][0]
assert action["object_ref"] == "change_item:ci-1"
assert action["object_anchor"]["parent_object_ref"] == "project:17"
assert options["rag_request_context"]["surface_context"]["workbench"]["counts"]["pendingConfirmations"] == 1
@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"
project_operating_context = ingress_kwargs["ingress_options"]["project_operating_context"]
assert project_operating_context["current_repo_scope"]
assert project_operating_context["fit_scope"] == "current_project_and_repo"
assert project_operating_context["decision_strategy"] == "answer_then_offer_plan"
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}
if settings.api_route_profile == "lean" and "knowledge" not in settings.api_optional_route_groups:
assert "/rag/ask" not in route_paths
else:
assert "/rag/ask" in route_paths
assert "/rag/rag/ask" not in route_paths
if settings.api_route_profile == "lean" and "ai_control" not in settings.api_optional_route_groups:
assert "/ai-steward/feedback-center/summary" not in route_paths
else:
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"
assert forwarded["message_text"] == "q1"
assert forwarded["sender"] == "PMFlow用户"
assert forwarded["channel_ref"] is None
assert forwarded["source_sender_open_id"] is None
assert forwarded["captured_at"] is None
assert forwarded["chat_type"] == "manual"
assert forwarded["project_hint_id"] is None
assert forwarded["ingress_options"]["rag_request_context"] == {
"question": "q1",
"asked_by": 1,
"asked_via": "shared-builder",
}
assert forwarded["ingress_options"]["project_operating_context"]["current_project_id"] is None
assert forwarded["ingress_options"]["project_operating_context"]["current_repo_scope"]
assert forwarded["ingress_options"]["project_operating_context"]["fit_scope"] == "current_project_and_repo"
assert forwarded["ingress_options"]["project_operating_context"]["decision_strategy"] == "answer_then_offer_plan"
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_answer_payload_normalizes_source_registry_fields_into_citations() -> None:
class _FakeService(AdaptiveRAGAnswerPayloadMixin):
@staticmethod
def _to_float(value, default: float) -> float:
try:
return float(value)
except Exception:
return float(default)
service = _FakeService()
citations = service._build_citations_from_sources(
[
{
"source": "pmflow",
"label": "PMFlow[task#88]",
"score": 0.91,
"metadata": {
"record_type": "task",
"record_id": 88,
"project_id": 17,
"target_url": "/projects/17/tasks/88",
"updated_at": "2026-04-01T09:30:00+08:00",
},
},
{
"source": "doc",
"label": "Document[架构说明]",
"score": 0.73,
"metadata": {
"doc_id": 3,
"chunk_index": 2,
"file_url": "https://docs.example.com/spec",
},
},
]
)
assert citations == [
{
"source_type": "pmflow",
"source_label": "PMFlow[task#88]",
"source_id": "task:88",
"chunk_id": None,
"target_url": "/projects/17/tasks/88",
"project_id": "17",
"updated_at": "2026-04-01T09:30:00+08:00",
"permission_scope": "project",
"source_priority": 100,
"confidence": 0.91,
},
{
"source_type": "doc",
"source_label": "Document[架构说明]",
"source_id": "3",
"chunk_id": "2",
"target_url": "https://docs.example.com/spec",
"project_id": None,
"updated_at": None,
"permission_scope": "tenant",
"source_priority": 70,
"confidence": 0.73,
},
]
assert service._build_sources_used_from_citations(citations) == [
{
"source_type": "pmflow",
"source_label": "PMFlow[task#88]",
"source_id": "task:88",
"chunk_id": None,
"target_url": "/projects/17/tasks/88",
"project_id": "17",
"updated_at": "2026-04-01T09:30:00+08:00",
"permission_scope": "project",
"source_priority": 100,
},
{
"source_type": "doc",
"source_label": "Document[架构说明]",
"source_id": "3",
"chunk_id": "2",
"target_url": "https://docs.example.com/spec",
"project_id": None,
"updated_at": None,
"permission_scope": "tenant",
"source_priority": 70,
},
]
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"] == ""
def test_customer_direct_answer_handles_greeting_without_route_clarification() -> None:
payload = rag_ask_contract.build_customer_direct_answer(
RAGAskRequest(question="hi"),
route_plan=_direct_route_plan(goal_kind="casual_chat", answer_contract="pmflow_product_help"),
)
assert payload is not None
assert "外部公开事实" not in payload["reply_text"]
assert payload["processing_stage"] == "planner_direct"
def test_customer_direct_answer_handles_identity_question_without_internal_retrieval() -> None:
payload = rag_ask_contract.build_customer_direct_answer(
RAGAskRequest(question="你是谁"),
route_plan=_direct_route_plan(goal_kind="pmflow_product_help", answer_contract="pmflow_product_help"),
)
assert payload is not None
assert payload["reason_code"] == "planner_product_capability"
assert "我是 PMFlow AI" in payload["answer"]
assert "系统代码" in payload["answer"]
assert payload["processing_stage"] == "planner_direct"
@pytest.mark.parametrize(
"question",
[
"这个系统能干啥",
"你能干嘛",
"PMFlow 能解决什么问题?",
"这个平台有什么功能",
"你能帮我什么",
],
)
def test_customer_direct_answer_handles_product_capability_questions_without_internal_retrieval(question: str) -> None:
payload = rag_ask_contract.build_customer_direct_answer(
RAGAskRequest(question=question),
route_plan=_direct_route_plan(goal_kind="pmflow_product_help", answer_contract="pmflow_product_help"),
)
assert payload is not None
assert payload["reason_code"] == "planner_product_capability"
assert "我是 PMFlow AI" in payload["answer"]
assert "当前租户下的项目" in payload["answer"]
assert payload["processing_stage"] == "planner_direct"
def test_customer_payload_sanitizes_internal_source_and_prompt_leaks() -> None:
payload = rag_ask_contract.sanitize_customer_rag_payload(
{
"answer": "模型调用失败:name 'PROMPT_RAG_PENDING_CLARIFICATION_SELECTION_SEMANTICS' is not defined",
"sources": [{"source": "system", "label": "Session State", "score": 0.8, "metadata": {"trace_id": "t1"}}],
"sources_used": [{"raw_source_label": "Project database"}],
"tool_trace": [{"tool": "query_route_gateway"}],
},
request=RAGAskRequest(question="hi"),
current_user=SimpleNamespace(id=7, tenant_id=3),
)
assert payload["reply_text"] == "这次回答生成失败,系统没有拿到可展示的业务结论。你可以换一种问法,或稍后重试。"
assert payload["sources"][0]["label"] == "当前空间状态"
assert payload["sources_used"][0]["display_label"] == "项目数据"
assert "tool_trace" not in payload
assert "trace_id" not in payload
assert payload["customer_answer_policy"]["visibility"] == "customer"
def test_packet_ad_customer_payload_keeps_project_operating_source_label() -> None:
payload = rag_ask_contract.sanitize_customer_rag_payload(
{
"answer": "当前项目可以继续推进。",
"reply_text": "当前项目可以继续推进。",
"sources": [
{"source": "project_status", "label": "PMFlow 项目执行态证据", "score": 1},
{"source": "project_object_resolver", "label": "PMFlow 项目对象解析", "score": 1},
],
"sources_used": [
{"source_type": "project_status", "display_label": "PMFlow 项目执行态证据"},
],
"customer_evidence_packet": {"answer_type": "execution_guidance", "facts": [], "source_summary": []},
},
apply_presentation_contract=False,
)
assert payload["sources"][0]["label"] == "项目运行态证据"
assert payload["sources"][1]["label"] == "项目数据"
assert payload["sources_used"][0]["display_label"] == "项目运行态证据"
assert not any(item["label"] == "PMFlow 上下文" for item in payload["sources"])
@pytest.mark.asyncio
async def test_rag_ask_route_returns_customer_safe_failure_for_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) is None
assert response["code"] == 0
assert response["message"] == "success"
assert response["data"]["answer"] == "我现在没有生成足够明确的可展示回答。你可以换一种问法,或补充项目/对象范围后我再查。"
assert response["data"]["answer_mode"] == "customer_deterministic"
assert response["data"]["customer_answer_policy"]["visibility"] == "customer"
assert response["data"]["answer_process_packet"]["steps"] == []