442 lines
20 KiB
Python
442 lines
20 KiB
Python
"""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
|