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

216 lines
8.3 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.

from __future__ import annotations
from .models import *
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:
raw = str(text or "").strip()
return raw or default