"""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。""" @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 any(token in pretty for token in QUESTION_HINTS): 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_SIGNAL_CREATE, 0.65, project_id, IntentResolver._build_signal_title(pretty, default="新信号"), pretty[:300], {"fallback": True}, ) @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"]*>", " ", 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), }