"""Adaptive human capacity planning for PMFlow Team Layer and Space.""" from __future__ import annotations from dataclasses import dataclass from datetime import date, datetime, time, timedelta from typing import Any, Optional from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession from ..models import DevWorkspaceBinding, HumanCapacityOverride, Project, User, UserProject, UserWorkload from ..schemas.pmflow_os import ( PMFlowCapacityFit, PMFlowCapacityOverridePayload, PMFlowCapacityOverrideRecord, PMFlowCapacityOverrideSimulation, PMFlowDailyCapacityPlan, PMFlowHumanTouchEstimate, PMFlowHumanWorkType, PMFlowMemberCapacityPlan, PMFlowWorkTypeDistribution, PMFlowWorkTypeMinutes, ) from ..utils.timezone import now_cn_naive from .pmflow_os_projection_service import PMFlowOSProjectionService WORK_TYPES: tuple[PMFlowHumanWorkType, ...] = ("focus", "review", "acceptance", "coordination") @dataclass class _MemberContext: user: User tools: list[str] workload_ratio: float class AdaptiveCapacityService: """Computes daily capacity plans from current task/review/execution facts plus overrides.""" def __init__(self, db: AsyncSession, *, tenant_id: int) -> None: self.db = db self.tenant_id = int(tenant_id) async def get_plan( self, *, project_id: int, current_user_id: int, plan_date: Optional[date] = None, extra_override: Optional[PMFlowCapacityOverridePayload] = None, ) -> PMFlowDailyCapacityPlan: plan_date = plan_date or now_cn_naive().date() 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_members(project_id=project_id) project = await self._load_project(project_id=project_id) member_ids = [int(ctx.user.id) for ctx in members] overrides = await self._load_active_overrides(project_id=project_id, plan_date=plan_date, member_ids=member_ids) if extra_override is not None: overrides[int(extra_override.userId)] = self._payload_to_record(extra_override, plan_date=plan_date, created_by=current_user_id) touches = self._build_touches( project_id=project_id, owner_id=int(project.owner_id or current_user_id) if project else current_user_id, member_ids=member_ids, truth_state=truth_state, execution_summary=execution_summary, ) team_minutes = self._sum_minutes(touches) team_mix = self._distribution(team_minutes) plans = [ self._member_plan( ctx=ctx, project_id=project_id, team_mix=team_mix, touches=[touch for touch in touches if str(touch.assignedUserId or "") == str(ctx.user.id)], override=overrides.get(int(ctx.user.id)), ) for ctx in members ] team_effective = sum(item.effectiveMinutes for item in plans) warnings = list(execution_summary.warnings[:6]) if not members: warnings.append("capacity_members_empty: 当前项目没有可计算人类容量的成员。") if not touches: warnings.append("capacity_touches_empty: 当前没有真实人工触点,容量计划保持空态。") recommendations = self._recommendations(touches=touches, plans=plans) return PMFlowDailyCapacityPlan( projectId=str(project_id), date=plan_date.isoformat(), generatedAt=now_cn_naive().isoformat(), teamDemandMix=team_mix, teamEffectiveMinutes=team_effective, teamPlannedTouchMinutes=team_minutes, memberPlans=plans, humanTouches=touches[:80], warnings=warnings[:12], recommendations=recommendations[:8], ) async def simulate_override( self, *, project_id: int, current_user_id: int, payload: PMFlowCapacityOverridePayload, ) -> PMFlowCapacityOverrideSimulation: plan_date = self._parse_date(payload.date) before = await self.get_plan(project_id=project_id, current_user_id=current_user_id, plan_date=plan_date) after = await self.get_plan( project_id=project_id, current_user_id=current_user_id, plan_date=plan_date, extra_override=payload, ) before_member = self._find_member_plan(before, payload.userId) after_member = self._find_member_plan(after, payload.userId) impact = [ f"有效容量 {before_member.effectiveMinutes}min -> {after_member.effectiveMinutes}min", f"剩余可计划容量 {before_member.remainingMinutes}min -> {after_member.remainingMinutes}min", f"状态 {before_member.capacityStatus} -> {after_member.capacityStatus}", ] if payload.addedMinutes: impact.append("加班只增加时间预算,不消除疲劳与高风险判断密度。") return PMFlowCapacityOverrideSimulation(beforePlan=before_member, afterPlan=after_member, impactSummary=impact, warnings=after.warnings[:6]) async def apply_override( self, *, project_id: int, current_user_id: int, payload: PMFlowCapacityOverridePayload, ) -> PMFlowCapacityOverrideRecord: plan_date = self._parse_date(payload.date) expires_at = self._parse_expires(payload.expiresAt, plan_date=plan_date) record = HumanCapacityOverride( tenant_id=self.tenant_id, project_id=int(project_id), user_id=int(payload.userId), date=plan_date, override_type=payload.overrideType, added_minutes=int(payload.addedMinutes or 0), overtime_type=payload.overtimeType, max_planned_utilization=payload.maxPlannedUtilization, work_type_bias=dict(payload.workTypeBias or {}), high_risk_limit=payload.highRiskLimit, reason=payload.reason.strip(), expires_at=expires_at, created_by=int(current_user_id), ) self.db.add(record) await self.db.commit() await self.db.refresh(record) return self._override_record(record) async def list_overrides(self, *, project_id: int, plan_date: Optional[date] = None) -> list[PMFlowCapacityOverrideRecord]: plan_date = plan_date or now_cn_naive().date() rows = ( await self.db.execute( select(HumanCapacityOverride) .where( and_( HumanCapacityOverride.tenant_id == self.tenant_id, HumanCapacityOverride.project_id == int(project_id), HumanCapacityOverride.date == plan_date, ) ) .order_by(HumanCapacityOverride.created_at.desc(), HumanCapacityOverride.id.desc()) ) ).scalars().all() return [self._override_record(row) for row in rows] async def _load_project(self, *, project_id: int) -> Optional[Project]: return ( await self.db.execute( select(Project).where(and_(Project.tenant_id == self.tenant_id, Project.id == int(project_id))) ) ).scalar_one_or_none() async def _load_members(self, *, project_id: int) -> list[_MemberContext]: 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() member_ids = [int(user.id) for user in rows] tool_map = await self._load_tools(project_id=project_id, member_ids=member_ids) workload_map = await self._load_workloads(member_ids=member_ids) return [ _MemberContext(user=user, tools=tool_map.get(int(user.id), []), workload_ratio=workload_map.get(int(user.id), 0.0)) for user in rows ] async def _load_tools(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() labels = {"cursor": "Cursor", "vscode": "VS Code", "codex": "Codex"} tool_map: dict[int, list[str]] = {} for user_id, editor_type in rows: tool_map.setdefault(int(user_id), []) label = labels.get(str(editor_type or "").lower(), str(editor_type or "Local Bridge")) if label not in tool_map[int(user_id)]: tool_map[int(user_id)].append(label) return tool_map async def _load_workloads(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): float(ratio or 0.0) for user_id, ratio in rows} async def _load_active_overrides( self, *, project_id: int, plan_date: date, member_ids: list[int], ) -> dict[int, PMFlowCapacityOverrideRecord]: if not member_ids: return {} rows = ( await self.db.execute( select(HumanCapacityOverride) .where( and_( HumanCapacityOverride.tenant_id == self.tenant_id, HumanCapacityOverride.project_id == int(project_id), HumanCapacityOverride.user_id.in_(member_ids), HumanCapacityOverride.date == plan_date, HumanCapacityOverride.expires_at >= now_cn_naive(), ) ) .order_by(HumanCapacityOverride.created_at.desc(), HumanCapacityOverride.id.desc()) ) ).scalars().all() overrides: dict[int, PMFlowCapacityOverrideRecord] = {} for row in rows: overrides.setdefault(int(row.user_id), self._override_record(row)) return overrides def _build_touches(self, *, project_id: int, owner_id: int, member_ids: list[int], truth_state: Any, execution_summary: Any) -> list[PMFlowHumanTouchEstimate]: touches: list[PMFlowHumanTouchEstimate] = [] fallback_user = str(owner_id if owner_id in member_ids else (member_ids[0] if member_ids else owner_id)) for item in getattr(truth_state, "changeItems", []) or []: if self._get(item, "status") != "pending_review": continue risk = self._risk(self._get(item, "riskLevel") or self._get(item, "risk_level")) minutes = 60 if risk == "high" else 35 if risk == "medium" else 20 touches.append( PMFlowHumanTouchEstimate( sourceType="changeset_confirmation", sourceId=str(self._get(item, "id") or f"change-{len(touches)}"), targetHref=f"/change-review?projectId={project_id}&changeSetId={self._get(item, 'changeSetId') or ''}", workType="focus", estimatedMinutes=minutes, riskLevel=risk, urgency="urgent" if risk == "high" else "normal", bindingType="hard_bound", assignedUserId=fallback_user, roleBinding="OWNER", contextReadiness="partial", aiReductionPotentialMinutes=25 if risk != "low" else 10, title=str(self._get(item, "title") or "待确认 ChangeSet"), summary=str(self._get(item, "summary") or "需要人类确认 AI 提取结果。"), ) ) for routing in getattr(getattr(truth_state, "reviewerRouting", None), "items", []) or []: reviewer = getattr(routing, "suggestedReviewer", None) assigned = str(getattr(reviewer, "userId", "") or fallback_user) risk = self._risk(getattr(routing, "riskLevel", "low")) touches.append( PMFlowHumanTouchEstimate( sourceType="review", sourceId=str(getattr(routing, "changeSetId", "")), targetHref=str(getattr(routing, "targetUrl", "") or f"/change-review?projectId={project_id}"), workType="review", estimatedMinutes=75 if risk == "high" else 45 if risk == "medium" else 30, riskLevel=risk, urgency=str(getattr(routing, "priority", "normal")), bindingType="role_bound", assignedUserId=assigned, roleBinding=str(getattr(routing, "fallbackReviewerRole", None) or "REVIEWER"), contextReadiness="partial", aiReductionPotentialMinutes=30 if risk == "high" else 15, title=str(getattr(routing, "changeSetTitle", "待审 ChangeSet")), summary=str(getattr(routing, "reviewReason", "需要人工审查 ChangeSet。")), ) ) for idx, event in enumerate(execution_summary.recentProgressEvents or []): event_type = str(event.get("event_type") or "") if event_type not in {"pr_opened", "test_passed", "checks_passed", "handoff_consumed", "failed", "blocked"}: continue work_type: PMFlowHumanWorkType = "coordination" if event_type in {"failed", "blocked"} else "acceptance" touches.append( PMFlowHumanTouchEstimate( sourceType="ai_failure_recovery" if work_type == "coordination" else "acceptance", sourceId=str(event.get("id") or f"event-{idx}"), targetHref=f"/execution-bridge?projectId={project_id}", workType=work_type, estimatedMinutes=45 if work_type == "coordination" else 30, riskLevel="medium" if work_type == "coordination" else "low", urgency="elevated" if work_type == "coordination" else "normal", bindingType="ai_shiftable", assignedUserId=str(event.get("user_id") or fallback_user), roleBinding="BUILDER", contextReadiness="partial", aiReductionPotentialMinutes=20, title=str(event.get("summary") or "AI 执行结果待处理"), summary="AI 执行结果需要验收或阻塞恢复。", ) ) if not execution_summary.repoBindings: touches.append(self._coordination_touch(project_id, fallback_user, "缺少 repo 绑定", "未接入 repo,AI 执行包缺少真实代码上下文。", 30)) if not execution_summary.promptPackage: touches.append(self._coordination_touch(project_id, fallback_user, "缺少执行包", "尚未生成可交给 Cursor / VS Code / Codex 的执行包。", 20)) return touches def _coordination_touch(self, project_id: int, assigned_user_id: str, title: str, summary: str, minutes: int) -> PMFlowHumanTouchEstimate: return PMFlowHumanTouchEstimate( sourceType="context_completion", sourceId=title, targetHref=f"/execution-bridge?projectId={project_id}", workType="coordination", estimatedMinutes=minutes, riskLevel="medium", urgency="normal", bindingType="ai_shiftable", assignedUserId=assigned_user_id, roleBinding="BUILDER", contextReadiness="blocked", aiReductionPotentialMinutes=10, title=title, summary=summary, ) def _member_plan( self, *, ctx: _MemberContext, project_id: int, team_mix: PMFlowWorkTypeDistribution, touches: list[PMFlowHumanTouchEstimate], override: Optional[PMFlowCapacityOverrideRecord], ) -> PMFlowMemberCapacityPlan: base_minutes = 480 added = int(override.addedMinutes if override else 0) effective = base_minutes + added max_util = float(override.maxPlannedUtilization if override and override.maxPlannedUtilization else 0.85) planned = self._sum_minutes(touches) current_mix = self._distribution(planned) baseline = self._suggested_baseline(role=str(ctx.user.role or ""), team_mix=team_mix, planned=planned, override=override) utilization = planned.total / max(effective, 1) capacity_status = self._capacity_status(utilization=utilization, override=override, tools=ctx.tools) remaining = round(effective * max_util - planned.total) bottleneck = self._bottleneck(planned) explanations = self._explanations(ctx=ctx, planned=planned, baseline=baseline, override=override, touches=touches) return PMFlowMemberCapacityPlan( userId=str(ctx.user.id), displayName=str(ctx.user.name or ctx.user.username), role=str(ctx.user.role or "MEMBER"), baseAvailableMinutes=base_minutes, calendarBlockedMinutes=0, effectiveMinutes=effective, reservedBufferMinutes=round(effective * max(0.0, 1 - max_util)), maxPlannedUtilization=max_util, suggestedBaseline=baseline, currentMix=current_mix, plannedTouchMinutes=planned, capacityStatus=capacity_status, remainingMinutes=remaining, bottleneckType=bottleneck, fatigueRisk=self._fatigue_risk(planned=planned, effective=effective), riskDensity={ "high": len([touch for touch in touches if touch.riskLevel == "high"]), "medium": len([touch for touch in touches if touch.riskLevel == "medium"]), "total": len(touches), }, canTakeMore=self._capacity_fit(project_id=project_id, planned=planned, effective=effective, max_util=max_util), manualOverride=override, explanations=explanations, ) def _suggested_baseline( self, *, role: str, team_mix: PMFlowWorkTypeDistribution, planned: PMFlowWorkTypeMinutes, override: Optional[PMFlowCapacityOverrideRecord], ) -> PMFlowWorkTypeDistribution: assigned_mix = self._distribution(planned) if planned.total else None base = {key: getattr(team_mix, key) for key in WORK_TYPES} if assigned_mix and planned.total: base = {key: (getattr(assigned_mix, key) * 0.7) + (base[key] * 0.3) for key in WORK_TYPES} elif not any(base.values()): role_token = role.upper() if "OWNER" in role_token: base = {"focus": 0.5, "review": 0.15, "acceptance": 0.2, "coordination": 0.15} elif "REVIEWER" in role_token: base = {"focus": 0.2, "review": 0.5, "acceptance": 0.2, "coordination": 0.1} elif "OPERATOR" in role_token: base = {"focus": 0.15, "review": 0.1, "acceptance": 0.25, "coordination": 0.5} else: base = {"focus": 0.25, "review": 0.2, "acceptance": 0.35, "coordination": 0.2} if override and override.workTypeBias: for key in WORK_TYPES: if key in override.workTypeBias: base[key] = max(0.0, float(override.workTypeBias[key])) return self._normalize_distribution(base) def _capacity_fit(self, *, project_id: int, planned: PMFlowWorkTypeMinutes, effective: int, max_util: float) -> list[PMFlowCapacityFit]: candidates: list[tuple[PMFlowHumanWorkType, str, int]] = [ ("focus", "低/中风险判断", 45), ("review", "普通审查", 45), ("acceptance", "AI 结果验收", 30), ("coordination", "补上下文/工具阻塞", 25), ] result: list[PMFlowCapacityFit] = [] for work_type, label, minutes in candidates: next_total = planned.total + minutes next_type = getattr(planned, work_type) + minutes total_util = next_total / max(effective, 1) type_share = next_type / max(effective, 1) status = "recommended" reason = "加入后仍处于可计划容量内。" if total_util > max_util * 1.1: status, reason = "not_recommended", "加入后超过今日建议计划容量。" elif total_util > max_util or type_share > self._soft_share(work_type): status, reason = "allowed_with_warning", "加入后接近容量或该类型疲劳风险上升。" result.append( PMFlowCapacityFit( workType=work_type, label=label, status=status, estimatedMinutes=minutes, afterTotalUtilization=round(total_util, 3), afterTypeShare=round(type_share, 3), reason=reason, ) ) return result def _recommendations(self, *, touches: list[PMFlowHumanTouchEstimate], plans: list[PMFlowMemberCapacityPlan]) -> list[str]: reduction = sum(max(0, touch.aiReductionPotentialMinutes) for touch in touches) recommendations: list[str] = [] if reduction: recommendations.append(f"建议先让 AI 预处理可转移触点,预计释放 {reduction} 分钟人工判断/审查容量。") overloaded = [plan.displayName for plan in plans if plan.capacityStatus in {"near_full", "overloaded"}] if overloaded: recommendations.append(f"{'、'.join(overloaded[:3])} 接近或超过今日建议容量,新增任务前需要模拟。") if not recommendations: recommendations.append("当前容量计划未发现明显瓶颈,可继续按执行能力图谱推进。") return recommendations def _sum_minutes(self, touches: list[PMFlowHumanTouchEstimate]) -> PMFlowWorkTypeMinutes: totals = {key: 0 for key in WORK_TYPES} for touch in touches: totals[touch.workType] += int(touch.estimatedMinutes or 0) return PMFlowWorkTypeMinutes(**totals, total=sum(totals.values())) def _distribution(self, minutes: PMFlowWorkTypeMinutes) -> PMFlowWorkTypeDistribution: total = max(minutes.total, 1) return PMFlowWorkTypeDistribution(**{key: round(getattr(minutes, key) / total, 3) for key in WORK_TYPES}) def _normalize_distribution(self, values: dict[str, float]) -> PMFlowWorkTypeDistribution: total = sum(max(0.0, float(values.get(key, 0.0))) for key in WORK_TYPES) or 1.0 return PMFlowWorkTypeDistribution(**{key: round(max(0.0, float(values.get(key, 0.0))) / total, 3) for key in WORK_TYPES}) def _capacity_status(self, *, utilization: float, override: Optional[PMFlowCapacityOverrideRecord], tools: list[str]) -> str: if override and override.overrideType == "lock_capacity": return "blocked" if utilization > 1.1: return "overloaded" if override and override.addedMinutes > 0: return "manually_extended" if utilization >= 0.9: return "near_full" if utilization >= 0.7: return "attention" return "normal" def _fatigue_risk(self, *, planned: PMFlowWorkTypeMinutes, effective: int) -> dict[str, str]: risks: dict[str, str] = {} for key in WORK_TYPES: share = getattr(planned, key) / max(effective, 1) if share > self._hard_share(key): risks[key] = "high" elif share > self._soft_share(key): risks[key] = "medium" else: risks[key] = "low" return risks def _soft_share(self, work_type: str) -> float: return {"focus": 0.7, "review": 0.65, "acceptance": 0.75, "coordination": 0.6}.get(work_type, 0.7) def _hard_share(self, work_type: str) -> float: return {"focus": 0.85, "review": 0.8, "acceptance": 0.9, "coordination": 0.75}.get(work_type, 0.85) def _bottleneck(self, minutes: PMFlowWorkTypeMinutes) -> Optional[str]: values = {key: getattr(minutes, key) for key in WORK_TYPES} if not any(values.values()): return None return max(values.items(), key=lambda item: item[1])[0] def _explanations( self, *, ctx: _MemberContext, planned: PMFlowWorkTypeMinutes, baseline: PMFlowWorkTypeDistribution, override: Optional[PMFlowCapacityOverrideRecord], touches: list[PMFlowHumanTouchEstimate], ) -> list[str]: explanations = [ f"今日动态基线来自当前任务池:深度判断 {round(baseline.focus * 100)}%,审查 {round(baseline.review * 100)}%,验收 {round(baseline.acceptance * 100)}%,协调 {round(baseline.coordination * 100)}%。", f"当前已计划人工触点 {planned.total} 分钟,包含 {len(touches)} 个真实触点。", ] if override: explanations.append(f"负责人已手工调整:{override.reason}") if not ctx.tools: explanations.append("未检测到活跃 Cursor / VS Code / Codex 工具连接,工具准备度会影响可承接建议。") return explanations[:4] def _payload_to_record(self, payload: PMFlowCapacityOverridePayload, *, plan_date: date, created_by: int) -> PMFlowCapacityOverrideRecord: return PMFlowCapacityOverrideRecord( id="simulated", userId=str(payload.userId), date=plan_date.isoformat(), overrideType=payload.overrideType, addedMinutes=int(payload.addedMinutes or 0), overtimeType=payload.overtimeType, maxPlannedUtilization=payload.maxPlannedUtilization, workTypeBias=dict(payload.workTypeBias or {}), highRiskLimit=payload.highRiskLimit, reason=payload.reason, expiresAt=self._parse_expires(payload.expiresAt, plan_date=plan_date).isoformat(), createdBy=str(created_by), createdAt=None, ) def _override_record(self, row: HumanCapacityOverride) -> PMFlowCapacityOverrideRecord: return PMFlowCapacityOverrideRecord( id=str(row.id), userId=str(row.user_id), date=row.date.isoformat(), overrideType=row.override_type, addedMinutes=int(row.added_minutes or 0), overtimeType=row.overtime_type, maxPlannedUtilization=row.max_planned_utilization, workTypeBias=dict(row.work_type_bias or {}), highRiskLimit=row.high_risk_limit, reason=row.reason, expiresAt=row.expires_at.isoformat(), createdBy=str(row.created_by), createdAt=row.created_at.isoformat() if row.created_at else None, ) def _find_member_plan(self, plan: PMFlowDailyCapacityPlan, user_id: str) -> PMFlowMemberCapacityPlan: for item in plan.memberPlans: if str(item.userId) == str(user_id): return item raise ValueError("capacity_member_not_found") def _parse_date(self, value: Optional[str]) -> date: if not value: return now_cn_naive().date() return date.fromisoformat(str(value)[:10]) def _parse_expires(self, value: Optional[str], *, plan_date: date) -> datetime: if value: parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00")) return parsed.replace(tzinfo=None) return datetime.combine(plan_date, time(23, 59, 59)) def _risk(self, value: Any) -> str: token = str(value or "low").lower() return token if token in {"low", "medium", "high"} else "low" def _get(self, obj: Any, key: str) -> Any: if isinstance(obj, dict): return obj.get(key) return getattr(obj, key, None)