2652 lines
111 KiB
Python
2652 lines
111 KiB
Python
"""AI PMO 决策中枢内核服务。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import hashlib
|
||
import json
|
||
import re
|
||
import time
|
||
from dataclasses import dataclass
|
||
from datetime import datetime, timezone
|
||
from typing import Any, Optional
|
||
|
||
from sqlalchemy import and_, func, or_, select
|
||
from sqlalchemy.exc import IntegrityError
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from ..core.logging import get_logger
|
||
from ..database import AsyncSessionLocal
|
||
from ..models import (
|
||
AIProcessingLog,
|
||
Budget,
|
||
DecisionRecord,
|
||
IngestEvent,
|
||
Notification,
|
||
PlanBundle,
|
||
PortfolioSnapshot,
|
||
Project,
|
||
ProjectDomain,
|
||
ProjectSignalLink,
|
||
ReplyReceipt,
|
||
Signal,
|
||
Task,
|
||
TruthNode,
|
||
User,
|
||
UserProject,
|
||
UserSkillBinding,
|
||
UserWorkload,
|
||
WorkItem,
|
||
)
|
||
from ..schemas.world_model import (
|
||
BlockedTask,
|
||
BudgetSummary,
|
||
CapacityItem,
|
||
CharterItem,
|
||
DependencySummary,
|
||
PortfolioSummary,
|
||
ProjectDomainSnapshot,
|
||
ProjectSnapshot,
|
||
RecentSignalItem,
|
||
RiskSummary,
|
||
SkillGraphItem,
|
||
TaskSummary,
|
||
WorldModel,
|
||
)
|
||
from .adaptive_rag_service import AdaptiveRAGService
|
||
from .ai_pmo_cadence_service import AIPMOCadenceService
|
||
from .ai_steward import AIStewardService
|
||
from .ai_pmo_review_service import AIPMOReviewService
|
||
from .context_cache import ContextCacheService
|
||
from .notification_service import NotificationService
|
||
from .openclaw_commander_service import OpenClawCommanderService
|
||
from .ai_pmo_domain_workflow import SignalSourceDomainWorkflow
|
||
from .signal_source_orchestration_service import SignalSourceOrchestrationService
|
||
|
||
logger = get_logger(__name__)
|
||
|
||
INTENT_QUERY = "query.read_only"
|
||
INTENT_PROJECT_CREATE = "project.create"
|
||
INTENT_SIGNAL_CREATE = "signal.create"
|
||
INTENT_SIGNAL_SOURCE_ONBOARD = "signal_source.onboard"
|
||
INTENT_FOLLOWUP_CONFIRM = "followup.confirm"
|
||
INTENT_FOLLOWUP_CONTINUE = "followup.continue"
|
||
INTENT_IGNORE = "ignore"
|
||
FOLLOWUP_TARGET_INTENTS = {
|
||
INTENT_PROJECT_CREATE,
|
||
INTENT_SIGNAL_CREATE,
|
||
INTENT_SIGNAL_SOURCE_ONBOARD,
|
||
}
|
||
|
||
STATE_RECEIVED = "received"
|
||
STATE_MODELED = "modeled"
|
||
STATE_DECIDED = "decided"
|
||
STATE_PLANNED = "planned"
|
||
STATE_EXECUTING = "executing"
|
||
STATE_WAITING_MANUAL = "waiting_manual"
|
||
STATE_SUCCEEDED = "succeeded"
|
||
STATE_FAILED = "failed"
|
||
STATE_IGNORED = "ignored"
|
||
|
||
RECEIPT_PHASE_ACTION = "action_applied"
|
||
RECEIPT_PHASE_PENDING = "pending_manual"
|
||
RECEIPT_PHASE_ERROR = "error"
|
||
|
||
QUESTION_HINTS = (
|
||
"?",
|
||
"?",
|
||
"怎么",
|
||
"如何",
|
||
"为何",
|
||
"为什么",
|
||
"什么",
|
||
"是什么",
|
||
"哪些",
|
||
"有哪些",
|
||
"特点",
|
||
"技术特点",
|
||
"能力",
|
||
"介绍",
|
||
"说明",
|
||
"多少",
|
||
"是否",
|
||
"帮我看",
|
||
"状态",
|
||
"风险",
|
||
"能做什么",
|
||
"可以做什么",
|
||
)
|
||
SMALLTALK_EXACT = {
|
||
"收到",
|
||
"收到啦",
|
||
"好的",
|
||
"ok",
|
||
"okk",
|
||
"yes",
|
||
"no",
|
||
"谢谢",
|
||
"thanks",
|
||
"thx",
|
||
"在吗",
|
||
"在不在",
|
||
}
|
||
TRANSPORT_MODE_CORRECTION_HINTS = ("不是", "不再", "改为", "改成", "替换为", "取代", "现在都", "现在是")
|
||
FOLLOWUP_CONFIRM_HINTS = ("确认归属", "确认项目", "确认到项目", "确认关联", "确认这条")
|
||
FOLLOWUP_CONTINUE_HINTS = ("继续处理", "继续执行", "接着处理", "往下做", "继续推进")
|
||
FOLLOWUP_WORK_ITEM_ID_RE = re.compile(r"(?:work[_ -]?item|工作项|队列项)\s*(?:id|ID)?\s*[:#:]?\s*(\d+)", re.IGNORECASE)
|
||
SIGNAL_CREATE_HINTS = ("新增一个信号", "新增一条信号", "增加一个信号", "录入一个信号", "关注", "跟踪")
|
||
PROJECT_CREATE_HINTS = ("创建项目", "新建项目", "建立项目", "做个项目", "立项", "开个项目")
|
||
POLICY_GATE_RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||
("delete_or_archive", ("删除项目", "删除信号", "归档项目", "归档", "archive", "清空项目")),
|
||
("budget_overrun", ("超预算", "突破预算", "额外预算", "加预算")),
|
||
("cross_project_staffing", ("跨项目抢人", "抽调", "抢占关键人力", "挪人")),
|
||
("charter_rewrite", ("改写charter", "success criteria", "成功标准改", "范围重大变更")),
|
||
("critical_path_change", ("关键路径", "里程碑改动", "影响多个项目", "跨项目依赖调整")),
|
||
)
|
||
|
||
WORLD_MODEL_STATIC_CACHE_TTL_SECONDS = 10 * 60
|
||
WORLD_MODEL_STATIC_CACHE_KEY_PREFIX = "wm:static"
|
||
|
||
|
||
def _utc_now_naive() -> datetime:
|
||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||
|
||
|
||
def _safe_int(value: Any) -> Optional[int]:
|
||
try:
|
||
if value is None or value == "":
|
||
return None
|
||
return int(value)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
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
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class CanonicalIntent:
|
||
name: str
|
||
confidence: float
|
||
project_id: Optional[int]
|
||
signal_title: str
|
||
signal_summary: str
|
||
metadata: dict[str, Any]
|
||
|
||
|
||
class IntentResolver:
|
||
"""把输入收口为 canonical intent。"""
|
||
|
||
OPERATION_VERBS = (
|
||
"新增",
|
||
"增加",
|
||
"加一个",
|
||
"加个",
|
||
"新建",
|
||
"创建",
|
||
"建立",
|
||
"立项",
|
||
"接入",
|
||
"修复",
|
||
"优化",
|
||
"改成",
|
||
"改为",
|
||
"更新",
|
||
"分配",
|
||
"指派",
|
||
"归档",
|
||
"执行",
|
||
)
|
||
|
||
@staticmethod
|
||
def resolve(*, text: str, project_id: Optional[int]) -> CanonicalIntent:
|
||
normalized = re.sub(r"\s+", "", str(text or "")).lower()
|
||
pretty = re.sub(r"\s+", " ", str(text or "")).strip()
|
||
if not pretty:
|
||
return CanonicalIntent(INTENT_IGNORE, 1.0, project_id, "", "", {"reason": "empty"})
|
||
if normalized in SMALLTALK_EXACT:
|
||
return CanonicalIntent(INTENT_IGNORE, 0.95, project_id, "", "", {"reason": "smalltalk"})
|
||
if any(token in pretty for token in FOLLOWUP_CONFIRM_HINTS):
|
||
return CanonicalIntent(INTENT_FOLLOWUP_CONFIRM, 0.82, project_id, "", "", {})
|
||
if any(token in pretty for token in FOLLOWUP_CONTINUE_HINTS):
|
||
return CanonicalIntent(INTENT_FOLLOWUP_CONTINUE, 0.82, project_id, "", "", {})
|
||
if SignalSourceOrchestrationService.looks_like_signal_source_request(pretty):
|
||
return CanonicalIntent(
|
||
INTENT_SIGNAL_SOURCE_ONBOARD,
|
||
0.92,
|
||
project_id,
|
||
IntentResolver._build_signal_title(pretty, default="新增信号源接入请求"),
|
||
pretty[:300],
|
||
{"source_request": True},
|
||
)
|
||
if any(token in pretty for token in PROJECT_CREATE_HINTS) or OpenClawCommanderService._extract_project_create_title(pretty):
|
||
return CanonicalIntent(
|
||
INTENT_PROJECT_CREATE,
|
||
0.9,
|
||
project_id,
|
||
IntentResolver._build_signal_title(pretty, default="新项目创建请求"),
|
||
pretty[:300],
|
||
{},
|
||
)
|
||
if IntentResolver._looks_like_query(text=pretty):
|
||
return CanonicalIntent(
|
||
INTENT_QUERY,
|
||
0.88,
|
||
project_id,
|
||
"",
|
||
pretty[:300],
|
||
{},
|
||
)
|
||
if any(token in pretty for token in SIGNAL_CREATE_HINTS) or any(token in pretty for token in ("新增", "增加", "录入", "跟踪", "关注")):
|
||
return CanonicalIntent(
|
||
INTENT_SIGNAL_CREATE,
|
||
0.78,
|
||
project_id,
|
||
IntentResolver._build_signal_title(pretty, default="新信号"),
|
||
pretty[:300],
|
||
{},
|
||
)
|
||
return CanonicalIntent(
|
||
INTENT_QUERY,
|
||
0.6,
|
||
project_id,
|
||
"",
|
||
pretty[:300],
|
||
{"fallback": True, "reason": "default_query_fallback"},
|
||
)
|
||
|
||
@staticmethod
|
||
def _looks_like_query(*, text: str) -> bool:
|
||
normalized = re.sub(r"\s+", "", str(text or "")).lower()
|
||
if not normalized:
|
||
return False
|
||
if any(token in normalized for token in QUESTION_HINTS):
|
||
return True
|
||
has_question_structure = (
|
||
"?" in normalized
|
||
or "?" in normalized
|
||
or any(token in normalized for token in ("吗", "呢", "吧", "是否", "是不是"))
|
||
)
|
||
if not has_question_structure:
|
||
return False
|
||
has_operation = any(token in normalized for token in IntentResolver.OPERATION_VERBS)
|
||
return not has_operation
|
||
|
||
@staticmethod
|
||
def _build_signal_title(text: str, *, default: str) -> str:
|
||
cleaned = re.sub(r"\s+", " ", str(text or "")).strip(" ,,::")
|
||
if not cleaned:
|
||
return default
|
||
for token in ("。", ";", ";", "!", "!", "?", "?"):
|
||
if token in cleaned:
|
||
cleaned = cleaned.split(token, 1)[0].strip()
|
||
break
|
||
return cleaned[:120] or default
|
||
|
||
|
||
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 = []
|
||
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:
|
||
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,
|
||
}
|
||
HOURS_BY_OPERATION = {
|
||
"query.answer": 0.2,
|
||
"project.create": 12.0,
|
||
"signal.enter": 1.5,
|
||
"signal_source.onboard": 6.0,
|
||
}
|
||
|
||
@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:
|
||
"""自动执行边界。"""
|
||
|
||
@staticmethod
|
||
def evaluate(
|
||
*,
|
||
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 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": "信号源接入缺少项目归属,必须先确认项目。",
|
||
},
|
||
}
|
||
|
||
# 轻量级事实写入/只读查询不应被项目当前依赖或产能压力阻塞。
|
||
# 否则“AI 雷达”会在最需要先收集事实时失去自动化能力。
|
||
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) -> None:
|
||
self.strategic_brain = StrategicBrain()
|
||
self.tactical_brain = TacticalBrain()
|
||
self.simulation_engine = SimulationEngine()
|
||
self.execution_policy_brain = ExecutionPolicyBrain()
|
||
|
||
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,
|
||
}
|
||
|
||
|
||
class ReplyRenderer:
|
||
"""唯一回执出口。"""
|
||
|
||
@staticmethod
|
||
def render(
|
||
*,
|
||
work_state: str,
|
||
intent: CanonicalIntent,
|
||
decision_record: dict[str, Any],
|
||
execution_result: dict[str, Any],
|
||
) -> dict[str, Any]:
|
||
if work_state == STATE_WAITING_MANUAL:
|
||
if intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
|
||
followup_text = str(execution_result.get("followup_reply") or "").strip()
|
||
if followup_text:
|
||
return {
|
||
"receipt_phase": RECEIPT_PHASE_PENDING,
|
||
"template_key": "pending_manual",
|
||
"payload": {"intent": intent.name, "reason": followup_text},
|
||
"text": followup_text,
|
||
}
|
||
gate_message = str(((decision_record.get("approval_requirement") or {}).get("message")) or "").strip()
|
||
if not gate_message:
|
||
gate_message = "已进入待人工确认队列。"
|
||
return {
|
||
"receipt_phase": RECEIPT_PHASE_PENDING,
|
||
"template_key": "pending_manual",
|
||
"payload": {"intent": intent.name, "reason": gate_message},
|
||
"text": gate_message,
|
||
}
|
||
if work_state == STATE_FAILED:
|
||
if intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
|
||
followup_text = str(execution_result.get("followup_reply") or "").strip()
|
||
if followup_text:
|
||
return {
|
||
"receipt_phase": RECEIPT_PHASE_ERROR,
|
||
"template_key": "safe_error",
|
||
"payload": {"intent": intent.name, "reason": followup_text},
|
||
"text": followup_text,
|
||
}
|
||
reason = str(execution_result.get("error_message") or "系统处理中出现异常,本次未执行。").strip()
|
||
return {
|
||
"receipt_phase": RECEIPT_PHASE_ERROR,
|
||
"template_key": "safe_error",
|
||
"payload": {"intent": intent.name, "reason": reason},
|
||
"text": reason,
|
||
}
|
||
if intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
|
||
followup_text = str(execution_result.get("followup_reply") or "已继续处理。").strip()
|
||
return {
|
||
"receipt_phase": RECEIPT_PHASE_ACTION,
|
||
"template_key": "action_succeeded",
|
||
"payload": {
|
||
"intent": intent.name,
|
||
"target_work_item_id": _safe_int(execution_result.get("target_work_item_id")),
|
||
"target_state": execution_result.get("target_state"),
|
||
},
|
||
"text": followup_text,
|
||
}
|
||
if intent.name == INTENT_QUERY:
|
||
answer = str(execution_result.get("answer") or "当前暂无可用回答。").strip()
|
||
return {
|
||
"receipt_phase": RECEIPT_PHASE_ACTION,
|
||
"template_key": "action_succeeded",
|
||
"payload": {"intent": intent.name, "answer": answer},
|
||
"text": answer,
|
||
}
|
||
if intent.name == INTENT_PROJECT_CREATE:
|
||
project = execution_result.get("project") if isinstance(execution_result.get("project"), dict) else {}
|
||
project_id = _safe_int(project.get("id"))
|
||
project_title = str(project.get("title") or project.get("name") or "新项目").strip()
|
||
return {
|
||
"receipt_phase": RECEIPT_PHASE_ACTION,
|
||
"template_key": "action_succeeded",
|
||
"payload": {"intent": intent.name, "project_id": project_id, "project_title": project_title},
|
||
"text": f"已立项:项目#{project_id}《{project_title}》已创建完成。" if project_id else f"已立项:《{project_title}》已创建完成。",
|
||
}
|
||
if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD:
|
||
signal_id = _safe_int(execution_result.get("signal_id"))
|
||
workflow = execution_result.get("signal_source_workflow") if isinstance(execution_result.get("signal_source_workflow"), dict) else {}
|
||
status = str(workflow.get("status") or (execution_result.get("signal_source_orchestration") or {}).get("status") or "").strip()
|
||
if status == "pending_manual":
|
||
return {
|
||
"receipt_phase": RECEIPT_PHASE_PENDING,
|
||
"template_key": "pending_manual",
|
||
"payload": {"intent": intent.name, "signal_id": signal_id, "status": status},
|
||
"text": f"已记录信号源接入请求,当前为待人工确认。信号#{signal_id} 已入库。" if signal_id else "已记录信号源接入请求,当前为待人工确认。",
|
||
}
|
||
return {
|
||
"receipt_phase": RECEIPT_PHASE_ACTION,
|
||
"template_key": "action_succeeded",
|
||
"payload": {"intent": intent.name, "signal_id": signal_id, "status": status},
|
||
"text": f"已接收入库并启动信号源接入流程。信号#{signal_id},状态:{status or 'running'}。",
|
||
}
|
||
signal_id = _safe_int(execution_result.get("signal_id"))
|
||
project_id = _safe_int(execution_result.get("project_id"))
|
||
if signal_id is not None and project_id is not None:
|
||
text = f"已入库:已为项目#{project_id}创建信号#{signal_id}。"
|
||
elif signal_id is not None:
|
||
text = f"已入库:已创建信号#{signal_id}。"
|
||
else:
|
||
text = "已收到并入库。"
|
||
return {
|
||
"receipt_phase": RECEIPT_PHASE_ACTION,
|
||
"template_key": "action_succeeded",
|
||
"payload": {"intent": intent.name, "signal_id": signal_id, "project_id": project_id},
|
||
"text": text,
|
||
}
|
||
|
||
|
||
class TypedExecutor:
|
||
"""执行层只做 typed action 落地。"""
|
||
|
||
def __init__(self, db: AsyncSession, tenant_id: int) -> None:
|
||
self.db = db
|
||
self.tenant_id = int(tenant_id)
|
||
self.signal_source_workflow = SignalSourceDomainWorkflow(db)
|
||
|
||
async def execute(
|
||
self,
|
||
*,
|
||
intent: CanonicalIntent,
|
||
message_text: str,
|
||
sender: str,
|
||
operator_user_id: Optional[int],
|
||
source_system: str,
|
||
source_message_id: str,
|
||
) -> dict[str, Any]:
|
||
if intent.name == INTENT_QUERY:
|
||
rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id)
|
||
rag_result = await rag_service.answer_question(
|
||
question=message_text,
|
||
asked_by=operator_user_id,
|
||
asked_via=source_system,
|
||
project_id=intent.project_id,
|
||
source_chat_id=source_message_id,
|
||
conversation_key=source_message_id,
|
||
action_mode="read",
|
||
sync_requirement=False,
|
||
)
|
||
return {
|
||
"status": "succeeded",
|
||
"answer": str(rag_result.get("answer") or "").strip(),
|
||
"rag_result": rag_result,
|
||
"project_id": intent.project_id,
|
||
}
|
||
|
||
if intent.name == INTENT_PROJECT_CREATE:
|
||
if operator_user_id is None:
|
||
return {
|
||
"status": "failed_safe",
|
||
"error_message": "未找到可用项目创建人,当前无法自动创建项目。",
|
||
}
|
||
steward = AIStewardService(self.db)
|
||
payload = await steward.create_project_from_natural_language(
|
||
input_text=message_text,
|
||
creator_id=int(operator_user_id),
|
||
tenant_id=self.tenant_id,
|
||
)
|
||
project = payload.get("project") if isinstance(payload.get("project"), dict) else {}
|
||
signal = payload.get("signal") if isinstance(payload.get("signal"), dict) else {}
|
||
return {
|
||
"status": "succeeded",
|
||
"project": project,
|
||
"signal": signal,
|
||
"project_id": _safe_int(project.get("id")),
|
||
"signal_id": _safe_int(signal.get("id")),
|
||
"tasks": payload.get("tasks") if isinstance(payload.get("tasks"), list) else [],
|
||
"raw_payload": payload,
|
||
}
|
||
|
||
if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD:
|
||
signal = await self._create_signal(
|
||
title=intent.signal_title or "新增信号源接入请求",
|
||
summary=intent.signal_summary or message_text,
|
||
content=message_text,
|
||
project_id=intent.project_id,
|
||
source="PMFlow",
|
||
signal_type="新需求",
|
||
recommended_action="enter_only",
|
||
)
|
||
domain_workflow = await self.signal_source_workflow.run(
|
||
tenant_id=self.tenant_id,
|
||
project_id=int(intent.project_id or 0),
|
||
signal_id=int(signal.id),
|
||
initiating_text=message_text,
|
||
requested_via=source_system,
|
||
operator_user_id=operator_user_id,
|
||
)
|
||
orchestration = domain_workflow.get("raw_result") if isinstance(domain_workflow.get("raw_result"), dict) else {}
|
||
return {
|
||
"status": "succeeded" if str(domain_workflow.get("status") or "").lower() == "completed" else (
|
||
"pending_manual" if str(domain_workflow.get("status") or "").lower() == "pending_manual" else "failed_safe"
|
||
),
|
||
"signal_id": int(signal.id),
|
||
"project_id": intent.project_id,
|
||
"signal_source_workflow": domain_workflow,
|
||
"signal_source_orchestration": orchestration,
|
||
"auto_tasks": self._extract_auto_tasks(orchestration.get("openclaw")),
|
||
}
|
||
|
||
if intent.name == INTENT_SIGNAL_CREATE:
|
||
signal = await self._create_signal(
|
||
title=intent.signal_title or "新信号",
|
||
summary=intent.signal_summary or message_text,
|
||
content=message_text,
|
||
project_id=intent.project_id,
|
||
source="PMFlow",
|
||
signal_type="新需求",
|
||
recommended_action="enter_only",
|
||
)
|
||
return {
|
||
"status": "succeeded",
|
||
"signal_id": int(signal.id),
|
||
"project_id": intent.project_id,
|
||
"auto_tasks": [],
|
||
}
|
||
|
||
return {
|
||
"status": "failed_safe",
|
||
"error_message": "当前意图尚未迁移到 AI PMO kernel,已保留给兼容链路处理。",
|
||
}
|
||
|
||
async def _create_signal(
|
||
self,
|
||
*,
|
||
title: str,
|
||
summary: str,
|
||
content: str,
|
||
project_id: Optional[int],
|
||
source: str,
|
||
signal_type: str,
|
||
recommended_action: str,
|
||
) -> Signal:
|
||
signal = Signal(
|
||
tenant_id=self.tenant_id,
|
||
source=source,
|
||
title=(title or "新信号")[:120],
|
||
summary=(summary or content or "")[:300],
|
||
content=(content or summary or "")[:2000],
|
||
signal_type=signal_type,
|
||
reasoning=json.dumps(["由 AI PMO kernel 统一入库。"], ensure_ascii=False),
|
||
confidence=0.78,
|
||
recommended_action=recommended_action,
|
||
matched_project_id=int(project_id) if project_id is not None else None,
|
||
impact_score=0.7,
|
||
priority="medium",
|
||
processed=False,
|
||
tasks_generated=json.dumps([], ensure_ascii=False),
|
||
status="待处理",
|
||
)
|
||
self.db.add(signal)
|
||
await self.db.flush()
|
||
if project_id is not None:
|
||
exists = (
|
||
await self.db.execute(
|
||
select(ProjectSignalLink.id).where(
|
||
and_(
|
||
ProjectSignalLink.tenant_id == self.tenant_id,
|
||
ProjectSignalLink.project_id == int(project_id),
|
||
ProjectSignalLink.signal_id == int(signal.id),
|
||
)
|
||
).limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
if exists is None:
|
||
self.db.add(
|
||
ProjectSignalLink(
|
||
tenant_id=self.tenant_id,
|
||
project_id=int(project_id),
|
||
signal_id=int(signal.id),
|
||
link_type="related",
|
||
relevance_score=1.0,
|
||
)
|
||
)
|
||
await self.db.flush()
|
||
return signal
|
||
|
||
@staticmethod
|
||
def _extract_auto_tasks(openclaw_payload: Any) -> list[dict[str, Any]]:
|
||
if not isinstance(openclaw_payload, dict):
|
||
return []
|
||
result = openclaw_payload.get("result") if isinstance(openclaw_payload.get("result"), dict) else openclaw_payload
|
||
execution = result.get("execution") if isinstance(result, dict) and isinstance(result.get("execution"), dict) else {}
|
||
rows = execution.get("results") if isinstance(execution.get("results"), list) else []
|
||
items: list[dict[str, Any]] = []
|
||
for row in rows:
|
||
if not isinstance(row, dict):
|
||
continue
|
||
operation_code = str(row.get("operation_code") or "").strip()
|
||
if operation_code != "task.create":
|
||
continue
|
||
payload = row.get("payload") if isinstance(row.get("payload"), dict) else {}
|
||
items.append(
|
||
{
|
||
"operation_code": operation_code,
|
||
"status": str(row.get("status") or "").strip(),
|
||
"entity_id": row.get("entity_id"),
|
||
"title": payload.get("title"),
|
||
"task_id": payload.get("task_id"),
|
||
}
|
||
)
|
||
return items
|
||
|
||
|
||
class AIPMOKernelService:
|
||
"""AI PMO kernel,作为新的统一事实链。"""
|
||
|
||
def __init__(self, db: AsyncSession, tenant_id: int = 1) -> None:
|
||
self.db = db
|
||
self.tenant_id = int(tenant_id)
|
||
self.context_cache = ContextCacheService()
|
||
self.intent_resolver = IntentResolver()
|
||
self.decision_engine = DecisionEngine()
|
||
self.reply_renderer = ReplyRenderer()
|
||
self.executor = TypedExecutor(db, tenant_id=self.tenant_id)
|
||
self.review_service = AIPMOReviewService(db, tenant_id=self.tenant_id)
|
||
self.cadence_service = AIPMOCadenceService(db, tenant_id=self.tenant_id)
|
||
|
||
async def process_feishu_message(
|
||
self,
|
||
*,
|
||
group_chat_id: str,
|
||
message_content: str,
|
||
sender: str,
|
||
source_message_id: Optional[str],
|
||
source_sender_open_id: Optional[str] = None,
|
||
captured_at: Optional[datetime] = None,
|
||
chat_type: Optional[str] = None,
|
||
) -> dict[str, Any]:
|
||
return await self.process_ingress(
|
||
source_system="feishu",
|
||
source_message_id=source_message_id,
|
||
message_text=message_content,
|
||
sender=sender,
|
||
channel_ref=group_chat_id,
|
||
source_sender_open_id=source_sender_open_id,
|
||
captured_at=captured_at,
|
||
chat_type=chat_type,
|
||
)
|
||
|
||
async def process_ingress(
|
||
self,
|
||
*,
|
||
source_system: str,
|
||
source_message_id: Optional[str],
|
||
message_text: str,
|
||
sender: str,
|
||
channel_ref: Optional[str] = None,
|
||
source_sender_open_id: Optional[str] = None,
|
||
captured_at: Optional[datetime] = None,
|
||
chat_type: Optional[str] = None,
|
||
project_hint_id: Optional[int] = None,
|
||
) -> dict[str, Any]:
|
||
started = time.perf_counter()
|
||
clean_text = self._normalize_text(message_text)
|
||
if not clean_text:
|
||
return {"handled": True, "status": "ignored", "classification": "empty", "direct_reply_required": False}
|
||
transport_mode_reply = self._build_transport_mode_reply(clean_text)
|
||
if transport_mode_reply:
|
||
return {
|
||
"handled": True,
|
||
"status": "answered",
|
||
"classification": "question",
|
||
"linked_project_ids": [int(project_hint_id)] if project_hint_id is not None else [],
|
||
"signal_id": None,
|
||
"created_project_id": None,
|
||
"auto_tasks": [],
|
||
"ai_trace_id": None,
|
||
"results": [],
|
||
"intent": INTENT_QUERY,
|
||
"direct_reply_required": True,
|
||
"direct_reply_text": transport_mode_reply,
|
||
"reply_template_key": "transport_mode_ack",
|
||
"work_item_id": None,
|
||
"decision_level": None,
|
||
"review_summary": None,
|
||
}
|
||
|
||
synthetic_message_id = self._build_source_message_id(
|
||
source_system=source_system,
|
||
source_message_id=source_message_id,
|
||
channel_ref=channel_ref,
|
||
sender=sender,
|
||
message_text=clean_text,
|
||
)
|
||
|
||
ingest_event, created = await self._claim_ingress_event(
|
||
source_system=source_system,
|
||
source_message_id=synthetic_message_id,
|
||
payload={
|
||
"sender": sender,
|
||
"source_sender_open_id": source_sender_open_id,
|
||
"channel_ref": channel_ref,
|
||
"chat_type": chat_type,
|
||
"captured_at": captured_at.isoformat() if isinstance(captured_at, datetime) else None,
|
||
"message_text": clean_text,
|
||
},
|
||
)
|
||
if not created:
|
||
return {
|
||
"handled": True,
|
||
"status": "ignored",
|
||
"reason": "duplicate_filtered",
|
||
"classification": "duplicate",
|
||
"direct_reply_required": False,
|
||
"source_message_id": synthetic_message_id,
|
||
}
|
||
|
||
operator = await self._resolve_operator_user(sender=sender, sender_open_id=source_sender_open_id)
|
||
work_item = await self._create_work_item(
|
||
ingest_event=ingest_event,
|
||
source_system=source_system,
|
||
source_message_id=synthetic_message_id,
|
||
owner_user_id=_safe_int(getattr(operator, "id", None)),
|
||
)
|
||
|
||
try:
|
||
world_model_query_tables: set[str] = set()
|
||
project = await self._resolve_project(
|
||
message_text=clean_text,
|
||
project_hint_id=project_hint_id,
|
||
query_tables=world_model_query_tables,
|
||
)
|
||
world_model = await self.build_world_model(
|
||
tenant_id=self.tenant_id,
|
||
project_id=_safe_int(getattr(project, "id", None)),
|
||
sender=sender,
|
||
project=project,
|
||
pre_queried_tables=world_model_query_tables,
|
||
)
|
||
self._schedule_portfolio_snapshot_persist(world_model=world_model)
|
||
work_item.state = STATE_MODELED
|
||
work_item.context_snapshot = world_model
|
||
intent = self.intent_resolver.resolve(
|
||
text=clean_text,
|
||
project_id=_safe_int(world_model.get("resolved_project_id")),
|
||
)
|
||
if intent.name == INTENT_IGNORE:
|
||
work_item.state = STATE_IGNORED
|
||
work_item.intent = intent.name
|
||
ingest_event.status = "done"
|
||
ingest_event.processed_at = _utc_now_naive()
|
||
await self._write_processing_log(
|
||
source_message_id=synthetic_message_id,
|
||
work_item=work_item,
|
||
message_text=clean_text,
|
||
route="ai_pmo.ignore",
|
||
operation_code=None,
|
||
operation_result={"reason": intent.metadata.get("reason")},
|
||
processing_status="success",
|
||
duration_ms=int((time.perf_counter() - started) * 1000),
|
||
)
|
||
await self.db.commit()
|
||
return {
|
||
"handled": True,
|
||
"status": "ignored",
|
||
"classification": "ignored",
|
||
"direct_reply_required": False,
|
||
"work_item_id": int(work_item.id),
|
||
"ai_trace_id": f"ai-pmo-{int(work_item.id)}",
|
||
}
|
||
|
||
work_item.intent = intent.name
|
||
work_item.project_id = intent.project_id
|
||
decision_payload = self.decision_engine.decide(
|
||
intent=intent,
|
||
world_model=world_model,
|
||
message_text=clean_text,
|
||
)
|
||
work_item.state = STATE_DECIDED
|
||
work_item.decision_level = str((decision_payload["strategic"] or {}).get("decision_level") or "portfolio")
|
||
|
||
decision_row = await self._create_decision_record(
|
||
work_item=work_item,
|
||
world_model=world_model,
|
||
decision_payload=decision_payload,
|
||
)
|
||
await self._create_plan_bundle(
|
||
work_item_id=int(work_item.id),
|
||
plan_type="strategic",
|
||
summary="Strategic Brain 产出的项目群/项目策略。",
|
||
payload=decision_payload["strategic_plan"],
|
||
)
|
||
await self._create_plan_bundle(
|
||
work_item_id=int(work_item.id),
|
||
plan_type="execution",
|
||
summary="Execution Plan typed action bundle。",
|
||
payload=decision_payload["execution_plan"],
|
||
)
|
||
|
||
policy = decision_payload["policy"]
|
||
if not bool(policy.get("auto_execute")):
|
||
work_item.state = STATE_WAITING_MANUAL
|
||
work_item.action_bundle = decision_payload["execution_plan"]
|
||
work_item.execution_result = {
|
||
"status": "waiting_manual",
|
||
"reason": ((policy.get("approval_requirement") or {}).get("message") or "").strip(),
|
||
}
|
||
manual_issues = ["waiting_manual"]
|
||
policy_gate_code = str(policy.get("policy_gate_code") or "").strip()
|
||
if policy_gate_code:
|
||
manual_issues.append(policy_gate_code)
|
||
work_item.review_summary = await self.review_service.review_work_item(
|
||
work_item=work_item,
|
||
intent_name=intent.name,
|
||
execution_result=work_item.execution_result,
|
||
world_model=world_model,
|
||
verification={"verified": False, "issues": manual_issues},
|
||
simulation=decision_payload["simulation"],
|
||
)
|
||
render = self.reply_renderer.render(
|
||
work_state=STATE_WAITING_MANUAL,
|
||
intent=intent,
|
||
decision_record={
|
||
"approval_requirement": policy.get("approval_requirement"),
|
||
},
|
||
execution_result={},
|
||
)
|
||
work_item.reply_template_key = str(render["template_key"])
|
||
await self._project_waiting_manual(
|
||
work_item=work_item,
|
||
message_text=clean_text,
|
||
render=render,
|
||
intent=intent,
|
||
)
|
||
await self._maybe_emit_risk_cadence_snapshot(
|
||
project_id=_safe_int(work_item.project_id),
|
||
review_summary=work_item.review_summary,
|
||
)
|
||
direct_reply_required = await self._claim_reply_receipt(
|
||
work_item_id=int(work_item.id),
|
||
source_system=source_system,
|
||
source_message_id=synthetic_message_id,
|
||
receipt_phase=str(render["receipt_phase"]),
|
||
template_key=str(render["template_key"]),
|
||
payload=render["payload"],
|
||
)
|
||
ingest_event.status = "done"
|
||
ingest_event.processed_at = _utc_now_naive()
|
||
await self._write_processing_log(
|
||
source_message_id=synthetic_message_id,
|
||
work_item=work_item,
|
||
message_text=clean_text,
|
||
route="ai_pmo.waiting_manual",
|
||
operation_code=None,
|
||
operation_result=work_item.execution_result,
|
||
processing_status="success",
|
||
duration_ms=int((time.perf_counter() - started) * 1000),
|
||
)
|
||
await self.db.commit()
|
||
return self._build_capture_result(
|
||
work_item=work_item,
|
||
intent=intent,
|
||
execution_result=work_item.execution_result or {},
|
||
render=render,
|
||
direct_reply_required=direct_reply_required,
|
||
status="captured",
|
||
classification="command",
|
||
)
|
||
|
||
work_item.state = STATE_PLANNED
|
||
work_item.action_bundle = decision_payload["execution_plan"]
|
||
work_item.state = STATE_EXECUTING
|
||
if intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
|
||
execution_result, verification = await self._execute_followup_intent(
|
||
work_item=work_item,
|
||
intent=intent,
|
||
message_text=clean_text,
|
||
sender=sender,
|
||
operator_user_id=_safe_int(getattr(operator, "id", None)),
|
||
source_system=source_system,
|
||
source_message_id=synthetic_message_id,
|
||
channel_ref=channel_ref,
|
||
source_sender_open_id=source_sender_open_id,
|
||
world_model=world_model,
|
||
)
|
||
else:
|
||
execution_result = await self.executor.execute(
|
||
intent=intent,
|
||
message_text=clean_text,
|
||
sender=sender,
|
||
operator_user_id=_safe_int(getattr(operator, "id", None)),
|
||
source_system=source_system,
|
||
source_message_id=synthetic_message_id,
|
||
)
|
||
verification = await self._verify_execution(intent=intent, execution_result=execution_result, world_model=world_model)
|
||
work_item.execution_result = execution_result
|
||
if _safe_int(execution_result.get("project_id")) is not None:
|
||
work_item.project_id = _safe_int(execution_result.get("project_id"))
|
||
if _safe_int(execution_result.get("signal_id")) is not None:
|
||
work_item.signal_id = _safe_int(execution_result.get("signal_id"))
|
||
work_item.review_summary = await self.review_service.review_work_item(
|
||
work_item=work_item,
|
||
intent_name=intent.name,
|
||
execution_result=execution_result,
|
||
world_model=world_model,
|
||
verification=verification,
|
||
simulation=decision_payload["simulation"],
|
||
)
|
||
await self._maybe_emit_risk_cadence_snapshot(
|
||
project_id=_safe_int(work_item.project_id),
|
||
review_summary=work_item.review_summary,
|
||
)
|
||
decision_row.execution_result = execution_result
|
||
issues = verification.get("issues") if isinstance(verification.get("issues"), list) else []
|
||
if verification["verified"]:
|
||
work_item.state = STATE_SUCCEEDED
|
||
await self._project_success(intent=intent, execution_result=execution_result)
|
||
elif any(
|
||
issue in {"waiting_manual", "followup_ambiguous", "signal_source_pending_manual"}
|
||
for issue in issues
|
||
) or str(execution_result.get("status") or "").strip().lower() == "pending_manual":
|
||
work_item.state = STATE_WAITING_MANUAL
|
||
else:
|
||
work_item.state = STATE_FAILED
|
||
decision_row.final_outcome = work_item.state
|
||
|
||
render = self.reply_renderer.render(
|
||
work_state=work_item.state,
|
||
intent=intent,
|
||
decision_record={
|
||
"approval_requirement": decision_payload["policy"].get("approval_requirement"),
|
||
},
|
||
execution_result=execution_result,
|
||
)
|
||
work_item.reply_template_key = str(render["template_key"])
|
||
direct_reply_required = await self._claim_reply_receipt(
|
||
work_item_id=int(work_item.id),
|
||
source_system=source_system,
|
||
source_message_id=synthetic_message_id,
|
||
receipt_phase=str(render["receipt_phase"]),
|
||
template_key=str(render["template_key"]),
|
||
payload=render["payload"],
|
||
)
|
||
ingest_event.status = "done"
|
||
ingest_event.processed_at = _utc_now_naive()
|
||
await self._write_processing_log(
|
||
source_message_id=synthetic_message_id,
|
||
work_item=work_item,
|
||
message_text=clean_text,
|
||
route=f"ai_pmo.{intent.name}",
|
||
operation_code=self._primary_operation_code(intent.name),
|
||
operation_result=execution_result,
|
||
processing_status="success" if work_item.state in {STATE_SUCCEEDED, STATE_WAITING_MANUAL} else "failed",
|
||
duration_ms=int((time.perf_counter() - started) * 1000),
|
||
)
|
||
await self.db.commit()
|
||
return self._build_capture_result(
|
||
work_item=work_item,
|
||
intent=intent,
|
||
execution_result=execution_result,
|
||
render=render,
|
||
direct_reply_required=direct_reply_required,
|
||
status="answered" if intent.name in {INTENT_QUERY, INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE} and work_item.state == STATE_SUCCEEDED else "captured",
|
||
classification=(
|
||
"question"
|
||
if intent.name == INTENT_QUERY
|
||
else ("signal" if intent.name == INTENT_SIGNAL_CREATE else "command")
|
||
),
|
||
)
|
||
except Exception as exc:
|
||
await self.db.rollback()
|
||
logger.warning(
|
||
"ai_pmo_kernel_failed",
|
||
tenant_id=self.tenant_id,
|
||
source_system=source_system,
|
||
source_message_id=synthetic_message_id,
|
||
error=str(exc),
|
||
)
|
||
return {"handled": False, "reason": "kernel_exception", "error": str(exc)}
|
||
|
||
async def _claim_ingress_event(
|
||
self,
|
||
*,
|
||
source_system: str,
|
||
source_message_id: str,
|
||
payload: dict[str, Any],
|
||
) -> tuple[IngestEvent, bool]:
|
||
channel = f"{source_system}_message"
|
||
existing = (
|
||
await self.db.execute(
|
||
select(IngestEvent).where(
|
||
and_(
|
||
IngestEvent.tenant_id == self.tenant_id,
|
||
IngestEvent.channel == channel,
|
||
IngestEvent.event_key == source_message_id,
|
||
)
|
||
).limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
if existing is not None:
|
||
return existing, False
|
||
row = IngestEvent(
|
||
tenant_id=self.tenant_id,
|
||
channel=channel,
|
||
event_key=source_message_id,
|
||
status="processing",
|
||
payload=payload,
|
||
retry_count=0,
|
||
error_message=None,
|
||
processed_at=None,
|
||
)
|
||
self.db.add(row)
|
||
try:
|
||
await self.db.flush()
|
||
except IntegrityError:
|
||
await self.db.rollback()
|
||
existing = (
|
||
await self.db.execute(
|
||
select(IngestEvent).where(
|
||
and_(
|
||
IngestEvent.tenant_id == self.tenant_id,
|
||
IngestEvent.channel == channel,
|
||
IngestEvent.event_key == source_message_id,
|
||
)
|
||
).limit(1)
|
||
)
|
||
).scalar_one()
|
||
return existing, False
|
||
return row, True
|
||
|
||
async def _create_work_item(
|
||
self,
|
||
*,
|
||
ingest_event: IngestEvent,
|
||
source_system: str,
|
||
source_message_id: str,
|
||
owner_user_id: Optional[int],
|
||
) -> WorkItem:
|
||
row = WorkItem(
|
||
tenant_id=self.tenant_id,
|
||
ingest_event_id=int(ingest_event.id),
|
||
source_system=source_system,
|
||
source_message_id=source_message_id,
|
||
state=STATE_RECEIVED,
|
||
owner_user_id=owner_user_id,
|
||
dedupe_key=f"{source_system}:{source_message_id}",
|
||
)
|
||
self.db.add(row)
|
||
await self.db.flush()
|
||
return row
|
||
|
||
async def build_world_model(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
project_id: Optional[int],
|
||
sender: str = "",
|
||
project: Optional[Project] = None,
|
||
pre_queried_tables: Optional[set[str]] = None,
|
||
) -> WorldModel:
|
||
if int(tenant_id) != int(self.tenant_id):
|
||
raise ValueError(f"tenant mismatch for world model build: input={tenant_id} service={self.tenant_id}")
|
||
|
||
started = time.perf_counter()
|
||
queried_tables: set[str] = set(pre_queried_tables or set())
|
||
resolved_project_id: Optional[int] = project_id
|
||
status = "failed"
|
||
try:
|
||
if project is None and project_id is not None:
|
||
self._record_world_model_query_table(queried_tables, "projects")
|
||
project = (
|
||
await self.db.execute(
|
||
select(Project).where(
|
||
and_(
|
||
Project.tenant_id == self.tenant_id,
|
||
Project.id == int(project_id),
|
||
)
|
||
).limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
|
||
resolved_project_id = _safe_int(getattr(project, "id", None)) if project is not None else project_id
|
||
project_snapshot = self._serialize_project(project)
|
||
domain: Optional[ProjectDomainSnapshot] = None
|
||
skill_graph: list[SkillGraphItem] = []
|
||
charter: list[CharterItem] = []
|
||
|
||
cached_static = await self._get_static_world_model_cache(tenant_id=self.tenant_id, project_id=resolved_project_id)
|
||
if cached_static is not None:
|
||
cached_project = cached_static.get("project")
|
||
if isinstance(cached_project, dict):
|
||
project_snapshot = cached_project
|
||
cached_domain = cached_static.get("project_domain")
|
||
if isinstance(cached_domain, dict):
|
||
domain = cached_domain
|
||
cached_skill_graph = cached_static.get("skill_graph")
|
||
if isinstance(cached_skill_graph, list):
|
||
skill_graph = [item for item in cached_skill_graph if isinstance(item, dict)]
|
||
cached_charter = cached_static.get("charter")
|
||
if isinstance(cached_charter, list):
|
||
charter = [item for item in cached_charter if isinstance(item, dict)]
|
||
else:
|
||
skill_graph = await self._build_skill_graph(project_id=resolved_project_id, query_tables=queried_tables)
|
||
charter = await self._build_charter_snapshot(project_id=resolved_project_id, query_tables=queried_tables)
|
||
domain = await self._build_project_domain_snapshot(project_id=resolved_project_id, query_tables=queried_tables)
|
||
await self._set_static_world_model_cache(
|
||
tenant_id=self.tenant_id,
|
||
project_id=resolved_project_id,
|
||
data={
|
||
"project": project_snapshot,
|
||
"project_domain": domain,
|
||
"skill_graph": skill_graph,
|
||
"charter": charter,
|
||
},
|
||
)
|
||
|
||
portfolio_summary = await self._build_portfolio_summary(project_id=resolved_project_id, query_tables=queried_tables)
|
||
budget = await self._build_budget_summary(project_id=resolved_project_id, query_tables=queried_tables)
|
||
capacity = await self._build_capacity_summary(project_id=resolved_project_id, query_tables=queried_tables)
|
||
task_summary = await self._build_task_summary(
|
||
project_id=resolved_project_id,
|
||
skill_graph=skill_graph,
|
||
query_tables=queried_tables,
|
||
)
|
||
risk_summary = await self._build_risk_summary(project_id=resolved_project_id, query_tables=queried_tables)
|
||
dependencies = self._build_dependency_summary(charter=charter, task_summary=task_summary)
|
||
recent_signals = await self._build_recent_signal_summary(project_id=resolved_project_id, query_tables=queried_tables)
|
||
|
||
world_model: WorldModel = {
|
||
"sender": sender,
|
||
"resolved_project_id": resolved_project_id,
|
||
"project": project_snapshot,
|
||
"project_domain": domain,
|
||
"portfolio": portfolio_summary,
|
||
"budget": budget,
|
||
"capacity": capacity,
|
||
"member_count": len(capacity),
|
||
"skill_graph": skill_graph,
|
||
"charter": charter,
|
||
"task_summary": task_summary,
|
||
"risk_summary": risk_summary,
|
||
"dependencies": dependencies,
|
||
"recent_signals": recent_signals,
|
||
"decision_context_snapshot": {
|
||
"captured_at": _utc_now_naive().isoformat(),
|
||
"project_id": resolved_project_id,
|
||
"budget": budget,
|
||
"capacity": capacity,
|
||
"skill_graph_size": len(skill_graph),
|
||
"dependency_pressure": dependencies.get("pressure"),
|
||
},
|
||
}
|
||
status = "success"
|
||
return world_model
|
||
finally:
|
||
duration_ms = int((time.perf_counter() - started) * 1000)
|
||
logger.info(
|
||
"ai_pmo_world_model_build",
|
||
tenant_id=self.tenant_id,
|
||
project_id=resolved_project_id,
|
||
status=status,
|
||
duration_ms=duration_ms,
|
||
queried_tables=sorted(queried_tables),
|
||
query_table_count=len(queried_tables),
|
||
)
|
||
|
||
def _schedule_portfolio_snapshot_persist(self, *, world_model: WorldModel) -> None:
|
||
portfolio_summary = world_model.get("portfolio")
|
||
if not isinstance(portfolio_summary, dict):
|
||
return
|
||
project_id = _safe_int(world_model.get("resolved_project_id"))
|
||
asyncio.create_task(
|
||
self._persist_portfolio_snapshot(world_model=world_model, portfolio_summary=portfolio_summary),
|
||
name=f"ai-pmo-portfolio-snapshot:{self.tenant_id}:{project_id or 'none'}",
|
||
)
|
||
|
||
async def _persist_portfolio_snapshot(
|
||
self,
|
||
*,
|
||
world_model: WorldModel,
|
||
portfolio_summary: PortfolioSummary,
|
||
) -> None:
|
||
try:
|
||
async with AsyncSessionLocal() as bg_db:
|
||
bg_db.add(
|
||
PortfolioSnapshot(
|
||
tenant_id=self.tenant_id,
|
||
scope_type="tenant",
|
||
scope_ref=f"tenant:{self.tenant_id}",
|
||
summary=portfolio_summary,
|
||
payload=world_model,
|
||
)
|
||
)
|
||
await bg_db.commit()
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"ai_pmo_portfolio_snapshot_persist_failed",
|
||
tenant_id=self.tenant_id,
|
||
project_id=_safe_int(world_model.get("resolved_project_id")),
|
||
error=str(exc),
|
||
)
|
||
|
||
@staticmethod
|
||
def _world_model_static_cache_key(*, tenant_id: int, project_id: int) -> str:
|
||
return f"{WORLD_MODEL_STATIC_CACHE_KEY_PREFIX}:{int(tenant_id)}:{int(project_id)}"
|
||
|
||
async def _get_static_world_model_cache(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
project_id: Optional[int],
|
||
) -> Optional[dict[str, Any]]:
|
||
if project_id is None:
|
||
return None
|
||
key = self._world_model_static_cache_key(tenant_id=tenant_id, project_id=int(project_id))
|
||
cached = await self.context_cache.get_json_key(key)
|
||
if not isinstance(cached, dict):
|
||
logger.debug(f"[WM_CACHE] MISS {tenant_id}:{project_id}")
|
||
return None
|
||
required_keys = {"project", "project_domain", "skill_graph", "charter"}
|
||
if not required_keys.issubset(set(cached.keys())):
|
||
logger.debug(f"[WM_CACHE] MISS {tenant_id}:{project_id}")
|
||
return None
|
||
logger.debug(f"[WM_CACHE] HIT {tenant_id}:{project_id}")
|
||
return cached
|
||
|
||
async def _set_static_world_model_cache(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
project_id: Optional[int],
|
||
data: dict[str, Any],
|
||
) -> None:
|
||
if project_id is None:
|
||
return
|
||
key = self._world_model_static_cache_key(tenant_id=tenant_id, project_id=int(project_id))
|
||
await self.context_cache.set_json_key(key, data, WORLD_MODEL_STATIC_CACHE_TTL_SECONDS)
|
||
|
||
async def _invalidate_world_model_cache(self, *, tenant_id: int, project_id: Optional[int]) -> None:
|
||
await self.invalidate_world_model_cache(tenant_id=tenant_id, project_id=project_id)
|
||
|
||
@classmethod
|
||
async def invalidate_world_model_cache(cls, *, tenant_id: int, project_id: Optional[int]) -> None:
|
||
if project_id is None:
|
||
return
|
||
cache = ContextCacheService()
|
||
key = cls._world_model_static_cache_key(tenant_id=int(tenant_id), project_id=int(project_id))
|
||
await cache.delete_keys(key)
|
||
|
||
@classmethod
|
||
async def invalidate_world_model_cache_for_user(
|
||
cls,
|
||
*,
|
||
db: AsyncSession,
|
||
tenant_id: int,
|
||
user_id: int,
|
||
) -> list[int]:
|
||
rows = (
|
||
await db.execute(
|
||
select(UserProject.project_id).where(
|
||
and_(
|
||
UserProject.tenant_id == int(tenant_id),
|
||
UserProject.user_id == int(user_id),
|
||
)
|
||
)
|
||
)
|
||
).scalars().all()
|
||
project_ids = sorted({int(pid) for pid in rows if pid is not None})
|
||
for pid in project_ids:
|
||
await cls.invalidate_world_model_cache(tenant_id=tenant_id, project_id=pid)
|
||
return project_ids
|
||
|
||
async def _build_world_model(
|
||
self,
|
||
*,
|
||
message_text: str,
|
||
sender: str,
|
||
project_hint_id: Optional[int],
|
||
) -> WorldModel:
|
||
query_tables: set[str] = set()
|
||
project = await self._resolve_project(
|
||
message_text=message_text,
|
||
project_hint_id=project_hint_id,
|
||
query_tables=query_tables,
|
||
)
|
||
return await self.build_world_model(
|
||
tenant_id=self.tenant_id,
|
||
project_id=_safe_int(getattr(project, "id", None)),
|
||
sender=sender,
|
||
project=project,
|
||
pre_queried_tables=query_tables,
|
||
)
|
||
|
||
@staticmethod
|
||
def _record_world_model_query_table(query_tables: Optional[set[str]], table_name: str) -> None:
|
||
if query_tables is not None:
|
||
query_tables.add(table_name)
|
||
|
||
async def _resolve_project(
|
||
self,
|
||
*,
|
||
message_text: str,
|
||
project_hint_id: Optional[int],
|
||
query_tables: Optional[set[str]] = None,
|
||
) -> Optional[Project]:
|
||
if project_hint_id is not None:
|
||
self._record_world_model_query_table(query_tables, "projects")
|
||
return (
|
||
await self.db.execute(
|
||
select(Project).where(
|
||
and_(
|
||
Project.tenant_id == self.tenant_id,
|
||
Project.id == int(project_hint_id),
|
||
)
|
||
).limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
|
||
id_match = re.search(r"(?:项目(?:id|ID)?|project(?:id)?)\s*[:#:]?\s*(\d+)", str(message_text or ""))
|
||
if id_match:
|
||
project_id = int(id_match.group(1))
|
||
self._record_world_model_query_table(query_tables, "projects")
|
||
return (
|
||
await self.db.execute(
|
||
select(Project).where(
|
||
and_(
|
||
Project.tenant_id == self.tenant_id,
|
||
Project.id == project_id,
|
||
)
|
||
).limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
|
||
self._record_world_model_query_table(query_tables, "projects")
|
||
projects = (
|
||
await self.db.execute(
|
||
select(Project).where(Project.tenant_id == self.tenant_id).order_by(Project.updated_at.desc(), Project.id.desc()).limit(50)
|
||
)
|
||
).scalars().all()
|
||
normalized_message = re.sub(r"\s+", "", str(message_text or "")).lower()
|
||
for project in projects:
|
||
for candidate in (project.title, project.name):
|
||
normalized_candidate = re.sub(r"\s+", "", str(candidate or "")).lower()
|
||
if normalized_candidate and normalized_candidate in normalized_message:
|
||
return project
|
||
return None
|
||
|
||
async def _build_portfolio_summary(
|
||
self,
|
||
*,
|
||
project_id: Optional[int],
|
||
query_tables: Optional[set[str]] = None,
|
||
) -> PortfolioSummary:
|
||
self._record_world_model_query_table(query_tables, "projects")
|
||
active_project_count = (
|
||
await self.db.execute(
|
||
select(func.count(Project.id)).where(
|
||
and_(
|
||
Project.tenant_id == self.tenant_id,
|
||
Project.status.in_(["active", "planning", "todo", "doing"]),
|
||
)
|
||
)
|
||
)
|
||
).scalar_one()
|
||
return {
|
||
"active_project_count": int(active_project_count or 0),
|
||
"focus_project_id": project_id,
|
||
}
|
||
|
||
async def _build_budget_summary(
|
||
self,
|
||
*,
|
||
project_id: Optional[int],
|
||
query_tables: Optional[set[str]] = None,
|
||
) -> BudgetSummary:
|
||
self._record_world_model_query_table(query_tables, "budgets")
|
||
stmt = select(Budget).where(Budget.tenant_id == self.tenant_id)
|
||
if project_id is not None:
|
||
stmt = stmt.where(or_(Budget.project_id == int(project_id), Budget.project_id.is_(None)))
|
||
row = (await self.db.execute(stmt.order_by(Budget.project_id.desc().nulls_last(), Budget.id.desc()).limit(1))).scalars().first()
|
||
if row is None:
|
||
return {"budget_id": None, "usage_ratio": 0.0, "hard_limit_enabled": False}
|
||
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
|
||
return {
|
||
"budget_id": int(row.id),
|
||
"budget_type": str(row.budget_type or ""),
|
||
"period": str(row.period or ""),
|
||
"budget_amount_usd": amount,
|
||
"current_usage_usd": usage,
|
||
"usage_ratio": round(ratio, 4),
|
||
"hard_limit_enabled": bool(row.hard_limit_enabled),
|
||
}
|
||
|
||
async def _build_capacity_summary(
|
||
self,
|
||
*,
|
||
project_id: Optional[int],
|
||
query_tables: Optional[set[str]] = None,
|
||
) -> list[CapacityItem]:
|
||
user_ids: list[int] = []
|
||
if project_id is not None:
|
||
self._record_world_model_query_table(query_tables, "user_projects")
|
||
rows = (
|
||
await self.db.execute(
|
||
select(UserProject.user_id).where(
|
||
and_(
|
||
UserProject.tenant_id == self.tenant_id,
|
||
UserProject.project_id == int(project_id),
|
||
)
|
||
)
|
||
)
|
||
).scalars().all()
|
||
user_ids = [int(uid) for uid in rows]
|
||
if not user_ids and project_id is not None:
|
||
self._record_world_model_query_table(query_tables, "projects")
|
||
owner_id = (
|
||
await self.db.execute(
|
||
select(Project.owner_id).where(
|
||
and_(
|
||
Project.tenant_id == self.tenant_id,
|
||
Project.id == int(project_id),
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if owner_id is not None:
|
||
user_ids = [int(owner_id)]
|
||
if not user_ids:
|
||
return []
|
||
self._record_world_model_query_table(query_tables, "user_workloads")
|
||
rows = (
|
||
await self.db.execute(
|
||
select(UserWorkload).where(
|
||
and_(
|
||
UserWorkload.tenant_id == self.tenant_id,
|
||
UserWorkload.user_id.in_(user_ids),
|
||
)
|
||
)
|
||
)
|
||
).scalars().all()
|
||
return [
|
||
{
|
||
"user_id": int(row.user_id),
|
||
"current_assigned_hours": float(row.current_assigned_hours or 0.0),
|
||
"max_available_hours_per_week": float(row.max_available_hours_per_week or 0.0),
|
||
"workload_ratio": float(row.workload_ratio or 0.0),
|
||
}
|
||
for row in rows
|
||
]
|
||
|
||
async def _build_skill_graph(
|
||
self,
|
||
*,
|
||
project_id: Optional[int],
|
||
query_tables: Optional[set[str]] = None,
|
||
) -> list[SkillGraphItem]:
|
||
user_ids: list[int] = []
|
||
if project_id is not None:
|
||
self._record_world_model_query_table(query_tables, "user_projects")
|
||
rows = (
|
||
await self.db.execute(
|
||
select(UserProject.user_id).where(
|
||
and_(
|
||
UserProject.tenant_id == self.tenant_id,
|
||
UserProject.project_id == int(project_id),
|
||
)
|
||
)
|
||
)
|
||
).scalars().all()
|
||
user_ids = [int(uid) for uid in rows]
|
||
if not user_ids:
|
||
return []
|
||
self._record_world_model_query_table(query_tables, "user_skill_bindings")
|
||
rows = (
|
||
await self.db.execute(
|
||
select(UserSkillBinding).where(
|
||
and_(
|
||
UserSkillBinding.tenant_id == self.tenant_id,
|
||
UserSkillBinding.user_id.in_(user_ids),
|
||
)
|
||
).limit(50)
|
||
)
|
||
).scalars().all()
|
||
return [
|
||
{
|
||
"user_id": int(row.user_id),
|
||
"skill_tag_id": int(row.skill_tag_id),
|
||
"proficiency_level": str(row.proficiency_level or ""),
|
||
"quality_score": float(row.quality_score or 0.0),
|
||
"efficiency_score": float(row.efficiency_score or 0.0),
|
||
}
|
||
for row in rows
|
||
]
|
||
|
||
async def _build_project_domain_snapshot(
|
||
self,
|
||
*,
|
||
project_id: Optional[int],
|
||
query_tables: Optional[set[str]] = None,
|
||
) -> Optional[ProjectDomainSnapshot]:
|
||
if project_id is None:
|
||
return None
|
||
self._record_world_model_query_table(query_tables, "projects")
|
||
self._record_world_model_query_table(query_tables, "project_domains")
|
||
domain_row = (
|
||
await self.db.execute(
|
||
select(ProjectDomain)
|
||
.join(Project, Project.domain_id == ProjectDomain.id)
|
||
.where(
|
||
and_(
|
||
Project.tenant_id == self.tenant_id,
|
||
Project.id == int(project_id),
|
||
ProjectDomain.tenant_id == self.tenant_id,
|
||
)
|
||
)
|
||
.limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
if domain_row is None:
|
||
return None
|
||
return {
|
||
"id": int(domain_row.id),
|
||
"name": str(domain_row.name or ""),
|
||
"slug": str(domain_row.slug or ""),
|
||
"description": str(domain_row.description or ""),
|
||
"owner_id": domain_row.owner_id,
|
||
}
|
||
|
||
async def _build_charter_snapshot(
|
||
self,
|
||
*,
|
||
project_id: Optional[int],
|
||
query_tables: Optional[set[str]] = None,
|
||
) -> list[CharterItem]:
|
||
if project_id is None:
|
||
return []
|
||
self._record_world_model_query_table(query_tables, "truth_nodes")
|
||
rows = (
|
||
await self.db.execute(
|
||
select(TruthNode).where(
|
||
and_(
|
||
TruthNode.tenant_id == self.tenant_id,
|
||
TruthNode.project_id == int(project_id),
|
||
TruthNode.node_type == "charter",
|
||
)
|
||
).order_by(TruthNode.updated_at.desc(), TruthNode.id.desc()).limit(3)
|
||
)
|
||
).scalars().all()
|
||
return [
|
||
{
|
||
"id": int(row.id),
|
||
"title": str(row.title or ""),
|
||
"status": str(row.status or ""),
|
||
"content": row.content if isinstance(row.content, dict) else {},
|
||
}
|
||
for row in rows
|
||
]
|
||
|
||
async def _build_task_summary(
|
||
self,
|
||
*,
|
||
project_id: Optional[int],
|
||
skill_graph: list[SkillGraphItem],
|
||
query_tables: Optional[set[str]] = None,
|
||
) -> TaskSummary:
|
||
if project_id is None:
|
||
return {"total": 0, "by_status": {}, "overdue_count": 0, "uncovered_skill_tag_ids": [], "blocked_tasks": []}
|
||
self._record_world_model_query_table(query_tables, "tasks")
|
||
tasks = (
|
||
await self.db.execute(
|
||
select(Task).where(and_(Task.tenant_id == self.tenant_id, Task.project_id == int(project_id)))
|
||
)
|
||
).scalars().all()
|
||
by_status: dict[str, int] = {}
|
||
required_skill_tag_ids: set[int] = set()
|
||
blocked_tasks: list[BlockedTask] = []
|
||
overdue_count = 0
|
||
now = _utc_now_naive()
|
||
for row in tasks:
|
||
status = str(row.status or "unknown")
|
||
by_status[status] = by_status.get(status, 0) + 1
|
||
for skill_id in row.required_skill_tag_ids or []:
|
||
if skill_id is not None:
|
||
required_skill_tag_ids.add(int(skill_id))
|
||
if status == "blocked":
|
||
blocked_tasks.append({"id": int(row.id), "title": str(row.title or "")[:120]})
|
||
if row.due_date and str(row.status or "").lower() not in {"done", "completed", "closed"}:
|
||
due_date = row.due_date.replace(tzinfo=None) if row.due_date.tzinfo else row.due_date
|
||
if due_date < now:
|
||
overdue_count += 1
|
||
available_skill_ids = {int(item["skill_tag_id"]) for item in skill_graph if item.get("skill_tag_id") is not None}
|
||
uncovered_skill_ids = sorted(skill_id for skill_id in required_skill_tag_ids if skill_id not in available_skill_ids)
|
||
return {
|
||
"total": sum(by_status.values()),
|
||
"by_status": by_status,
|
||
"overdue_count": overdue_count,
|
||
"required_skill_tag_ids": sorted(required_skill_tag_ids),
|
||
"uncovered_skill_tag_ids": uncovered_skill_ids,
|
||
"blocked_tasks": blocked_tasks[:8],
|
||
}
|
||
|
||
async def _build_recent_signal_summary(
|
||
self,
|
||
*,
|
||
project_id: Optional[int],
|
||
query_tables: Optional[set[str]] = None,
|
||
) -> list[RecentSignalItem]:
|
||
if project_id is None:
|
||
return []
|
||
self._record_world_model_query_table(query_tables, "signals")
|
||
self._record_world_model_query_table(query_tables, "project_signal_links")
|
||
rows = (
|
||
await self.db.execute(
|
||
select(Signal).where(
|
||
and_(
|
||
Signal.tenant_id == self.tenant_id,
|
||
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),
|
||
)
|
||
)
|
||
),
|
||
),
|
||
)
|
||
).order_by(Signal.updated_at.desc(), Signal.id.desc()).limit(5)
|
||
)
|
||
).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 _build_risk_summary(
|
||
self,
|
||
*,
|
||
project_id: Optional[int],
|
||
query_tables: Optional[set[str]] = None,
|
||
) -> RiskSummary:
|
||
self._record_world_model_query_table(query_tables, "notifications")
|
||
stmt = select(func.count(Notification.id)).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(or_(Notification.entity_id == int(project_id), Notification.payload.is_(None), Notification.entity_type == "project"))
|
||
open_risk_count = (await self.db.execute(stmt)).scalar_one()
|
||
return {"open_risk_count": int(open_risk_count or 0)}
|
||
|
||
@staticmethod
|
||
def _build_dependency_summary(*, charter: list[CharterItem], task_summary: TaskSummary) -> DependencySummary:
|
||
blocked_tasks = task_summary.get("blocked_tasks") if isinstance(task_summary.get("blocked_tasks"), list) else []
|
||
overdue_count = int(task_summary.get("overdue_count") or 0)
|
||
pressure = "high" if blocked_tasks or overdue_count > 0 else ("medium" if not charter else "low")
|
||
return {
|
||
"pressure": pressure,
|
||
"blocked_tasks": blocked_tasks[:5],
|
||
"overdue_count": overdue_count,
|
||
"charter_ready": bool(charter),
|
||
}
|
||
|
||
async def _create_decision_record(
|
||
self,
|
||
*,
|
||
work_item: WorkItem,
|
||
world_model: dict[str, Any],
|
||
decision_payload: dict[str, Any],
|
||
) -> DecisionRecord:
|
||
strategic = decision_payload["strategic"]
|
||
policy = decision_payload["policy"]
|
||
row = DecisionRecord(
|
||
tenant_id=self.tenant_id,
|
||
work_item_id=int(work_item.id),
|
||
decision_object_type=str(strategic.get("decision_object_type") or "portfolio"),
|
||
decision_object_id=_safe_int(strategic.get("decision_object_id")),
|
||
decision_level=str(strategic.get("decision_level") or "portfolio"),
|
||
context_snapshot=world_model,
|
||
rationale=str(strategic.get("rationale") or ""),
|
||
confidence=0.82,
|
||
risk_score=float(strategic.get("risk_score") or 0.0),
|
||
budget_impact=strategic.get("budget_impact"),
|
||
resource_impact=strategic.get("resource_impact"),
|
||
simulation_result=decision_payload.get("simulation"),
|
||
policy_gate_hit=bool(policy.get("policy_gate_hit")),
|
||
policy_gate_code=(str(policy.get("policy_gate_code") or "")[:80] or None),
|
||
action_bundle=decision_payload["execution_plan"],
|
||
approval_requirement=policy.get("approval_requirement"),
|
||
final_outcome=STATE_DECIDED,
|
||
)
|
||
self.db.add(row)
|
||
await self.db.flush()
|
||
return row
|
||
|
||
async def _create_plan_bundle(
|
||
self,
|
||
*,
|
||
work_item_id: int,
|
||
plan_type: str,
|
||
summary: str,
|
||
payload: dict[str, Any],
|
||
) -> PlanBundle:
|
||
row = PlanBundle(
|
||
tenant_id=self.tenant_id,
|
||
work_item_id=int(work_item_id),
|
||
plan_type=plan_type[:20],
|
||
summary=summary[:2000],
|
||
payload=payload,
|
||
)
|
||
self.db.add(row)
|
||
await self.db.flush()
|
||
return row
|
||
|
||
async def _claim_reply_receipt(
|
||
self,
|
||
*,
|
||
work_item_id: int,
|
||
source_system: str,
|
||
source_message_id: str,
|
||
receipt_phase: str,
|
||
template_key: str,
|
||
payload: dict[str, Any],
|
||
) -> bool:
|
||
row = ReplyReceipt(
|
||
tenant_id=self.tenant_id,
|
||
work_item_id=int(work_item_id),
|
||
source_system=source_system[:32],
|
||
source_message_id=source_message_id[:128],
|
||
receipt_phase=receipt_phase[:32],
|
||
template_key=template_key[:64],
|
||
payload=payload,
|
||
status="ready",
|
||
)
|
||
self.db.add(row)
|
||
try:
|
||
await self.db.flush()
|
||
return True
|
||
except IntegrityError:
|
||
await self.db.rollback()
|
||
existing_ingest = (
|
||
await self.db.execute(
|
||
select(IngestEvent).where(
|
||
and_(
|
||
IngestEvent.tenant_id == self.tenant_id,
|
||
IngestEvent.channel == f"{source_system}_message",
|
||
IngestEvent.event_key == source_message_id,
|
||
)
|
||
).limit(1)
|
||
)
|
||
).scalar_one()
|
||
existing_work_item = (
|
||
await self.db.execute(
|
||
select(WorkItem).where(
|
||
and_(
|
||
WorkItem.tenant_id == self.tenant_id,
|
||
WorkItem.ingest_event_id == int(existing_ingest.id),
|
||
)
|
||
).limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
if existing_work_item is not None:
|
||
self.db.add(existing_work_item)
|
||
return False
|
||
|
||
async def _project_waiting_manual(
|
||
self,
|
||
*,
|
||
work_item: WorkItem,
|
||
message_text: str,
|
||
render: dict[str, Any],
|
||
intent: CanonicalIntent,
|
||
) -> None:
|
||
await NotificationService(self.db).create_notifications(
|
||
tenant_id=self.tenant_id,
|
||
user_ids=await NotificationService(self.db)._resolve_recipients(tenant_id=self.tenant_id),
|
||
title="AI PMO 待人工确认",
|
||
content=str(render.get("text") or message_text)[:4000],
|
||
category="decision",
|
||
source="ai_pmo",
|
||
entity_type="work_item",
|
||
entity_id=int(work_item.id),
|
||
target_url=f"/inbox?workItemId={int(work_item.id)}",
|
||
kind="action_required",
|
||
priority="high",
|
||
status="pending_manual",
|
||
recommended_action="view_detail",
|
||
dedupe_key=f"ai-pmo-waiting:{int(work_item.id)}",
|
||
payload={
|
||
"work_item_id": int(work_item.id),
|
||
"intent": intent.name,
|
||
"project_id": intent.project_id,
|
||
"message_text": message_text[:500],
|
||
},
|
||
commit=False,
|
||
)
|
||
|
||
async def _project_success(self, *, intent: CanonicalIntent, execution_result: dict[str, Any]) -> None:
|
||
if intent.name == INTENT_SIGNAL_CREATE and _safe_int(execution_result.get("signal_id")) is not None:
|
||
await NotificationService(self.db).notify_signal_created(
|
||
tenant_id=self.tenant_id,
|
||
signal_id=int(execution_result["signal_id"]),
|
||
signal_content=str(execution_result.get("summary") or execution_result.get("signal_id") or ""),
|
||
source="ai_pmo",
|
||
target_url=(
|
||
f"/projects/{int(execution_result['project_id'])}/board"
|
||
if _safe_int(execution_result.get("project_id")) is not None
|
||
else None
|
||
),
|
||
)
|
||
|
||
async def _execute_followup_intent(
|
||
self,
|
||
*,
|
||
work_item: WorkItem,
|
||
intent: CanonicalIntent,
|
||
message_text: str,
|
||
sender: str,
|
||
operator_user_id: Optional[int],
|
||
source_system: str,
|
||
source_message_id: str,
|
||
channel_ref: Optional[str],
|
||
source_sender_open_id: Optional[str],
|
||
world_model: dict[str, Any],
|
||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||
explicit_target_id = self._extract_followup_work_item_id(message_text)
|
||
candidates = await self._load_recent_followup_candidates(
|
||
current_work_item_id=int(work_item.id),
|
||
source_system=source_system,
|
||
channel_ref=channel_ref,
|
||
sender=sender,
|
||
source_sender_open_id=source_sender_open_id,
|
||
explicit_target_id=explicit_target_id,
|
||
)
|
||
actionable = [
|
||
(target, ingest)
|
||
for target, ingest in candidates
|
||
if str(target.state or "") == STATE_WAITING_MANUAL
|
||
and str(target.intent or "") in FOLLOWUP_TARGET_INTENTS
|
||
]
|
||
if explicit_target_id is not None and not actionable:
|
||
matched = next((target for target, _ in candidates if int(target.id) == int(explicit_target_id)), None)
|
||
if matched is not None:
|
||
reply_text = f"工作项#{int(matched.id)} 当前状态为 {matched.state},无需继续处理。"
|
||
return {
|
||
"status": "succeeded",
|
||
"followup_reply": reply_text,
|
||
"target_work_item_id": int(matched.id),
|
||
"target_state": matched.state,
|
||
"target_intent": matched.intent,
|
||
"project_id": _safe_int(matched.project_id),
|
||
"signal_id": _safe_int(matched.signal_id),
|
||
}, {"verified": True, "issues": []}
|
||
|
||
if not actionable:
|
||
reply_text = "当前群最近没有可继续处理的待确认工作项。"
|
||
return {
|
||
"status": "pending_manual",
|
||
"followup_reply": reply_text,
|
||
"candidate_work_item_ids": [],
|
||
}, {"verified": False, "issues": ["waiting_manual"]}
|
||
|
||
if len(actionable) > 1:
|
||
reply_text = self._build_followup_candidate_clarification([target for target, _ in actionable])
|
||
return {
|
||
"status": "pending_manual",
|
||
"followup_reply": reply_text,
|
||
"candidate_work_item_ids": [int(target.id) for target, _ in actionable[:5]],
|
||
}, {"verified": False, "issues": ["waiting_manual", "followup_ambiguous"]}
|
||
|
||
target_work_item, target_ingest = actionable[0]
|
||
return await self._resume_followup_target(
|
||
target_work_item=target_work_item,
|
||
target_ingest=target_ingest,
|
||
followup_intent=intent,
|
||
followup_sender=sender,
|
||
operator_user_id=operator_user_id,
|
||
source_system=source_system,
|
||
source_message_id=source_message_id,
|
||
world_model=world_model,
|
||
)
|
||
|
||
async def _load_recent_followup_candidates(
|
||
self,
|
||
*,
|
||
current_work_item_id: int,
|
||
source_system: str,
|
||
channel_ref: Optional[str],
|
||
sender: str,
|
||
source_sender_open_id: Optional[str],
|
||
explicit_target_id: Optional[int],
|
||
limit: int = 12,
|
||
) -> list[tuple[WorkItem, IngestEvent]]:
|
||
rows = (
|
||
await self.db.execute(
|
||
select(WorkItem, IngestEvent)
|
||
.join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id)
|
||
.where(
|
||
and_(
|
||
WorkItem.tenant_id == self.tenant_id,
|
||
WorkItem.id != int(current_work_item_id),
|
||
)
|
||
)
|
||
.order_by(WorkItem.id.desc())
|
||
.limit(max(limit * 3, 18))
|
||
)
|
||
).all()
|
||
selected: list[tuple[WorkItem, IngestEvent]] = []
|
||
normalized_channel = str(channel_ref or "").strip()
|
||
normalized_sender = str(sender or "").strip()
|
||
normalized_open_id = str(source_sender_open_id or "").strip()
|
||
for target_work_item, ingest in rows:
|
||
if explicit_target_id is not None and int(target_work_item.id) == int(explicit_target_id):
|
||
selected.append((target_work_item, ingest))
|
||
continue
|
||
payload = ingest.payload if isinstance(ingest.payload, dict) else {}
|
||
same_channel = normalized_channel and str(payload.get("channel_ref") or "").strip() == normalized_channel
|
||
same_open_id = normalized_open_id and str(payload.get("source_sender_open_id") or "").strip() == normalized_open_id
|
||
same_sender = normalized_sender and str(payload.get("sender") or "").strip() == normalized_sender
|
||
if not (same_channel or same_open_id or same_sender):
|
||
continue
|
||
selected.append((target_work_item, ingest))
|
||
if len(selected) >= limit:
|
||
break
|
||
return selected
|
||
|
||
async def _resume_followup_target(
|
||
self,
|
||
*,
|
||
target_work_item: WorkItem,
|
||
target_ingest: IngestEvent,
|
||
followup_intent: CanonicalIntent,
|
||
followup_sender: str,
|
||
operator_user_id: Optional[int],
|
||
source_system: str,
|
||
source_message_id: str,
|
||
world_model: dict[str, Any],
|
||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||
payload = target_ingest.payload if isinstance(target_ingest.payload, dict) else {}
|
||
original_message = str(payload.get("message_text") or "").strip()
|
||
override_project_id = _safe_int(world_model.get("resolved_project_id"))
|
||
target_project_id = _safe_int(target_work_item.project_id) or override_project_id
|
||
rebuilt_intent = self.intent_resolver.resolve(text=original_message, project_id=target_project_id)
|
||
target_intent_name = str(target_work_item.intent or rebuilt_intent.name or "").strip()
|
||
target_intent = CanonicalIntent(
|
||
name=target_intent_name or rebuilt_intent.name,
|
||
confidence=float(rebuilt_intent.confidence or 0.88),
|
||
project_id=target_project_id,
|
||
signal_title=rebuilt_intent.signal_title,
|
||
signal_summary=rebuilt_intent.signal_summary,
|
||
metadata=dict(rebuilt_intent.metadata),
|
||
)
|
||
if target_intent.name not in FOLLOWUP_TARGET_INTENTS:
|
||
reply_text = f"工作项#{int(target_work_item.id)} 当前类型为 {target_intent.name or 'unknown'},暂不支持继续处理。"
|
||
return {
|
||
"status": "failed_safe",
|
||
"followup_reply": reply_text,
|
||
"target_work_item_id": int(target_work_item.id),
|
||
"target_state": target_work_item.state,
|
||
"target_intent": target_intent.name,
|
||
}, {"verified": False, "issues": ["unsupported_intent"]}
|
||
if target_intent.name in {INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD} and target_project_id is None:
|
||
reply_text = "未执行:目标工作项仍缺少项目归属,请补充项目 ID 后再继续处理。"
|
||
return {
|
||
"status": "pending_manual",
|
||
"followup_reply": reply_text,
|
||
"target_work_item_id": int(target_work_item.id),
|
||
"target_state": STATE_WAITING_MANUAL,
|
||
"target_intent": target_intent.name,
|
||
}, {"verified": False, "issues": ["waiting_manual"]}
|
||
|
||
target_work_item.project_id = target_project_id
|
||
target_work_item.state = STATE_EXECUTING
|
||
target_execution_result = await self.executor.execute(
|
||
intent=target_intent,
|
||
message_text=original_message,
|
||
sender=followup_sender,
|
||
operator_user_id=operator_user_id,
|
||
source_system=source_system,
|
||
source_message_id=f"{source_message_id}:resume:{int(target_work_item.id)}",
|
||
)
|
||
target_work_item.execution_result = target_execution_result
|
||
if _safe_int(target_execution_result.get("signal_id")) is not None:
|
||
target_work_item.signal_id = _safe_int(target_execution_result.get("signal_id"))
|
||
|
||
target_verification = await self._verify_execution(
|
||
intent=target_intent,
|
||
execution_result=target_execution_result,
|
||
world_model=target_work_item.context_snapshot if isinstance(target_work_item.context_snapshot, dict) else world_model,
|
||
)
|
||
target_work_item.review_summary = await self.review_service.review_work_item(
|
||
work_item=target_work_item,
|
||
intent_name=target_intent.name,
|
||
execution_result=target_execution_result,
|
||
world_model=target_work_item.context_snapshot if isinstance(target_work_item.context_snapshot, dict) else world_model,
|
||
verification=target_verification,
|
||
simulation={
|
||
"followup_resumed": True,
|
||
"target_work_item_id": int(target_work_item.id),
|
||
"followup_intent": followup_intent.name,
|
||
},
|
||
)
|
||
await self._maybe_emit_risk_cadence_snapshot(
|
||
project_id=_safe_int(target_work_item.project_id),
|
||
review_summary=target_work_item.review_summary,
|
||
)
|
||
if target_verification.get("verified"):
|
||
target_work_item.state = STATE_SUCCEEDED
|
||
await self._project_success(intent=target_intent, execution_result=target_execution_result)
|
||
else:
|
||
target_issues = target_verification.get("issues") if isinstance(target_verification.get("issues"), list) else []
|
||
if any(issue in {"pending_manual", "signal_source_pending_manual"} for issue in target_issues):
|
||
target_work_item.state = STATE_WAITING_MANUAL
|
||
else:
|
||
target_work_item.state = STATE_FAILED
|
||
|
||
target_decision = (
|
||
await self.db.execute(
|
||
select(DecisionRecord)
|
||
.where(
|
||
and_(
|
||
DecisionRecord.tenant_id == self.tenant_id,
|
||
DecisionRecord.work_item_id == int(target_work_item.id),
|
||
)
|
||
)
|
||
.order_by(DecisionRecord.id.desc())
|
||
.limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
if target_decision is not None:
|
||
target_decision.execution_result = target_execution_result
|
||
target_decision.final_outcome = target_work_item.state
|
||
|
||
target_render = self.reply_renderer.render(
|
||
work_state=target_work_item.state,
|
||
intent=target_intent,
|
||
decision_record={},
|
||
execution_result=target_execution_result,
|
||
)
|
||
reply_text = str(target_render.get("text") or "").strip()
|
||
if target_work_item.state == STATE_SUCCEEDED:
|
||
reply_text = f"已继续处理:{reply_text}"
|
||
elif not reply_text:
|
||
reply_text = "未执行:继续处理后仍未形成可验证终态。"
|
||
project_id = _safe_int(target_execution_result.get("project_id")) or _safe_int(target_work_item.project_id)
|
||
signal_id = _safe_int(target_execution_result.get("signal_id")) or _safe_int(target_work_item.signal_id)
|
||
status = (
|
||
"succeeded"
|
||
if target_work_item.state == STATE_SUCCEEDED
|
||
else ("pending_manual" if target_work_item.state == STATE_WAITING_MANUAL else "failed_safe")
|
||
)
|
||
current_verification = {"verified": target_work_item.state == STATE_SUCCEEDED, "issues": []}
|
||
if target_work_item.state == STATE_WAITING_MANUAL:
|
||
current_verification = {"verified": False, "issues": ["waiting_manual"]}
|
||
elif target_work_item.state == STATE_FAILED:
|
||
current_verification = {"verified": False, "issues": ["executor_failed"]}
|
||
return {
|
||
"status": status,
|
||
"followup_reply": reply_text,
|
||
"target_work_item_id": int(target_work_item.id),
|
||
"target_state": target_work_item.state,
|
||
"target_intent": target_intent.name,
|
||
"project_id": project_id,
|
||
"signal_id": signal_id,
|
||
"auto_tasks": target_execution_result.get("auto_tasks") if isinstance(target_execution_result.get("auto_tasks"), list) else [],
|
||
}, current_verification
|
||
|
||
async def _verify_execution(
|
||
self,
|
||
*,
|
||
intent: CanonicalIntent,
|
||
execution_result: dict[str, Any],
|
||
world_model: dict[str, Any],
|
||
) -> dict[str, Any]:
|
||
if str(execution_result.get("status") or "").lower() == "failed_safe":
|
||
return {"verified": False, "issues": ["executor_failed"]}
|
||
if str(execution_result.get("status") or "").lower() == "pending_manual":
|
||
return {"verified": False, "issues": ["pending_manual"]}
|
||
if intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
|
||
return {
|
||
"verified": bool(
|
||
_safe_int(execution_result.get("target_work_item_id")) is not None
|
||
and str(execution_result.get("target_state") or "").strip() == STATE_SUCCEEDED
|
||
),
|
||
"issues": [] if str(execution_result.get("target_state") or "").strip() == STATE_SUCCEEDED else ["waiting_manual"],
|
||
}
|
||
if intent.name == INTENT_QUERY:
|
||
return {"verified": bool(str(execution_result.get("answer") or "").strip()), "issues": []}
|
||
if intent.name == INTENT_PROJECT_CREATE:
|
||
return {"verified": _safe_int(execution_result.get("project_id")) is not None, "issues": []}
|
||
if intent.name in {INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD}:
|
||
signal_id = _safe_int(execution_result.get("signal_id"))
|
||
if signal_id is None:
|
||
return {"verified": False, "issues": ["signal_missing"]}
|
||
if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD:
|
||
workflow = execution_result.get("signal_source_workflow") if isinstance(execution_result.get("signal_source_workflow"), dict) else {}
|
||
if str(workflow.get("status") or "").lower() != "completed":
|
||
return {"verified": False, "issues": ["signal_source_pending_manual"]}
|
||
if intent.project_id is None:
|
||
return {"verified": True, "issues": []}
|
||
link_exists = (
|
||
await self.db.execute(
|
||
select(ProjectSignalLink.id).where(
|
||
and_(
|
||
ProjectSignalLink.tenant_id == self.tenant_id,
|
||
ProjectSignalLink.project_id == int(intent.project_id),
|
||
ProjectSignalLink.signal_id == int(signal_id),
|
||
)
|
||
).limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
return {"verified": link_exists is not None, "issues": [] if link_exists is not None else ["project_signal_link_missing"]}
|
||
return {"verified": False, "issues": ["unsupported_intent"]}
|
||
|
||
@staticmethod
|
||
def _build_review_summary(
|
||
*,
|
||
intent: CanonicalIntent,
|
||
verification: dict[str, Any],
|
||
world_model: dict[str, Any],
|
||
) -> dict[str, Any]:
|
||
return {
|
||
"verified": bool(verification.get("verified")),
|
||
"issues": verification.get("issues") if isinstance(verification.get("issues"), list) else [],
|
||
"next_step": "进入下一轮调度" if verification.get("verified") else "等待人工复核",
|
||
"risk_score_after": float((world_model.get("risk_summary") or {}).get("open_risk_count") or 0.0),
|
||
"intent": intent.name,
|
||
}
|
||
|
||
async def _maybe_emit_risk_cadence_snapshot(
|
||
self,
|
||
*,
|
||
project_id: Optional[int],
|
||
review_summary: dict[str, Any],
|
||
) -> None:
|
||
if not isinstance(review_summary, dict):
|
||
return
|
||
risk_after = float(review_summary.get("risk_score_after") or 0.0)
|
||
divergences = review_summary.get("divergences") if isinstance(review_summary.get("divergences"), list) else []
|
||
if risk_after < 0.7 and not divergences:
|
||
return
|
||
await self.cadence_service.generate_snapshot(
|
||
cadence_type="risk_review",
|
||
project_id=int(project_id) if project_id is not None else None,
|
||
)
|
||
|
||
async def _resolve_operator_user(self, *, sender: str, sender_open_id: Optional[str]) -> Optional[User]:
|
||
cleaned_sender = str(sender or "").strip()
|
||
if cleaned_sender:
|
||
user = (
|
||
await self.db.execute(
|
||
select(User).where(
|
||
and_(
|
||
User.tenant_id == self.tenant_id,
|
||
User.is_active.is_(True),
|
||
or_(User.name == cleaned_sender, User.username == cleaned_sender),
|
||
)
|
||
).limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
if user is not None:
|
||
return user
|
||
preferred = (
|
||
await self.db.execute(
|
||
select(User).where(
|
||
and_(
|
||
User.tenant_id == self.tenant_id,
|
||
User.is_active.is_(True),
|
||
User.status == "active",
|
||
User.role.in_(["BOSS", "ADMIN", "PM"]),
|
||
)
|
||
).order_by(User.id.asc()).limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
if preferred is not None:
|
||
return preferred
|
||
return (
|
||
await self.db.execute(
|
||
select(User).where(
|
||
and_(
|
||
User.tenant_id == self.tenant_id,
|
||
User.is_active.is_(True),
|
||
)
|
||
).order_by(User.id.asc()).limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
|
||
async def _write_processing_log(
|
||
self,
|
||
*,
|
||
source_message_id: str,
|
||
work_item: WorkItem,
|
||
message_text: str,
|
||
route: str,
|
||
operation_code: Optional[str],
|
||
operation_result: Any,
|
||
processing_status: str,
|
||
duration_ms: int,
|
||
) -> None:
|
||
self.db.add(
|
||
AIProcessingLog(
|
||
tenant_id=self.tenant_id,
|
||
feishu_message_id=source_message_id if work_item.source_system == "feishu" else None,
|
||
feishu_item_id=None,
|
||
raw_message=message_text[:8000],
|
||
ai_raw_output=json.dumps(
|
||
{
|
||
"work_item_id": int(work_item.id),
|
||
"intent": work_item.intent,
|
||
"state": work_item.state,
|
||
"reply_template_key": work_item.reply_template_key,
|
||
},
|
||
ensure_ascii=False,
|
||
),
|
||
project_score=None,
|
||
signal_confidence=None,
|
||
level="ai_pmo",
|
||
normalized_text=message_text[:8000],
|
||
route=route[:80],
|
||
intent=(str(work_item.intent or "")[:40] or None),
|
||
executor="ai_pmo_kernel",
|
||
operation_code=(operation_code[:80] if operation_code else None),
|
||
operation_result=operation_result if isinstance(operation_result, dict) else {"result": operation_result},
|
||
reply_source=(str(work_item.reply_template_key or "")[:40] or None),
|
||
routing_decision=route[:64],
|
||
routing_reason=f"AI PMO kernel route={route}",
|
||
reasoning_bullets=[
|
||
f"work_item_id={int(work_item.id)}",
|
||
f"state={work_item.state}",
|
||
f"intent={work_item.intent}",
|
||
],
|
||
processing_status=processing_status[:20],
|
||
error_message=None,
|
||
duration_ms=int(duration_ms),
|
||
)
|
||
)
|
||
await self.db.flush()
|
||
|
||
def _build_capture_result(
|
||
self,
|
||
*,
|
||
work_item: WorkItem,
|
||
intent: CanonicalIntent,
|
||
execution_result: dict[str, Any],
|
||
render: dict[str, Any],
|
||
direct_reply_required: bool,
|
||
status: str,
|
||
classification: str,
|
||
) -> dict[str, Any]:
|
||
linked_project_ids = [int(work_item.project_id)] if work_item.project_id is not None else []
|
||
result = {
|
||
"handled": True,
|
||
"status": status,
|
||
"classification": classification,
|
||
"linked_project_ids": linked_project_ids,
|
||
"signal_id": _safe_int(execution_result.get("signal_id")) or _safe_int(work_item.signal_id),
|
||
"created_project_id": _safe_int(execution_result.get("project_id")) if intent.name == INTENT_PROJECT_CREATE else None,
|
||
"auto_tasks": execution_result.get("auto_tasks") if isinstance(execution_result.get("auto_tasks"), list) else execution_result.get("tasks", []),
|
||
"ai_trace_id": f"ai-pmo-{int(work_item.id)}",
|
||
"results": [
|
||
{
|
||
"work_item_id": int(work_item.id),
|
||
"state": work_item.state,
|
||
"intent": intent.name,
|
||
"route_status": "queued_for_decision" if work_item.state == STATE_WAITING_MANUAL else "auto_done",
|
||
}
|
||
],
|
||
"intent": intent.name,
|
||
"direct_reply_required": bool(direct_reply_required),
|
||
"direct_reply_text": str(render.get("text") or "").strip(),
|
||
"reply_template_key": str(render.get("template_key") or "").strip(),
|
||
"work_item_id": int(work_item.id),
|
||
"decision_level": work_item.decision_level,
|
||
"review_summary": work_item.review_summary,
|
||
}
|
||
if intent.name == INTENT_QUERY and isinstance(execution_result.get("rag_result"), dict):
|
||
result["rag_result"] = execution_result["rag_result"]
|
||
if intent.name == INTENT_PROJECT_CREATE:
|
||
raw_payload = execution_result.get("raw_payload") if isinstance(execution_result.get("raw_payload"), dict) else {}
|
||
result["project"] = raw_payload.get("project")
|
||
if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD:
|
||
result["signal_source_workflow"] = execution_result.get("signal_source_workflow")
|
||
result["signal_source_orchestration"] = execution_result.get("signal_source_orchestration")
|
||
result["ai_execution"] = execution_result.get("signal_source_orchestration")
|
||
return result
|
||
|
||
@staticmethod
|
||
def _primary_operation_code(intent_name: str) -> Optional[str]:
|
||
if intent_name == INTENT_QUERY:
|
||
return "query.answer"
|
||
if intent_name == INTENT_PROJECT_CREATE:
|
||
return "project.create"
|
||
if intent_name == INTENT_SIGNAL_CREATE:
|
||
return "signal.enter"
|
||
if intent_name == INTENT_SIGNAL_SOURCE_ONBOARD:
|
||
return "signal_source.onboard"
|
||
if intent_name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
|
||
return "work_item.resume"
|
||
return None
|
||
|
||
@staticmethod
|
||
def _extract_followup_work_item_id(message: str) -> Optional[int]:
|
||
match = FOLLOWUP_WORK_ITEM_ID_RE.search(str(message or ""))
|
||
return int(match.group(1)) if match else None
|
||
|
||
@staticmethod
|
||
def _build_followup_candidate_clarification(items: list[WorkItem]) -> str:
|
||
if not items:
|
||
return "当前没有可继续处理的待确认工作项。"
|
||
labels = [f"工作项#{int(item.id)}({str(item.intent or '').strip() or 'unknown'})" for item in items[:3]]
|
||
return f"当前存在多个待确认工作项:{'、'.join(labels)}。请补充工作项 ID 后再继续处理。"
|
||
|
||
@staticmethod
|
||
def _build_source_message_id(
|
||
*,
|
||
source_system: str,
|
||
source_message_id: Optional[str],
|
||
channel_ref: Optional[str],
|
||
sender: str,
|
||
message_text: str,
|
||
) -> str:
|
||
explicit = str(source_message_id or "").strip()
|
||
if explicit:
|
||
return explicit[:128]
|
||
basis = f"{source_system}|{channel_ref or ''}|{sender}|{message_text}"
|
||
digest = hashlib.sha1(basis.encode("utf-8"), usedforsecurity=False).hexdigest()
|
||
return f"{source_system}-{digest[:32]}"
|
||
|
||
@staticmethod
|
||
def _normalize_text(text: str) -> str:
|
||
cleaned = re.sub(r"</?at[^>]*>", " ", str(text or ""), flags=re.IGNORECASE)
|
||
cleaned = re.sub(r"^@PMFlow\s*", "", cleaned, flags=re.IGNORECASE)
|
||
cleaned = re.sub(r"\s+", " ", cleaned).strip()
|
||
return cleaned[:4000]
|
||
|
||
@staticmethod
|
||
def _build_transport_mode_reply(message_text: str) -> Optional[str]:
|
||
normalized = re.sub(r"\s+", "", str(message_text or "")).lower()
|
||
has_long_connection = "长连接" in normalized or "longconnection" in normalized
|
||
has_webhook = "webhook" in normalized
|
||
if not (has_long_connection and has_webhook):
|
||
return None
|
||
if not any(token in normalized for token in TRANSPORT_MODE_CORRECTION_HINTS):
|
||
return None
|
||
return "已按长连接模式处理飞书接入,当前不会走 webhook;这条说明不会再被当成信号源类型变更。"
|
||
|
||
@staticmethod
|
||
def _serialize_project(project: Optional[Project]) -> Optional[ProjectSnapshot]:
|
||
if project is None:
|
||
return None
|
||
return {
|
||
"id": int(project.id),
|
||
"name": str(project.name or ""),
|
||
"title": str(project.title or project.name or ""),
|
||
"description": str(project.description or ""),
|
||
"status": str(project.status or ""),
|
||
"owner_id": _safe_int(project.owner_id),
|
||
}
|