411 lines
16 KiB
Python
411 lines
16 KiB
Python
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
|