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