PMFlow/backend/app/services/ai_pmo_decision_runtime.py

356 lines
16 KiB
Python

"""AI PMO decision brains (strategic/tactical/simulation/policy)."""
from __future__ import annotations
import re
from typing import Any
from .ai_pmo_intent_routing import (
INTENT_FOLLOWUP_CONFIRM,
INTENT_FOLLOWUP_CONTINUE,
INTENT_PROJECT_CREATE,
INTENT_QUERY,
INTENT_SIGNAL_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD,
ROUTE_PROJECT_SIGNAL_CANCEL,
ROUTE_PROJECT_SIGNAL_COMMIT,
ROUTE_PROJECT_SIGNAL_DISCOVER,
ROUTE_PROJECT_SIGNAL_REFRESH,
CanonicalIntent,
)
def _normalize_workload_ratio(value: Any) -> float:
try:
ratio = float(value or 0.0)
except (TypeError, ValueError):
return 0.0
if ratio <= 0:
return 0.0
return ratio / 100.0 if ratio > 1.0 else ratio
class StrategicBrain:
"""组合级判断。"""
@staticmethod
def evaluate(*, intent: CanonicalIntent, world_model: dict[str, Any], message_text: str) -> dict[str, Any]:
project = world_model.get("project") if isinstance(world_model.get("project"), dict) else None
budget = world_model.get("budget") if isinstance(world_model.get("budget"), dict) else {}
capacity = world_model.get("capacity") if isinstance(world_model.get("capacity"), list) else []
risk_count = int((world_model.get("risk_summary") or {}).get("open_risk_count") or 0)
active_projects = int((world_model.get("portfolio") or {}).get("active_project_count") or 0)
budget_ratio = float(budget.get("usage_ratio") or 0.0)
overloaded_people = [row for row in capacity if _normalize_workload_ratio(row.get("workload_ratio")) >= 0.85]
risk_score = min(1.0, 0.18 + risk_count * 0.08 + budget_ratio * 0.25 + len(overloaded_people) * 0.1)
if intent.name == INTENT_PROJECT_CREATE:
risk_score = max(risk_score, 0.62)
if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD:
risk_score = max(risk_score, 0.58)
return {
"decision_level": "project" if project else "portfolio",
"decision_object_type": "project" if project else "portfolio",
"decision_object_id": _safe_int((project or {}).get("id")),
"risk_score": round(risk_score, 3),
"budget_impact": {
"budget_id": budget.get("budget_id"),
"usage_ratio": budget_ratio,
"hard_limit_enabled": bool(budget.get("hard_limit_enabled")),
},
"resource_impact": {
"overloaded_user_ids": [row.get("user_id") for row in overloaded_people],
"active_project_count": active_projects,
},
"rationale": (
f"intent={intent.name}; "
f"project_scope={'project' if project else 'portfolio'}; "
f"budget_ratio={budget_ratio:.2f}; "
f"open_risks={risk_count}; "
f"overloaded={len(overloaded_people)}"
),
"message_excerpt": message_text[:240],
}
class TacticalBrain:
"""周/日执行计划。"""
@staticmethod
def plan(*, intent: CanonicalIntent, world_model: dict[str, Any], decision: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
project = world_model.get("project") if isinstance(world_model.get("project"), dict) else None
project_ref = f"项目#{project['id']}" if project and project.get("id") is not None else "项目群"
strategic_plan = {
"priority": "high" if float(decision.get("risk_score") or 0.0) >= 0.6 else "medium",
"objectives": [
f"围绕 {project_ref} 控制风险与节奏",
"优先保证关键路径和资源边界不被破坏",
],
"signals": [
f"budget_usage_ratio={float((decision.get('budget_impact') or {}).get('usage_ratio') or 0.0):.2f}",
f"overloaded_users={len((decision.get('resource_impact') or {}).get('overloaded_user_ids') or [])}",
],
}
execution_actions = []
workflow_route = str((intent.metadata or {}).get("workflow_route") or "").strip().lower()
if intent.name == INTENT_QUERY:
execution_actions.append({"operation_code": "query.answer", "mode": "read_only"})
elif intent.name == INTENT_PROJECT_CREATE:
execution_actions.append({"operation_code": "project.create", "mode": "write"})
elif intent.name == INTENT_SIGNAL_SOURCE_ONBOARD:
execution_actions.append({"operation_code": "signal.enter", "mode": "write"})
execution_actions.append({"operation_code": "signal_source.onboard", "mode": "write"})
elif intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
execution_actions.append({"operation_code": "work_item.resume", "mode": "write"})
elif intent.name == INTENT_SIGNAL_CREATE:
if workflow_route == ROUTE_PROJECT_SIGNAL_DISCOVER:
execution_actions.append({"operation_code": ROUTE_PROJECT_SIGNAL_DISCOVER, "mode": "write"})
elif workflow_route == ROUTE_PROJECT_SIGNAL_COMMIT:
execution_actions.append({"operation_code": ROUTE_PROJECT_SIGNAL_COMMIT, "mode": "write"})
elif workflow_route == ROUTE_PROJECT_SIGNAL_REFRESH:
execution_actions.append({"operation_code": ROUTE_PROJECT_SIGNAL_REFRESH, "mode": "write"})
elif workflow_route == ROUTE_PROJECT_SIGNAL_CANCEL:
execution_actions.append({"operation_code": ROUTE_PROJECT_SIGNAL_CANCEL, "mode": "write"})
else:
execution_actions.append({"operation_code": "signal.enter", "mode": "write"})
execution_plan = {
"intent": intent.name,
"project_id": intent.project_id,
"actions": execution_actions,
"summary": f"执行 {intent.name},共 {len(execution_actions)} 个 typed actions",
}
return strategic_plan, execution_plan
class SimulationEngine:
"""执行前仿真。"""
BUDGET_COST_BY_OPERATION = {
"query.answer": 1.0,
"project.create": 120.0,
"signal.enter": 8.0,
"signal_source.onboard": 40.0,
ROUTE_PROJECT_SIGNAL_DISCOVER: 3.0,
ROUTE_PROJECT_SIGNAL_COMMIT: 8.0,
ROUTE_PROJECT_SIGNAL_REFRESH: 3.0,
ROUTE_PROJECT_SIGNAL_CANCEL: 0.2,
}
HOURS_BY_OPERATION = {
"query.answer": 0.2,
"project.create": 12.0,
"signal.enter": 1.5,
"signal_source.onboard": 6.0,
ROUTE_PROJECT_SIGNAL_DISCOVER: 0.8,
ROUTE_PROJECT_SIGNAL_COMMIT: 1.5,
ROUTE_PROJECT_SIGNAL_REFRESH: 0.8,
ROUTE_PROJECT_SIGNAL_CANCEL: 0.1,
}
@classmethod
def simulate(cls, *, intent: CanonicalIntent, world_model: dict[str, Any], execution_plan: dict[str, Any]) -> dict[str, Any]:
actions = execution_plan.get("actions") if isinstance(execution_plan.get("actions"), list) else []
operation_codes = [str(item.get("operation_code") or "").strip() for item in actions if isinstance(item, dict)]
estimated_budget_delta = sum(cls.BUDGET_COST_BY_OPERATION.get(code, 0.0) for code in operation_codes)
estimated_capacity_delta = sum(cls.HOURS_BY_OPERATION.get(code, 0.0) for code in operation_codes)
budget = world_model.get("budget") if isinstance(world_model.get("budget"), dict) else {}
budget_amount = float(budget.get("budget_amount_usd") or 0.0)
current_ratio = float(budget.get("usage_ratio") or 0.0)
projected_budget_ratio = current_ratio + (estimated_budget_delta / budget_amount if budget_amount > 0 else 0.0)
capacity = world_model.get("capacity") if isinstance(world_model.get("capacity"), list) else []
current_peak_ratio = max([_normalize_workload_ratio(item.get("workload_ratio")) for item in capacity], default=0.0)
project_member_count = max(1, int(world_model.get("member_count") or len(capacity) or 1))
projected_capacity_peak_ratio = current_peak_ratio + (estimated_capacity_delta / (40.0 * project_member_count))
task_summary = world_model.get("task_summary") if isinstance(world_model.get("task_summary"), dict) else {}
blocked_count = int((task_summary.get("by_status") or {}).get("blocked") or 0)
overdue_count = int(task_summary.get("overdue_count") or 0)
missing_skill_tag_ids = task_summary.get("uncovered_skill_tag_ids") if isinstance(task_summary.get("uncovered_skill_tag_ids"), list) else []
charter = world_model.get("charter") if isinstance(world_model.get("charter"), list) else []
dependency_pressure = "high" if blocked_count > 0 or overdue_count > 0 else ("medium" if not charter else "low")
projected_risk_score = min(
1.0,
0.12
+ projected_budget_ratio * 0.24
+ projected_capacity_peak_ratio * 0.28
+ blocked_count * 0.08
+ overdue_count * 0.05
+ len(missing_skill_tag_ids) * 0.07,
)
return {
"intent": intent.name,
"operation_codes": operation_codes,
"estimated_budget_delta_usd": round(estimated_budget_delta, 2),
"projected_budget_usage_ratio": round(projected_budget_ratio, 4),
"estimated_capacity_delta_hours": round(estimated_capacity_delta, 2),
"projected_capacity_peak_ratio": round(projected_capacity_peak_ratio, 4),
"blocked_task_count": blocked_count,
"overdue_task_count": overdue_count,
"dependency_pressure": dependency_pressure,
"missing_skill_tag_ids": missing_skill_tag_ids[:20],
"missing_skill_count": len(missing_skill_tag_ids),
"projected_risk_score": round(projected_risk_score, 4),
}
class ExecutionPolicyBrain:
"""自动执行边界。"""
def __init__(self, *, policy_gate_rules: tuple[tuple[str, tuple[str, ...]], ...]) -> None:
self.policy_gate_rules = policy_gate_rules
def evaluate(
self,
*,
message_text: str,
world_model: dict[str, Any],
intent: CanonicalIntent,
simulation: dict[str, Any],
) -> dict[str, Any]:
normalized = re.sub(r"\s+", "", str(message_text or "")).lower()
for gate_code, tokens in self.policy_gate_rules:
if any(token.lower().replace(" ", "") in normalized for token in tokens):
return {
"auto_execute": False,
"policy_gate_hit": True,
"policy_gate_code": gate_code,
"approval_requirement": {
"required": True,
"reason": gate_code,
"message": "命中硬策略边界,必须进入显式审批/人工确认。",
},
}
if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD and intent.project_id is None:
return {
"auto_execute": False,
"policy_gate_hit": True,
"policy_gate_code": "missing_project_scope",
"approval_requirement": {
"required": True,
"reason": "missing_project_scope",
"message": "信号源接入缺少项目归属,必须先确认项目。",
},
}
if intent.name in {INTENT_QUERY, INTENT_SIGNAL_CREATE}:
return {
"auto_execute": True,
"policy_gate_hit": False,
"policy_gate_code": None,
"approval_requirement": {"required": False},
}
budget = world_model.get("budget") if isinstance(world_model.get("budget"), dict) else {}
if bool(budget.get("hard_limit_enabled")) and float(budget.get("usage_ratio") or 0.0) >= 1.0:
return {
"auto_execute": False,
"policy_gate_hit": True,
"policy_gate_code": "budget_hard_limit",
"approval_requirement": {
"required": True,
"reason": "budget_hard_limit",
"message": "项目预算已触达硬限制,自动执行被拦截。",
},
}
if float(simulation.get("projected_budget_usage_ratio") or 0.0) >= 1.0:
return {
"auto_execute": False,
"policy_gate_hit": True,
"policy_gate_code": "simulation_budget_limit",
"approval_requirement": {
"required": True,
"reason": "simulation_budget_limit",
"message": "执行前仿真显示预算将触顶,自动执行被拦截。",
},
}
if float(simulation.get("projected_capacity_peak_ratio") or 0.0) >= 1.0:
return {
"auto_execute": False,
"policy_gate_hit": True,
"policy_gate_code": "simulation_capacity_limit",
"approval_requirement": {
"required": True,
"reason": "simulation_capacity_limit",
"message": "执行前仿真显示产能会超载,自动执行被拦截。",
},
}
if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD and int(simulation.get("missing_skill_count") or 0) > 0:
return {
"auto_execute": False,
"policy_gate_hit": True,
"policy_gate_code": "simulation_skill_gap",
"approval_requirement": {
"required": True,
"reason": "simulation_skill_gap",
"message": "执行前仿真显示当前项目存在技能缺口,信号源接入先进入待确认。",
},
}
if intent.name in {INTENT_SIGNAL_SOURCE_ONBOARD, INTENT_SIGNAL_CREATE} and str(simulation.get("dependency_pressure") or "") == "high":
return {
"auto_execute": False,
"policy_gate_hit": True,
"policy_gate_code": "simulation_dependency_pressure",
"approval_requirement": {
"required": True,
"reason": "simulation_dependency_pressure",
"message": "执行前仿真显示关键依赖压力过高,当前先进入待人工确认。",
},
}
return {
"auto_execute": True,
"policy_gate_hit": False,
"policy_gate_code": None,
"approval_requirement": {"required": False},
}
class DecisionEngine:
"""汇总三脑输出。"""
def __init__(self, *, policy_gate_rules: tuple[tuple[str, tuple[str, ...]], ...]) -> None:
self.strategic_brain = StrategicBrain()
self.tactical_brain = TacticalBrain()
self.simulation_engine = SimulationEngine()
self.execution_policy_brain = ExecutionPolicyBrain(policy_gate_rules=policy_gate_rules)
def decide(self, *, intent: CanonicalIntent, world_model: dict[str, Any], message_text: str) -> dict[str, Any]:
strategic = self.strategic_brain.evaluate(intent=intent, world_model=world_model, message_text=message_text)
strategic_plan, execution_plan = self.tactical_brain.plan(intent=intent, world_model=world_model, decision=strategic)
simulation = self.simulation_engine.simulate(intent=intent, world_model=world_model, execution_plan=execution_plan)
strategic_plan["simulation"] = simulation
execution_plan["simulation"] = simulation
policy = self.execution_policy_brain.evaluate(
message_text=message_text,
world_model=world_model,
intent=intent,
simulation=simulation,
)
strategic["risk_score"] = max(float(strategic.get("risk_score") or 0.0), float(simulation.get("projected_risk_score") or 0.0))
return {
"strategic": strategic,
"strategic_plan": strategic_plan,
"execution_plan": execution_plan,
"simulation": simulation,
"policy": policy,
}
def _safe_int(value: Any) -> int | None:
try:
if value is None or value == "":
return None
return int(value)
except (TypeError, ValueError):
return None