PMFlow/backend/app/services/ai_pmo_intent/route_resolver.py

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,
)