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