PMFlow/backend/tests/test_ai_pmo_qa_http_mainlin...

411 lines
16 KiB
Python
Raw 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 fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from app.api.router import api_router
from app.api.ai_pmo_api import routes_core
from app.api.rag_api import routes_query
from app.core.database import get_db
from app.dependencies.auth import get_current_user
from app.dependencies.tenant import get_tenant_id
def _build_test_app() -> FastAPI:
app = FastAPI()
app.include_router(routes_query.router)
app.include_router(routes_core.router)
async def _fake_get_db():
yield object()
app.dependency_overrides[get_db] = _fake_get_db
app.dependency_overrides[get_tenant_id] = lambda: 9
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(
id=101,
tenant_id=9,
name="测试用户",
username="tester",
)
return app
@pytest.mark.asyncio
async def test_http_rag_ask_readonly_mainline_contract(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": "当前主链状态正常。",
"rag_result": {
"answer": "当前主链状态正常。",
"processing_stage": "qa",
"route_decision": {
"route_mode": "internal",
"effective_source_policy": "internal",
"decision_origin": "reply_anchor",
},
"clarification_code": None,
"clarification_candidates": [],
"answer_mode": "fast_path",
"grounding_status": "grounded",
"policy_version": "retrieval-v2.1|routing:v3|prompt:deterministic",
"citations": [],
"sources_used": [],
"evidence_pack": {
"summary": {
"conclusion": "当前主链状态正常。",
"evidence": [],
"uncertainties": [],
},
"tool_trace": [],
},
},
}
monkeypatch.setattr(routes_query, "AIPMOKernelService", FakeKernel)
app = _build_test_app()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/rag/ask",
json={
"question": "当前主链状态?",
"asked_via": "pmflow_manual",
"action_mode": "read",
},
)
assert response.status_code == 200
payload = response.json()
assert payload["code"] == 0
assert "当前主链状态正常" in str((payload.get("data") or {}).get("answer") or "")
assert payload["data"]["route_decision"]["effective_source_policy"] == "internal"
assert payload["data"]["clarification_code"] is None
assert payload["data"]["clarification_candidates"] == []
assert payload["data"]["answer_mode"] == "fast_path"
assert payload["data"]["grounding_status"] == "grounded"
assert payload["data"]["policy_version"] == "retrieval-v2.1|routing:v3|prompt:deterministic"
assert payload["data"]["citations"] == []
assert payload["data"]["sources_used"] == []
assert payload["data"]["evidence_pack"]["summary"]["conclusion"] == "当前主链状态正常。"
ingress_kwargs = captured["ingress_kwargs"]
assert isinstance(ingress_kwargs, dict)
assert ingress_kwargs["source_system"] == "pmflow_manual"
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"] is None
@pytest.mark.asyncio
async def test_http_rag_ask_mixed_mainline_contract(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": "[回答] 进展稳定\n[已收到需求] 新增任务确认流",
"rag_result": {"answer": "[回答] 进展稳定\n[已收到需求] 新增任务确认流"},
}
monkeypatch.setattr(routes_query, "AIPMOKernelService", FakeKernel)
app = _build_test_app()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/rag/ask",
json={
"question": "顺便问进展并新增任务确认流",
"asked_via": "pmflow_manual",
"action_mode": "read",
"message_intent": "mixed",
"mixed_signal_part": "新增任务确认流",
},
)
assert response.status_code == 200
payload = response.json()
assert payload["code"] == 0
assert "[回答]" in str((payload.get("data") or {}).get("answer") or "")
ingress_kwargs = captured["ingress_kwargs"]
assert isinstance(ingress_kwargs, dict)
rag_request_context = ingress_kwargs["ingress_options"]["rag_request_context"]
assert rag_request_context["message_intent"] == "mixed"
assert rag_request_context["mixed_signal_part"] == "新增任务确认流"
@pytest.mark.asyncio
async def test_http_rag_ask_should_attach_project_operating_context_for_current_project(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": "先给判断,再给下一步。",
"rag_result": {
"answer": "先给判断,再给下一步。",
"route_decision": {
"route_mode": "external",
"effective_source_policy": "external",
"decision_origin": "query_route_gateway",
},
},
}
monkeypatch.setattr(routes_query, "AIPMOKernelService", FakeKernel)
app = _build_test_app()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/rag/ask",
json={
"question": "OpenAI Docs 这种能力适不适合我们当前项目?",
"asked_via": "feishu",
"project_id": 12,
"source_chat_id": "chat-12",
"action_mode": "read",
},
)
assert response.status_code == 200
ingress_options = captured["ingress_kwargs"]["ingress_options"]
packet = ingress_options["project_operating_context"]
assert packet["current_project_id"] == 12
assert packet["current_repo_scope"]
assert packet["fit_scope"] == "current_project_and_repo"
assert packet["decision_strategy"] == "answer_then_offer_plan"
@pytest.mark.asyncio
async def test_http_rag_ask_returns_offer_after_answer_handoff_metadata_for_current_project_fit_question(
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_required": True,
"direct_reply_text": "外部实时查询:目前证据不足,建议先明确要查融资还是招聘动态。",
"rag_result": {
"answer": "外部实时查询:目前证据不足,建议先明确要查融资还是招聘动态。",
"route_decision": {
"route_mode": "external",
"effective_source_policy": "external",
"decision_origin": "query_route_gateway",
},
"project_operating_context": {
"context_version": "v1",
"current_project_id": 12,
"current_repo_scope": "alpha-repo",
"page_anchor": {"type": "project_board", "ref": "project:12"},
"thread_anchor": {"source": "pmflow"},
"fit_scope": "current_project_and_repo",
"decision_strategy": "answer_then_offer_plan",
},
"execution_recipe": {
"primary_lane": "external",
"fit_brief_required": True,
"external_evidence_required": True,
"plan_handoff_mode": "offer_after_answer",
},
"fit_brief": {
"project_summary": "Alpha 是一个 AI 项目管理工作台。",
"repo_summary": "当前仓库负责统一项目上下文与 AI handoff。",
},
"external_recommendation": {
"retrieval_status": "evidence_found",
"impact_status": "indirect_only",
"external_impact": "目前更像背景相关,还不能直接映射到当前正式对象。",
},
"handoff_mode": "offer_after_answer",
"can_enter_plan": True,
"recommended_next_step": "如果你要,我可以基于当前结论进入 plan,继续把需求范围和下一步收清楚。",
},
}
monkeypatch.setattr(routes_query, "AIPMOKernelService", FakeKernel)
app = _build_test_app()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/rag/ask",
json={
"question": "这个适合吗",
"asked_via": "pmflow_manual",
"project_id": 12,
"source_chat_id": "chat-12",
"action_mode": "read",
},
)
assert response.status_code == 200
payload = response.json()
assert payload["code"] == 0
data = payload["data"]
assert data["route_decision"]["route_mode"] == "external"
assert data["project_operating_context"]["current_project_id"] == 12
assert data["execution_recipe"]["plan_handoff_mode"] == "offer_after_answer"
assert data["fit_brief"]["project_summary"] == "Alpha 是一个 AI 项目管理工作台。"
assert data["external_recommendation"]["impact_status"] == "indirect_only"
assert data["handoff_mode"] == "offer_after_answer"
assert data["can_enter_plan"] is True
assert "进入 plan" in data["recommended_next_step"]
@pytest.mark.asyncio
async def test_http_ai_pmo_process_text_draft_pending_confirm_mainline(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": "draft_pending_confirm",
"classification": "command",
"direct_reply_required": True,
"direct_reply_text": "草稿已生成,请确认。",
"draft_version": 2,
}
monkeypatch.setattr(routes_core, "AIPMOKernelService", FakeKernel)
app = _build_test_app()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/ai-pmo/process-text",
json={
"message_text": "请创建任务并先给草稿",
"sender": "产品经理A",
"source_message_id": "manual-msg-100",
"project_id": 12,
},
)
assert response.status_code == 200
payload = response.json()
assert payload["code"] == 0
data = payload["data"]
assert data["status"] == "draft_pending_confirm"
kwargs = captured["ingress_kwargs"]
assert isinstance(kwargs, dict)
assert kwargs["source_system"] == "pmflow_manual"
assert kwargs["message_text"] == "请创建任务并先给草稿"
assert kwargs["project_hint_id"] == 12
@pytest.mark.asyncio
async def test_http_ai_pmo_process_text_should_attach_project_operating_context(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",
"classification": "query",
"direct_reply_required": True,
"direct_reply_text": "这个方向先判断,再决定是否进入计划。",
}
monkeypatch.setattr(routes_core, "AIPMOKernelService", FakeKernel)
app = _build_test_app()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/ai-pmo/process-text",
json={
"message_text": "这个方案适不适合我们当前项目?",
"sender": "产品经理A",
"source_message_id": "manual-msg-200",
"project_id": 12,
},
)
assert response.status_code == 200
ingress_options = captured["ingress_kwargs"]["ingress_options"]
packet = ingress_options["project_operating_context"]
assert packet["current_project_id"] == 12
assert packet["fit_scope"] == "current_project_and_repo"
assert packet["decision_strategy"] == "answer_then_offer_plan"
@pytest.mark.asyncio
async def test_http_ai_pmo_work_items_list_smoke(monkeypatch) -> None:
class FakeResult:
def all(self):
return []
class FakeDB:
async def execute(self, *_args, **_kwargs):
return FakeResult()
async def _fake_get_db():
yield FakeDB()
app = _build_test_app()
app.dependency_overrides[get_db] = _fake_get_db
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/ai-pmo/work-items", params={"limit": 18})
assert response.status_code == 200
payload = response.json()
assert payload["code"] == 0
assert payload["data"]["items"] == []
def test_ai_pmo_api_router_uses_single_prefix() -> None:
route_paths = {str(getattr(route, "path", "")).strip() for route in api_router.routes}
assert "/ai-pmo/work-items" in route_paths
assert "/ai-pmo/process-text" in route_paths
assert "/ai-pmo/ai-pmo/work-items" not in route_paths
assert "/ai-pmo/ai-pmo/process-text" not in route_paths