"""AI PMO 决策中枢内核服务。""" from __future__ import annotations 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 ..models import ( AIProcessingLog, Budget, DecisionRecord, IngestEvent, Notification, PlanBundle, PortfolioSnapshot, Project, ProjectDomain, ProjectSignalLink, ReplyReceipt, Signal, Task, TruthNode, User, UserProject, UserSkillBinding, UserWorkload, WorkItem, ) 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 .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", ("关键路径", "里程碑改动", "影响多个项目", "跨项目依赖调整")), ) 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.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 = await self._build_world_model( message_text=clean_text, sender=sender, project_hint_id=project_hint_id, ) 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, *, message_text: str, sender: str, project_hint_id: Optional[int], ) -> dict[str, Any]: project = await self._resolve_project(message_text=message_text, project_hint_id=project_hint_id) project_id = _safe_int(getattr(project, "id", None)) portfolio_summary = await self._build_portfolio_summary(project_id=project_id) budget = await self._build_budget_summary(project_id=project_id) capacity = await self._build_capacity_summary(project_id=project_id) skill_graph = await self._build_skill_graph(project_id=project_id) charter = await self._build_charter_snapshot(project_id=project_id) task_summary = await self._build_task_summary(project_id=project_id) risk_summary = await self._build_risk_summary(project_id=project_id) domain = await self._build_project_domain_snapshot(project_id=project_id) dependencies = self._build_dependency_summary(charter=charter, task_summary=task_summary) recent_signals = await self._build_recent_signal_summary(project_id=project_id) world_model = { "sender": sender, "resolved_project_id": project_id, "project": self._serialize_project(project) if project is not None else None, "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": project_id, "budget": budget, "capacity": capacity, "skill_graph_size": len(skill_graph), "dependency_pressure": dependencies.get("pressure"), }, } self.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 self.db.flush() return world_model async def _resolve_project(self, *, message_text: str, project_hint_id: Optional[int]) -> Optional[Project]: if project_hint_id is not None: 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)) 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() 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]) -> dict[str, Any]: 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]) -> dict[str, Any]: 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]) -> list[dict[str, Any]]: user_ids: list[int] = [] if project_id is not None: 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: 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 [] 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]) -> list[dict[str, Any]]: user_ids: list[int] = [] if project_id is not None: 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 [] 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]) -> Optional[dict[str, Any]]: if project_id is None: return None 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]) -> list[dict[str, Any]]: if project_id is None: return [] 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]) -> dict[str, Any]: if project_id is None: return {"total": 0, "by_status": {}, "overdue_count": 0, "uncovered_skill_tag_ids": [], "blocked_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[dict[str, Any]] = [] 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 skill_graph = await self._build_skill_graph(project_id=project_id) 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]) -> list[dict[str, Any]]: if project_id is None: return [] 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]) -> dict[str, Any]: 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[dict[str, Any]], task_summary: dict[str, Any]) -> dict[str, Any]: 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[dict[str, Any]]: 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), }