679 lines
26 KiB
Python
679 lines
26 KiB
Python
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"] == []
|