from __future__ import annotations from .models import * 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, )