677 lines
25 KiB
Python
677 lines
25 KiB
Python
"""AI PMO intent/route constants and resolvers."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from dataclasses import dataclass
|
||
from typing import Any, Optional
|
||
|
||
from .project_command_utils import extract_project_create_title
|
||
from .signal_source_orchestration_service import SignalSourceOrchestrationService
|
||
|
||
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,
|
||
}
|
||
|
||
ROUTE_FAMILY_QUERY = "query"
|
||
ROUTE_FAMILY_PROJECT_CREATE = "project_create"
|
||
ROUTE_FAMILY_PROJECT_SIGNAL = "project_signal"
|
||
ROUTE_FAMILY_SIGNAL_SOURCE = "signal_source"
|
||
ROUTE_FAMILY_FOLLOWUP = "followup"
|
||
|
||
ROUTE_ACTION_ANSWER = "answer"
|
||
ROUTE_ACTION_CREATE = "create"
|
||
ROUTE_ACTION_DISCOVER = "discover"
|
||
ROUTE_ACTION_COMMIT = "commit"
|
||
ROUTE_ACTION_CANCEL = "cancel"
|
||
ROUTE_ACTION_REFRESH = "refresh"
|
||
ROUTE_ACTION_ONBOARD = "onboard"
|
||
ROUTE_ACTION_CONTINUE = "continue"
|
||
ROUTE_ACTION_CONFIRM = "confirm"
|
||
|
||
ROUTE_QUERY_ANSWER = f"{ROUTE_FAMILY_QUERY}.{ROUTE_ACTION_ANSWER}"
|
||
ROUTE_PROJECT_CREATE = f"{ROUTE_FAMILY_PROJECT_CREATE}.{ROUTE_ACTION_CREATE}"
|
||
ROUTE_PROJECT_SIGNAL_CREATE = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_CREATE}"
|
||
ROUTE_PROJECT_SIGNAL_DISCOVER = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_DISCOVER}"
|
||
ROUTE_PROJECT_SIGNAL_COMMIT = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_COMMIT}"
|
||
ROUTE_PROJECT_SIGNAL_CANCEL = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_CANCEL}"
|
||
ROUTE_PROJECT_SIGNAL_REFRESH = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_REFRESH}"
|
||
ROUTE_SIGNAL_SOURCE_ONBOARD = f"{ROUTE_FAMILY_SIGNAL_SOURCE}.{ROUTE_ACTION_ONBOARD}"
|
||
ROUTE_FOLLOWUP_CONFIRM = f"{ROUTE_FAMILY_FOLLOWUP}.{ROUTE_ACTION_CONFIRM}"
|
||
ROUTE_FOLLOWUP_CONTINUE = f"{ROUTE_FAMILY_FOLLOWUP}.{ROUTE_ACTION_CONTINUE}"
|
||
|
||
WORKFLOW_ROUTE_KEYS = {
|
||
ROUTE_QUERY_ANSWER,
|
||
ROUTE_PROJECT_CREATE,
|
||
ROUTE_PROJECT_SIGNAL_CREATE,
|
||
ROUTE_PROJECT_SIGNAL_DISCOVER,
|
||
ROUTE_PROJECT_SIGNAL_COMMIT,
|
||
ROUTE_PROJECT_SIGNAL_CANCEL,
|
||
ROUTE_PROJECT_SIGNAL_REFRESH,
|
||
ROUTE_SIGNAL_SOURCE_ONBOARD,
|
||
ROUTE_FOLLOWUP_CONFIRM,
|
||
ROUTE_FOLLOWUP_CONTINUE,
|
||
}
|
||
|
||
ROUTE_CANONICAL_INTENT_MAP: dict[str, str] = {
|
||
ROUTE_QUERY_ANSWER: INTENT_QUERY,
|
||
ROUTE_PROJECT_CREATE: INTENT_PROJECT_CREATE,
|
||
ROUTE_PROJECT_SIGNAL_CREATE: INTENT_SIGNAL_CREATE,
|
||
ROUTE_PROJECT_SIGNAL_DISCOVER: INTENT_SIGNAL_CREATE,
|
||
ROUTE_PROJECT_SIGNAL_COMMIT: INTENT_SIGNAL_CREATE,
|
||
ROUTE_PROJECT_SIGNAL_CANCEL: INTENT_SIGNAL_CREATE,
|
||
ROUTE_PROJECT_SIGNAL_REFRESH: INTENT_SIGNAL_CREATE,
|
||
ROUTE_SIGNAL_SOURCE_ONBOARD: INTENT_SIGNAL_SOURCE_ONBOARD,
|
||
ROUTE_FOLLOWUP_CONFIRM: INTENT_FOLLOWUP_CONFIRM,
|
||
ROUTE_FOLLOWUP_CONTINUE: INTENT_FOLLOWUP_CONTINUE,
|
||
}
|
||
|
||
LEGACY_INTENT_ROUTE_MAP: dict[str, str] = {
|
||
INTENT_QUERY: ROUTE_QUERY_ANSWER,
|
||
INTENT_PROJECT_CREATE: ROUTE_PROJECT_CREATE,
|
||
INTENT_SIGNAL_CREATE: ROUTE_PROJECT_SIGNAL_CREATE,
|
||
INTENT_SIGNAL_SOURCE_ONBOARD: ROUTE_SIGNAL_SOURCE_ONBOARD,
|
||
INTENT_FOLLOWUP_CONFIRM: ROUTE_FOLLOWUP_CONFIRM,
|
||
INTENT_FOLLOWUP_CONTINUE: ROUTE_FOLLOWUP_CONTINUE,
|
||
"query.answer": ROUTE_QUERY_ANSWER,
|
||
"project_create.create": ROUTE_PROJECT_CREATE,
|
||
"project_signal.create": ROUTE_PROJECT_SIGNAL_CREATE,
|
||
"project_signal.discover": ROUTE_PROJECT_SIGNAL_DISCOVER,
|
||
"project_signal.commit": ROUTE_PROJECT_SIGNAL_COMMIT,
|
||
"project_signal.cancel": ROUTE_PROJECT_SIGNAL_CANCEL,
|
||
"project_signal.refresh": ROUTE_PROJECT_SIGNAL_REFRESH,
|
||
"signal_source.onboard": ROUTE_SIGNAL_SOURCE_ONBOARD,
|
||
}
|
||
|
||
PROJECT_SIGNAL_DISCOVER_HINTS = (
|
||
"候选",
|
||
"先分析",
|
||
"先看候选",
|
||
"给我确认",
|
||
"待确认",
|
||
"再让我确认",
|
||
"确认后入库",
|
||
"先不要入库",
|
||
"先别入库",
|
||
)
|
||
FOLLOWUP_REFRESH_HINTS = ("换一批", "再来一批", "刷新候选", "重新找", "再找一组", "换一组")
|
||
FOLLOWUP_CANCEL_HINTS = ("取消", "先不做", "先别做", "不要了", "算了", "reject", "驳回")
|
||
FOLLOWUP_COMMIT_HINTS = ("确认", "继续处理", "继续执行", "同意", "批准", "approve")
|
||
|
||
QUESTION_HINTS = (
|
||
"?",
|
||
"?",
|
||
"怎么",
|
||
"如何",
|
||
"为何",
|
||
"为什么",
|
||
"什么",
|
||
"是什么",
|
||
"哪些",
|
||
"有哪些",
|
||
"特点",
|
||
"技术特点",
|
||
"能力",
|
||
"介绍",
|
||
"说明",
|
||
"多少",
|
||
"是否",
|
||
"帮我看",
|
||
"状态",
|
||
"风险",
|
||
"能做什么",
|
||
"可以做什么",
|
||
)
|
||
SMALLTALK_EXACT = {
|
||
"收到",
|
||
"收到啦",
|
||
"好的",
|
||
"ok",
|
||
"okk",
|
||
"yes",
|
||
"no",
|
||
"谢谢",
|
||
"thanks",
|
||
"thx",
|
||
"在吗",
|
||
"在不在",
|
||
"怎么样了",
|
||
"咋样了",
|
||
"辛苦了",
|
||
}
|
||
SMALLTALK_SHORT_HINTS = ("怎么样了", "咋样了", "在吗", "辛苦了", "收到", "谢谢", "ok")
|
||
SMALLTALK_STRUCTURED_HINTS = ("项目", "信号", "任务", "风险", "进度", "状态", "work_item", "工作项", "#")
|
||
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 = ("新增一个信号", "新增一条信号", "增加一个信号", "录入一个信号", "关注", "跟踪")
|
||
PROJECT_CREATE_HINTS = (
|
||
"创建项目",
|
||
"新建项目",
|
||
"建立项目",
|
||
"做个项目",
|
||
"立项",
|
||
"开个项目",
|
||
"新增项目",
|
||
"新增个项目",
|
||
"新增一个项目",
|
||
"增加项目",
|
||
"增加个项目",
|
||
"增加一个项目",
|
||
"添加项目",
|
||
"添加一个项目",
|
||
"加个项目",
|
||
"加一个项目",
|
||
)
|
||
SESSION_SHORT_CONFIRM_WINDOW_SECONDS = 5 * 60
|
||
SHORT_CONFIRM_MAX_MESSAGE_LENGTH = 10
|
||
SHORT_CONFIRM_HINTS = (
|
||
"是",
|
||
"好",
|
||
"对",
|
||
"确认",
|
||
"新增",
|
||
"加",
|
||
"嗯",
|
||
"yes",
|
||
"ok",
|
||
"好的",
|
||
"是的",
|
||
"行",
|
||
"可以",
|
||
"没问题",
|
||
)
|
||
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"}
|
||
|
||
|
||
def _safe_int(value: Any) -> Optional[int]:
|
||
try:
|
||
if value is None or value == "":
|
||
return None
|
||
return int(value)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class CanonicalIntent:
|
||
name: str
|
||
confidence: float
|
||
project_id: Optional[int]
|
||
signal_title: str
|
||
signal_summary: str
|
||
metadata: dict[str, Any]
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class WorkflowRoute:
|
||
family: str
|
||
action: str
|
||
project_id: Optional[int]
|
||
requested_count: int
|
||
requires_confirmation: bool
|
||
metadata: dict[str, Any]
|
||
|
||
@property
|
||
def key(self) -> str:
|
||
return f"{self.family}.{self.action}"
|
||
|
||
|
||
class RouteResolver:
|
||
"""Map canonical intent + text into workflow route."""
|
||
|
||
@staticmethod
|
||
def _split_route_key(route_key: str) -> tuple[str, str]:
|
||
normalized = str(route_key or "").strip().lower()
|
||
if "." not in normalized:
|
||
return ROUTE_FAMILY_QUERY, ROUTE_ACTION_ANSWER
|
||
family, action = normalized.split(".", 1)
|
||
return family.strip() or ROUTE_FAMILY_QUERY, action.strip() or ROUTE_ACTION_ANSWER
|
||
|
||
@classmethod
|
||
def normalize_route_key(cls, value: str) -> str:
|
||
normalized = str(value or "").strip().lower()
|
||
if not normalized:
|
||
return ROUTE_QUERY_ANSWER
|
||
mapped = LEGACY_INTENT_ROUTE_MAP.get(normalized)
|
||
if mapped:
|
||
return mapped
|
||
if normalized in WORKFLOW_ROUTE_KEYS:
|
||
return normalized
|
||
family, action = cls._split_route_key(normalized)
|
||
if family and action:
|
||
return f"{family}.{action}"
|
||
return ROUTE_QUERY_ANSWER
|
||
|
||
@classmethod
|
||
def canonical_intent_for_route(cls, route_key: str) -> str:
|
||
normalized = cls.normalize_route_key(route_key)
|
||
mapped = ROUTE_CANONICAL_INTENT_MAP.get(normalized)
|
||
if mapped:
|
||
return mapped
|
||
family, _ = cls._split_route_key(normalized)
|
||
if family == ROUTE_FAMILY_PROJECT_SIGNAL:
|
||
return INTENT_SIGNAL_CREATE
|
||
if family == ROUTE_FAMILY_SIGNAL_SOURCE:
|
||
return INTENT_SIGNAL_SOURCE_ONBOARD
|
||
if family == ROUTE_FAMILY_PROJECT_CREATE:
|
||
return INTENT_PROJECT_CREATE
|
||
if family == ROUTE_FAMILY_FOLLOWUP:
|
||
return INTENT_FOLLOWUP_CONTINUE
|
||
return INTENT_QUERY
|
||
|
||
@staticmethod
|
||
def _parse_chinese_count_token(token: str) -> Optional[int]:
|
||
cleaned = str(token or "").strip()
|
||
if not cleaned:
|
||
return None
|
||
map_digit = {
|
||
"零": 0,
|
||
"一": 1,
|
||
"二": 2,
|
||
"两": 2,
|
||
"三": 3,
|
||
"四": 4,
|
||
"五": 5,
|
||
"六": 6,
|
||
"七": 7,
|
||
"八": 8,
|
||
"九": 9,
|
||
"十": 10,
|
||
}
|
||
if cleaned in map_digit:
|
||
return map_digit[cleaned]
|
||
if len(cleaned) == 2 and cleaned[0] == "十" and cleaned[1] in map_digit:
|
||
return 10 + map_digit[cleaned[1]]
|
||
if len(cleaned) == 2 and cleaned[1] == "十" and cleaned[0] in map_digit:
|
||
return map_digit[cleaned[0]] * 10
|
||
if len(cleaned) == 3 and cleaned[1] == "十" and cleaned[0] in map_digit and cleaned[2] in map_digit:
|
||
return map_digit[cleaned[0]] * 10 + map_digit[cleaned[2]]
|
||
return None
|
||
|
||
@classmethod
|
||
def extract_requested_count(cls, *, message_text: str, metadata: dict[str, Any]) -> int:
|
||
metadata_count = _safe_int(metadata.get("requested_count")) if isinstance(metadata, dict) else None
|
||
if metadata_count is not None and metadata_count > 0:
|
||
return min(max(metadata_count, 1), 5)
|
||
normalized_text = str(message_text or "")
|
||
digit_match = re.search(r"(\d{1,2})\s*(?:个|条|项)?\s*(?:信号|候选)", normalized_text, flags=re.IGNORECASE)
|
||
if digit_match:
|
||
return min(max(int(digit_match.group(1)), 1), 5)
|
||
chinese_match = re.search(r"([零一二两三四五六七八九十]{1,3})\s*(?:个|条|项)?\s*(?:信号|候选)", normalized_text)
|
||
if chinese_match:
|
||
parsed = cls._parse_chinese_count_token(chinese_match.group(1))
|
||
if parsed is not None and parsed > 0:
|
||
return min(max(parsed, 1), 5)
|
||
return 1
|
||
|
||
@staticmethod
|
||
def looks_like_project_signal_discover(*, message_text: str, metadata: dict[str, Any]) -> bool:
|
||
explicit_route = str(metadata.get("workflow_route") or "").strip().lower() if isinstance(metadata, dict) else ""
|
||
if explicit_route == ROUTE_PROJECT_SIGNAL_DISCOVER:
|
||
return True
|
||
normalized = re.sub(r"\s+", "", str(message_text or "")).lower()
|
||
if not normalized:
|
||
return False
|
||
has_discover_hint = any(token in normalized for token in PROJECT_SIGNAL_DISCOVER_HINTS)
|
||
has_signal_hint = any(token in normalized for token in ("信号", "候选", "分析", "拆分"))
|
||
return has_discover_hint and has_signal_hint
|
||
|
||
@staticmethod
|
||
def parse_followup_action(*, message_text: str, canonical_intent: str) -> str:
|
||
normalized = re.sub(r"\s+", "", str(message_text or "")).lower()
|
||
if any(token in normalized for token in FOLLOWUP_REFRESH_HINTS):
|
||
return ROUTE_ACTION_REFRESH
|
||
if any(token in normalized for token in FOLLOWUP_CANCEL_HINTS):
|
||
return ROUTE_ACTION_CANCEL
|
||
if any(token in normalized for token in FOLLOWUP_COMMIT_HINTS):
|
||
return ROUTE_ACTION_COMMIT
|
||
if canonical_intent in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
|
||
return ROUTE_ACTION_COMMIT
|
||
return ROUTE_ACTION_CONTINUE
|
||
|
||
@classmethod
|
||
def from_canonical(
|
||
cls,
|
||
*,
|
||
canonical_intent: CanonicalIntent,
|
||
message_text: str,
|
||
) -> WorkflowRoute:
|
||
metadata = dict(canonical_intent.metadata or {})
|
||
route_key = LEGACY_INTENT_ROUTE_MAP.get(canonical_intent.name, ROUTE_QUERY_ANSWER)
|
||
if canonical_intent.name == INTENT_SIGNAL_CREATE:
|
||
route_key = (
|
||
ROUTE_PROJECT_SIGNAL_DISCOVER
|
||
if cls.looks_like_project_signal_discover(message_text=message_text, metadata=metadata)
|
||
else ROUTE_PROJECT_SIGNAL_CREATE
|
||
)
|
||
elif canonical_intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
|
||
action = cls.parse_followup_action(message_text=message_text, canonical_intent=canonical_intent.name)
|
||
if action == ROUTE_ACTION_CONFIRM:
|
||
route_key = ROUTE_FOLLOWUP_CONFIRM
|
||
else:
|
||
route_key = ROUTE_FOLLOWUP_CONTINUE
|
||
metadata["followup_action"] = action
|
||
family, action = cls._split_route_key(route_key)
|
||
requested_count = cls.extract_requested_count(message_text=message_text, metadata=metadata)
|
||
if family != ROUTE_FAMILY_PROJECT_SIGNAL:
|
||
requested_count = 1
|
||
requires_confirmation = bool(metadata.get("requires_confirmation")) or action in {
|
||
ROUTE_ACTION_DISCOVER,
|
||
ROUTE_ACTION_COMMIT,
|
||
ROUTE_ACTION_CANCEL,
|
||
ROUTE_ACTION_REFRESH,
|
||
}
|
||
metadata["workflow_route"] = f"{family}.{action}"
|
||
metadata["workflow_family"] = family
|
||
metadata["workflow_action"] = action
|
||
return WorkflowRoute(
|
||
family=family,
|
||
action=action,
|
||
project_id=canonical_intent.project_id,
|
||
requested_count=requested_count,
|
||
requires_confirmation=requires_confirmation,
|
||
metadata=metadata,
|
||
)
|
||
|
||
@classmethod
|
||
def from_structured_payload(
|
||
cls,
|
||
*,
|
||
payload: dict[str, Any],
|
||
message_text: str,
|
||
fallback_project_id: Optional[int],
|
||
) -> Optional[WorkflowRoute]:
|
||
family = str(payload.get("family") or "").strip().lower()
|
||
action = str(payload.get("action") or "").strip().lower()
|
||
if not family or not action:
|
||
intent_name = str(payload.get("intent") or "").strip().lower()
|
||
route_key = cls.normalize_route_key(intent_name)
|
||
family, action = cls._split_route_key(route_key)
|
||
route_key = cls.normalize_route_key(f"{family}.{action}")
|
||
family, action = cls._split_route_key(route_key)
|
||
if family == ROUTE_FAMILY_PROJECT_SIGNAL and action not in {
|
||
ROUTE_ACTION_CREATE,
|
||
ROUTE_ACTION_DISCOVER,
|
||
ROUTE_ACTION_COMMIT,
|
||
ROUTE_ACTION_CANCEL,
|
||
ROUTE_ACTION_REFRESH,
|
||
}:
|
||
return None
|
||
if f"{family}.{action}" not in WORKFLOW_ROUTE_KEYS and family != ROUTE_FAMILY_PROJECT_SIGNAL:
|
||
return None
|
||
requested_count = cls.extract_requested_count(
|
||
message_text=message_text,
|
||
metadata=payload if isinstance(payload, dict) else {},
|
||
)
|
||
if family != ROUTE_FAMILY_PROJECT_SIGNAL:
|
||
requested_count = 1
|
||
project_id = _safe_int(payload.get("project_id"))
|
||
if project_id is None:
|
||
project_id = fallback_project_id
|
||
if project_id is not None and int(project_id) <= 0:
|
||
project_id = None
|
||
requires_confirmation = bool(payload.get("requires_confirmation")) or action in {
|
||
ROUTE_ACTION_DISCOVER,
|
||
ROUTE_ACTION_COMMIT,
|
||
ROUTE_ACTION_CANCEL,
|
||
ROUTE_ACTION_REFRESH,
|
||
}
|
||
metadata = dict(payload)
|
||
metadata["workflow_route"] = f"{family}.{action}"
|
||
metadata["workflow_family"] = family
|
||
metadata["workflow_action"] = action
|
||
metadata["requested_count"] = requested_count
|
||
return WorkflowRoute(
|
||
family=family,
|
||
action=action,
|
||
project_id=project_id,
|
||
requested_count=requested_count,
|
||
requires_confirmation=requires_confirmation,
|
||
metadata=metadata,
|
||
)
|
||
|
||
|
||
class IntentResolver:
|
||
"""Resolve raw text to canonical intent."""
|
||
|
||
OPERATION_VERBS = (
|
||
"新增",
|
||
"增加",
|
||
"添加",
|
||
"加",
|
||
"加一个",
|
||
"加个",
|
||
"新建",
|
||
"创建",
|
||
"建立",
|
||
"立项",
|
||
"接入",
|
||
"修复",
|
||
"优化",
|
||
"改成",
|
||
"改为",
|
||
"更新",
|
||
"分配",
|
||
"指派",
|
||
"归档",
|
||
"执行",
|
||
)
|
||
|
||
@staticmethod
|
||
def _looks_like_negated_write_statement(*, text: str) -> bool:
|
||
normalized = re.sub(r"\s+", "", str(text or "")).lower()
|
||
if not normalized:
|
||
return False
|
||
negation_tokens = ("别", "别再", "别乱", "不要", "不要再", "先别", "先不要", "禁止", "不能", "无需", "不用")
|
||
if not any(token in normalized for token in negation_tokens):
|
||
return False
|
||
write_tokens = ("创建", "新建", "新增", "增加", "添加", "加", "接入", "执行", "分配", "指派", "立项")
|
||
object_tokens = ("信号", "需求", "任务", "项目", "事项", "工单", "流程", "信号源")
|
||
if any(w in normalized for w in write_tokens) and any(o in normalized for o in object_tokens):
|
||
return True
|
||
return bool(
|
||
re.search(
|
||
r"(?:别|不要|先别|先不要|禁止|不能).{0,8}(?:创建|新建|新增|增加|添加|加|接入|执行|分配|指派|立项)",
|
||
normalized,
|
||
re.IGNORECASE,
|
||
)
|
||
)
|
||
|
||
@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 IntentResolver._looks_like_negated_write_statement(text=pretty):
|
||
return CanonicalIntent(
|
||
INTENT_QUERY,
|
||
0.9,
|
||
project_id,
|
||
"",
|
||
pretty[:300],
|
||
{"reason": "negated_write_statement"},
|
||
)
|
||
explicit_followup_target = FOLLOWUP_WORK_ITEM_ID_RE.search(pretty)
|
||
if explicit_followup_target is not None:
|
||
return CanonicalIntent(
|
||
INTENT_FOLLOWUP_CONTINUE,
|
||
0.92,
|
||
project_id,
|
||
"",
|
||
"",
|
||
{
|
||
"followup_by_work_item_id": True,
|
||
"target_work_item_id": _safe_int(explicit_followup_target.group(1)),
|
||
},
|
||
)
|
||
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 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_signal_create_request(text=pretty):
|
||
return CanonicalIntent(
|
||
INTENT_SIGNAL_CREATE,
|
||
0.78,
|
||
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],
|
||
{},
|
||
)
|
||
return CanonicalIntent(
|
||
INTENT_QUERY,
|
||
0.6,
|
||
project_id,
|
||
"",
|
||
pretty[:300],
|
||
{"fallback": True, "reason": "default_query_fallback"},
|
||
)
|
||
|
||
@staticmethod
|
||
def _looks_like_signal_create_request(*, text: str) -> bool:
|
||
normalized = re.sub(r"\s+", "", str(text or "")).lower()
|
||
if not normalized:
|
||
return False
|
||
if IntentResolver._looks_like_negated_write_statement(text=normalized):
|
||
return False
|
||
candidate_discover_hints = ("候选", "换一批", "刷新", "再来一批", "先分析")
|
||
if (
|
||
any(token in normalized for token in candidate_discover_hints)
|
||
and any(token in normalized for token in ("确认", "入库", "提交"))
|
||
and any(token in normalized for token in ("项目", "project"))
|
||
):
|
||
return True
|
||
if re.search(r"(?:抓|找|给).{0,8}(?:\d+|[一二两三四五])(?:条|个)?候选", normalized, re.IGNORECASE):
|
||
return True
|
||
if any(token in normalized for token in SIGNAL_CREATE_HINTS):
|
||
return True
|
||
signal_object_hints = ("信号", "需求", "任务", "事项", "问题", "改造", "优化")
|
||
has_operation = any(token in normalized for token in IntentResolver.OPERATION_VERBS)
|
||
has_signal_object = any(token in normalized for token in signal_object_hints)
|
||
if has_operation and has_signal_object:
|
||
return True
|
||
return bool(
|
||
re.search(
|
||
r"(?:给|帮|请).{0,20}(?:新增|增加|添加|补充|加).{0,20}(?:信号|需求|任务|事项)",
|
||
normalized,
|
||
re.IGNORECASE,
|
||
)
|
||
)
|
||
|
||
@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 IntentResolver._looks_like_signal_create_request(text=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
|