import json 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.api.users import _create_user_with_defaults from app.models import ( Base, Budget, Project, ProjectDomain, ProjectSignalLink, Signal, SkillTag, Task, TruthNode, User, UserProject, UserSkillBinding, UserWorkload, WorkItem, ) from app.schemas import UserCreate from app.services.ai_pmo_cadence_service import AIPMOCadenceService from app.services.ai_pmo_kernel_service import AIPMOKernelService from app.services.signal_capture import ProactiveSignalCaptureService from app.services.skill_service import recommend_personnel from app.services import workload_service class AIPMOFullScenarioTest(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self) -> None: tmp = NamedTemporaryFile(prefix="pmflow-ai-pmo-scenario-", 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: self.policy_skill_id = await self._create_skill_tag(db, name="政策分析", slug="policy-analysis") self.research_skill_id = await self._create_skill_tag(db, name="行业研究", slug="market-research") self.web_skill_id = await self._create_skill_tag(db, name="爬虫采集", slug="web-crawling") self.graph_skill_id = await self._create_skill_tag(db, name="知识图谱", slug="knowledge-graph") self.pipeline_skill_id = await self._create_skill_tag(db, name="数据管道", slug="data-pipeline") boss = 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(boss) await db.flush() self.boss_id = int(boss.id) 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(boss.id), ) db.add(domain) await db.flush() self.domain_id = int(domain.id) project = Project( tenant_id=1, name="医药行业十五五规划布局研究", title="医药行业十五五规划布局研究", description="跟踪医保支付改革、器械招采与创新药方向。", owner_id=int(boss.id), status="active", domain_id=int(domain.id), assigned_role="pm", signal_count=0, ) db.add(project) await db.flush() self.base_project_id = int(project.id) charter = TruthNode( tenant_id=1, node_type="charter", project_id=int(project.id), signal_id=None, created_by=int(boss.id), title="医药研究项目宪章", content={"success_criteria": ["两周内形成华东医保支付改革观察报告"]}, version=1, status="active", ) db.add(charter) await db.flush() self.base_charter_id = int(charter.id) db.add( Budget( tenant_id=1, budget_type="project", project_id=int(project.id), period="monthly", budget_amount_usd=1500.0, alert_threshold_percent=80.0, hard_limit_enabled=True, current_usage_usd=620.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(boss.id), project_id=int(project.id))) db.add( UserWorkload( tenant_id=1, user_id=int(boss.id), current_assigned_hours=18.0, max_available_hours_per_week=40.0, workload_ratio=0.0, ) ) await self._bind_skill(db, user_id=int(boss.id), skill_tag_id=self.policy_skill_id, level="advanced", proficiency=86) await self._bind_skill(db, user_id=int(boss.id), skill_tag_id=self.research_skill_id, level="expert", proficiency=92) db.add( Task( tenant_id=1, truth_node_id=int(charter.id), project_id=int(project.id), assignee_id=int(boss.id), ai_recommended_assignees=None, title="拆解华东医保支付改革样本", description="需要比对江苏/浙江 DRG-DIP 改革口径和器械招采变化。", required_skill_tag_ids=[self.policy_skill_id, self.web_skill_id], status="blocked", priority="high", estimated_hours=12.0, actual_hours=0.0, quality_score=None, due_date=datetime(2026, 3, 12), completed_at=None, ) ) await workload_service.recalculate_user_workload_ratio(db, tenant_id=1, user_id=int(boss.id), commit=False) await db.commit() async def asyncTearDown(self) -> None: await self.engine.dispose() if getattr(self, "db_path", None): try: os.remove(self.db_path) except FileNotFoundError: pass async def _create_skill_tag(self, db: AsyncSession, *, name: str, slug: str) -> int: row = SkillTag( tenant_id=1, name=name, slug=slug, category="skill", description=f"{name}能力", is_system=False, sort_order=0, ) db.add(row) await db.flush() return int(row.id) async def _bind_skill( self, db: AsyncSession, *, user_id: int, skill_tag_id: int, level: str, proficiency: int, ) -> None: db.add( UserSkillBinding( tenant_id=1, user_id=int(user_id), skill_tag_id=int(skill_tag_id), proficiency_level=level, proficiency=int(proficiency), level=level, years_experience=5.0, quality_score=8.5, efficiency_score=8.0, ) ) await db.flush() async def _set_workload_hours(self, db: AsyncSession, *, user_id: int, assigned_hours: float) -> None: workload = ( await db.execute( select(UserWorkload).where( and_( UserWorkload.tenant_id == 1, UserWorkload.user_id == int(user_id), ) ) ) ).scalar_one() workload.current_assigned_hours = float(assigned_hours) await workload_service.recalculate_user_workload_ratio(db, tenant_id=1, user_id=int(user_id), commit=False) async def test_full_difficult_scenario_covers_feishu_context_project_signal_workflow_and_workload(self) -> None: async with self.session_factory() as db: strategy_user = await _create_user_with_defaults( db, tenant_id=1, user_data=UserCreate( username="hanqing", password="password-1", name="韩青", email="hanqing@example.com", role="ADMIN", assigned_domains=["pharma"], project_ids=[self.base_project_id], ), actor_id=self.boss_id, commit=False, ) crawler_user = await _create_user_with_defaults( db, tenant_id=1, user_data=UserCreate( username="zhouzhan", password="password-2", name="周湛", email="zhouzhan@example.com", role="BE", assigned_domains=["pharma"], project_ids=[], ), actor_id=self.boss_id, commit=False, ) graph_user = await _create_user_with_defaults( db, tenant_id=1, user_data=UserCreate( username="guyan", password="password-3", name="顾言", email="guyan@example.com", role="AI", assigned_domains=["pharma"], project_ids=[], ), actor_id=self.boss_id, commit=False, ) await self._bind_skill(db, user_id=int(strategy_user.id), skill_tag_id=self.policy_skill_id, level="expert", proficiency=93) await self._bind_skill(db, user_id=int(strategy_user.id), skill_tag_id=self.research_skill_id, level="expert", proficiency=90) await self._bind_skill(db, user_id=int(crawler_user.id), skill_tag_id=self.web_skill_id, level="expert", proficiency=95) await self._bind_skill(db, user_id=int(crawler_user.id), skill_tag_id=self.policy_skill_id, level="advanced", proficiency=84) await self._bind_skill(db, user_id=int(crawler_user.id), skill_tag_id=self.pipeline_skill_id, level="advanced", proficiency=88) await self._bind_skill(db, user_id=int(graph_user.id), skill_tag_id=self.graph_skill_id, level="expert", proficiency=91) await self._bind_skill(db, user_id=int(graph_user.id), skill_tag_id=self.pipeline_skill_id, level="advanced", proficiency=82) await self._set_workload_hours(db, user_id=int(strategy_user.id), assigned_hours=28.0) await self._set_workload_hours(db, user_id=int(crawler_user.id), assigned_hours=8.0) await self._set_workload_hours(db, user_id=int(graph_user.id), assigned_hours=10.0) await db.commit() kernel = AIPMOKernelService(db, tenant_id=1) capture_service = ProactiveSignalCaptureService(db) hard_question = ( "项目ID: 1 如果把样本窗口收紧到 2025Q4-2026Q1,只看江苏/浙江的 DRG-DIP 支付、" "三甲院器械招采与设备更新预算,当前状态下最可能拖慢结论闭环的到底是预算、" "blocked task 还是技能缺口?为什么?别给套话,按证据优先级直接判断。" ) with ( patch( "app.services.ai_pmo_kernel_service.AdaptiveRAGService.answer_question", AsyncMock( return_value={ "answer": "优先级 1 是 blocked task,其次是技能缺口,预算暂未触顶。", "sources": [], } ), ), patch( "app.services.signal_capture.context_cache_service.get_session_messages", AsyncMock(return_value=[]), ), patch( "app.services.signal_capture.context_cache_service.append_session_messages", AsyncMock(), ) as cache_append_mock, patch( "app.services.signal_capture.AdaptiveRAGService.ingest_feishu_message_async", AsyncMock(), ), ): qa_result = await capture_service.capture_from_feishu( group_chat_id="oc_complex_case", message_content=hard_question, sender="老板", tenant_id=1, proposer_open_id="ou_boss", captured_at=datetime(2026, 3, 9, 9, 0, 0), feishu_message_id="om-qa-hard-1", chat_type="group", ) async def create_project_side_effect(*, input_text: str, creator_id: int, tenant_id: int) -> dict: project = Project( tenant_id=int(tenant_id), name="华东医保控费与器械国产替代观察台", title="华东医保控费与器械国产替代观察台", description="聚焦华东 DRG-DIP、器械带量采购和国产替代节奏。", owner_id=int(creator_id), status="active", domain_id=self.domain_id, assigned_role="ai_engineer", 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(creator_id), title="华东医保观察项目宪章", content={"success_criteria": ["10 天内交付策略 memo、政策雷达和竞品跟踪框架"]}, version=1, status="active", ) db.add(charter) await db.flush() bootstrap_signal = Signal( tenant_id=1, source="PMFlow", title="医保控费与器械国产替代立项信号", content=input_text, summary="立项信号", signal_type="新需求", priority="high", impact_score=0.85, matched_project_id=int(project.id), reasoning=json.dumps(["来自高强度情景立项指令"], ensure_ascii=False), confidence=0.92, recommended_action="enter_only", processed=True, tasks_generated=json.dumps([], ensure_ascii=False), status="已处理", ) db.add(bootstrap_signal) await db.flush() db.add( ProjectSignalLink( tenant_id=1, project_id=int(project.id), signal_id=int(bootstrap_signal.id), link_type="related", relevance_score=1.0, ) ) db.add( Budget( tenant_id=1, budget_type="project", project_id=int(project.id), period="monthly", budget_amount_usd=3200.0, alert_threshold_percent=80.0, hard_limit_enabled=True, current_usage_usd=240.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(creator_id), project_id=int(project.id))) task_one = Task( tenant_id=1, truth_node_id=int(charter.id), project_id=int(project.id), assignee_id=None, ai_recommended_assignees=None, title="搭建政策抓取与公告解析管道", description="每天跟踪江苏/浙江/安徽 医保支付改革、招采公告与价格体系拐点。", required_skill_tag_ids=[self.policy_skill_id, self.web_skill_id], status="todo", priority="high", estimated_hours=18.0, actual_hours=0.0, quality_score=None, due_date=datetime(2026, 3, 19), completed_at=None, ) task_two = Task( tenant_id=1, truth_node_id=int(charter.id), project_id=int(project.id), assignee_id=None, ai_recommended_assignees=None, title="沉淀器械中标结构知识图谱", description="把区域政策、招采公告和竞品动作串成结构化图谱。", required_skill_tag_ids=[self.graph_skill_id], status="todo", priority="medium", estimated_hours=12.0, actual_hours=0.0, quality_score=None, due_date=datetime(2026, 3, 20), completed_at=None, ) db.add_all([task_one, task_two]) await db.flush() return { "project": {"id": int(project.id), "title": project.title, "name": project.name}, "signal": {"id": int(bootstrap_signal.id), "title": bootstrap_signal.title}, "tasks": [ {"id": int(task_one.id), "title": task_one.title}, {"id": int(task_two.id), "title": task_two.title}, ], } with patch( "app.services.ai_pmo_kernel_service.AIStewardService.create_project_from_natural_language", AsyncMock(side_effect=create_project_side_effect), ): project_result = await kernel.process_ingress( source_system="feishu", source_message_id="om-project-hard-1", message_text=( "不要复用现在这个宏观研究盘子,单独立一个项目,名字就叫" "“华东医保控费与器械国产替代观察台”,目标不是写周报," "而是 10 天内拿出策略 memo、政策雷达和竞品跟踪框架。" ), sender="老板", channel_ref="oc_complex_case", source_sender_open_id="ou_boss", ) new_project_id = int(project_result["created_project_id"]) signal_result = await kernel.process_ingress( source_system="feishu", source_message_id="om-signal-hard-1", message_text=( f"给项目ID: {new_project_id} 新增一条信号:持续盯江苏、浙江、安徽 2026 年器械带量采购、" "DRG-DIP 支付改革和国产替代中标结构,只要价格体系或准入口径出现拐点就预警," "不要只抓新闻标题。" ), sender="老板", channel_ref="oc_complex_case", source_sender_open_id="ou_boss", project_hint_id=new_project_id, ) source_result = await kernel.process_ingress( source_system="feishu", source_message_id="om-source-hard-1", message_text=( f"给项目ID: {new_project_id} 接一个复合信号源,不是简单 RSS:每天自动抓医保支付改革、" "器械招采公告、上市公司电话会和 GitHub 开源竞品动态,优先走自动接入。" ), sender="老板", channel_ref="oc_complex_case", source_sender_open_id="ou_boss", project_hint_id=new_project_id, ) db.add_all( [ UserProject(tenant_id=1, user_id=int(strategy_user.id), project_id=new_project_id), UserProject(tenant_id=1, user_id=int(crawler_user.id), project_id=new_project_id), UserProject(tenant_id=1, user_id=int(graph_user.id), project_id=new_project_id), ] ) await db.flush() with patch( "app.services.ai_pmo_kernel_service.SignalSourceDomainWorkflow.run", AsyncMock( return_value={ "status": "completed", "registry_id": 9, "registry_status": "active", "onboarding_status": "completed", "openclaw_status": "completed", "typed_actions": [{"operation_code": "task.create", "status": "applied"}], "material_action_count": 1, "worker_executions": [{"used_fallback": False}], "pending_workers": [], "next_step": "继续自动执行后续任务", "fallback_used": False, "raw_result": {}, } ), ) as onboarding_run_mock: resume_result = await kernel.process_ingress( source_system="ai_pmo_ui", source_message_id="resume-work-item-hard-1", message_text=f"继续处理 work_item #{int(source_result['work_item_id'])}", sender="老板", channel_ref="ai_pmo_ui", project_hint_id=new_project_id, ) target_work_item = ( await db.execute( select(WorkItem).where( and_( WorkItem.tenant_id == 1, WorkItem.id == int(source_result["work_item_id"]), ) ) ) ).scalar_one() new_project_tasks = ( await db.execute( select(Task) .where(and_(Task.tenant_id == 1, Task.project_id == new_project_id)) .order_by(Task.id.asc()) ) ).scalars().all() task_policy_stream = next(task for task in new_project_tasks if "抓取" in task.title) task_graph = next(task for task in new_project_tasks if "知识图谱" in task.title) recommendation_result = await recommend_personnel( db=db, tenant_id=1, required_skills=["policy-analysis", "web-crawling"], task_description=task_policy_stream.description, task_id=str(task_policy_stream.id), limit=5, ) with patch( "app.services.workload_service.llm_service.generate", AsyncMock( return_value=json.dumps( { "proposals": [ { "task_id": int(task_policy_stream.id), "new_assignee_id": int(crawler_user.id), "reason": "crawler engineer has exact skill mix and lower load", }, { "task_id": int(task_graph.id), "new_assignee_id": int(graph_user.id), "reason": "graph engineer owns ontology and extraction pipeline", }, ] }, ensure_ascii=False, ) ), ): optimize_preview = await workload_service.build_ai_optimize_preview( db, tenant_id=1, project_id=new_project_id, max_moves=5, unassigned_only=True, ) optimize_result = await workload_service.apply_ai_optimize_plan( db, tenant_id=1, proposals=optimize_preview, ) board_rows = await workload_service.get_team_workload_board(db, tenant_id=1, project_id=new_project_id) cadence_service = AIPMOCadenceService(db, tenant_id=1) program_view = await cadence_service.build_program_view() self.assertEqual(qa_result["status"], "answered") self.assertIn("blocked task", qa_result["direct_reply_text"]) cache_append_mock.assert_awaited_once() self.assertEqual(cache_append_mock.await_args.kwargs["question"], hard_question) self.assertEqual(project_result["status"], "captured") self.assertIsNotNone(project_result["created_project_id"]) self.assertIn("已立项", project_result["direct_reply_text"]) self.assertEqual(signal_result["status"], "captured") self.assertEqual(signal_result["linked_project_ids"], [new_project_id]) self.assertIsNotNone(signal_result["signal_id"]) self.assertEqual(source_result["status"], "captured") self.assertIn("技能缺口", source_result["direct_reply_text"]) self.assertFalse(bool(source_result["review_summary"]["verified"])) self.assertEqual(onboarding_run_mock.await_count, 1) self.assertEqual(resume_result["status"], "answered") self.assertIn("已继续处理", resume_result["direct_reply_text"]) self.assertEqual(target_work_item.state, "succeeded") self.assertEqual(target_work_item.execution_result["signal_source_workflow"]["status"], "completed") self.assertGreaterEqual(recommendation_result["total_count"], 1) self.assertEqual(recommendation_result["recommendations"][0]["user_name"], "周湛") self.assertGreaterEqual(len(optimize_preview), 2) self.assertEqual(optimize_result["applied_count"], 2) task_assignee_map = {int(task.id): int(task.assignee_id or 0) for task in new_project_tasks} self.assertEqual(task_assignee_map[int(task_policy_stream.id)], int(crawler_user.id)) self.assertEqual(task_assignee_map[int(task_graph.id)], int(graph_user.id)) board_by_name = {str(row["user_name"]): row for row in board_rows} self.assertIn("周湛", board_by_name) self.assertIn("顾言", board_by_name) self.assertGreater(float(board_by_name["周湛"]["current_assigned_hours"]), 8.0) self.assertGreaterEqual(float(board_by_name["顾言"]["current_assigned_hours"]), 9.5) self.assertGreaterEqual(program_view["summary"]["program_count"], 1) self.assertTrue( any(new_project_id in program["project_ids"] for program in program_view["programs"]) )