265 lines
8.8 KiB
Python
265 lines
8.8 KiB
Python
"""AI Web Agent Console preset catalog와 현재 질문 기반의 결정적 intent router."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
import re
|
|
import unicodedata
|
|
|
|
|
|
QUESTION_CATEGORIES = ("STRUCTURED", "RAG", "HYBRID")
|
|
GENERIC_RAG_QUESTION_ID = "R0"
|
|
MAX_QUESTION_CHARS = 2_000
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DemoQuestion:
|
|
"""ID와 분류가 고정된 데모 질문."""
|
|
|
|
question_id: str
|
|
category: str
|
|
text: str
|
|
|
|
def __post_init__(self) -> None:
|
|
if self.category not in QUESTION_CATEGORIES:
|
|
raise ValueError("unsupported demo question category")
|
|
if not self.question_id or not self.text.strip():
|
|
raise ValueError("demo question id/text is required")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ResolvedQuestionIntent:
|
|
"""현재 질문만으로 결정된 실행 intent.
|
|
|
|
``question_id``는 local route/fixture를 설명하는 분류 label이다. 8500 provider
|
|
입력으로 전달되지 않으며 UI에서 선택한 scenario ID도 이 모델에 들어오지
|
|
않는다.
|
|
"""
|
|
|
|
question_id: str
|
|
category: str
|
|
|
|
def __post_init__(self) -> None:
|
|
if self.category not in QUESTION_CATEGORIES:
|
|
raise ValueError("unsupported resolved question category")
|
|
if self.question_id not in {
|
|
GENERIC_RAG_QUESTION_ID,
|
|
"S1",
|
|
"S2",
|
|
"S3",
|
|
"S4",
|
|
"S5",
|
|
"R1",
|
|
"R2",
|
|
"H1",
|
|
"H2",
|
|
"H3",
|
|
"H4",
|
|
}:
|
|
raise ValueError("unsupported resolved question id")
|
|
|
|
|
|
COMMON_DEMO_QUESTIONS = (
|
|
DemoQuestion("S1", "STRUCTURED", "상품별 계약 건수를 보여줘."),
|
|
DemoQuestion("S2", "STRUCTURED", "총 지급보험금이 가장 큰 상품은?"),
|
|
DemoQuestion(
|
|
"S3",
|
|
"STRUCTURED",
|
|
"숫자로 계산 가능한 평균 보장금액이 높은 상품 10개를 보여줘.",
|
|
),
|
|
DemoQuestion("S4", "STRUCTURED", "고객 등급별 평균 보험료를 보여줘."),
|
|
DemoQuestion("S5", "STRUCTURED", "이해관계자 역할별 인원 수를 보여줘."),
|
|
DemoQuestion("R1", "RAG", "자동차보험 약관의 면책 사항은?"),
|
|
DemoQuestion("R2", "RAG", "보험금 청구 시 필요한 서류는?"),
|
|
DemoQuestion(
|
|
"H1",
|
|
"HYBRID",
|
|
"보험금이 가장 큰 상품의 주요 면책 조항을 알려줘.",
|
|
),
|
|
DemoQuestion(
|
|
"H2",
|
|
"HYBRID",
|
|
"청구가 많은 상품군의 보장 제외 조건을 알려줘.",
|
|
),
|
|
DemoQuestion(
|
|
"H3",
|
|
"HYBRID",
|
|
"고객 등급별 보험료 수준을 보고, 관련 약관상 유의해야 할 보장 제외 조건도 함께 알려줘.",
|
|
),
|
|
)
|
|
|
|
_QUESTION_BY_ID = {item.question_id: item for item in COMMON_DEMO_QUESTIONS}
|
|
if len(_QUESTION_BY_ID) != len(COMMON_DEMO_QUESTIONS):
|
|
raise RuntimeError("duplicate AI Web Agent Console demo question id")
|
|
|
|
|
|
def question_by_id(question_id: str) -> DemoQuestion:
|
|
"""정규화된 ID로 질문을 찾되, 알 수 없는 ID는 거부한다."""
|
|
|
|
normalized = str(question_id).strip().upper()
|
|
try:
|
|
return _QUESTION_BY_ID[normalized]
|
|
except KeyError:
|
|
raise ValueError("unknown AI Web Agent Console demo question id") from None
|
|
|
|
|
|
def normalize_scenario_id(value: object) -> str | None:
|
|
"""선택적인 preset ID를 정규화하되 실행 routing에는 관여하지 않는다."""
|
|
|
|
if value is None:
|
|
return None
|
|
if not isinstance(value, str):
|
|
raise ValueError("scenario id must be a string")
|
|
normalized = value.strip().upper()
|
|
if not normalized:
|
|
return None
|
|
question_by_id(normalized)
|
|
return normalized
|
|
|
|
|
|
def _normalized_question(question: object) -> tuple[str, str]:
|
|
if not isinstance(question, str):
|
|
raise ValueError("question must be a non-empty string")
|
|
if len(question) > MAX_QUESTION_CHARS:
|
|
raise ValueError("question exceeds the supported length")
|
|
normalized = re.sub(
|
|
r"\s+", " ", unicodedata.normalize("NFKC", question).strip().lower()
|
|
)
|
|
if not normalized:
|
|
raise ValueError("question must be a non-empty string")
|
|
return normalized, normalized.replace(" ", "")
|
|
|
|
|
|
def _contains_any(text: str, candidates: tuple[str, ...]) -> bool:
|
|
return any(candidate in text for candidate in candidates)
|
|
|
|
|
|
def resolve_question_intent(question: object) -> ResolvedQuestionIntent:
|
|
"""현재 질문 텍스트만으로 S/R/H route intent를 결정한다.
|
|
|
|
명확한 structured/hybrid intent에 해당하지 않는 질문은 임의로 추측하지 않고
|
|
``R0`` generic RAG 검색으로 보낸다. 이 함수는 scenario 또는
|
|
question ID hint를 받지 않으므로 preset metadata가 실행을 바꿀 수 없다.
|
|
"""
|
|
|
|
normalized, compact = _normalized_question(question)
|
|
|
|
# Canonical preset은 기존 10문항 동작을 byte-for-byte 보존한다.
|
|
for item in COMMON_DEMO_QUESTIONS:
|
|
candidate, _ = _normalized_question(item.text)
|
|
if normalized == candidate:
|
|
return ResolvedQuestionIntent(item.question_id, item.category)
|
|
|
|
has_product = _contains_any(compact, ("상품", "상품군"))
|
|
has_exclusion = _contains_any(
|
|
compact,
|
|
("면책", "보장제외", "제외조건", "보상하지않", "약관상유의"),
|
|
)
|
|
has_top = _contains_any(
|
|
compact, ("가장큰", "최대", "최고", "1위", "상위", "제일많", "높은")
|
|
)
|
|
|
|
# 자유 질문에서 자동차보험 보유 규모와 타사 대비 강점을 함께 요구하면
|
|
# generic structured 조회와 자유 evidence 검색을 로컬에서 합성하는 H4로
|
|
# 보낸다. Label은 routing metadata일 뿐 provider query ID가 아니다.
|
|
has_auto_insurance = "자동차보험" in compact
|
|
has_count = _contains_any(
|
|
compact,
|
|
(
|
|
"갯수",
|
|
"개수",
|
|
"건수",
|
|
"계약수",
|
|
"몇개",
|
|
"몇건",
|
|
"보유수",
|
|
"상품수",
|
|
),
|
|
)
|
|
has_competitor_comparison = _contains_any(
|
|
compact, ("타사", "경쟁사", "다른회사", "타보험사")
|
|
) and _contains_any(compact, ("강점", "장점", "차별", "우위", "비교"))
|
|
if has_auto_insurance and has_count and has_competitor_comparison:
|
|
return ResolvedQuestionIntent("H4", "HYBRID")
|
|
|
|
# Hybrid를 먼저 판별해 정형 키워드가 포함된 복합 질문이 S/R 단일 route로
|
|
# 축소되지 않도록 한다.
|
|
if (
|
|
_contains_any(compact, ("고객등급", "등급별"))
|
|
and "보험료" in compact
|
|
and has_exclusion
|
|
):
|
|
return ResolvedQuestionIntent("H3", "HYBRID")
|
|
if (
|
|
"청구" in compact
|
|
and _contains_any(compact, ("많은", "빈도", "건수", "상위"))
|
|
and has_product
|
|
and has_exclusion
|
|
):
|
|
return ResolvedQuestionIntent("H2", "HYBRID")
|
|
if (
|
|
has_product
|
|
and _contains_any(compact, ("지급보험금", "보험금"))
|
|
and has_top
|
|
and has_exclusion
|
|
):
|
|
return ResolvedQuestionIntent("H1", "HYBRID")
|
|
|
|
if (
|
|
has_product
|
|
and "계약" in compact
|
|
and _contains_any(compact, ("건수", "계약수", "몇건", "집계"))
|
|
):
|
|
return ResolvedQuestionIntent("S1", "STRUCTURED")
|
|
if (
|
|
has_product
|
|
and _contains_any(compact, ("지급보험금", "보험금총액", "총보험금"))
|
|
and has_top
|
|
):
|
|
return ResolvedQuestionIntent("S2", "STRUCTURED")
|
|
if (
|
|
has_product
|
|
and _contains_any(compact, ("보장금액", "가입금액"))
|
|
and "평균" in compact
|
|
and has_top
|
|
):
|
|
return ResolvedQuestionIntent("S3", "STRUCTURED")
|
|
if (
|
|
_contains_any(compact, ("고객등급", "등급별"))
|
|
and "보험료" in compact
|
|
and "평균" in compact
|
|
):
|
|
return ResolvedQuestionIntent("S4", "STRUCTURED")
|
|
if (
|
|
"이해관계자" in compact
|
|
and "역할" in compact
|
|
and _contains_any(compact, ("인원", "사람수", "몇명", "명수", "수"))
|
|
):
|
|
return ResolvedQuestionIntent("S5", "STRUCTURED")
|
|
|
|
if (
|
|
"자동차보험" in compact
|
|
and _contains_any(compact, ("면책", "보상하지않", "보장제외", "제외사항"))
|
|
):
|
|
return ResolvedQuestionIntent("R1", "RAG")
|
|
if (
|
|
_contains_any(compact, ("보험금", "청구"))
|
|
and _contains_any(compact, ("서류", "문서", "증빙", "제출자료"))
|
|
):
|
|
return ResolvedQuestionIntent("R2", "RAG")
|
|
|
|
return ResolvedQuestionIntent(GENERIC_RAG_QUESTION_ID, "RAG")
|
|
|
|
|
|
__all__ = [
|
|
"COMMON_DEMO_QUESTIONS",
|
|
"DemoQuestion",
|
|
"GENERIC_RAG_QUESTION_ID",
|
|
"MAX_QUESTION_CHARS",
|
|
"QUESTION_CATEGORIES",
|
|
"ResolvedQuestionIntent",
|
|
"normalize_scenario_id",
|
|
"question_by_id",
|
|
"resolve_question_intent",
|
|
]
|