PMFlow/backend/tests/test_ai_pmo_kernel_service.py

1515 lines
66 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 json
import os
import unittest
from datetime import datetime
from tempfile import NamedTemporaryFile
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.models import (
Base,
Budget,
ConversationSession,
PortfolioSnapshot,
Project,
ProjectDomain,
Signal,
SignalSourceRegistry,
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_LLM_FUNCTION_NAME,
INTENT_PROJECT_CREATE,
INTENT_QUERY,
INTENT_SIGNAL_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD,
IntentResolver,
_intent_llm_timeout_seconds,
)
from app.services.ai_pmo_domain_workflow import SignalSourceDomainWorkflow
from app.services.llm import StructuredExtractionResult
from app.services.signal_source_orchestration_service import SignalSourceOrchestrationService
class AIPMOKernelServiceTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
self._old_intent_llm_enabled = os.environ.get("AI_PMO_INTENT_LLM_ENABLED")
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = "0"
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 self._old_intent_llm_enabled is None:
os.environ.pop("AI_PMO_INTENT_LLM_ENABLED", None)
else:
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = self._old_intent_llm_enabled
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)
def test_intent_resolver_detects_project_create_for_add_project_phrase(self) -> None:
intent = IntentResolver.resolve(
text="给我新增个项目,openclaw自动化部署。",
project_id=None,
)
self.assertEqual(intent.name, INTENT_PROJECT_CREATE)
def test_intent_resolver_filters_smalltalk_phrase(self) -> None:
intent = IntentResolver.resolve(
text="怎么样了?",
project_id=None,
)
self.assertEqual(intent.name, "ignore")
def test_intent_resolver_prefers_signal_create_for_operation_with_query_words(self) -> None:
intent = IntentResolver.resolve(
text="给AI劝退助手加一个需求,劝退完后第二天问情况如何跟进?",
project_id=1,
)
self.assertEqual(intent.name, INTENT_SIGNAL_CREATE)
def test_intent_resolver_negated_write_statement_prefers_query(self) -> None:
intent = IntentResolver.resolve(
text="别乱创建信号,说明你是谁",
project_id=1,
)
self.assertEqual(intent.name, INTENT_QUERY)
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_process_ingress_negated_write_statement_forces_query(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": "我是 PMFlow 项目协同助手。", "sources": []}),
):
result = await service.process_ingress(
source_system="pmflow_manual",
source_message_id="msg-negated-write-1",
message_text="别乱创建信号,说明你是谁",
sender="老板",
channel_ref="pmflow",
)
work_item = (
await db.execute(
select(WorkItem).where(
and_(
WorkItem.tenant_id == 1,
WorkItem.source_message_id == "msg-negated-write-1",
)
)
)
).scalar_one()
signal_count = (
await db.execute(
select(func.count()).select_from(Signal).where(Signal.tenant_id == 1)
)
).scalar_one()
self.assertEqual(result["status"], "answered")
self.assertEqual(work_item.intent, INTENT_QUERY)
self.assertEqual(int(signal_count or 0), 0)
async def test_process_query_message_returns_failed_safe_when_rag_raises(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(side_effect=RuntimeError("模型调用失败:模型调用失败:function=adaptive_rag_answer_normal")),
):
result = await service.process_ingress(
source_system="pmflow_manual",
source_message_id="msg-query-failed-safe-1",
message_text="项目ID: 1 现在状态如何?",
sender="老板",
channel_ref="pmflow",
project_hint_id=1,
)
self.assertTrue(result["handled"])
self.assertEqual(result["status"], "captured")
self.assertEqual(result["classification"], "question")
reply_text = str(result.get("direct_reply_text") or "")
self.assertIn("模型调用失败:function=adaptive_rag_answer_normal", reply_text)
self.assertNotIn("模型调用失败:模型调用失败:", reply_text)
async def test_resolve_intent_prefers_rule_for_query_without_llm_call(self) -> None:
prev = os.environ.get("AI_PMO_INTENT_LLM_ENABLED")
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = "1"
try:
async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1)
with patch.object(service, "_resolve_intent_with_llm", AsyncMock(return_value=None)) as llm_mock:
intent, source = await service._resolve_intent(
message_text="你是谁?",
session=None,
world_model={"resolved_project_id": 1},
)
self.assertEqual(intent.name, INTENT_QUERY)
self.assertEqual(source, "rule_preferred")
llm_mock.assert_not_awaited()
finally:
if prev is None:
os.environ.pop("AI_PMO_INTENT_LLM_ENABLED", None)
else:
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = prev
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_short_confirmation_inherits_last_intent_and_project(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="ctx-source-1",
message_text="给项目ID: 1 新增一个 GitHub 信号源,监控 openai/codex。",
sender="老板",
channel_ref="oc_ctx",
source_sender_open_id="ou_boss",
project_hint_id=1,
)
second = await service.process_ingress(
source_system="feishu",
source_message_id="ctx-source-2",
message_text="是的新增吧",
sender="老板",
channel_ref="oc_ctx",
source_sender_open_id="ou_boss",
)
second_item = (
await db.execute(
select(WorkItem).where(
and_(
WorkItem.tenant_id == 1,
WorkItem.source_message_id == "ctx-source-2",
)
)
)
).scalar_one()
session_row = (
await db.execute(
select(ConversationSession).where(
and_(
ConversationSession.tenant_id == 1,
ConversationSession.sender == "老板",
)
)
)
).scalar_one()
self.assertEqual(second["status"], "captured")
self.assertEqual(second_item.intent, INTENT_SIGNAL_SOURCE_ONBOARD)
self.assertEqual(int(second_item.project_id or 0), 1)
self.assertEqual((second_item.context_snapshot or {}).get("context_source"), "session_inherited")
self.assertEqual(session_row.last_intent, INTENT_SIGNAL_SOURCE_ONBOARD)
self.assertEqual(int(session_row.current_project_id or 0), 1)
async def test_signal_source_execute_persists_closure_summary_and_modules(self) -> None:
async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1)
intent = service._build_canonical_intent(
intent_name=INTENT_SIGNAL_SOURCE_ONBOARD,
message_text="按照这个分步骤加入到信号源中",
project_id=1,
confidence=0.93,
metadata={
"closure_summary": "同一消息闭环内,用户要求将需求按步骤接入信号源,重点模块:用户管理与权限、SSTC 工作流。",
"closure_dialogue": "机器人:当前无法直接创建,请先确认项目。\n用户:按照这个分步骤加入到信号源中",
"closure_modules": ["用户管理与权限", "SSTC 工作流"],
},
)
with patch.object(
SignalSourceDomainWorkflow,
"run",
AsyncMock(return_value={"status": "completed", "raw_result": {}}),
):
result = await service.executor.execute(
intent=intent,
message_text="按照这个分步骤加入到信号源中",
sender="老板",
operator_user_id=1,
source_system="feishu",
source_message_id="semantic-signal-source-1",
)
signal_row = (
await db.execute(
select(Signal).where(
and_(
Signal.tenant_id == 1,
Signal.id == int(result["signal_id"]),
)
)
)
).scalar_one()
strategic = json.loads(str(signal_row.strategic_analysis or "{}") or "{}")
source_context = strategic.get("source_context") if isinstance(strategic, dict) else {}
self.assertEqual(signal_row.title, "信号源接入 · 用户管理与权限")
self.assertIn("重点模块:用户管理与权限", str(signal_row.summary or ""))
self.assertIn("机器人:当前无法直接创建", str(signal_row.content or ""))
self.assertEqual(source_context.get("implementation_modules"), ["用户管理与权限", "SSTC 工作流"])
async def test_project_id_inherits_from_recent_session_when_message_has_no_project_text(self) -> None:
async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1)
await service.process_ingress(
source_system="pmflow_manual",
source_message_id="project-inherit-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="project-inherit-2",
message_text="新增一条信号:跟踪医药招采节奏变化。",
sender="老板",
channel_ref="pmflow",
)
second_item = (
await db.execute(
select(WorkItem).where(
and_(
WorkItem.tenant_id == 1,
WorkItem.source_message_id == "project-inherit-2",
)
)
)
).scalar_one()
self.assertTrue(second["handled"])
self.assertEqual(second_item.intent, "signal.create")
self.assertEqual(int(second_item.project_id or 0), 1)
self.assertEqual(second["linked_project_ids"], [1])
async def test_intent_llm_primary_path_marks_context_source(self) -> None:
prev = os.environ.get("AI_PMO_INTENT_LLM_ENABLED")
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = "1"
try:
async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1)
with patch(
"app.services.ai_pmo_kernel_service.llm_service.generate_structured_object",
AsyncMock(
return_value=StructuredExtractionResult(
payload={
"intent": "signal_source.onboard",
"confidence": 0.95,
"project_id": 1,
"reasoning": "用户要新增信号源",
},
source_field="content",
provider="openai",
model="GLM-4.7",
raw_text='{"intent":"signal_source.onboard"}',
validation="ok",
)
),
):
result = await service.process_ingress(
source_system="pmflow_manual",
source_message_id="llm-intent-1",
message_text="这个项目每天看竞品动态,帮我接入一下。",
sender="老板",
channel_ref="pmflow",
project_hint_id=1,
)
item = (
await db.execute(
select(WorkItem).where(
and_(
WorkItem.tenant_id == 1,
WorkItem.source_message_id == "llm-intent-1",
)
)
)
).scalar_one()
self.assertTrue(result["handled"])
self.assertEqual(item.intent, INTENT_SIGNAL_SOURCE_ONBOARD)
self.assertEqual((item.context_snapshot or {}).get("context_source"), "llm")
finally:
if prev is None:
os.environ.pop("AI_PMO_INTENT_LLM_ENABLED", None)
else:
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = prev
def test_intent_llm_timeout_default_is_no_longer_eight_seconds(self) -> None:
self.assertGreaterEqual(_intent_llm_timeout_seconds(function_name="classify_intent_complex"), 30.0)
self.assertGreaterEqual(_intent_llm_timeout_seconds(function_name=INTENT_LLM_FUNCTION_NAME), 30.0)
async def test_correction_message_forces_low_tier_glm47_intent_function(self) -> None:
prev = os.environ.get("AI_PMO_INTENT_LLM_ENABLED")
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = "1"
captured_function_name: dict[str, str] = {}
try:
async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1)
async def _mock_generate(_: str, **kwargs):
captured_function_name["value"] = str(kwargs.get("function_name") or "")
return StructuredExtractionResult(
payload={
"intent": INTENT_PROJECT_CREATE,
"confidence": 0.93,
"project_id": None,
"reasoning": "否定纠错后回退项目创建",
},
source_field="content",
provider="glm",
model="GLM-4.7",
validation="ok",
)
with patch(
"app.services.ai_pmo_kernel_service.select_intent_route",
return_value=SimpleNamespace(
function_name="classify_intent_complex",
tier="high",
reasons=["complex_multi_clause"],
is_complex=True,
),
), patch(
"app.services.ai_pmo_kernel_service.kernel_resolve_correction",
return_value=SimpleNamespace(action="rewrite", matched_keywords=["不是"], confidence=0.92),
), patch(
"app.services.ai_pmo_kernel_service.llm_service.generate_structured_object",
AsyncMock(side_effect=_mock_generate),
):
llm_intent = await service._resolve_intent_with_llm(
message_text="不是创建信号,是创建项目名为openclaw自动化部署。",
session=None,
world_model={"resolved_project_id": None, "project": {}},
)
self.assertIsNotNone(llm_intent)
self.assertEqual(captured_function_name.get("value"), INTENT_LLM_FUNCTION_NAME)
self.assertTrue(bool((llm_intent.metadata or {}).get("forced_low_tier")))
finally:
if prev is None:
os.environ.pop("AI_PMO_INTENT_LLM_ENABLED", None)
else:
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = prev
async def test_two_stage_intent_escalates_to_high_on_conflict(self) -> None:
prev_enabled = os.environ.get("AI_PMO_INTENT_LLM_ENABLED")
prev_budget = os.environ.get("AI_PMO_INTENT_LLM_TOTAL_BUDGET_SECONDS")
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = "1"
os.environ["AI_PMO_INTENT_LLM_TOTAL_BUDGET_SECONDS"] = "80"
called_functions: list[str] = []
try:
async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1)
async def _mock_generate(_: str, **kwargs):
fn = str(kwargs.get("function_name") or "")
called_functions.append(fn)
if fn == INTENT_LLM_FUNCTION_NAME:
return StructuredExtractionResult(
payload={
"intent": INTENT_SIGNAL_CREATE,
"confidence": 0.58,
"project_id": None,
"reasoning": "低阶先判成信号创建",
},
source_field="content",
provider="glm",
model="GLM-4.7",
validation="ok",
)
return StructuredExtractionResult(
payload={
"intent": INTENT_PROJECT_CREATE,
"confidence": 0.93,
"project_id": None,
"reasoning": "高阶复判为项目创建",
},
source_field="content",
provider="openai",
model="GLM-5",
validation="ok",
)
with patch(
"app.services.ai_pmo_kernel_service.select_intent_route",
return_value=SimpleNamespace(
function_name="classify_intent_complex",
tier="high",
reasons=["complex_multi_clause"],
is_complex=True,
),
), patch(
"app.services.ai_pmo_kernel_service.kernel_resolve_correction",
return_value=SimpleNamespace(action="none", matched_keywords=[], confidence=0.0),
), patch(
"app.services.ai_pmo_kernel_service.llm_service.generate_structured_object",
AsyncMock(side_effect=_mock_generate),
):
llm_intent = await service._resolve_intent_with_llm(
message_text="给我新增个项目,需求名字为openclaw自动化部署。",
session=None,
world_model={"resolved_project_id": None, "project": {}},
rule_intent=service._build_canonical_intent(
intent_name=INTENT_PROJECT_CREATE,
message_text="给我新增个项目,需求名字为openclaw自动化部署。",
project_id=None,
confidence=0.88,
metadata={"context_source": "rule"},
),
)
self.assertIsNotNone(llm_intent)
self.assertEqual(called_functions, [INTENT_LLM_FUNCTION_NAME, "classify_intent_complex"])
self.assertEqual(llm_intent.name, INTENT_PROJECT_CREATE)
self.assertEqual((llm_intent.metadata or {}).get("llm_function"), "classify_intent_complex")
self.assertTrue(bool((llm_intent.metadata or {}).get("stage2_promoted")))
finally:
if prev_enabled is None:
os.environ.pop("AI_PMO_INTENT_LLM_ENABLED", None)
else:
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = prev_enabled
if prev_budget is None:
os.environ.pop("AI_PMO_INTENT_LLM_TOTAL_BUDGET_SECONDS", None)
else:
os.environ["AI_PMO_INTENT_LLM_TOTAL_BUDGET_SECONDS"] = prev_budget
async def test_two_stage_intent_keeps_low_when_confident_and_no_conflict(self) -> None:
prev_enabled = os.environ.get("AI_PMO_INTENT_LLM_ENABLED")
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = "1"
called_functions: list[str] = []
try:
async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1)
async def _mock_generate(_: str, **kwargs):
fn = str(kwargs.get("function_name") or "")
called_functions.append(fn)
return StructuredExtractionResult(
payload={
"intent": INTENT_QUERY,
"confidence": 0.95,
"project_id": None,
"reasoning": "低阶高置信查询",
},
source_field="content",
provider="glm",
model="GLM-4.7",
validation="ok",
)
with patch(
"app.services.ai_pmo_kernel_service.select_intent_route",
return_value=SimpleNamespace(
function_name=INTENT_LLM_FUNCTION_NAME,
tier="low",
reasons=["default_simple"],
is_complex=False,
),
), patch(
"app.services.ai_pmo_kernel_service.kernel_resolve_correction",
return_value=SimpleNamespace(action="none", matched_keywords=[], confidence=0.0),
), patch(
"app.services.ai_pmo_kernel_service.llm_service.generate_structured_object",
AsyncMock(side_effect=_mock_generate),
):
llm_intent = await service._resolve_intent_with_llm(
message_text="项目进展如何?",
session=None,
world_model={"resolved_project_id": None, "project": {}},
rule_intent=service._build_canonical_intent(
intent_name=INTENT_QUERY,
message_text="项目进展如何?",
project_id=None,
confidence=0.90,
metadata={"context_source": "rule"},
),
)
self.assertIsNotNone(llm_intent)
self.assertEqual(called_functions, [INTENT_LLM_FUNCTION_NAME])
self.assertEqual(llm_intent.name, INTENT_QUERY)
self.assertEqual((llm_intent.metadata or {}).get("stage"), "stage1_low")
finally:
if prev_enabled is None:
os.environ.pop("AI_PMO_INTENT_LLM_ENABLED", None)
else:
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = prev_enabled
async def test_intent_llm_failure_falls_back_to_rule_resolver(self) -> None:
prev = os.environ.get("AI_PMO_INTENT_LLM_ENABLED")
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = "1"
try:
async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1)
with patch(
"app.services.ai_pmo_kernel_service.llm_service.generate_structured_object",
AsyncMock(
return_value=StructuredExtractionResult(
payload=None,
source_field="",
provider="openai",
model="GLM-4.7",
error_code="model_not_allowed",
raw_text="403 ModelNotAllowed",
validation="invalid",
)
),
):
await service.process_ingress(
source_system="pmflow_manual",
source_message_id="llm-intent-fallback-1",
message_text="给项目ID: 1 新增一个 GitHub 信号源,监控 openai/codex。",
sender="老板",
channel_ref="pmflow",
project_hint_id=1,
)
item = (
await db.execute(
select(WorkItem).where(
and_(
WorkItem.tenant_id == 1,
WorkItem.source_message_id == "llm-intent-fallback-1",
)
)
)
).scalar_one()
self.assertEqual(item.intent, INTENT_SIGNAL_SOURCE_ONBOARD)
self.assertEqual((item.context_snapshot or {}).get("context_source"), "rule_fallback")
finally:
if prev is None:
os.environ.pop("AI_PMO_INTENT_LLM_ENABLED", None)
else:
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = prev
async def test_intent_arbitration_conflict_write_requires_manual_review(self) -> None:
old_llm = os.environ.get("AI_PMO_INTENT_LLM_ENABLED")
old_arb = os.environ.get("AI_PMO_INTENT_ARBITRATION_ENABLED")
old_policy = os.environ.get("AI_PMO_INTENT_CONFLICT_POLICY")
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = "1"
os.environ["AI_PMO_INTENT_ARBITRATION_ENABLED"] = "1"
os.environ["AI_PMO_INTENT_CONFLICT_POLICY"] = "pending_manual"
try:
async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1)
with patch(
"app.services.ai_pmo_kernel_service.llm_service.generate_structured_object",
AsyncMock(
return_value=StructuredExtractionResult(
payload={
"intent": INTENT_SIGNAL_CREATE,
"confidence": 0.95,
"project_id": None,
"reasoning": "用户像是在提新增记录",
},
source_field="content",
provider="openai",
model="MiniMax-M2.5",
raw_text='{"intent":"signal.create"}',
validation="ok",
)
),
):
intent, source = await service._resolve_intent(
message_text="给我新增个项目,openclaw自动化部署。",
session=None,
world_model={"resolved_project_id": None, "project": {}},
)
self.assertIn(source, {"llm", "rule_fallback"})
self.assertTrue(bool((intent.metadata or {}).get("requires_manual_review")))
self.assertEqual((intent.metadata or {}).get("manual_review_code"), "intent_conflict_write")
self.assertEqual((intent.metadata or {}).get("arbitration"), "conflict_manual_review")
finally:
if old_llm is None:
os.environ.pop("AI_PMO_INTENT_LLM_ENABLED", None)
else:
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = old_llm
if old_arb is None:
os.environ.pop("AI_PMO_INTENT_ARBITRATION_ENABLED", None)
else:
os.environ["AI_PMO_INTENT_ARBITRATION_ENABLED"] = old_arb
if old_policy is None:
os.environ.pop("AI_PMO_INTENT_CONFLICT_POLICY", None)
else:
os.environ["AI_PMO_INTENT_CONFLICT_POLICY"] = old_policy
async def test_project_create_slot_guard_requires_title(self) -> None:
old_llm = os.environ.get("AI_PMO_INTENT_LLM_ENABLED")
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = "0"
try:
async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1)
intent, source = await service._resolve_intent(
message_text="请创建项目。",
session=None,
world_model={"resolved_project_id": None, "project": {}},
)
self.assertEqual(source, "rule_fallback")
self.assertEqual(intent.name, INTENT_PROJECT_CREATE)
self.assertFalse(bool((intent.metadata or {}).get("slot_ok")))
self.assertTrue(bool((intent.metadata or {}).get("requires_manual_review")))
self.assertEqual((intent.metadata or {}).get("manual_review_code"), "slot_incomplete")
finally:
if old_llm is None:
os.environ.pop("AI_PMO_INTENT_LLM_ENABLED", None)
else:
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = old_llm
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_process_add_project_phrase_routes_to_project_create(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": 12, "title": "OpenClaw 自动化部署", "name": "OpenClaw 自动化部署"},
"signal": {"id": 31},
"tasks": [],
}
),
):
result = await service.process_ingress(
source_system="pmflow_manual",
source_message_id="project-phrase-1",
message_text="给我新增个项目,openclaw自动化部署。",
sender="老板",
channel_ref="pmflow",
)
work_item = (
await db.execute(
select(WorkItem).where(
and_(
WorkItem.tenant_id == 1,
WorkItem.source_message_id == "project-phrase-1",
)
)
)
).scalar_one()
self.assertEqual(result["status"], "captured")
self.assertEqual(result["created_project_id"], 12)
self.assertEqual(result["linked_project_ids"], [12])
self.assertEqual(work_item.intent, INTENT_PROJECT_CREATE)
self.assertEqual(work_item.project_id, 12)
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"])
async def test_followup_resume_marks_signal_source_pending_manual_as_succeeded_when_gate_resolved(self) -> None:
async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1)
with patch(
"app.services.ai_pmo_kernel_service.SignalSourceDomainWorkflow.run",
AsyncMock(
return_value={
"status": "pending_manual",
"registry_id": 12,
"typed_actions": [
{
"operation_code": "notification.create",
"status": "applied",
"payload": {"title": "待确认"},
}
],
"material_action_count": 0,
"fallback_used": True,
"raw_result": {
"signal_source": {
"registry_id": 12,
"status": "pending",
"onboarding_status": "pending_manual",
},
"openclaw": {
"status": "completed",
"result": {
"execution": {
"results": [
{
"operation_code": "notification.create",
"status": "applied",
"payload": {"title": "待确认"},
}
]
}
},
},
"worker_executions": [],
"pending_workers": [],
},
}
),
):
first = await service.process_ingress(
source_system="feishu",
source_message_id="feishu-source-pending-manual-1",
message_text="给项目ID: 1 新增一个 GitHub 信号源,监控 openai/codex。",
sender="老板",
channel_ref="oc_phase3_resume_pending",
source_sender_open_id="ou_boss",
project_hint_id=1,
)
target_id = int(first["work_item_id"])
followup = await service.process_ingress(
source_system="ai_pmo_ui",
source_message_id="resume-ui-pending-manual-1",
message_text=f"继续处理 work_item #{target_id}",
sender="老板",
channel_ref="ai_pmo_ui",
project_hint_id=1,
)
original = (
await db.execute(
select(WorkItem).where(
and_(
WorkItem.tenant_id == 1,
WorkItem.source_message_id == "feishu-source-pending-manual-1",
)
)
)
).scalar_one()
followup_item = (
await db.execute(
select(WorkItem).where(
and_(
WorkItem.tenant_id == 1,
WorkItem.source_message_id == "resume-ui-pending-manual-1",
)
)
)
).scalar_one()
self.assertEqual(first["status"], "captured")
self.assertEqual(followup["status"], "answered")
self.assertIn("已继续处理", followup["direct_reply_text"])
self.assertEqual(original.state, "succeeded")
self.assertEqual(original.execution_result.get("status"), "succeeded")
self.assertTrue(bool(original.execution_result.get("manual_gate_resolved")))
self.assertEqual(original.execution_result.get("post_process_status"), "pending_manual")
self.assertEqual(followup_item.state, "succeeded")
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:"))
async def test_signal_source_registry_reuse_does_not_rollback_new_signal_in_outer_tx(self) -> None:
async with self.session_factory() as db:
registry_service = SignalSourceOrchestrationService(db)
source_payload = {
"source_kind": "github",
"source_name": "openai/codex",
"source_identifier": "github:openai/codex",
"description": "监控仓库动态",
"config": {"repo": "openai/codex"},
}
created = await registry_service._create_or_reuse_registry(
tenant_id=1,
project_id=1,
signal_id=None,
operator_user_id=1,
requested_via="feishu",
source_payload=source_payload,
initiating_text="新增 GitHub 信号源 openai/codex",
dry_run=False,
)
signal = Signal(
tenant_id=1,
source="PMFlow",
title="重复信号源续跑校验",
summary="用于校验事务回滚边界",
content="用于校验事务回滚边界",
signal_type="新需求",
reasoning="[]",
confidence=0.78,
recommended_action="enter_only",
matched_project_id=1,
impact_score=0.7,
priority="medium",
processed=False,
tasks_generated="[]",
status="待处理",
)
db.add(signal)
await db.flush()
reused = await registry_service._create_or_reuse_registry(
tenant_id=1,
project_id=1,
signal_id=int(signal.id),
operator_user_id=1,
requested_via="ai_pmo_ui",
source_payload=source_payload,
initiating_text="继续处理同一信号源",
dry_run=False,
)
persisted_signal = (
await db.execute(
select(Signal).where(
and_(
Signal.tenant_id == 1,
Signal.id == int(signal.id),
)
)
)
).scalar_one_or_none()
registry_row = (
await db.execute(
select(SignalSourceRegistry).where(
and_(
SignalSourceRegistry.tenant_id == 1,
SignalSourceRegistry.project_id == 1,
SignalSourceRegistry.source_kind == "github",
SignalSourceRegistry.source_identifier == "github:openai/codex",
)
)
)
).scalar_one()
await db.commit()
self.assertEqual(created["status"], "created")
self.assertEqual(reused["status"], "reused")
self.assertIsNotNone(persisted_signal)
self.assertEqual(int(registry_row.signal_id or 0), int(signal.id))
async def test_dispatch_openclaw_isolated_session_does_not_rollback_outer_signal(self) -> None:
async with self.session_factory() as db:
service = SignalSourceOrchestrationService(db)
project = (
await db.execute(
select(Project).where(
and_(
Project.tenant_id == 1,
Project.id == 1,
)
)
)
).scalar_one()
operator = (
await db.execute(
select(User).where(
and_(
User.tenant_id == 1,
User.id == 1,
)
)
)
).scalar_one()
signal = Signal(
tenant_id=1,
source="PMFlow",
title="OpenClaw 事务隔离校验",
summary="验证回滚不影响外层事务",
content="验证回滚不影响外层事务",
signal_type="新需求",
reasoning="[]",
confidence=0.78,
recommended_action="enter_only",
matched_project_id=1,
impact_score=0.7,
priority="medium",
processed=False,
tasks_generated="[]",
status="待处理",
)
db.add(signal)
await db.flush()
signal_id = int(signal.id)
async def _fake_dispatch(commander_self, **_: object): # type: ignore[no-untyped-def]
await commander_self.db.rollback()
return {"execution": {"results": []}}
with patch(
"app.services.openclaw_commander_service.OpenClawCommanderService.dispatch_command",
new=_fake_dispatch,
):
result = await service._dispatch_openclaw(
tenant_id=1,
operator_user=operator,
project=project,
instruction="生成任务并通知负责人",
dry_run=False,
response_mode="fast",
)
persisted_signal = (
await db.execute(
select(Signal).where(
and_(
Signal.tenant_id == 1,
Signal.id == signal_id,
)
)
)
).scalar_one_or_none()
await db.commit()
self.assertIn(result["status"], {"completed", "failed"})
self.assertIsNotNone(persisted_signal)