PMFlow/backend/app/services/ai_pmo_kernel_service.py

4161 lines
174 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 .signal_planner_service import SignalPlannerService
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,
channel_ref: Optional[str] = None,
) -> 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,
)
strategic_payload = (
semantic_payload.get("strategic_analysis")
if isinstance(semantic_payload.get("strategic_analysis"), dict)
else {}
)
source_context = (
strategic_payload.get("source_context")
if isinstance(strategic_payload.get("source_context"), dict)
else {}
)
source_context["source_message_id"] = source_message_id
source_context["channel_ref"] = channel_ref
strategic_payload["source_context"] = source_context
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=(
strategic_payload
if isinstance(strategic_payload, 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:
strategic_analysis = {
"source_context": {
"source_message_id": source_message_id,
"channel_ref": channel_ref,
},
"ai_analysis": {
"summary": str(intent.signal_summary or message_text)[:300],
"intent": INTENT_SIGNAL_CREATE,
"requirement_project_scope": (
"existing_project" if intent.project_id is not None else "pending_classification"
),
},
}
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",
strategic_analysis=strategic_analysis,
)
return {
"status": "succeeded",
"signal_id": int(signal.id),
"project_id": intent.project_id,
"summary": str(intent.signal_summary or message_text)[:300],
"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,
channel_ref=channel_ref,
)
_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()
if (
intent.name == INTENT_SIGNAL_CREATE
and work_item.state == STATE_SUCCEEDED
and _safe_int(execution_result.get("signal_id")) is not None
):
asyncio.create_task(
SignalPlannerService.analyze(
signal_id=int(execution_result["signal_id"]),
tenant_id=int(self.tenant_id),
source_message_id=synthetic_message_id,
source_chat_id=channel_ref,
dedupe_seed=f"signal-planner-failed:{int(execution_result['signal_id'])}",
)
)
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"</?at[^>]*>", " ", 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"</?at[^>]*>", " ", 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),
}