PMFlow/backend/tests/test_signal_source_orchestr...

428 lines
17 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.

import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from app.models.signal_source_registry import SignalSourceRegistry
from app.services.llm import StructuredExtractionResult
from app.services.signal_source_orchestration_service import SignalSourceOrchestrationService
class _StubSignalSourceOrchestrationService(SignalSourceOrchestrationService):
def __init__(self) -> None:
super().__init__(db=None) # type: ignore[arg-type]
async def _load_project(self, **_: object):
return SimpleNamespace(id=3, title="项目A", name="项目A", description="desc", owner_id=1)
async def _load_signal(self, **_: object):
return SimpleNamespace(
id=9,
title="新增 GitHub 信号源",
summary="监控关键仓库 issue/PR",
signal_type="新需求",
recommended_action="enter_only",
content="新增 GitHub 信号源并自动拆任务",
)
async def _plan_onboarding(self, **_: object):
return (
{
"should_onboard": True,
"summary": "接入 GitHub 仓库信号源并下发 AI worker。",
"source": {
"source_kind": "github",
"source_name": "核心仓库",
"source_identifier": "github:core-repo",
"description": "跟踪 issue、pr、release",
},
"worker_roles": ["pm_dispatcher", "tech_architect"],
"openclaw_instruction": "为 GitHub 信号源创建接入任务并通知负责人。",
},
"planned",
{
"used_fallback": False,
"error_code": None,
"source_field": "message.content",
"provider": "glm",
"model": "GLM-4.7",
},
)
async def _create_or_reuse_registry(self, **_: object):
row = SignalSourceRegistry(
tenant_id=1,
project_id=3,
signal_id=9,
source_kind="github",
source_name="核心仓库",
source_identifier="github:core-repo",
description="跟踪 issue、pr、release",
requested_via="pmflow",
status="active",
onboarding_status="pending",
)
row.id = 11 # type: ignore[assignment]
return {
"status": "created",
"registry_id": 11,
"row": row,
}
async def _resolve_operator_user(self, **_: object):
return SimpleNamespace(id=1, role="BOSS")
async def _dispatch_openclaw(self, **_: object):
return {
"status": "completed",
"result": {
"trace_id": "openclaw-123",
"execution": {
"results": [
{
"operation_code": "task.create",
"status": "applied",
"entity_id": "21",
"payload": {"title": "接入 GitHub 监听"},
}
]
},
},
}
async def _execute_worker_roles(self, **_: object):
return [
{
"worker_role_key": "pm_dispatcher",
"worker_role_name": "产品经理(任务分派)",
"status": "completed",
"summary": "已输出接入拆解。",
},
{
"worker_role_key": "tech_architect",
"worker_role_name": "技术架构师",
"status": "completed",
"summary": "已输出接入约束。",
},
]
class SignalSourceOrchestrationServiceTest(unittest.TestCase):
def async_run(self, awaitable):
import asyncio
return asyncio.run(awaitable)
def test_detect_signal_source_request(self) -> None:
self.assertTrue(
SignalSourceOrchestrationService.looks_like_signal_source_request(
"请新增一个 GitHub 信号源,监控核心仓库 issue。"
)
)
self.assertTrue(
SignalSourceOrchestrationService.looks_like_signal_source_request(
"给这个项目加个信号源。"
)
)
self.assertFalse(
SignalSourceOrchestrationService.looks_like_signal_source_request(
"把任务 12 分给张三。"
)
)
def test_orchestrate_from_signal_returns_completed_payload(self) -> None:
service = _StubSignalSourceOrchestrationService()
result = self.async_run(
service.orchestrate_from_signal(
tenant_id=1,
project_id=3,
signal_id=9,
initiating_text="新增一个 GitHub 信号源,监控核心仓库 issue。",
requested_via="pmflow",
operator_user_id=1,
dry_run=True,
)
)
self.assertEqual(result["status"], "planned")
self.assertEqual(result["signal_source"]["registry_id"], 11)
self.assertEqual(result["signal_source"]["source_kind"], "github")
self.assertEqual(len(result["worker_executions"]), 2)
self.assertEqual(result["openclaw"]["status"], "completed")
def test_orchestrate_from_signal_skips_non_source_request(self) -> None:
service = _StubSignalSourceOrchestrationService()
result = self.async_run(
service.orchestrate_from_signal(
tenant_id=1,
project_id=3,
signal_id=9,
initiating_text="把任务 12 调成 P0。",
requested_via="pmflow",
operator_user_id=1,
dry_run=True,
)
)
self.assertEqual(result["status"], "skipped")
self.assertEqual(result["reason"], "not_signal_source_request")
def test_normalize_source_payload_uses_request_hash_for_generic_source_name(self) -> None:
service = _StubSignalSourceOrchestrationService()
normalized = service._normalize_source_payload(
source_payload={},
fallback_text="请新增一个 GitHub 信号源,监控核心仓库 issue 和 PR。",
requested_via="pmflow",
)
self.assertEqual(normalized["source_kind"], "github")
self.assertEqual(normalized["source_name"], "GitHub")
self.assertTrue(normalized["source_identifier"].startswith("github:req-"))
def test_build_source_identifier_prefers_explicit_repo_locator(self) -> None:
identifier = SignalSourceOrchestrationService._build_source_identifier(
source_kind="github",
source_name="GitHub",
text="新增 GitHub 信号源,仓库是 openai/codex。",
source_payload={"repo": "OpenAI/Codex"},
)
self.assertEqual(identifier, "github:openai/codex")
def test_map_orchestration_status_updates_signal_status_label(self) -> None:
self.assertEqual(
SignalSourceOrchestrationService._map_orchestration_status_to_signal_status("completed"),
"已编排",
)
self.assertEqual(
SignalSourceOrchestrationService._map_orchestration_status_to_signal_status("pending_manual"),
"待确认接入",
)
self.assertEqual(
SignalSourceOrchestrationService._map_orchestration_status_to_signal_status("partial"),
"部分编排",
)
self.assertEqual(
SignalSourceOrchestrationService._map_orchestration_status_to_signal_status("failed"),
"编排失败",
)
def test_resolve_registry_activation_requires_real_config_and_non_fallback_result(self) -> None:
registry_status, onboarding_status = SignalSourceOrchestrationService._resolve_registry_activation(
source_payload={
"source_kind": "pmflow",
"source_name": "Web搜索",
"source_identifier": "pmflow:req-abc123",
"description": "新增一个Web搜索信号源,关键词:医药行业。",
"config": {},
},
openclaw_result={
"status": "completed",
"result": {
"execution": {
"results": [
{
"operation_code": "task.create",
"status": "applied",
"payload": {"title": "接入 GitHub 监听"},
}
]
}
},
},
worker_results=[
{
"worker_role_key": "pm_dispatcher",
"status": "completed",
"used_fallback": True,
}
],
)
self.assertEqual(registry_status, "pending")
self.assertEqual(onboarding_status, "pending_manual")
def test_resolve_registry_activation_allows_explicit_repo_with_real_worker_output(self) -> None:
registry_status, onboarding_status = SignalSourceOrchestrationService._resolve_registry_activation(
source_payload={
"source_kind": "github",
"source_name": "核心仓库",
"source_identifier": "github:openai/codex",
"description": "监控 openai/codex issue 和 PR",
"config": {},
"repo": "openai/codex",
},
openclaw_result={
"status": "completed",
"result": {
"execution": {
"results": [
{
"operation_code": "task.create",
"status": "applied",
"payload": {"title": "接入 GitHub 监听"},
}
]
}
},
},
worker_results=[
{
"worker_role_key": "tech_architect",
"status": "completed",
"used_fallback": False,
}
],
)
self.assertEqual(registry_status, "active")
self.assertEqual(onboarding_status, "completed")
def test_plan_onboarding_accepts_reasoning_only_json(self) -> None:
service = SignalSourceOrchestrationService(db=None) # type: ignore[arg-type]
with patch(
"app.services.signal_source_orchestration_service.llm_service.generate_structured_object",
AsyncMock(
return_value=StructuredExtractionResult(
payload={
"should_onboard": True,
"summary": "接入 GitHub 仓库信号源。",
"source": {
"source_kind": "github",
"source_name": "核心仓库",
"source_identifier": "github:openai/codex",
"description": "监控 issue 与 PR",
},
"worker_roles": ["pm_dispatcher"],
"openclaw_instruction": "创建接入任务。",
},
source_field="message.reasoning_content",
provider="glm",
model="GLM-4.7",
validation="ok",
)
),
):
plan, planner_status, diagnostics = self.async_run(
service._plan_onboarding(
tenant_id=1,
project=SimpleNamespace(id=3, title="项目A", name="项目A", description="desc"),
signal=SimpleNamespace(id=9, title="新增 GitHub 信号源", summary="监控 issue", signal_type="新需求", recommended_action="enter_only"),
initiating_text="新增一个 GitHub 信号源,监控核心仓库 issue。",
requested_via="pmflow",
response_mode="fast",
)
)
self.assertEqual(planner_status, "planned")
self.assertTrue(plan["should_onboard"])
self.assertEqual(diagnostics["source_field"], "message.reasoning_content")
def test_plan_onboarding_uses_tool_call_payload_when_content_is_empty(self) -> None:
service = SignalSourceOrchestrationService(db=None) # type: ignore[arg-type]
with patch(
"app.services.signal_source_orchestration_service.llm_service.generate_structured_object",
AsyncMock(
return_value=StructuredExtractionResult(
payload={
"should_onboard": True,
"summary": "接入 GitHub 仓库信号源。",
"source": {
"source_kind": "github",
"source_name": "核心仓库",
"source_identifier": "github:openai/codex",
"description": "监控 issue 与 PR",
},
"worker_roles": ["pm_dispatcher"],
"openclaw_instruction": "创建接入任务。",
},
source_field="tool_calls.function.arguments",
provider="glm",
model="GLM-4.7",
validation="ok",
)
),
):
_, planner_status, diagnostics = self.async_run(
service._plan_onboarding(
tenant_id=1,
project=SimpleNamespace(id=3, title="项目A", name="项目A", description="desc"),
signal=SimpleNamespace(id=9, title="新增 GitHub 信号源", summary="监控 issue", signal_type="新需求", recommended_action="enter_only"),
initiating_text="新增一个 GitHub 信号源,监控核心仓库 issue。",
requested_via="pmflow",
response_mode="fast",
)
)
self.assertEqual(planner_status, "planned")
self.assertEqual(diagnostics["source_field"], "tool_calls.function.arguments")
def test_plan_onboarding_returns_schema_invalid_diagnostics(self) -> None:
service = SignalSourceOrchestrationService(db=None) # type: ignore[arg-type]
with patch(
"app.services.signal_source_orchestration_service.llm_service.generate_structured_object",
AsyncMock(
return_value=StructuredExtractionResult(
payload=None,
source_field="message.content",
provider="glm",
model="GLM-4.7",
error_code="structured_schema_invalid",
validation="invalid",
)
),
):
plan, planner_status, diagnostics = self.async_run(
service._plan_onboarding(
tenant_id=1,
project=SimpleNamespace(id=3, title="项目A", name="项目A", description="desc"),
signal=SimpleNamespace(id=9, title="新增 GitHub 信号源", summary="监控 issue", signal_type="新需求", recommended_action="enter_only"),
initiating_text="新增一个 GitHub 信号源,监控核心仓库 issue。",
requested_via="pmflow",
response_mode="fast",
)
)
self.assertEqual(planner_status, "fallback")
self.assertEqual(diagnostics["error_code"], "structured_schema_invalid")
self.assertTrue(plan["should_onboard"])
def test_plan_onboarding_returns_json_not_found_diagnostics(self) -> None:
service = SignalSourceOrchestrationService(db=None) # type: ignore[arg-type]
with patch(
"app.services.signal_source_orchestration_service.llm_service.generate_structured_object",
AsyncMock(
return_value=StructuredExtractionResult(
payload=None,
source_field="response.reasoning_content",
provider="glm",
model="GLM-4.7",
error_code="structured_json_not_found",
validation="invalid",
)
),
):
_, planner_status, diagnostics = self.async_run(
service._plan_onboarding(
tenant_id=1,
project=SimpleNamespace(id=3, title="项目A", name="项目A", description="desc"),
signal=SimpleNamespace(id=9, title="新增 GitHub 信号源", summary="监控 issue", signal_type="新需求", recommended_action="enter_only"),
initiating_text="新增一个 GitHub 信号源,监控核心仓库 issue。",
requested_via="pmflow",
response_mode="fast",
)
)
self.assertEqual(planner_status, "fallback")
self.assertEqual(diagnostics["error_code"], "structured_json_not_found")
if __name__ == "__main__":
unittest.main()