PMFlow/backend/app/services/ai_pmo_cadence_service.py

485 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.

"""AI PMO cadence engine。"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import Budget, Notification, PortfolioSnapshot, Project, ProjectDomain, ProjectSignalLink, Signal, SignalSourceRegistry, Task, UserProject, UserWorkload, WorkItem
from ..utils.timezone import now_cn_naive
def _utc_now_naive() -> datetime:
return now_cn_naive()
class AIPMOCadenceService:
"""日会 / 周会 / 风险例会 / 里程碑检查 / 项目群视角。"""
SUPPORTED_CADENCE_TYPES = {"daily_standup", "weekly_review", "risk_review", "milestone_check"}
def __init__(self, db: AsyncSession, tenant_id: int) -> None:
self.db = db
self.tenant_id = int(tenant_id)
async def generate_snapshot(self, *, cadence_type: str, project_id: Optional[int] = None) -> dict[str, Any]:
normalized_type = str(cadence_type or "").strip().lower()
if normalized_type not in self.SUPPORTED_CADENCE_TYPES:
raise ValueError("unsupported cadence_type")
if normalized_type == "daily_standup":
payload = await self._build_daily_standup(project_id=project_id)
elif normalized_type == "weekly_review":
payload = await self._build_weekly_review(project_id=project_id)
elif normalized_type == "risk_review":
payload = await self._build_risk_review(project_id=project_id)
else:
payload = await self._build_milestone_check(project_id=project_id)
scope_ref = f"project:{int(project_id)}" if project_id is not None else f"tenant:{self.tenant_id}"
row = PortfolioSnapshot(
tenant_id=self.tenant_id,
scope_type=normalized_type[:20],
scope_ref=scope_ref[:64],
summary=payload.get("summary"),
payload=payload,
)
self.db.add(row)
await self.db.flush()
return {
"id": int(row.id),
"scope_type": row.scope_type,
"scope_ref": row.scope_ref,
"summary": row.summary,
"payload": row.payload,
"created_at": row.created_at.isoformat() if row.created_at else None,
}
async def get_latest_snapshot(self, *, cadence_type: str, project_id: Optional[int] = None) -> Optional[dict[str, Any]]:
normalized_type = str(cadence_type or "").strip().lower()
scope_ref = f"project:{int(project_id)}" if project_id is not None else f"tenant:{self.tenant_id}"
row = (
await self.db.execute(
select(PortfolioSnapshot).where(
and_(
PortfolioSnapshot.tenant_id == self.tenant_id,
PortfolioSnapshot.scope_type == normalized_type,
PortfolioSnapshot.scope_ref == scope_ref,
)
).order_by(PortfolioSnapshot.id.desc()).limit(1)
)
).scalar_one_or_none()
if row is None:
return None
return {
"id": int(row.id),
"scope_type": row.scope_type,
"scope_ref": row.scope_ref,
"summary": row.summary,
"payload": row.payload,
"created_at": row.created_at.isoformat() if row.created_at else None,
}
async def build_program_view(self) -> dict[str, Any]:
projects = (
await self.db.execute(
select(Project, ProjectDomain)
.outerjoin(ProjectDomain, and_(ProjectDomain.id == Project.domain_id, ProjectDomain.tenant_id == self.tenant_id))
.where(
and_(
Project.tenant_id == self.tenant_id,
Project.status.in_(["active", "planning", "doing", "todo"]),
)
)
.order_by(Project.id.asc())
)
).all()
domain_groups: dict[str, dict[str, Any]] = {}
for project, domain in projects:
key = str((domain.slug if domain is not None else "unscoped") or "unscoped")
group = domain_groups.setdefault(
key,
{
"program_key": key,
"program_name": str((domain.name if domain is not None else "未分组项目") or "未分组项目"),
"project_count": 0,
"project_ids": [],
"blocked_task_count": 0,
"pending_manual_count": 0,
"budget_usage_ratio_max": 0.0,
},
)
group["project_count"] += 1
group["project_ids"].append(int(project.id))
if not domain_groups:
return {"summary": {"program_count": 0, "active_project_count": 0}, "programs": []}
project_ids = [pid for group in domain_groups.values() for pid in group["project_ids"]]
task_rows = (
await self.db.execute(
select(Task.project_id, func.count(Task.id))
.where(
and_(
Task.tenant_id == self.tenant_id,
Task.project_id.in_(project_ids),
Task.status == "blocked",
)
)
.group_by(Task.project_id)
)
).all()
task_map = {int(project_id): int(count or 0) for project_id, count in task_rows}
work_rows = (
await self.db.execute(
select(WorkItem.project_id, func.count(WorkItem.id))
.where(
and_(
WorkItem.tenant_id == self.tenant_id,
WorkItem.project_id.in_(project_ids),
WorkItem.state == "waiting_manual",
)
)
.group_by(WorkItem.project_id)
)
).all()
work_map = {int(project_id): int(count or 0) for project_id, count in work_rows}
budget_rows = (
await self.db.execute(
select(Budget.project_id, Budget.budget_amount_usd, Budget.current_usage_usd)
.where(
and_(
Budget.tenant_id == self.tenant_id,
Budget.project_id.in_(project_ids),
)
)
)
).all()
budget_ratio_map = {
int(project_id): (float(usage or 0.0) / float(amount or 1.0)) if float(amount or 0.0) > 0 else 0.0
for project_id, amount, usage in budget_rows
if project_id is not None
}
for group in domain_groups.values():
group["blocked_task_count"] = sum(task_map.get(pid, 0) for pid in group["project_ids"])
group["pending_manual_count"] = sum(work_map.get(pid, 0) for pid in group["project_ids"])
ratios = [budget_ratio_map.get(pid, 0.0) for pid in group["project_ids"]]
group["budget_usage_ratio_max"] = round(max(ratios, default=0.0), 4)
programs = sorted(domain_groups.values(), key=lambda item: (item["pending_manual_count"], item["blocked_task_count"], item["project_count"]), reverse=True)
return {
"summary": {
"program_count": len(programs),
"active_project_count": len(project_ids),
"pending_manual_total": sum(item["pending_manual_count"] for item in programs),
"blocked_task_total": sum(item["blocked_task_count"] for item in programs),
},
"programs": programs,
}
async def _build_daily_standup(self, *, project_id: Optional[int]) -> dict[str, Any]:
now = _utc_now_naive()
since = now - timedelta(days=1)
work_items = await self._list_recent_work_items(since=since, project_id=project_id)
blocked_tasks = await self._list_blocked_tasks(project_id=project_id)
recent_signals = await self._list_recent_signals(since=since, project_id=project_id)
pending_manual_count = await self._count_work_items(state="waiting_manual", project_id=project_id)
return {
"summary": {
"cadence_type": "daily_standup",
"recent_work_item_count": len(work_items),
"pending_manual_count": pending_manual_count,
"blocked_task_count": len(blocked_tasks),
"recent_signal_count": len(recent_signals),
},
"work_items": work_items[:10],
"blocked_tasks": blocked_tasks[:10],
"recent_signals": recent_signals[:10],
"focus_actions": self._build_focus_actions(pending_manual_count=pending_manual_count, blocked_task_count=len(blocked_tasks)),
}
async def _build_weekly_review(self, *, project_id: Optional[int]) -> dict[str, Any]:
now = _utc_now_naive()
since = now - timedelta(days=7)
weekly_rows = await self._list_recent_work_items(since=since, project_id=project_id)
budgets = await self._list_budget_hotspots(project_id=project_id)
capacity = await self._list_capacity_hotspots(project_id=project_id)
program_view = await self.build_program_view() if project_id is None else None
by_state: dict[str, int] = {}
for item in weekly_rows:
state = str(item.get("state") or "unknown")
by_state[state] = by_state.get(state, 0) + 1
return {
"summary": {
"cadence_type": "weekly_review",
"work_item_total": len(weekly_rows),
"by_state": by_state,
"budget_hotspot_count": len(budgets),
"capacity_hotspot_count": len(capacity),
},
"work_items": weekly_rows[:20],
"budget_hotspots": budgets[:10],
"capacity_hotspots": capacity[:10],
"program_view": program_view,
}
async def _build_risk_review(self, *, project_id: Optional[int]) -> dict[str, Any]:
blocked_tasks = await self._list_blocked_tasks(project_id=project_id)
signal_sources = await self._list_pending_signal_sources(project_id=project_id)
notifications = await self._list_unread_risk_notifications(project_id=project_id)
return {
"summary": {
"cadence_type": "risk_review",
"blocked_task_count": len(blocked_tasks),
"pending_signal_source_count": len(signal_sources),
"unread_risk_notification_count": len(notifications),
},
"blocked_tasks": blocked_tasks[:20],
"pending_signal_sources": signal_sources[:20],
"risk_notifications": notifications[:20],
}
async def _build_milestone_check(self, *, project_id: Optional[int]) -> dict[str, Any]:
overdue_tasks = await self._list_overdue_tasks(project_id=project_id)
work_items = await self._count_work_items(state="waiting_manual", project_id=project_id)
stmt = select(Project.id, Project.title, Project.status).where(Project.tenant_id == self.tenant_id)
if project_id is not None:
stmt = stmt.where(Project.id == int(project_id))
projects = (await self.db.execute(stmt)).all()
return {
"summary": {
"cadence_type": "milestone_check",
"project_count": len(projects),
"overdue_task_count": len(overdue_tasks),
"pending_manual_count": work_items,
},
"projects": [
{"project_id": int(pid), "title": str(title or ""), "status": str(status or "")}
for pid, title, status in projects
],
"overdue_tasks": overdue_tasks[:20],
}
async def _list_recent_work_items(self, *, since: datetime, project_id: Optional[int]) -> list[dict[str, Any]]:
stmt = select(WorkItem).where(
and_(
WorkItem.tenant_id == self.tenant_id,
WorkItem.created_at >= since,
)
)
if project_id is not None:
stmt = stmt.where(WorkItem.project_id == int(project_id))
rows = (await self.db.execute(stmt.order_by(WorkItem.created_at.desc()).limit(50))).scalars().all()
return [
{
"id": int(row.id),
"state": row.state,
"intent": row.intent,
"project_id": row.project_id,
"signal_id": row.signal_id,
"created_at": row.created_at.isoformat() if row.created_at else None,
}
for row in rows
]
async def _count_work_items(self, *, state: str, project_id: Optional[int]) -> int:
stmt = select(func.count(WorkItem.id)).where(
and_(
WorkItem.tenant_id == self.tenant_id,
WorkItem.state == state,
)
)
if project_id is not None:
stmt = stmt.where(WorkItem.project_id == int(project_id))
return int((await self.db.execute(stmt)).scalar_one() or 0)
async def _list_blocked_tasks(self, *, project_id: Optional[int]) -> list[dict[str, Any]]:
stmt = select(Task).where(
and_(
Task.tenant_id == self.tenant_id,
Task.status == "blocked",
)
)
if project_id is not None:
stmt = stmt.where(Task.project_id == int(project_id))
rows = (await self.db.execute(stmt.order_by(Task.updated_at.desc()).limit(50))).scalars().all()
return [
{
"id": int(row.id),
"project_id": int(row.project_id),
"title": str(row.title or ""),
"priority": str(row.priority or ""),
}
for row in rows
]
async def _list_overdue_tasks(self, *, project_id: Optional[int]) -> list[dict[str, Any]]:
now = _utc_now_naive()
stmt = select(Task).where(
and_(
Task.tenant_id == self.tenant_id,
Task.due_date.is_not(None),
Task.due_date < now,
Task.status.not_in(["done", "completed", "closed"]),
)
)
if project_id is not None:
stmt = stmt.where(Task.project_id == int(project_id))
rows = (await self.db.execute(stmt.order_by(Task.due_date.asc()).limit(50))).scalars().all()
return [
{
"id": int(row.id),
"project_id": int(row.project_id),
"title": str(row.title or ""),
"due_date": row.due_date.isoformat() if row.due_date else None,
}
for row in rows
]
async def _list_recent_signals(self, *, since: datetime, project_id: Optional[int]) -> list[dict[str, Any]]:
stmt = select(Signal).where(
and_(
Signal.tenant_id == self.tenant_id,
Signal.created_at >= since,
)
)
if project_id is not None:
stmt = stmt.where(
or_(
Signal.matched_project_id == int(project_id),
Signal.id.in_(
select(ProjectSignalLink.signal_id).where(
and_(
ProjectSignalLink.tenant_id == self.tenant_id,
ProjectSignalLink.project_id == int(project_id),
)
)
),
)
)
rows = (await self.db.execute(stmt.order_by(Signal.created_at.desc()).limit(50))).scalars().all()
return [
{
"id": int(row.id),
"title": str(row.title or ""),
"status": str(row.status or ""),
"signal_type": str(row.signal_type or ""),
}
for row in rows
]
async def _list_budget_hotspots(self, *, project_id: Optional[int]) -> list[dict[str, Any]]:
stmt = select(Budget).where(Budget.tenant_id == self.tenant_id)
if project_id is not None:
stmt = stmt.where(Budget.project_id == int(project_id))
rows = (await self.db.execute(stmt)).scalars().all()
hotspots = []
for row in rows:
amount = float(row.budget_amount_usd or 0.0)
usage = float(row.current_usage_usd or 0.0)
ratio = (usage / amount) if amount > 0 else 0.0
if ratio >= 0.8:
hotspots.append(
{
"budget_id": int(row.id),
"project_id": row.project_id,
"usage_ratio": round(ratio, 4),
"hard_limit_enabled": bool(row.hard_limit_enabled),
}
)
return sorted(hotspots, key=lambda item: item["usage_ratio"], reverse=True)
async def _list_capacity_hotspots(self, *, project_id: Optional[int]) -> list[dict[str, Any]]:
stmt = select(UserWorkload).where(
and_(
UserWorkload.tenant_id == self.tenant_id,
UserWorkload.workload_ratio >= 0.85,
)
)
if project_id is not None:
stmt = stmt.where(
UserWorkload.user_id.in_(
select(UserProject.user_id).where(
and_(
UserProject.tenant_id == self.tenant_id,
UserProject.project_id == int(project_id),
)
)
)
)
rows = (await self.db.execute(stmt.limit(50))).scalars().all()
items = [
{
"user_id": int(row.user_id),
"workload_ratio": float(row.workload_ratio or 0.0),
"current_assigned_hours": float(row.current_assigned_hours or 0.0),
}
for row in rows
]
return items
async def _list_pending_signal_sources(self, *, project_id: Optional[int]) -> list[dict[str, Any]]:
stmt = select(SignalSourceRegistry).where(
and_(
SignalSourceRegistry.tenant_id == self.tenant_id,
SignalSourceRegistry.onboarding_status == "pending_manual",
)
)
if project_id is not None:
stmt = stmt.where(SignalSourceRegistry.project_id == int(project_id))
rows = (await self.db.execute(stmt.order_by(SignalSourceRegistry.updated_at.desc()).limit(50))).scalars().all()
return [
{
"registry_id": int(row.id),
"project_id": int(row.project_id),
"source_kind": str(row.source_kind or ""),
"source_name": str(row.source_name or ""),
"status": str(row.status or ""),
"onboarding_status": str(row.onboarding_status or ""),
}
for row in rows
]
async def _list_unread_risk_notifications(self, *, project_id: Optional[int]) -> list[dict[str, Any]]:
stmt = select(Notification).where(
and_(
Notification.tenant_id == self.tenant_id,
Notification.is_read.is_(False),
Notification.kind.in_(["risk", "alert", "action_required"]),
)
)
if project_id is not None:
stmt = stmt.where(
and_(
Notification.entity_type == "project",
Notification.entity_id == int(project_id),
)
)
rows = (await self.db.execute(stmt.order_by(Notification.updated_at.desc()).limit(50))).scalars().all()
return [
{
"id": int(row.id),
"title": str(row.title or ""),
"status": str(row.status or ""),
"priority": str(row.priority or ""),
}
for row in rows
]
@staticmethod
def _build_focus_actions(*, pending_manual_count: int, blocked_task_count: int) -> list[str]:
actions: list[str] = []
if pending_manual_count > 0:
actions.append("优先清理待人工确认队列,避免自动执行链路阻塞。")
if blocked_task_count > 0:
actions.append("优先处理 blocked tasks,降低关键依赖压力。")
if not actions:
actions.append("当前节奏平稳,继续按计划推进。")
return actions