"""AI PMO 决策中枢内核服务。""" from __future__ import annotations import asyncio import hashlib import json import os import re import time from datetime import datetime, timedelta, timezone from typing import Any, Optional from sqlalchemy import and_, func, or_, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from ..config.ai_prompts import ( PROMPT_AI_PMO_INTENT_CLASSIFICATION, PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS, ) from ..core.logging import get_logger from ..models import ( AIProcessingLog, Budget, ConversationSession, DecisionRecord, Experiment, IngestEvent, Notification, OperationExecutionLedger, PlanBundle, PortfolioSnapshot, Project, ProjectDomain, ProjectSignalLink, QARecord, ReplyReceipt, Signal, SignalSourceRegistry, 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_pmo_decision_runtime import DecisionEngine from .ai_pmo_reply_renderer import ReplyRenderer from .ai_pmo_typed_executor import TypedExecutor from .ai_steward import AIStewardService from .ai_pmo_review_service import AIPMOReviewService from .ai_pmo_intent_routing import ( FOLLOWUP_TARGET_INTENTS, FOLLOWUP_WORK_ITEM_ID_RE, INTENT_CANDIDATES, INTENT_CONFLICT_POLICIES, INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE, INTENT_IGNORE, INTENT_LLM_FUNCTION_NAME, INTENT_PROJECT_CREATE, INTENT_QUERY, INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD, LEGACY_INTENT_ROUTE_MAP, ROUTE_ACTION_ANSWER, ROUTE_ACTION_CANCEL, ROUTE_ACTION_COMMIT, ROUTE_ACTION_CONFIRM, ROUTE_ACTION_CONTINUE, ROUTE_ACTION_CREATE, ROUTE_ACTION_DISCOVER, ROUTE_ACTION_ONBOARD, ROUTE_ACTION_REFRESH, ROUTE_FAMILY_FOLLOWUP, ROUTE_FAMILY_PROJECT_CREATE, ROUTE_FAMILY_PROJECT_SIGNAL, ROUTE_FAMILY_QUERY, ROUTE_FAMILY_SIGNAL_SOURCE, ROUTE_FOLLOWUP_CONFIRM, ROUTE_FOLLOWUP_CONTINUE, ROUTE_PROJECT_CREATE, ROUTE_PROJECT_SIGNAL_CANCEL, ROUTE_PROJECT_SIGNAL_COMMIT, ROUTE_PROJECT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_DISCOVER, ROUTE_PROJECT_SIGNAL_REFRESH, ROUTE_QUERY_ANSWER, ROUTE_SIGNAL_SOURCE_ONBOARD, SESSION_SHORT_CONFIRM_WINDOW_SECONDS, SHORT_CONFIRM_HINTS, SHORT_CONFIRM_MAX_MESSAGE_LENGTH, WRITE_INTENTS, CanonicalIntent, IntentResolver, RouteResolver, WorkflowRoute, ) from .ai_pmo_workflow_runtime import ContextAssembler, WorkflowRegistry from .context_cache import ContextCacheService from .notification_service import NotificationService from .ai_pmo_domain_workflow import SignalSourceDomainWorkflow from .llm import llm_service from .model_context_bundle_service import ModelContextBundleService from .signal_planner_service import SignalPlannerService from ..utils.timezone import now_cn_naive logger = get_logger(__name__) 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) TRANSPORT_MODE_CORRECTION_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", "群聊", "机器人")), ("消息台与详情页", ("消息台", "详情页", "展开详情", "摘要", "时间戳", "按钮")), ) 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 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 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_signal_id_list(value: Any) -> list[int]: ids: list[int] = [] seen: set[int] = set() if isinstance(value, list): items = value else: items = [value] for item in items: signal_id = _safe_int(item) if signal_id is None or signal_id <= 0: continue if signal_id in seen: continue seen.add(signal_id) ids.append(signal_id) return ids