PMFlow/backend/app/services/team_execution_capability_s...

442 lines
20 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.

"""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