Files
call-center/services/shared/kb_search.py
T
didar d2438b6954
deploy / deploy (push) Successful in 30s
feat: canonical intent taxonomy for AI operator (kb_answer -> intent_code)
Centralizes fixed control intents and adds a data-driven intent_code
field on kb_articles so many phrasings of the same FAQ question
resolve to one stable code (e.g. VOUCHER_ACTIVATION) instead of a
free-form, unvalidated string the LLM invented on the fly.

- services/shared/intents.py: CONTROL_INTENTS + normalize_intent()
- kb_articles.intent_code column (ORM + dev/sqlite runtime compat +
  migrations/sql/0034_* for postgres/sqlite)
- kb_service CRUD exposes intent_code
- orchestrator surfaces intent_code to the LLM and validates its
  intent output against control intents + the KB codes shown that turn
- voice.py: _voice_early_intent_bucket renamed to _voice_ack_topic_bucket
  to stop it being conflated with the canonical FAQ intent
2026-08-31 00:17:51 +05:00

148 lines
4.6 KiB
Python

from __future__ import annotations
import json
import re
import unicodedata
from typing import Protocol, Sequence, TypeVar
_FIELD_WEIGHTS = {
"tags": 3.0,
"title": 2.0,
"body": 1.0,
}
_EXACT_TOKEN_SCORE = 10.0
_SOFT_TOKEN_SCORE = 6.0
_PHRASE_BASE_SCORE = 12.0
_MIN_TOKEN_LENGTH = 2
_MIN_SOFT_MATCH_LENGTH = 4
_NON_WORD_RE = re.compile(r"[^\w]+", re.UNICODE)
_SPACE_RE = re.compile(r"\s+")
class KBSearchRow(Protocol):
id: int
title: str
body: str
tags_json: str
intent_code: str | None
T = TypeVar("T", bound=KBSearchRow)
def normalize_kb_text(value: str | None) -> str:
text = unicodedata.normalize("NFKC", str(value or "")).lower().replace("\u0451", "\u0435")
text = _NON_WORD_RE.sub(" ", text)
return _SPACE_RE.sub(" ", text).strip()
def tokenize_kb_text(value: str | None) -> list[str]:
return [token for token in normalize_kb_text(value).split(" ") if len(token) >= _MIN_TOKEN_LENGTH]
def search_kb_rows(
rows: Sequence[T],
query: str | None,
*,
limit: int,
empty_query_returns_all: bool = False,
) -> list[T]:
ordered_rows = sorted(rows, key=lambda row: int(getattr(row, "id", 0) or 0), reverse=True)
normalized_query = normalize_kb_text(query)
if not normalized_query:
return ordered_rows[:limit] if empty_query_returns_all else []
query_tokens = list(dict.fromkeys(tokenize_kb_text(normalized_query)))
if not query_tokens:
return ordered_rows[:limit] if empty_query_returns_all else []
ranked: list[tuple[float, int, T]] = []
for row in ordered_rows:
score = _score_row(row, normalized_query, query_tokens)
if score <= 0:
continue
ranked.append((score, int(getattr(row, "id", 0) or 0), row))
ranked.sort(key=lambda item: (-item[0], -item[1]))
return [row for _, _, row in ranked[:limit]]
def _score_row(row: T, normalized_query: str, query_tokens: list[str]) -> float:
tags_text = " ".join(_parse_tags(getattr(row, "tags_json", "[]")))
fields = {
"tags": {
"text": normalize_kb_text(tags_text),
"tokens": tokenize_kb_text(tags_text),
},
"title": {
"text": normalize_kb_text(getattr(row, "title", "")),
"tokens": tokenize_kb_text(getattr(row, "title", "")),
},
"body": {
"text": normalize_kb_text(getattr(row, "body", "")),
"tokens": tokenize_kb_text(getattr(row, "body", "")),
},
}
score = 0.0
for field_name, payload in fields.items():
field_tokens = payload["tokens"]
if not field_tokens:
continue
field_weight = _FIELD_WEIGHTS[field_name]
score += _field_phrase_bonus(query_tokens, payload["text"], field_weight)
score += _field_token_score(query_tokens, field_tokens, field_weight)
return score
def _field_phrase_bonus(query_tokens: list[str], field_text: str, field_weight: float) -> float:
max_window = min(4, len(query_tokens))
for size in range(max_window, 1, -1):
for start in range(0, len(query_tokens) - size + 1):
phrase = " ".join(query_tokens[start : start + size])
if phrase and phrase in field_text:
return field_weight * (_PHRASE_BASE_SCORE + size)
return 0.0
def _field_token_score(query_tokens: list[str], field_tokens: list[str], field_weight: float) -> float:
score = 0.0
field_token_set = set(field_tokens)
for query_token in query_tokens:
if query_token in field_token_set:
score += field_weight * _EXACT_TOKEN_SCORE
continue
if _has_soft_token_match(query_token, field_tokens):
score += field_weight * _SOFT_TOKEN_SCORE
return score
def _has_soft_token_match(query_token: str, field_tokens: list[str]) -> bool:
return any(_is_soft_token_match(query_token, field_token) for field_token in field_tokens)
def _is_soft_token_match(query_token: str, field_token: str) -> bool:
min_length = min(len(query_token), len(field_token))
if min_length < _MIN_SOFT_MATCH_LENGTH:
return False
common_prefix = _common_prefix_len(query_token, field_token)
return common_prefix >= _MIN_SOFT_MATCH_LENGTH and common_prefix >= min_length - 1
def _common_prefix_len(left: str, right: str) -> int:
index = 0
for left_char, right_char in zip(left, right):
if left_char != right_char:
break
index += 1
return index
def _parse_tags(raw_tags: str | None) -> list[str]:
try:
data = json.loads(raw_tags or "[]")
except json.JSONDecodeError:
return []
return [str(item) for item in data if str(item).strip()]