PMFlow/backend/tests/test_ai_pmo_kernel_service.py

591 lines
25 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 os
import unittest
from datetime import datetime
from tempfile import NamedTemporaryFile
from unittest.mock import AsyncMock, patch
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.models import Base, Budget, PortfolioSnapshot, Project, ProjectDomain, Task, TruthNode, User, UserProject, UserSkillBinding, UserWorkload, WorkItem
from app.services.ai_pmo_cadence_service import AIPMOCadenceService
from app.services.ai_pmo_kernel_service import (
AIPMOKernelService,
INTENT_SIGNAL_SOURCE_ONBOARD,
IntentResolver,
)
from app.services.ai_pmo_domain_workflow import SignalSourceDomainWorkflow
from app.services.signal_source_orchestration_service import SignalSourceOrchestrationService
class AIPMOKernelServiceTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
tmp = NamedTemporaryFile(prefix="pmflow-ai-pmo-test-", suffix=".db", delete=False)
tmp.close()
self.db_path = tmp.name
self.engine = create_async_engine(f"sqlite+aiosqlite:///{self.db_path}", future=True)
self.session_factory = async_sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession)
async with self.engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with self.session_factory() as db:
user = User(
tenant_id=1,
username="boss",
email="boss@example.com",
password_hash="hash",
name="老板",
role="BOSS",
assigned_domains=[],
workload=0,
status="active",
is_active=True,
)
db.add(user)
await db.flush()
domain = ProjectDomain(
tenant_id=1,
name="医药",
slug="pharma",
description="医药研究域",
config={},
is_active=True,
is_default=False,
signal_count=0,
context_node_count=0,
experiment_count=0,
owner_id=str(user.id),
)
db.add(domain)
await db.flush()
project = Project(
tenant_id=1,
name="医药行业十五五规划布局研究",
title="医药行业十五五规划布局研究",
description="研究医药行业方向和机会。",
owner_id=int(user.id),
status="active",
domain_id=int(domain.id),
assigned_role="pm",
signal_count=0,
)
db.add(project)
await db.flush()
charter = TruthNode(
tenant_id=1,
node_type="charter",
project_id=int(project.id),
signal_id=None,
created_by=int(user.id),
title="项目宪章",
content={"success_criteria": ["两周内形成研究报告"]},
version=1,
status="active",
)
db.add(charter)
await db.flush()
db.add(
Budget(
tenant_id=1,
budget_type="project",
project_id=int(project.id),
period="monthly",
budget_amount_usd=1000.0,
alert_threshold_percent=80.0,
hard_limit_enabled=True,
current_usage_usd=500.0,
current_period_start=datetime(2026, 3, 1),
current_period_end=datetime(2026, 3, 31),
status="active",
override_history={},
)
)
db.add(UserProject(tenant_id=1, user_id=int(user.id), project_id=int(project.id)))
db.add(
UserWorkload(
tenant_id=1,
user_id=int(user.id),
current_assigned_hours=20.0,
max_available_hours_per_week=40.0,
workload_ratio=0.5,
)
)
db.add(
UserSkillBinding(
tenant_id=1,
user_id=int(user.id),
skill_tag_id=1,
proficiency_level="expert",
proficiency=5,
level="expert",
years_experience=8.0,
quality_score=9.0,
efficiency_score=8.5,
)
)
db.add(
Task(
tenant_id=1,
truth_node_id=int(charter.id),
project_id=int(project.id),
assignee_id=int(user.id),
ai_recommended_assignees=None,
title="梳理外部依赖",
description="当前依赖较多,任务处于 blocked。",
required_skill_tag_ids=[2],
status="blocked",
priority="high",
estimated_hours=8.0,
actual_hours=0.0,
quality_score=None,
due_date=datetime(2026, 3, 1),
completed_at=None,
)
)
await db.commit()
async def asyncTearDown(self) -> None:
await self.engine.dispose()
if getattr(self, "db_path", None):
try:
os.remove(self.db_path)
except FileNotFoundError:
pass
def test_intent_resolver_detects_signal_source_request(self) -> None:
intent = IntentResolver.resolve(
text="给项目ID: 1 新增一个 GitHub 信号源,监控 openai/codex 仓库。",
project_id=1,
)
self.assertEqual(intent.name, INTENT_SIGNAL_SOURCE_ONBOARD)
async def test_build_world_model_includes_budget_capacity_and_skill_graph(self) -> None:
async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1)
world_model = await service._build_world_model(
message_text="给项目ID: 1 新增一个医药信号。",
sender="老板",
project_hint_id=1,
)
self.assertEqual(world_model["budget"]["budget_id"], 1)
self.assertEqual(len(world_model["capacity"]), 1)
self.assertEqual(len(world_model["skill_graph"]), 1)
self.assertEqual(world_model["resolved_project_id"], 1)
self.assertEqual(world_model["project_domain"]["slug"], "pharma")
self.assertEqual(world_model["dependencies"]["pressure"], "high")
self.assertEqual(world_model["task_summary"]["uncovered_skill_tag_ids"], [2])
async def test_process_query_message_persists_work_item_and_reply_receipt(self) -> None:
async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1)
with patch(
"app.services.ai_pmo_kernel_service.AdaptiveRAGService.answer_question",
AsyncMock(return_value={"answer": "当前项目状态正常。", "sources": []}),
):
result = await service.process_ingress(
source_system="pmflow_manual",
source_message_id="msg-query-1",
message_text="项目ID: 1 现在状态如何?",
sender="老板",
channel_ref="pmflow",
project_hint_id=1,
)
work_item = (
await db.execute(
select(WorkItem).where(
and_(
WorkItem.tenant_id == 1,
WorkItem.source_message_id == "msg-query-1",
)
)
)
).scalar_one()
review_cycle = (
await db.execute(
select(PortfolioSnapshot).where(
and_(
PortfolioSnapshot.tenant_id == 1,
PortfolioSnapshot.scope_type == "review_cycle",
PortfolioSnapshot.scope_ref == f"work_item:{int(work_item.id)}",
)
)
)
).scalar_one()
self.assertTrue(result["handled"])
self.assertEqual(result["status"], "answered")
self.assertTrue(result["direct_reply_required"])
self.assertEqual(result["direct_reply_text"], "当前项目状态正常。")
self.assertEqual(result["decision_level"], "project")
self.assertIn("agent_quality_score", work_item.review_summary)
self.assertTrue(bool(work_item.review_summary.get("verified")))
self.assertEqual(review_cycle.summary["work_item_id"], int(work_item.id))
async def test_duplicate_message_is_filtered(self) -> None:
async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1)
with patch(
"app.services.ai_pmo_kernel_service.AdaptiveRAGService.answer_question",
AsyncMock(return_value={"answer": "OK", "sources": []}),
):
first = await service.process_ingress(
source_system="pmflow_manual",
source_message_id="dup-1",
message_text="项目ID: 1 现在状态如何?",
sender="老板",
channel_ref="pmflow",
project_hint_id=1,
)
second = await service.process_ingress(
source_system="pmflow_manual",
source_message_id="dup-1",
message_text="项目ID: 1 现在状态如何?",
sender="老板",
channel_ref="pmflow",
project_hint_id=1,
)
self.assertEqual(first["status"], "answered")
self.assertEqual(second["status"], "ignored")
self.assertFalse(second["direct_reply_required"])
async def test_policy_gate_blocks_archive_like_request(self) -> None:
async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1)
result = await service.process_ingress(
source_system="pmflow_manual",
source_message_id="gate-1",
message_text="请把项目ID: 1 直接归档。",
sender="老板",
channel_ref="pmflow",
project_hint_id=1,
)
self.assertTrue(result["handled"])
self.assertEqual(result["status"], "captured")
self.assertIn("人工确认", result["direct_reply_text"])
async def test_process_project_create_uses_new_kernel(self) -> None:
async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1)
with patch(
"app.services.ai_pmo_kernel_service.AIStewardService.create_project_from_natural_language",
AsyncMock(
return_value={
"project": {"id": 9, "title": "医药新项目", "name": "医药新项目"},
"signal": {"id": 11},
"tasks": [{"id": 21, "title": "任务A"}],
}
),
):
result = await service.process_ingress(
source_system="pmflow_manual",
source_message_id="project-1",
message_text="请创建项目:医药新项目,做医药趋势研究。",
sender="老板",
channel_ref="pmflow",
)
work_item = (
await db.execute(
select(WorkItem).where(
and_(
WorkItem.tenant_id == 1,
WorkItem.source_message_id == "project-1",
)
)
)
).scalar_one()
self.assertEqual(result["status"], "captured")
self.assertEqual(result["created_project_id"], 9)
self.assertEqual(result["linked_project_ids"], [9])
self.assertTrue(result["direct_reply_required"])
self.assertEqual(work_item.project_id, 9)
async def test_signal_source_request_hits_simulation_policy_gate_when_skill_gap_exists(self) -> None:
async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1)
result = await service.process_ingress(
source_system="pmflow_manual",
source_message_id="source-1",
message_text="给项目ID: 1 新增一个 GitHub 信号源,监控 openai/codex。",
sender="老板",
channel_ref="pmflow",
project_hint_id=1,
)
self.assertTrue(result["handled"])
self.assertEqual(result["status"], "captured")
self.assertIn("技能缺口", result["direct_reply_text"])
async def test_signal_create_stays_auto_executable_under_dependency_pressure(self) -> None:
async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1)
result = await service.process_ingress(
source_system="pmflow_manual",
source_message_id="signal-auto-1",
message_text="给项目ID: 1 新增一条信号:跟踪江苏医保支付改革和器械招采拐点。",
sender="老板",
channel_ref="pmflow",
project_hint_id=1,
)
self.assertTrue(result["handled"])
self.assertEqual(result["status"], "captured")
self.assertIsNotNone(result["signal_id"])
self.assertEqual(result["linked_project_ids"], [1])
self.assertIn("已入库", result["direct_reply_text"])
async def test_final_phase_generates_risk_cadence_and_program_view(self) -> None:
async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1)
result = await service.process_ingress(
source_system="pmflow_manual",
source_message_id="source-risk-1",
message_text="给项目ID: 1 新增一个 GitHub 信号源,监控 openai/codex。",
sender="老板",
channel_ref="pmflow",
project_hint_id=1,
)
cadence_service = AIPMOCadenceService(db, tenant_id=1)
latest_risk = await cadence_service.get_latest_snapshot(
cadence_type="risk_review",
project_id=1,
)
program_view = await cadence_service.build_program_view()
self.assertTrue(result["handled"])
self.assertEqual(result["status"], "captured")
self.assertIsNotNone(latest_risk)
self.assertEqual(latest_risk["scope_type"], "risk_review")
self.assertGreaterEqual(int(latest_risk["summary"]["blocked_task_count"]), 1)
self.assertEqual(program_view["summary"]["program_count"], 1)
self.assertGreaterEqual(int(program_view["summary"]["pending_manual_total"]), 1)
async def test_followup_continue_resumes_single_waiting_manual_work_item(self) -> None:
async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1)
first = await service.process_ingress(
source_system="feishu",
source_message_id="feishu-source-1",
message_text="给项目ID: 1 新增一个 GitHub 信号源,监控 openai/codex。",
sender="老板",
channel_ref="oc_phase3",
source_sender_open_id="ou_boss",
project_hint_id=1,
)
with patch(
"app.services.ai_pmo_kernel_service.SignalSourceDomainWorkflow.run",
AsyncMock(
return_value={
"status": "completed",
"registry_id": 7,
"typed_actions": [{"operation_code": "task.create", "status": "applied"}],
"material_action_count": 1,
"fallback_used": False,
"raw_result": {},
}
),
):
followup = await service.process_ingress(
source_system="feishu",
source_message_id="feishu-followup-1",
message_text="继续处理",
sender="老板",
channel_ref="oc_phase3",
source_sender_open_id="ou_boss",
project_hint_id=1,
)
original = (
await db.execute(
select(WorkItem).where(
and_(
WorkItem.tenant_id == 1,
WorkItem.source_message_id == "feishu-source-1",
)
)
)
).scalar_one()
self.assertEqual(first["status"], "captured")
self.assertEqual(followup["status"], "answered")
self.assertIn("已继续处理", followup["direct_reply_text"])
self.assertEqual(original.state, "succeeded")
async def test_followup_continue_requires_explicit_target_when_multiple_waiting_manual_items(self) -> None:
async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1)
await service.process_ingress(
source_system="feishu",
source_message_id="feishu-source-2a",
message_text="给项目ID: 1 新增一个 GitHub 信号源,监控 openai/codex。",
sender="老板",
channel_ref="oc_phase3_multi",
source_sender_open_id="ou_boss",
project_hint_id=1,
)
await service.process_ingress(
source_system="feishu",
source_message_id="feishu-source-2b",
message_text="给项目ID: 1 新增一个 Web 搜索信号源,监控医保动态。",
sender="老板",
channel_ref="oc_phase3_multi",
source_sender_open_id="ou_boss",
project_hint_id=1,
)
followup = await service.process_ingress(
source_system="feishu",
source_message_id="feishu-followup-2",
message_text="继续处理",
sender="老板",
channel_ref="oc_phase3_multi",
source_sender_open_id="ou_boss",
project_hint_id=1,
)
followup_item = (
await db.execute(
select(WorkItem).where(
and_(
WorkItem.tenant_id == 1,
WorkItem.source_message_id == "feishu-followup-2",
)
)
)
).scalar_one()
self.assertEqual(followup["status"], "captured")
self.assertIn("多个待确认工作项", followup["direct_reply_text"])
self.assertEqual(followup_item.state, "waiting_manual")
async def test_followup_explicit_work_item_id_can_resume_across_source_system(self) -> None:
async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1)
first = await service.process_ingress(
source_system="feishu",
source_message_id="feishu-source-3",
message_text="给项目ID: 1 新增一个 GitHub 信号源,监控 openai/codex。",
sender="老板",
channel_ref="oc_phase3_explicit",
source_sender_open_id="ou_boss",
project_hint_id=1,
)
target_id = int(first["work_item_id"])
with patch(
"app.services.ai_pmo_kernel_service.SignalSourceDomainWorkflow.run",
AsyncMock(
return_value={
"status": "completed",
"registry_id": 9,
"typed_actions": [{"operation_code": "task.create", "status": "applied"}],
"material_action_count": 1,
"fallback_used": False,
"raw_result": {},
}
),
):
followup = await service.process_ingress(
source_system="ai_pmo_ui",
source_message_id="resume-ui-1",
message_text=f"继续处理 work_item #{target_id}",
sender="老板",
channel_ref="ai_pmo_ui",
project_hint_id=1,
)
self.assertEqual(followup["status"], "answered")
self.assertIn("已继续处理", followup["direct_reply_text"])
def test_signal_source_domain_workflow_keeps_notification_only_pending_manual(self) -> None:
normalized = SignalSourceDomainWorkflow.normalize(
{
"status": "partial",
"signal_source": {
"registry_id": 7,
"status": "pending",
"onboarding_status": "pending_manual",
},
"openclaw": {
"status": "completed",
"result": {
"execution": {
"results": [
{
"operation_code": "notification.create",
"status": "applied",
"payload": {"title": "待确认"},
}
]
}
},
},
"worker_executions": [
{
"worker_role_key": "pm_dispatcher",
"status": "completed",
"used_fallback": True,
}
],
"pending_workers": [],
}
)
self.assertEqual(normalized["status"], "pending_manual")
self.assertEqual(normalized["material_action_count"], 0)
async def test_transport_mode_statement_is_answered_without_creating_work_item(self) -> None:
async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1)
result = await service.process_ingress(
source_system="pmflow_manual",
source_message_id="transport-mode-1",
message_text="现在都是以长连接的形式,并不是webhook。",
sender="老板",
channel_ref="pmflow",
project_hint_id=1,
)
persisted = (
await db.execute(
select(WorkItem).where(
and_(
WorkItem.tenant_id == 1,
WorkItem.source_message_id == "transport-mode-1",
)
)
)
).scalar_one_or_none()
self.assertTrue(result["handled"])
self.assertEqual(result["status"], "answered")
self.assertTrue(result["direct_reply_required"])
self.assertIn("长连接模式", result["direct_reply_text"])
self.assertIsNone(persisted)
async def test_signal_source_registry_write_re_normalizes_invalid_kind_and_generic_name(self) -> None:
async with self.session_factory() as db:
service = SignalSourceOrchestrationService(db)
result = await service._create_or_reuse_registry(
tenant_id=1,
project_id=1,
signal_id=None,
operator_user_id=None,
requested_via="feishu",
source_payload={
"source_kind": "long",
"source_name": "给项目ID",
"source_identifier": "long:给项目id",
"description": "每日搜寻最新的医药行业变革动态",
"config": {"keywords": ["医药行业", "变革"]},
},
initiating_text="给项目ID: 1 接一个复合信号源:每日搜寻最新的医药行业变革动态。",
dry_run=False,
)
self.assertEqual(result["source_kind"], "feishu")
self.assertNotEqual(result["source_name"], "给项目ID")
self.assertTrue(str(result["source_identifier"]).startswith("feishu:"))