311 lines
10 KiB
Python
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
|
|
|
|
|