PMFlow/backend/app/services/ai_pmo_intent_routing.py

677 lines
25 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 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