1119 lines
49 KiB
Python
1119 lines
49 KiB
Python
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_, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||
|
||
from app.models import (
|
||
Base,
|
||
Budget,
|
||
ConversationSession,
|
||
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_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)
|
||
|
||
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_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_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"])
|
||
|
||
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:"))
|