PMFlow/backend/app/services/ai_pmo_kernel_common.py

311 lines
10 KiB
Python

"""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