"""AI PMO 决策中枢内核服务。""" from __future__ import annotations import asyncio import hashlib import json import os 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 ..config.ai_prompts import PROMPT_AI_PMO_INTENT_CLASSIFICATION from ..core.logging import get_logger from ..database import AsyncSessionLocal from ..models import ( AIProcessingLog, Budget, ConversationSession, DecisionRecord, IngestEvent, Notification, PlanBundle, PortfolioSnapshot, Project, ProjectDomain, ProjectSignalLink, QARecord, 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_agent_kernel import ( build_decision_trace, gate_execution as kernel_gate_execution, resolve_correction as kernel_resolve_correction, select_intent_route, ) 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 .llm import llm_service from .signal_source_orchestration_service import SignalSourceOrchestrationService from ..utils.timezone import now_cn_naive 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" def _looks_like_upstream_timeout_message(text: str) -> bool: lowered = str(text or "").strip().lower() if not lowered: return False timeout_signals = ( "request timed out before a response was generated", "agents.defaults.timeoutseconds", "timeoutseconds", ) return any(signal in lowered for signal in timeout_signals) QUESTION_HINTS = ( "?", "?", "怎么", "如何", "为何", "为什么", "什么", "是什么", "哪些", "有哪些", "特点", "技术特点", "能力", "介绍", "说明", "多少", "是否", "帮我看", "状态", "风险", "能做什么", "可以做什么", ) SMALLTALK_EXACT = { "收到", "收到啦", "好的", "ok", "okk", "yes", "no", "谢谢", "thanks", "thx", "在吗", "在不在", "怎么样了", "咋样了", "辛苦了", } SMALLTALK_SHORT_HINTS = ("怎么样了", "咋样了", "在吗", "辛苦了", "收到", "谢谢", "ok") SMALLTALK_STRUCTURED_HINTS = ("项目", "信号", "任务", "风险", "进度", "状态", "work_item", "工作项", "#") 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 = ("新增一个信号", "新增一条信号", "增加一个信号", "录入一个信号", "关注", "跟踪") GENERIC_SIGNAL_SOURCE_MESSAGE_RE = re.compile( r"^(?:请)?(?:按|按照)?(?:这个|上述|这条)?(?:分步|步骤)?(?:骤)?(?:把|将)?(?:它|该需求|这条需求)?(?:加入|接入|录入).{0,24}信号源(?:中)?$", re.IGNORECASE, ) MODULE_KEYWORD_RULES: tuple[tuple[str, tuple[str, ...]], ...] = ( ("用户管理与权限", ("用户管理", "角色权限", "crud", "批量导入", "成员管理")), ("技能图谱", ("技能图谱", "技能标签", "能力图谱", "雷达图")), ("工作量管理", ("工作量", "负载", "产能", "workload")), ("SSTC 工作流", ("sstc", "charter", "signal", "task", "execution", "release", "review", "工作流")), ("AI 能力编排", ("ai能力", "智能", "自动化", "推荐", "编排", "agent", "llm")), ("信号源接入", ("信号源", "source", "onboard", "接入")), ("飞书集成", ("飞书", "feishu", "群聊", "机器人")), ("消息台与详情页", ("消息台", "详情页", "展开详情", "摘要", "时间戳", "按钮")), ) 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", ("关键路径", "里程碑改动", "影响多个项目", "跨项目依赖调整")), ) BOOL_TRUE_VALUES = {"1", "true", "yes", "y", "on", "是"} SESSION_PROJECT_INHERIT_WINDOW_SECONDS = 30 * 60 SESSION_SHORT_CONFIRM_WINDOW_SECONDS = 5 * 60 SHORT_CONFIRM_MAX_MESSAGE_LENGTH = 10 SHORT_CONFIRM_HINTS = ( "是", "好", "对", "确认", "新增", "加", "嗯", "yes", "ok", "好的", "是的", "行", "可以", "没问题", ) INTENT_LLM_TIMEOUT_DEFAULT_SECONDS = 30.0 INTENT_LLM_TIMEOUT_COMPLEX_DEFAULT_SECONDS = 30.0 INTENT_LLM_TIMEOUT_FALLBACK_DEFAULT_SECONDS = 35.0 INTENT_LLM_TOTAL_BUDGET_DEFAULT_SECONDS = 55.0 INTENT_LLM_STAGE2_CONFIDENCE_DEFAULT = 0.72 INTENT_LLM_STAGE2_MIN_REMAINING_DEFAULT_SECONDS = 12.0 INTENT_LLM_FUNCTION_NAME = "classify_intent_ai_pmo_kernel" INTENT_CANDIDATES = ( INTENT_SIGNAL_SOURCE_ONBOARD, INTENT_SIGNAL_CREATE, INTENT_QUERY, INTENT_PROJECT_CREATE, INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE, INTENT_IGNORE, ) WRITE_INTENTS = { INTENT_PROJECT_CREATE, INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD, INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE, } INTENT_CONFLICT_POLICIES = {"pending_manual", "prefer_rule", "prefer_llm"} WORLD_MODEL_STATIC_CACHE_TTL_SECONDS = 10 * 60 WORLD_MODEL_STATIC_CACHE_KEY_PREFIX = "wm:static" def _utc_now_naive() -> datetime: return now_cn_naive() def _env_bool(name: str, default: bool = False) -> bool: raw = os.getenv(name) if raw is None: return default return str(raw).strip().lower() in BOOL_TRUE_VALUES def _env_float(name: str, default: float) -> float: raw = os.getenv(name) if raw is None: return float(default) try: return float(raw) except (TypeError, ValueError): return float(default) def _intent_llm_timeout_seconds(*, function_name: str) -> float: normalized = str(function_name or "").strip().lower() if normalized == "classify_intent_complex": default_timeout = INTENT_LLM_TIMEOUT_COMPLEX_DEFAULT_SECONDS configured = _env_float( "AI_PMO_INTENT_LLM_TIMEOUT_COMPLEX_SECONDS", _env_float("AI_PMO_INTENT_LLM_TIMEOUT_SECONDS", default_timeout), ) elif normalized == str(INTENT_LLM_FUNCTION_NAME or "").strip().lower(): default_timeout = INTENT_LLM_TIMEOUT_FALLBACK_DEFAULT_SECONDS configured = _env_float( "AI_PMO_INTENT_LLM_TIMEOUT_FALLBACK_SECONDS", _env_float("AI_PMO_INTENT_LLM_TIMEOUT_SECONDS", default_timeout), ) else: default_timeout = INTENT_LLM_TIMEOUT_DEFAULT_SECONDS configured = _env_float("AI_PMO_INTENT_LLM_TIMEOUT_SECONDS", default_timeout) return min(max(float(configured), 12.0), 120.0) def _intent_llm_total_budget_seconds() -> float: configured = _env_float("AI_PMO_INTENT_LLM_TOTAL_BUDGET_SECONDS", INTENT_LLM_TOTAL_BUDGET_DEFAULT_SECONDS) return min(max(float(configured), 20.0), 120.0) def _intent_llm_stage2_confidence_threshold() -> float: configured = _env_float("AI_PMO_INTENT_LLM_STAGE2_CONFIDENCE_THRESHOLD", INTENT_LLM_STAGE2_CONFIDENCE_DEFAULT) return min(max(float(configured), 0.40), 0.95) def _intent_llm_stage2_min_remaining_seconds() -> float: configured = _env_float( "AI_PMO_INTENT_LLM_STAGE2_MIN_REMAINING_SECONDS", INTENT_LLM_STAGE2_MIN_REMAINING_DEFAULT_SECONDS, ) return min(max(float(configured), 8.0), 30.0) def _should_force_low_tier_intent_route(*, message_text: str, correction_action: str) -> bool: if str(correction_action or "").strip().lower() != "none": return True normalized = re.sub(r"\s+", "", str(message_text or "")).lower() if not normalized: return False return any(token in normalized for token in TRANSPORT_MODE_CORRECTION_HINTS) def _should_escalate_to_high_intent_route( *, route_is_complex: bool, low_intent: Optional["CanonicalIntent"], rule_intent: Optional["CanonicalIntent"], ) -> tuple[bool, list[str]]: reasons: list[str] = [] if route_is_complex: reasons.append("route_complex") if low_intent is None: reasons.append("stage1_empty") else: low_confidence = float(low_intent.confidence or 0.0) if low_confidence < _intent_llm_stage2_confidence_threshold(): reasons.append("stage1_low_confidence") if rule_intent is not None and low_intent is not None and str(rule_intent.name or "") != str(low_intent.name or ""): reasons.append("stage1_rule_conflict") return bool(reasons), reasons def _safe_int(value: Any) -> Optional[int]: try: if value is None or value == "": return None return int(value) except (TypeError, ValueError): return None def _normalize_workload_ratio(value: Any) -> float: try: ratio = float(value or 0.0) except (TypeError, ValueError): return 0.0 if ratio <= 0: return 0.0 return ratio / 100.0 if ratio > 1.0 else ratio @dataclass(slots=True) class CanonicalIntent: name: str confidence: float project_id: Optional[int] signal_title: str signal_summary: str metadata: dict[str, Any] class IntentResolver: """把输入收口为 canonical intent。""" OPERATION_VERBS = ( "新增", "增加", "加一个", "加个", "新建", "创建", "建立", "立项", "接入", "修复", "优化", "改成", "改为", "更新", "分配", "指派", "归档", "执行", ) @staticmethod def resolve(*, text: str, project_id: Optional[int]) -> CanonicalIntent: normalized = re.sub(r"\s+", "", str(text or "")).lower() pretty = re.sub(r"\s+", " ", str(text or "")).strip() if not pretty: return CanonicalIntent(INTENT_IGNORE, 1.0, project_id, "", "", {"reason": "empty"}) if IntentResolver._is_smalltalk_message(pretty=pretty, normalized=normalized): 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 ( IntentResolver._looks_like_project_create_request(text=pretty) or OpenClawCommanderService._extract_project_create_title(pretty) ): return CanonicalIntent( INTENT_PROJECT_CREATE, 0.9, project_id, IntentResolver._build_signal_title(pretty, default="新项目创建请求"), pretty[:300], {}, ) if IntentResolver._looks_like_query(text=pretty): return CanonicalIntent( INTENT_QUERY, 0.88, project_id, "", pretty[:300], {}, ) if any(token in pretty for token in SIGNAL_CREATE_HINTS) or any(token in pretty for token in ("新增", "增加", "录入", "跟踪", "关注")): return CanonicalIntent( INTENT_SIGNAL_CREATE, 0.78, project_id, IntentResolver._build_signal_title(pretty, default="新信号"), pretty[:300], {}, ) return CanonicalIntent( INTENT_QUERY, 0.6, project_id, "", pretty[:300], {"fallback": True, "reason": "default_query_fallback"}, ) @staticmethod def _is_smalltalk_message(*, pretty: str, normalized: str) -> bool: compact = re.sub(r"[\\s\\??!!,,。~~、]+", "", str(pretty or "")).strip().lower() if not compact: return True if any(keyword in compact for keyword in SMALLTALK_STRUCTURED_HINTS): return False if compact in SMALLTALK_EXACT: return True if len(compact) <= 8 and any(keyword in compact for keyword in SMALLTALK_SHORT_HINTS): return True return False @staticmethod def _looks_like_query(*, text: str) -> bool: normalized = re.sub(r"\s+", "", str(text or "")).lower() if not normalized: return False if any(token in normalized for token in QUESTION_HINTS): return True has_question_structure = ( "?" in normalized or "?" in normalized or any(token in normalized for token in ("吗", "呢", "吧", "是否", "是不是")) ) if not has_question_structure: return False has_operation = any(token in normalized for token in IntentResolver.OPERATION_VERBS) return not has_operation @staticmethod def _looks_like_project_create_request(*, text: str) -> bool: normalized = re.sub(r"\s+", "", str(text or "")).lower() if not normalized: return False if any(token in normalized for token in PROJECT_CREATE_HINTS): return True return bool( re.search( r"(?:新增|增加|添加|新建|创建|建立|开个|开一个|建个|建一个|做个|做一个|加个|加一个|立项)(?:一个|个)?项目", normalized, re.IGNORECASE, ) ) @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) @staticmethod def _normalize_short_text(value: Any, *, max_len: int) -> str: text = re.sub(r"\s+", " ", str(value or "")).strip() if not text: return "" return text[:max_len] @staticmethod def _is_generic_signal_source_title(value: str) -> bool: compact = re.sub(r"\s+", "", str(value or "")).strip().lower() if not compact: return True if bool(GENERIC_SIGNAL_SOURCE_MESSAGE_RE.match(compact)): return True return compact in {"信号源接入请求", "新增信号源接入请求", "新增信号源"} @classmethod def _build_signal_source_semantic_payload( cls, *, intent: CanonicalIntent, message_text: str, ) -> dict[str, Any]: metadata = intent.metadata if isinstance(intent.metadata, dict) else {} modules = metadata.get("closure_modules") if isinstance(metadata.get("closure_modules"), list) else [] normalized_modules = [str(item).strip() for item in modules if str(item).strip()][:6] summary = cls._normalize_short_text( metadata.get("closure_summary") or intent.signal_summary or message_text, max_len=300, ) dialogue = cls._normalize_short_text( metadata.get("closure_dialogue") or message_text, max_len=1800, ) title_seed = cls._normalize_short_text(intent.signal_title or "信号源接入请求", max_len=120) if cls._is_generic_signal_source_title(title_seed): if normalized_modules: title_seed = f"信号源接入 · {normalized_modules[0]}"[:120] else: title_seed = "信号源接入请求" reasoning_bullets = ["由 AI PMO kernel 统一入库。"] if normalized_modules: reasoning_bullets.append(f"闭环提炼模块:{'、'.join(normalized_modules[:4])}") if summary: reasoning_bullets.append(f"闭环摘要:{summary[:120]}") strategic_analysis = { "source_context": { "conversation_closure": dialogue or None, "implementation_modules": normalized_modules, }, "ai_analysis": { "summary": summary or None, "intent": INTENT_SIGNAL_SOURCE_ONBOARD, "requirement_project_scope": "existing_project" if intent.project_id is not None else "pending_classification", }, } return { "title": title_seed, "summary": summary or cls._normalize_short_text(message_text, max_len=300), "content": dialogue or cls._normalize_short_text(message_text, max_len=1800), "modules": normalized_modules, "reasoning_bullets": reasoning_bullets, "strategic_analysis": strategic_analysis, } 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: semantic_payload = self._build_signal_source_semantic_payload( intent=intent, message_text=message_text, ) signal = await self._create_signal( title=str(semantic_payload.get("title") or intent.signal_title or "新增信号源接入请求"), summary=str(semantic_payload.get("summary") or intent.signal_summary or message_text), content=str(semantic_payload.get("content") or message_text), project_id=intent.project_id, source="PMFlow", signal_type="新需求", recommended_action="enter_only", reasoning_bullets=( semantic_payload.get("reasoning_bullets") if isinstance(semantic_payload.get("reasoning_bullets"), list) else None ), strategic_analysis=( semantic_payload.get("strategic_analysis") if isinstance(semantic_payload.get("strategic_analysis"), dict) else None ), ) 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, reasoning_bullets: Optional[list[str]] = None, strategic_analysis: Optional[dict[str, Any]] = None, ) -> 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(reasoning_bullets or ["由 AI PMO kernel 统一入库。"], ensure_ascii=False), strategic_analysis=(json.dumps(strategic_analysis, ensure_ascii=False) if isinstance(strategic_analysis, dict) else None), 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() perf_work_item_id: Optional[int] = None perf_intent_name = "" def _log_perf(stage_name: str, stage_started_at: float) -> None: elapsed_seconds = time.perf_counter() - stage_started_at perf_line = ( f"[PERF] {stage_name} took {elapsed_seconds:.2f}s " f"work_item={perf_work_item_id if perf_work_item_id is not None else 'N/A'} " f"intent={perf_intent_name or 'N/A'}" ) logger.info(perf_line) print(perf_line, flush=True) 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, ) closure_payload = await self._build_message_closure_payload( source_system=source_system, channel_ref=channel_ref, sender=sender, source_sender_open_id=source_sender_open_id, message_text=clean_text, source_message_id=synthetic_message_id, captured_at=captured_at, project_id=project_hint_id, ) 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, "closure_dialogue": closure_payload.get("closure_dialogue"), "closure_summary": closure_payload.get("closure_summary"), "closure_modules": closure_payload.get("closure_modules"), "closure_turns": closure_payload.get("closure_turns"), }, ) 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)), ) perf_work_item_id = int(work_item.id) session = await self._get_or_create_session(tenant_id=self.tenant_id, sender=sender) 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, session=session, ) stage_started_at = time.perf_counter() 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, ) _log_perf("_build_world_model", stage_started_at) self._schedule_portfolio_snapshot_persist(world_model=world_model) work_item.state = STATE_MODELED stage_started_at = time.perf_counter() intent, intent_context_source = await self._resolve_intent( message_text=clean_text, session=session, world_model=world_model, ) intent = self._enrich_signal_source_intent_from_closure( intent=intent, message_text=clean_text, closure_payload=closure_payload, ) perf_intent_name = intent.name _log_perf("_resolve_intent", stage_started_at) if intent_context_source: world_model["context_source"] = intent_context_source work_item.context_snapshot = world_model resolved_project_for_session = _safe_int(intent.project_id) or _safe_int(world_model.get("resolved_project_id")) fallback_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._update_session( tenant_id=self.tenant_id, sender=sender, current_project_id=resolved_project_for_session, last_intent=intent.name, last_message_text=clean_text[:200], ) 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 stage_started_at = time.perf_counter() decision_payload = self.decision_engine.decide( intent=intent, world_model=world_model, message_text=clean_text, ) _log_perf("DecisionEngine.decide", stage_started_at) correction_action = str((intent.metadata or {}).get("correction_action") or "none").strip() or "none" requires_manual_review = bool((intent.metadata or {}).get("requires_manual_review")) manual_review_code = str((intent.metadata or {}).get("manual_review_code") or "manual_review_required").strip() manual_review_reason = str((intent.metadata or {}).get("manual_review_reason") or "").strip() llm_function = str((intent.metadata or {}).get("llm_function") or INTENT_LLM_FUNCTION_NAME).strip() route_tier = str((intent.metadata or {}).get("route_tier") or "rule").strip() route_reasons = (intent.metadata or {}).get("route_reasons") gate = kernel_gate_execution( confidence=float(intent.confidence or 0.0), risk_level=self._risk_level_for_intent(intent.name), correction_action=correction_action, force_manual=requires_manual_review, force_manual_code=manual_review_code, force_manual_reason=manual_review_reason, ) decision_trace = build_decision_trace( intent=intent.name, confidence=float(intent.confidence or 0.0), route_tier=route_tier, route_function=llm_function, gate_action=gate.action, gate_code=gate.gate_code, correction_action=correction_action, correction_keywords=(intent.metadata or {}).get("correction_keywords") if isinstance(intent.metadata, dict) else [], complexity_reasons=route_reasons if isinstance(route_reasons, list) else [], ) 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 gate.auto_execute: policy["auto_execute"] = False policy["policy_gate_hit"] = True policy["policy_gate_code"] = gate.gate_code policy["approval_requirement"] = { "required": True, "reason": gate.gate_code, "message": gate.reason, } 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(), "decision_trace": decision_trace, } 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._update_session( tenant_id=self.tenant_id, sender=sender, current_project_id=_safe_int(work_item.project_id), last_intent=intent.name, last_message_text=clean_text[:200], ) await self.db.commit() stage_started_at = time.perf_counter() capture_result = 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", ) _log_perf("_build_capture_result", stage_started_at) return capture_result 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}: stage_started_at = time.perf_counter() 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, ) _log_perf("TypedExecutor.execute", stage_started_at) else: stage_started_at = time.perf_counter() 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, ) _log_perf("TypedExecutor.execute", stage_started_at) verification = await self._verify_execution(intent=intent, execution_result=execution_result, world_model=world_model) if isinstance(execution_result, dict): execution_result.setdefault("decision_trace", decision_trace) 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), ) final_project_id = _safe_int(work_item.project_id) or resolved_project_for_session final_intent_name = str(work_item.intent or fallback_intent.name or "").strip() or intent.name await self._update_session( tenant_id=self.tenant_id, sender=sender, current_project_id=final_project_id, last_intent=final_intent_name, last_message_text=clean_text[:200], ) await self.db.commit() stage_started_at = time.perf_counter() capture_result = 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") ), ) _log_perf("_build_capture_result", stage_started_at) return capture_result 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], session: Optional[ConversationSession] = None, ) -> 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, session=session, ) 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, session: Optional[ConversationSession] = 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 inherited_project_id = self._extract_inherited_project_id(session=session) if inherited_project_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(inherited_project_id), ) ).limit(1) ) ).scalar_one_or_none() return None @staticmethod def _within_recent_window(last_active_at: Optional[datetime], *, window_seconds: int) -> bool: if not isinstance(last_active_at, datetime): return False now = _utc_now_naive() delta = now - last_active_at return 0 <= delta.total_seconds() <= float(window_seconds) def _extract_inherited_project_id(self, *, session: Optional[ConversationSession]) -> Optional[int]: if session is None: return None session_project_id = _safe_int(getattr(session, "current_project_id", None)) if session_project_id is None or int(session_project_id) <= 0: return None if not self._within_recent_window( getattr(session, "last_active_at", None), window_seconds=SESSION_PROJECT_INHERIT_WINDOW_SECONDS, ): return None return session_project_id async def _get_or_create_session(self, *, tenant_id: int, sender: str) -> ConversationSession: sender_key = str(sender or "").strip()[:128] or "unknown" existing = ( await self.db.execute( select(ConversationSession).where( and_( ConversationSession.tenant_id == int(tenant_id), ConversationSession.sender == sender_key, ) ).limit(1) ) ).scalar_one_or_none() if existing is not None: return existing created = ConversationSession( tenant_id=int(tenant_id), sender=sender_key, current_project_id=None, last_intent=None, last_message_text=None, last_active_at=_utc_now_naive(), ) nested_tx = await self.db.begin_nested() try: self.db.add(created) await self.db.flush() await nested_tx.commit() return created except IntegrityError: await nested_tx.rollback() existing = ( await self.db.execute( select(ConversationSession).where( and_( ConversationSession.tenant_id == int(tenant_id), ConversationSession.sender == sender_key, ) ).limit(1) ) ).scalar_one_or_none() if existing is not None: return existing raise async def _update_session( self, *, tenant_id: int, sender: str, current_project_id: Optional[int], last_intent: Optional[str], last_message_text: Optional[str], ) -> None: row = await self._get_or_create_session(tenant_id=tenant_id, sender=sender) if current_project_id is not None: row.current_project_id = int(current_project_id) row.last_intent = (str(last_intent or "").strip()[:64] or None) trimmed_message = str(last_message_text or "").strip() row.last_message_text = trimmed_message[:200] if trimmed_message else None row.last_active_at = _utc_now_naive() await self.db.flush() @staticmethod def _intent_llm_enabled() -> bool: return _env_bool("AI_PMO_INTENT_LLM_ENABLED", True) @staticmethod def _intent_arbitration_enabled() -> bool: return _env_bool("AI_PMO_INTENT_ARBITRATION_ENABLED", True) @staticmethod def _intent_conflict_policy() -> str: raw = str(os.getenv("AI_PMO_INTENT_CONFLICT_POLICY", "pending_manual") or "").strip().lower() return raw if raw in INTENT_CONFLICT_POLICIES else "pending_manual" @staticmethod def _copy_intent_with_metadata(intent: CanonicalIntent, **metadata_updates: Any) -> CanonicalIntent: metadata = dict(intent.metadata or {}) metadata.update(metadata_updates) return CanonicalIntent( intent.name, float(intent.confidence or 0.0), intent.project_id, intent.signal_title, intent.signal_summary, metadata, ) @staticmethod def _is_default_rule_query(intent: CanonicalIntent) -> bool: if intent.name != INTENT_QUERY: return False metadata = intent.metadata if isinstance(intent.metadata, dict) else {} return bool(metadata.get("fallback")) and str(metadata.get("reason") or "") == "default_query_fallback" @staticmethod def _has_required_slots(*, intent: CanonicalIntent, message_text: str) -> tuple[bool, dict[str, Any]]: if intent.name != INTENT_PROJECT_CREATE: return True, {} extracted_title = AIStewardService._extract_project_title_from_instruction(message_text) has_title = bool(str(extracted_title or "").strip()) return has_title, { "slot_check": "project_title", "slot_ok": has_title, "extracted_project_title": extracted_title[:50] if extracted_title else "", } def _apply_slot_guard(self, *, intent: CanonicalIntent, message_text: str) -> CanonicalIntent: slot_ok, slot_meta = self._has_required_slots(intent=intent, message_text=message_text) if slot_ok: if slot_meta: return self._copy_intent_with_metadata(intent, **slot_meta) return intent return self._copy_intent_with_metadata( intent, **slot_meta, requires_manual_review=True, manual_review_code="slot_incomplete", manual_review_reason="关键信息不完整,需人工确认后执行。", ) def _arbitrate_intent( self, *, rule_intent: CanonicalIntent, llm_intent: CanonicalIntent, message_text: str, ) -> tuple[CanonicalIntent, str]: if rule_intent.name == llm_intent.name: return self._copy_intent_with_metadata( llm_intent, arbitration="consensus", arbitration_rule_intent=rule_intent.name, arbitration_llm_intent=llm_intent.name, ), "llm" policy = self._intent_conflict_policy() rule_is_write = rule_intent.name in WRITE_INTENTS llm_is_write = llm_intent.name in WRITE_INTENTS has_write_conflict = rule_is_write or llm_is_write prefer_rule_by_default = ( not self._is_default_rule_query(rule_intent) and ( rule_intent.confidence >= llm_intent.confidence or self._is_default_rule_query(llm_intent) ) ) if policy == "prefer_rule": chosen = rule_intent source = "rule_fallback" elif policy == "prefer_llm": chosen = llm_intent source = "llm" else: chosen = rule_intent if prefer_rule_by_default else llm_intent source = "rule_fallback" if chosen is rule_intent else "llm" if has_write_conflict and policy == "pending_manual": chosen = self._copy_intent_with_metadata( chosen, arbitration="conflict_manual_review", arbitration_rule_intent=rule_intent.name, arbitration_llm_intent=llm_intent.name, arbitration_rule_confidence=float(rule_intent.confidence or 0.0), arbitration_llm_confidence=float(llm_intent.confidence or 0.0), requires_manual_review=True, manual_review_code="intent_conflict_write", manual_review_reason="规则与LLM意图冲突,且涉及写操作,需人工确认后执行。", ) return chosen, source return self._copy_intent_with_metadata( chosen, arbitration="prefer_rule" if source == "rule_fallback" else "prefer_llm", arbitration_rule_intent=rule_intent.name, arbitration_llm_intent=llm_intent.name, arbitration_rule_confidence=float(rule_intent.confidence or 0.0), arbitration_llm_confidence=float(llm_intent.confidence or 0.0), ), source @classmethod def _is_short_confirmation_message( cls, *, message_text: str, session: Optional[ConversationSession], ) -> bool: if session is None: return False if not cls._within_recent_window( getattr(session, "last_active_at", None), window_seconds=SESSION_SHORT_CONFIRM_WINDOW_SECONDS, ): return False last_intent = str(getattr(session, "last_intent", "") or "").strip() if not last_intent: return False normalized = re.sub(r"\s+", "", str(message_text or "")).lower() if not normalized: return False if len(normalized) > SHORT_CONFIRM_MAX_MESSAGE_LENGTH: return False return any(token in normalized for token in SHORT_CONFIRM_HINTS) @staticmethod def _build_canonical_intent( *, intent_name: str, message_text: str, project_id: Optional[int], confidence: float, metadata: Optional[dict[str, Any]] = None, ) -> CanonicalIntent: pretty = re.sub(r"\s+", " ", str(message_text or "")).strip() extras = dict(metadata or {}) safe_confidence = max(0.0, min(1.0, float(confidence or 0.0))) if intent_name == INTENT_SIGNAL_SOURCE_ONBOARD: extras.setdefault("source_request", True) return CanonicalIntent( INTENT_SIGNAL_SOURCE_ONBOARD, safe_confidence, project_id, IntentResolver._build_signal_title(pretty, default="新增信号源接入请求"), pretty[:300], extras, ) if intent_name == INTENT_SIGNAL_CREATE: return CanonicalIntent( INTENT_SIGNAL_CREATE, safe_confidence, project_id, IntentResolver._build_signal_title(pretty, default="新信号"), pretty[:300], extras, ) if intent_name == INTENT_PROJECT_CREATE: return CanonicalIntent( INTENT_PROJECT_CREATE, safe_confidence, project_id, IntentResolver._build_signal_title(pretty, default="新项目创建请求"), pretty[:300], extras, ) if intent_name == INTENT_QUERY: return CanonicalIntent(INTENT_QUERY, safe_confidence, project_id, "", pretty[:300], extras) if intent_name == INTENT_FOLLOWUP_CONFIRM: return CanonicalIntent(INTENT_FOLLOWUP_CONFIRM, safe_confidence, project_id, "", "", extras) if intent_name == INTENT_FOLLOWUP_CONTINUE: return CanonicalIntent(INTENT_FOLLOWUP_CONTINUE, safe_confidence, project_id, "", "", extras) return CanonicalIntent(INTENT_IGNORE, safe_confidence, project_id, "", "", extras or {"reason": "unsupported_intent"}) def _build_short_confirm_intent( self, *, message_text: str, session: ConversationSession, world_model: dict[str, Any], ) -> CanonicalIntent: inherited_name = str(getattr(session, "last_intent", "") or "").strip() if inherited_name not in INTENT_CANDIDATES: inherited_name = INTENT_QUERY inherited_project_id = _safe_int(getattr(session, "current_project_id", None)) or _safe_int(world_model.get("resolved_project_id")) if inherited_project_id is not None and int(inherited_project_id) <= 0: inherited_project_id = None inherited_text = str(getattr(session, "last_message_text", "") or "").strip() or message_text return self._build_canonical_intent( intent_name=inherited_name, message_text=inherited_text, project_id=inherited_project_id, confidence=0.86, metadata={ "context_source": "session_inherited", "inherited_from_intent": str(getattr(session, "last_intent", "") or "").strip(), }, ) async def _resolve_intent_with_llm( self, *, message_text: str, session: Optional[ConversationSession], world_model: dict[str, Any], rule_intent: Optional[CanonicalIntent] = None, ) -> Optional[CanonicalIntent]: project_snapshot = world_model.get("project") if isinstance(world_model.get("project"), dict) else {} project_names = [ str(project_snapshot.get("title") or "").strip(), str(project_snapshot.get("name") or "").strip(), ] route_decision = select_intent_route(message_text, project_names=project_names) correction_decision = kernel_resolve_correction(message_text) forced_low_tier = _should_force_low_tier_intent_route( message_text=message_text, correction_action=correction_decision.action, ) prompt = PROMPT_AI_PMO_INTENT_CLASSIFICATION.format( message_text=str(message_text or "")[:1200], session_last_intent=str(getattr(session, "last_intent", "") or "").strip() if session is not None else "", session_project_id=_safe_int(getattr(session, "current_project_id", None)) if session is not None else None, world_model_project_id=_safe_int(world_model.get("resolved_project_id")), candidate_intents_json=json.dumps(list(INTENT_CANDIDATES), ensure_ascii=False), ).strip() total_budget_seconds = _intent_llm_total_budget_seconds() stage_started_at = time.perf_counter() async def _invoke_intent_stage( *, function_name: str, route_tier: str, route_reasons: list[str], stage_label: str, timeout_cap_seconds: Optional[float] = None, ) -> Optional[CanonicalIntent]: llm_timeout_seconds = _intent_llm_timeout_seconds(function_name=function_name) if timeout_cap_seconds is not None: llm_timeout_seconds = min(llm_timeout_seconds, max(float(timeout_cap_seconds), 0.0)) if llm_timeout_seconds <= 0.0: return None logger.info( "ai_pmo_intent_llm_route", tenant_id=self.tenant_id, stage=stage_label, function_name=function_name, route_tier=route_tier, timeout_seconds=llm_timeout_seconds, forced_low_tier=forced_low_tier, correction_action=correction_decision.action, correction_keywords=correction_decision.matched_keywords, route_reasons=route_reasons, total_budget_seconds=total_budget_seconds, ) try: extraction = await asyncio.wait_for( llm_service.generate_structured_object( prompt, db=self.db, tenant_id=self.tenant_id, function_name=function_name, use_prompt_profile=False, temperature=0.0, max_tokens=260, response_mode="fast", ), timeout=llm_timeout_seconds, ) except TimeoutError: logger.warning( "ai_pmo_intent_llm_timeout", tenant_id=self.tenant_id, stage=stage_label, function_name=function_name, timeout_seconds=llm_timeout_seconds, ) return None except Exception as exc: logger.warning( "ai_pmo_intent_llm_failed", tenant_id=self.tenant_id, stage=stage_label, function_name=function_name, error=str(exc), ) return None payload = extraction.payload if extraction and isinstance(extraction.payload, dict) else None if payload is None: logger.warning( "ai_pmo_intent_llm_empty_payload", tenant_id=self.tenant_id, stage=stage_label, function_name=function_name, error_code=str(getattr(extraction, "error_code", "") or ""), provider=str(getattr(extraction, "provider", "") or ""), model=str(getattr(extraction, "model", "") or ""), ) return None intent_name = str(payload.get("intent") or "").strip() if intent_name not in INTENT_CANDIDATES: return None confidence = float(payload.get("confidence") or 0.0) project_id = _safe_int(payload.get("project_id")) if project_id is not None and int(project_id) <= 0: project_id = None if project_id is None: project_id = _safe_int(world_model.get("resolved_project_id")) reasoning = str(payload.get("reasoning") or "").strip() decision_trace = build_decision_trace( intent=intent_name, confidence=confidence, route_tier=route_tier, route_function=function_name, gate_action="unresolved", gate_code="intent_only", correction_action=correction_decision.action, correction_keywords=correction_decision.matched_keywords, complexity_reasons=route_reasons, ) return self._build_canonical_intent( intent_name=intent_name, message_text=message_text, project_id=project_id, confidence=confidence, metadata={ "context_source": "llm", "stage": stage_label, "reasoning": reasoning[:300], "llm_function": function_name, "route_tier": route_tier, "route_complex": route_tier == "high", "route_reasons": route_reasons, "forced_low_tier": forced_low_tier, "timeout_seconds": llm_timeout_seconds, "total_budget_seconds": total_budget_seconds, "correction_action": correction_decision.action, "correction_keywords": correction_decision.matched_keywords, "decision_trace": decision_trace, }, ) low_stage_reasons = ["stage1_low_first", *list(route_decision.reasons or [])] if forced_low_tier: low_stage_reasons.append("correction_forced_low_tier") low_intent = await _invoke_intent_stage( function_name=INTENT_LLM_FUNCTION_NAME, route_tier="low", route_reasons=low_stage_reasons, stage_label="stage1_low", ) if forced_low_tier: return low_intent should_escalate, escalate_reasons = _should_escalate_to_high_intent_route( route_is_complex=bool(route_decision.is_complex), low_intent=low_intent, rule_intent=rule_intent, ) if not should_escalate: return low_intent elapsed_seconds = time.perf_counter() - stage_started_at remaining_budget_seconds = total_budget_seconds - elapsed_seconds stage2_min_remaining_seconds = _intent_llm_stage2_min_remaining_seconds() if remaining_budget_seconds < stage2_min_remaining_seconds: logger.info( "ai_pmo_intent_stage2_skipped", tenant_id=self.tenant_id, reason="budget_insufficient", remaining_budget_seconds=max(0.0, remaining_budget_seconds), min_remaining_seconds=stage2_min_remaining_seconds, escalate_reasons=escalate_reasons, ) if low_intent is not None: return self._copy_intent_with_metadata( low_intent, stage2_skipped=True, stage2_skip_reason="budget_insufficient", stage2_reasons=escalate_reasons, ) return None high_stage_reasons = [*list(route_decision.reasons or []), *escalate_reasons, "stage2_high_retry"] high_intent = await _invoke_intent_stage( function_name="classify_intent_complex", route_tier="high", route_reasons=high_stage_reasons, stage_label="stage2_high", timeout_cap_seconds=remaining_budget_seconds, ) if high_intent is not None: if low_intent is not None: return self._copy_intent_with_metadata( high_intent, stage2_promoted=True, stage1_intent=low_intent.name, stage1_confidence=float(low_intent.confidence or 0.0), ) return high_intent if low_intent is not None: return self._copy_intent_with_metadata( low_intent, stage2_attempted=True, stage2_failed=True, stage2_reasons=escalate_reasons, ) return None async def _resolve_intent( self, *, message_text: str, session: Optional[ConversationSession], world_model: dict[str, Any], ) -> tuple[CanonicalIntent, str]: project_id = _safe_int(world_model.get("resolved_project_id")) rule_intent = self.intent_resolver.resolve(text=message_text, project_id=project_id) if self._is_short_confirmation_message(message_text=message_text, session=session): if session is None: return rule_intent, "rule_fallback" return ( self._build_short_confirm_intent( message_text=message_text, session=session, world_model=world_model, ), "session_inherited", ) if self._intent_llm_enabled(): llm_intent = await self._resolve_intent_with_llm( message_text=message_text, session=session, world_model=world_model, rule_intent=rule_intent, ) if llm_intent is not None: if self._intent_arbitration_enabled(): selected_intent, source = self._arbitrate_intent( rule_intent=rule_intent, llm_intent=llm_intent, message_text=message_text, ) else: selected_intent, source = llm_intent, "llm" return self._apply_slot_guard(intent=selected_intent, message_text=message_text), source logger.info( "ai_pmo_intent_rule_fallback", tenant_id=self.tenant_id, reason="intent_llm_empty_or_failed_or_timeout", ) return self._apply_slot_guard(intent=rule_intent, message_text=message_text), "rule_fallback" 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), ) target_intent = self._enrich_signal_source_intent_from_closure( intent=target_intent, message_text=original_message, closure_payload={ "closure_summary": payload.get("closure_summary"), "closure_dialogue": payload.get("closure_dialogue"), "closure_modules": payload.get("closure_modules"), }, ) 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_issues = target_verification.get("issues") if isinstance(target_verification.get("issues"), list) else [] if ( not bool(target_verification.get("verified")) and target_intent.name == INTENT_SIGNAL_SOURCE_ONBOARD and any(issue in {"signal_source_pending_manual", "pending_manual"} for issue in target_issues) and _safe_int(target_execution_result.get("signal_id")) is not None and _safe_int(target_work_item.project_id) is not None ): target_execution_result["post_process_status"] = str(target_execution_result.get("status") or "").strip() or "pending_manual" target_execution_result["status"] = "succeeded" target_execution_result["manual_gate_resolved"] = True target_verification = {"verified": True, "issues": []} target_issues = [] 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: 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 [] direct_reply_text = str(render.get("text") or "").strip() if _looks_like_upstream_timeout_message(direct_reply_text): logger.warning( "ai_pmo_upstream_timeout_reply_detected", tenant_id=int(self.tenant_id), work_item_id=int(work_item.id), intent=str(intent.name or ""), source_message_id=str(work_item.source_message_id or "")[:128], observed_at=now_cn_naive().isoformat(), reply_preview=direct_reply_text[:260], ) 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": direct_reply_text, "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, "decision_trace": ( execution_result.get("decision_trace") if isinstance(execution_result, dict) else ((intent.metadata or {}).get("decision_trace") if isinstance(intent.metadata, dict) else None) ), } 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 _risk_level_for_intent(intent_name: str) -> str: if intent_name in {INTENT_PROJECT_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD}: return "high" if intent_name in {INTENT_SIGNAL_CREATE, INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: return "medium" return "low" @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_feishu_rag_conversation_key( *, source_chat_id: Optional[str], source_sender_open_id: Optional[str], sender: Optional[str], ) -> str: chat = str(source_chat_id or "").strip() if not chat: return "" actor = str(source_sender_open_id or "").strip() if not actor: actor = str(sender or "").strip().lower() actor = re.sub(r"\s+", "", actor) actor = actor[:96] if actor else "unknown" return f"feishu:{chat}:{actor}" @staticmethod def _normalize_dialogue_text(value: Any, *, max_len: int) -> str: text = re.sub(r"\s+", " ", str(value or "")).strip() if not text: return "" return text[:max_len] @staticmethod def _parse_datetime_like(value: Any) -> Optional[datetime]: if isinstance(value, datetime): dt = value else: raw = str(value or "").strip() if not raw: return None try: dt = datetime.fromisoformat(raw.replace("Z", "+00:00")) except Exception: return None if dt.tzinfo is not None: return dt.astimezone(timezone.utc).replace(tzinfo=None) return dt @staticmethod def _normalize_feishu_user_text(value: Any, *, max_len: int) -> str: text = str(value or "") text = re.sub(r"^@_user_\d+\s*", "", text) text = re.sub(r"]*>", " ", text, flags=re.IGNORECASE) text = re.sub(r"\s+", " ", text).strip() if not text: return "" return text[:max_len] @classmethod def _extract_assistant_text_from_log_payload(cls, payload: Any) -> str: if not isinstance(payload, dict): return "" candidates = ( payload.get("followup_reply"), payload.get("direct_reply_text"), payload.get("clarification_message"), payload.get("answer"), payload.get("reason"), payload.get("result_summary"), payload.get("error_message"), payload.get("message"), ) for item in candidates: text = cls._normalize_dialogue_text(item, max_len=420) if not text: continue if any(token in text for token in ("chat_id", "message_type", "sender_name")) and len(text) < 48: continue return text return "" @staticmethod def _merge_closure_turns( base_turns: list[dict[str, str]], extra_turns: list[dict[str, str]], *, prepend: bool = False, ) -> list[dict[str, str]]: if not extra_turns: return list(base_turns) merged = list(extra_turns) + list(base_turns) if prepend else list(base_turns) + list(extra_turns) compact: list[dict[str, str]] = [] for row in merged: role = str(row.get("role") or "").strip().lower() text = str(row.get("text") or "").strip() if role not in {"user", "assistant"} or not text: continue if compact and compact[-1].get("role") == role and compact[-1].get("text") == text: continue compact.append({"role": role, "text": text}) return compact[-8:] @staticmethod def _turns_have_assistant(turns: list[dict[str, str]]) -> bool: return any(str(turn.get("role") or "").strip().lower() == "assistant" and str(turn.get("text") or "").strip() for turn in turns) async def _load_closure_turns_from_processing_logs( self, *, source_message_id: Optional[str], message_text: str, ) -> list[dict[str, str]]: message_id = str(source_message_id or "").strip() if not message_id: return [] rows = ( await self.db.execute( select(AIProcessingLog) .where( and_( AIProcessingLog.tenant_id == self.tenant_id, AIProcessingLog.feishu_message_id == message_id, ) ) .order_by(AIProcessingLog.id.asc()) .limit(12) ) ).scalars().all() if not rows: return [] normalized_message = self._normalize_dialogue_text(message_text, max_len=600) turns: list[dict[str, str]] = [] for row in rows: user_text = self._normalize_feishu_user_text( row.normalized_text or row.raw_message, max_len=260, ) if user_text and (not normalized_message or user_text != normalized_message): turns = self._merge_closure_turns(turns, [{"role": "user", "text": user_text}]) assistant_text = self._extract_assistant_text_from_log_payload(row.operation_result) if assistant_text: turns = self._merge_closure_turns(turns, [{"role": "assistant", "text": assistant_text}]) return turns[-4:] @classmethod def _score_qa_row_for_closure( cls, *, row: QARecord, normalized_message: str, reference_at: Optional[datetime], ) -> float: question = cls._normalize_dialogue_text(row.question, max_len=500) answer = cls._normalize_dialogue_text(row.answer, max_len=900) if not question or not answer: return -1.0 score = 0.0 merged = f"{question} {answer}" if "信号源" in merged: score += 3.0 if any(token in merged for token in ("模块", "后端", "前端", "流程", "SSTC", "任务", "执行", "接口")): score += 2.0 if any(token in answer for token in ("暂时无法生成可靠回答", "模型服务异常", "请稍后重试")): score -= 5.0 if normalized_message and cls._is_generic_signal_source_message(normalized_message): if "信号源" in question: score += 1.5 if "项目" in answer: score += 1.0 created_at = cls._parse_datetime_like(getattr(row, "created_at", None)) if reference_at is not None and created_at is not None: gap_seconds = abs((reference_at - created_at).total_seconds()) if gap_seconds > 7 * 24 * 3600: score -= 4.0 elif gap_seconds <= 6 * 3600: score += 1.5 elif gap_seconds <= 24 * 3600: score += 0.8 return score async def _load_closure_turns_from_qa_records( self, *, owner_user_id: Optional[int], project_id: Optional[int], captured_at: Optional[datetime], message_text: str, ) -> list[dict[str, str]]: uid = _safe_int(owner_user_id) pid = _safe_int(project_id) if uid is None and pid is None: return [] normalized_message = self._normalize_dialogue_text(message_text, max_len=600) reference_at = self._parse_datetime_like(captured_at) candidates: list[QARecord] = [] conditions: list[Any] = [] if uid is not None and pid is not None: conditions.append(and_(QARecord.asked_by == int(uid), QARecord.project_id == int(pid))) if pid is not None: conditions.append(QARecord.project_id == int(pid)) if uid is not None: conditions.append(QARecord.asked_by == int(uid)) for condition in conditions: stmt = select(QARecord).where( and_( QARecord.tenant_id == self.tenant_id, QARecord.asked_via == "feishu", condition, ) ) if reference_at is not None: stmt = stmt.where(QARecord.created_at <= reference_at) rows = ( await self.db.execute( stmt.order_by(QARecord.created_at.desc(), QARecord.id.desc()).limit(12) ) ).scalars().all() candidates.extend(rows) if rows: break if not candidates: return [] best_row: Optional[QARecord] = None best_score = -999.0 seen_ids: set[int] = set() for row in candidates: row_id = int(getattr(row, "id", 0) or 0) if row_id > 0 and row_id in seen_ids: continue seen_ids.add(row_id) score = self._score_qa_row_for_closure( row=row, normalized_message=normalized_message, reference_at=reference_at, ) if score > best_score: best_score = score best_row = row if best_row is None or best_score < 0.5: return [] question = self._normalize_dialogue_text(best_row.question, max_len=260) answer = self._normalize_dialogue_text(best_row.answer, max_len=420) if not question or not answer: return [] return [ {"role": "user", "text": question}, {"role": "assistant", "text": answer}, ] @staticmethod def _is_generic_signal_source_message(text: str) -> bool: compact = re.sub(r"\s+", "", str(text or "")).strip().lower() if not compact: return False if bool(GENERIC_SIGNAL_SOURCE_MESSAGE_RE.match(compact)): return True return len(compact) <= 24 and "信号源" in compact and ("这个" in compact or "步骤" in compact) @classmethod def _extract_implementation_modules_from_text(cls, text: str) -> list[str]: normalized = str(text or "").lower() modules: list[str] = [] for module_name, keywords in MODULE_KEYWORD_RULES: if any(str(keyword).lower() in normalized for keyword in keywords): modules.append(module_name) return modules[:6] @staticmethod def _build_closure_dialogue_excerpt(turns: list[dict[str, str]]) -> str: if not turns: return "" lines: list[str] = [] for turn in turns[-4:]: role = str(turn.get("role") or "").strip().lower() text = str(turn.get("text") or "").strip() if not text: continue if role == "assistant": lines.append(f"机器人:{text[:380]}") else: lines.append(f"用户:{text[:260]}") return "\n".join(lines)[:1600] @classmethod def _derive_closure_summary( cls, *, turns: list[dict[str, str]], modules: list[str], fallback_text: str, ) -> str: latest_user = "" latest_assistant = "" for turn in reversed(turns): role = str(turn.get("role") or "").strip().lower() text = str(turn.get("text") or "").strip() if not text: continue if role == "user" and not latest_user: latest_user = text if role == "assistant" and not latest_assistant: latest_assistant = text if latest_user and latest_assistant: break if latest_assistant and any(token in latest_assistant for token in ("缺少项目归属", "不能创建", "无法创建", "先确认项目")): if latest_user: return f"机器人先提示条件不足,用户随后继续要求执行:{latest_user[:120]}" return "机器人提示条件不足,需补齐项目归属后继续执行信号源接入。" if latest_user and cls._is_generic_signal_source_message(latest_user) and modules: if len(modules) == 1 and str(modules[0]).strip() == "信号源接入": return "用户要求将需求按步骤接入信号源,需要先补齐项目归属再继续执行。" return f"同一消息闭环内,用户要求将需求按步骤接入信号源,重点模块:{'、'.join(modules[:4])}。" if modules: return f"用户在本次消息闭环中推进信号源接入,涉及模块:{'、'.join(modules[:4])}。" if latest_user: return latest_user[:200] return str(fallback_text or "").strip()[:200] async def _build_message_closure_payload( self, *, source_system: str, channel_ref: Optional[str], sender: str, source_sender_open_id: Optional[str], message_text: str, source_message_id: Optional[str] = None, owner_user_id: Optional[int] = None, project_id: Optional[int] = None, captured_at: Optional[Any] = None, ) -> dict[str, Any]: normalized_message = self._normalize_dialogue_text(message_text, max_len=600) turns: list[dict[str, str]] = [] normalized_source_system = str(source_system or "").strip().lower() normalized_channel = str(channel_ref or "").strip() if normalized_source_system == "feishu" and normalized_channel: conversation_key = self._build_feishu_rag_conversation_key( source_chat_id=normalized_channel, source_sender_open_id=source_sender_open_id, sender=sender, ) session_id = AdaptiveRAGService._external_session_id_from_key(conversation_key) if session_id is not None: cached_messages = await self.context_cache.get_session_messages( tenant_id=self.tenant_id, session_id=int(session_id), ) cached_turns = self.context_cache.messages_to_turns(cached_messages) for row in cached_turns[-2:]: question = self._normalize_dialogue_text(row.get("question"), max_len=260) answer = self._normalize_dialogue_text(row.get("answer"), max_len=420) if question: turns.append({"role": "user", "text": question}) if answer: turns.append({"role": "assistant", "text": answer}) if normalized_source_system == "feishu" and not self._turns_have_assistant(turns): log_turns = await self._load_closure_turns_from_processing_logs( source_message_id=source_message_id, message_text=normalized_message, ) turns = self._merge_closure_turns(turns, log_turns) needs_semantic_turns = ( normalized_source_system == "feishu" and ( (not self._turns_have_assistant(turns)) or (self._is_generic_signal_source_message(normalized_message) and len(turns) <= 2) ) ) if needs_semantic_turns: qa_turns = await self._load_closure_turns_from_qa_records( owner_user_id=owner_user_id, project_id=project_id, captured_at=self._parse_datetime_like(captured_at), message_text=normalized_message, ) turns = self._merge_closure_turns(turns, qa_turns, prepend=True) if normalized_message: if not turns or str(turns[-1].get("text") or "").strip() != normalized_message: turns.append({"role": "user", "text": normalized_message}) closure_dialogue = self._build_closure_dialogue_excerpt(turns) modules = self._extract_implementation_modules_from_text( " ".join(str(turn.get("text") or "") for turn in turns) ) closure_summary = self._derive_closure_summary( turns=turns, modules=modules, fallback_text=normalized_message, ) return { "closure_dialogue": closure_dialogue or None, "closure_summary": closure_summary or None, "closure_modules": modules, "closure_turns": turns[-4:], } @classmethod def _enrich_signal_source_intent_from_closure( cls, *, intent: CanonicalIntent, message_text: str, closure_payload: dict[str, Any], ) -> CanonicalIntent: if intent.name != INTENT_SIGNAL_SOURCE_ONBOARD: return intent modules = closure_payload.get("closure_modules") if isinstance(closure_payload.get("closure_modules"), list) else [] closure_summary = cls._normalize_dialogue_text(closure_payload.get("closure_summary"), max_len=300) closure_dialogue = cls._normalize_dialogue_text(closure_payload.get("closure_dialogue"), max_len=1600) if closure_summary: intent.signal_summary = closure_summary elif not str(intent.signal_summary or "").strip(): intent.signal_summary = cls._normalize_dialogue_text(message_text, max_len=300) if cls._is_generic_signal_source_message(str(intent.signal_title or "")): if modules: intent.signal_title = f"信号源接入 · {str(modules[0])[:60]}"[:120] else: intent.signal_title = "信号源接入请求" metadata = dict(intent.metadata or {}) metadata["closure_summary"] = closure_summary or None metadata["closure_dialogue"] = closure_dialogue or None metadata["closure_modules"] = [str(item) for item in modules[:6] if str(item).strip()] intent.metadata = metadata return intent @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), }