PMFlow/backend/tests/test_ai_pmo_kernel_service.py

914 lines
39 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,
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_PROJECT_CREATE,
INTENT_SIGNAL_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD,
IntentResolver,
)
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
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:"))