"""Execution capability snapshot for solo-first, team-ready PMFlow surfaces.""" from __future__ import annotations from collections import defaultdict from typing import Any, Optional from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession from ..models import DevWorkspaceBinding, SkillTag, User, UserProject, UserSkillBinding, UserWorkload from ..schemas.pmflow_os import ( PMFlowExecutionCapabilityMode, PMFlowExecutionCapabilitySnapshot, PMFlowExecutionModeCapability, PMFlowPersonalExecutionReadiness, PMFlowTeamCapacitySignal, PMFlowTeamContextSignal, PMFlowTeamExecutionSignal, PMFlowTeamToolSignal, PMFlowToolReadinessItem, ) from .adaptive_capacity_service import AdaptiveCapacityService from .pmflow_os_projection_service import PMFlowOSProjectionService class TeamExecutionCapabilityService: """Builds read-only execution capability signals from existing PMFlow data.""" def __init__(self, db: AsyncSession, *, tenant_id: int) -> None: self.db = db self.tenant_id = int(tenant_id) async def get_snapshot(self, *, project_id: int, current_user_id: int) -> PMFlowExecutionCapabilitySnapshot: projection = PMFlowOSProjectionService(self.db, tenant_id=self.tenant_id) truth_state = await projection.get_truth_state(project_id=project_id, current_user_id=current_user_id) execution_summary = await projection.get_execution_summary(project_id=project_id, current_user_id=current_user_id) members = await self._load_project_members(project_id=project_id) member_ids = [int(user.id) for user in members] skill_map = await self._load_skill_map(member_ids=member_ids) workload_map = await self._load_workload_map(member_ids=member_ids) workspace_map = await self._load_workspace_map(project_id=project_id, member_ids=member_ids) pending_reviews_by_user = self._pending_review_counts_by_user(truth_state.reviewerRouting.items) active_execution_by_user = self._active_execution_counts_by_user(execution_summary.activeDevTasks) warnings = list(execution_summary.warnings) if not members: warnings.append("team_members_empty: 当前项目没有可用于能力快照的成员归属。") if truth_state.reviewerRouting.summary.missingReviewerCount > 0: warnings.append("reviewer_missing: 存在待审 ChangeSet 缺少 reviewer/owner。") if not execution_summary.repoBindings: warnings.append("repo_missing: 未接入 repo,AI 执行包无法落到真实代码上下文。") if not execution_summary.promptPackage: warnings.append("prompt_package_missing: 未生成执行 Prompt Package。") if not execution_summary.recentProgressEvents: warnings.append("execution_evidence_missing: 缺少 branch/commit/PR/test 等执行证据。") personal_readiness = self._personal_readiness( project_id=project_id, current_user_id=current_user_id, truth_state=truth_state, execution_summary=execution_summary, workspace_map=workspace_map, ) adaptive_capacity = await AdaptiveCapacityService(self.db, tenant_id=self.tenant_id).get_plan( project_id=project_id, current_user_id=current_user_id, ) team_signals = [ self._member_signal( user=user, project_id=project_id, skills=skill_map.get(int(user.id), []), workload_ratio=workload_map.get(int(user.id), 0.0), workspace_tools=workspace_map.get(int(user.id), []), pending_review_count=pending_reviews_by_user.get(int(user.id), 0), active_execution_count=active_execution_by_user.get(int(user.id), 0), evidence_count=len(truth_state.evidence), ) for user in members ] return PMFlowExecutionCapabilitySnapshot( projectId=str(project_id), executionModes=self._execution_modes( project_id=project_id, has_repo=bool(execution_summary.repoBindings), has_prompt=bool(execution_summary.promptPackage), has_progress=bool(execution_summary.recentProgressEvents), missing_reviewer_count=truth_state.reviewerRouting.summary.missingReviewerCount, pending_review_count=truth_state.reviewerRouting.summary.totalPendingReview, ), personalReadiness=personal_readiness, teamSignals=team_signals, adaptiveCapacitySummary=adaptive_capacity, warnings=warnings[:12], ) async def _load_project_members(self, *, project_id: int) -> list[User]: rows = ( await self.db.execute( select(User) .join(UserProject, UserProject.user_id == User.id) .where( and_( User.tenant_id == self.tenant_id, UserProject.tenant_id == self.tenant_id, UserProject.project_id == int(project_id), User.is_active.is_(True), ) ) .order_by(User.id.asc()) ) ).scalars().all() return list(rows) async def _load_skill_map(self, *, member_ids: list[int]) -> dict[int, list[str]]: if not member_ids: return {} rows = ( await self.db.execute( select(UserSkillBinding.user_id, SkillTag.name) .join(SkillTag, SkillTag.id == UserSkillBinding.skill_tag_id) .where( and_( UserSkillBinding.tenant_id == self.tenant_id, SkillTag.tenant_id == self.tenant_id, UserSkillBinding.user_id.in_(member_ids), ) ) .order_by(UserSkillBinding.user_id.asc(), SkillTag.sort_order.asc(), SkillTag.name.asc()) ) ).all() skill_map: dict[int, list[str]] = defaultdict(list) for user_id, skill_name in rows: if skill_name and len(skill_map[int(user_id)]) < 5: skill_map[int(user_id)].append(str(skill_name)) return dict(skill_map) async def _load_workload_map(self, *, member_ids: list[int]) -> dict[int, float]: if not member_ids: return {} rows = ( await self.db.execute( select(UserWorkload.user_id, UserWorkload.workload_ratio).where( and_( UserWorkload.tenant_id == self.tenant_id, UserWorkload.user_id.in_(member_ids), ) ) ) ).all() return {int(user_id): self._safe_float(ratio) for user_id, ratio in rows} async def _load_workspace_map(self, *, project_id: int, member_ids: list[int]) -> dict[int, list[str]]: if not member_ids: return {} rows = ( await self.db.execute( select(DevWorkspaceBinding.user_id, DevWorkspaceBinding.editor_type).where( and_( DevWorkspaceBinding.tenant_id == self.tenant_id, DevWorkspaceBinding.project_id == int(project_id), DevWorkspaceBinding.user_id.in_(member_ids), DevWorkspaceBinding.status == "active", ) ) ) ).all() workspace_map: dict[int, list[str]] = defaultdict(list) for user_id, editor_type in rows: label = self._tool_label(str(editor_type or "local_bridge")) if label not in workspace_map[int(user_id)]: workspace_map[int(user_id)].append(label) return dict(workspace_map) def _personal_readiness( self, *, project_id: int, current_user_id: int, truth_state: Any, execution_summary: Any, workspace_map: dict[int, list[str]], ) -> PMFlowPersonalExecutionReadiness: pending_confirmations = len( [item for item in truth_state.changeItems if self._read_field(item, "status") == "pending_review"] ) pending_reviews = len( [ item for item in truth_state.reviewerRouting.items if item.suggestedReviewer and str(item.suggestedReviewer.userId or "") == str(current_user_id) ] ) waiting_acceptance = len( [ event for event in execution_summary.recentProgressEvents if str(event.get("event_type") or "") in {"pr_opened", "test_passed", "checks_passed", "handoff_consumed"} ] ) personal_tools = workspace_map.get(int(current_user_id), []) tool_readiness = [ PMFlowToolReadinessItem( key="repo", label="Repo 连接", status="ready" if execution_summary.repoBindings else "blocked", summary="已接入代码仓库。" if execution_summary.repoBindings else "未接入 repo,AI 执行包缺少真实代码落点。", href=f"/execution-bridge?projectId={project_id}", ), PMFlowToolReadinessItem( key="prompt_package", label="执行 Prompt Package", status="ready" if execution_summary.promptPackage else "partial", summary="已有可交给 Cursor / VS Code / Codex 的执行包。" if execution_summary.promptPackage else "尚未生成执行包,需要先从任务进入 Execution Bridge。", href=f"/execution-bridge?projectId={project_id}", ), PMFlowToolReadinessItem( key="local_workspace", label="本地工具连接", status="ready" if personal_tools else "unknown", summary=f"已连接:{' / '.join(personal_tools)}。" if personal_tools else "未发现当前用户的活跃 Cursor / VS Code / Codex 工作区连接。", href=f"/execution-bridge?projectId={project_id}", ), PMFlowToolReadinessItem( key="execution_evidence", label="执行证据回写", status="ready" if execution_summary.recentProgressEvents else "partial", summary="已有 branch / commit / PR / test 等回写证据。" if execution_summary.recentProgressEvents else "尚未回写执行证据,完成 IDE/Agent 操作后需要同步进度。", href=f"/execution-bridge?projectId={project_id}", ), ] blockers = [ item.summary for item in tool_readiness if item.status == "blocked" or (item.key in {"prompt_package", "execution_evidence"} and item.status != "ready") ][:4] if pending_reviews > 0: recommended = "先处理分配给你的审查项,再继续推进 AI 执行。" target = f"/change-review?projectId={project_id}" elif pending_confirmations > 0: recommended = "先确认 AI 生成的 ChangeSet,避免执行包基于未确认范围继续扩散。" target = f"/change-review?projectId={project_id}" elif blockers: recommended = "先补齐执行桥的 repo、执行包或回写证据。" target = f"/execution-bridge?projectId={project_id}" else: recommended = "可以继续生成或验收 AI 执行包。" target = f"/execution-bridge?projectId={project_id}" status = "blocked" if any(item.status == "blocked" for item in tool_readiness) else "partial" if blockers or pending_confirmations else "ready" return PMFlowPersonalExecutionReadiness( status=status, toolReadiness=tool_readiness, waitingForUser={ "pendingConfirmations": pending_confirmations, "pendingReviews": pending_reviews, "waitingAcceptance": waiting_acceptance, }, blockers=blockers, recommendedNextStep=recommended, targetHref=target, ) def _member_signal( self, *, user: User, project_id: int, skills: list[str], workload_ratio: float, workspace_tools: list[str], pending_review_count: int, active_execution_count: int, evidence_count: int, ) -> PMFlowTeamExecutionSignal: role = str(user.role or "MEMBER") capacity_status = "blocked" if workload_ratio >= 100 else "partial" if workload_ratio >= 70 or pending_review_count >= 3 else "ready" capacity_label = "过载" if capacity_status == "blocked" else "偏紧" if capacity_status == "partial" else "可承接" context_status = "partial" if skills or evidence_count else "unknown" tool_status = "ready" if workspace_tools else "unknown" warnings: list[str] = [] if not workspace_tools: warnings.append("未发现活跃 IDE / Agent 工具连接。") if not skills: warnings.append("缺少能力参考标签;只能依据角色、审查和执行证据判断。") if capacity_status == "blocked": warnings.append("容量过载,不建议继续追加高风险执行任务。") return PMFlowTeamExecutionSignal( userId=str(user.id), displayName=user.name or user.username, role=role, judgementResponsibilities=self._judgement_responsibilities(role), reviewResponsibilities=self._review_responsibilities(role), capacity=PMFlowTeamCapacitySignal( status=capacity_status, label=capacity_label, workloadRatio=round(workload_ratio, 2), pendingReviewCount=pending_review_count, activeExecutionCount=active_execution_count, ), context=PMFlowTeamContextSignal( status=context_status, label="有项目证据或能力参考" if context_status == "partial" else "证据不足", evidenceCount=evidence_count, recentContributionCount=pending_review_count + active_execution_count, ), toolStatus=PMFlowTeamToolSignal( status=tool_status, label=" / ".join(workspace_tools) if workspace_tools else "未接入", connectedTools=workspace_tools, ), referenceSkills=skills, recommendedModes=self._recommended_modes(role=role, capacity_status=capacity_status, has_tools=bool(workspace_tools)), warnings=warnings, ) @staticmethod def _execution_modes( *, project_id: int, has_repo: bool, has_prompt: bool, has_progress: bool, missing_reviewer_count: int, pending_review_count: int, ) -> list[PMFlowExecutionModeCapability]: return [ PMFlowExecutionModeCapability( mode="ai_direct", label="AI 直接生成执行包", status="ready" if has_repo and has_prompt else "partial" if has_repo else "blocked", reason="适合低风险、边界清晰的开发动作;必须有 repo 和执行包。", targetHref=f"/execution-bridge?projectId={project_id}", ), PMFlowExecutionModeCapability( mode="ai_with_user_acceptance", label="AI 执行 + 用户验收", status="ready" if has_repo else "blocked", reason="适合非技术用户推进 UI、文案、轻量功能,用户负责验收结果。", targetHref=f"/execution-bridge?projectId={project_id}", ), PMFlowExecutionModeCapability( mode="ai_with_technical_review", label="AI 执行 + 技术审查", status="blocked" if missing_reviewer_count > 0 else "ready" if pending_review_count > 0 or has_repo else "partial", reason="涉及权限、数据、安全、迁移或发布门禁时必须进入审查链。", targetHref=f"/team-layer?projectId={project_id}", ), PMFlowExecutionModeCapability( mode="needs_human_judgement", label="需要人类先补判断", status="ready" if pending_review_count > 0 else "partial", reason="目标、范围、风险或商业取舍未确认时,先回到 Change Review。", targetHref=f"/change-review?projectId={project_id}", ), PMFlowExecutionModeCapability( mode="blocked", label="当前不可执行", status="ready" if not has_repo or not has_prompt or not has_progress or missing_reviewer_count > 0 else "unknown", reason="缺 repo、执行包、回写证据或 reviewer 时必须显式阻塞,不冒充分配成功。", targetHref=f"/execution-bridge?projectId={project_id}", ), ] @staticmethod def _pending_review_counts_by_user(items: list[Any]) -> dict[int, int]: counts: dict[int, int] = defaultdict(int) for item in items: reviewer = getattr(item, "suggestedReviewer", None) user_id = getattr(reviewer, "userId", None) if reviewer else None if user_id is not None: counts[int(user_id)] += 1 return dict(counts) @staticmethod def _active_execution_counts_by_user(tasks: list[dict[str, Any]]) -> dict[int, int]: counts: dict[int, int] = defaultdict(int) for task in tasks: assignee = task.get("assignee") if isinstance(task, dict) else None if isinstance(assignee, dict) and assignee.get("id") is not None: counts[int(assignee["id"])] += 1 return dict(counts) @staticmethod def _judgement_responsibilities(role: str) -> list[str]: normalized = role.upper() if normalized == "OWNER": return ["目标取舍", "范围确认", "发布拍板", "高风险变更最终责任"] if normalized == "REVIEWER": return ["技术/产品审查", "风险判断", "发布门禁建议"] return ["结果验收", "上下文补充", "低风险变更确认"] @staticmethod def _review_responsibilities(role: str) -> list[str]: normalized = role.upper() if normalized == "OWNER": return ["高风险 ChangeSet", "范围/发布/商业取舍"] if normalized == "REVIEWER": return ["实现风险", "架构/测试/权限审查"] return ["本人相关任务验收"] @staticmethod def _recommended_modes(*, role: str, capacity_status: str, has_tools: bool) -> list[PMFlowExecutionCapabilityMode]: if capacity_status == "blocked": return ["needs_human_judgement"] normalized = role.upper() if normalized in {"OWNER", "REVIEWER"}: return ["ai_with_technical_review", "needs_human_judgement"] if has_tools: return ["ai_with_user_acceptance", "ai_direct"] return ["ai_with_user_acceptance"] @staticmethod def _read_field(item: Any, field: str) -> Any: if isinstance(item, dict): return item.get(field) return getattr(item, field, None) @staticmethod def _tool_label(editor_type: str) -> str: normalized = editor_type.lower() if "cursor" in normalized: return "Cursor" if "codex" in normalized: return "Codex" if "vscode" in normalized or "vs_code" in normalized: return "VS Code" return editor_type or "本地桥" @staticmethod def _safe_float(value: Any) -> float: try: return float(value or 0.0) except (TypeError, ValueError): return 0.0