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.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()