219 lines
9.1 KiB
Python
219 lines
9.1 KiB
Python
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,
|
|
)
|
|
|