504 lines
18 KiB
Python
504 lines
18 KiB
Python
from __future__ import annotations
|
|
|
|
from collections import defaultdict
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from app.aliases import ProductAliasIndex, ProductTypeMatch
|
|
from app.attributes import (
|
|
BrandIndex,
|
|
Compatibility,
|
|
ParsedAttributes,
|
|
compare_attributes,
|
|
model_code_matches,
|
|
parse_attributes,
|
|
)
|
|
from app.catalog import CatalogItem, build_catalog, load_raw_catalog
|
|
from app.intents import detect_service_intent
|
|
from app.models import Candidate, MatchResult
|
|
from app.normalization import ZERO_WEIGHT_TOKENS, normalize_text, tokenize
|
|
from app.search_index import LexicalIndex, LexicalScore
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _RankedCandidate:
|
|
item: CatalogItem
|
|
lexical: LexicalScore
|
|
compatibility: Compatibility
|
|
ranking_score: float
|
|
|
|
|
|
_STRUCTURED_QUERY_WORDS = frozenset(
|
|
{
|
|
"x",
|
|
"max",
|
|
"plus",
|
|
"sds",
|
|
"t",
|
|
"z",
|
|
"v",
|
|
"w",
|
|
"в",
|
|
"ватт",
|
|
"ватта",
|
|
"ваттов",
|
|
"вольт",
|
|
"вольта",
|
|
"вольтов",
|
|
"зуб",
|
|
"зуба",
|
|
"зубов",
|
|
"зубьев",
|
|
"м",
|
|
"метр",
|
|
"метра",
|
|
"метров",
|
|
"миллиметр",
|
|
"миллиметра",
|
|
"миллиметров",
|
|
"мм",
|
|
"размер",
|
|
"размера",
|
|
"размером",
|
|
"вт",
|
|
}
|
|
)
|
|
|
|
|
|
class CatalogMatcher:
|
|
def __init__(self, catalog_path: Path) -> None:
|
|
self.alias_index = ProductAliasIndex()
|
|
self.brand_index = BrandIndex()
|
|
self.items = build_catalog(
|
|
load_raw_catalog(catalog_path), self.alias_index, self.brand_index
|
|
)
|
|
self.lexical_index = LexicalIndex(self.items)
|
|
|
|
type_index: dict[str, list[int]] = defaultdict(list)
|
|
model_index: dict[str, set[int]] = defaultdict(set)
|
|
brand_item_index: dict[str, list[int]] = defaultdict(list)
|
|
for index, item in enumerate(self.items):
|
|
type_index[item.product_type].append(index)
|
|
if item.attributes.brand is not None:
|
|
brand_item_index[item.attributes.brand].append(index)
|
|
for model_code in item.attributes.model_codes:
|
|
model_index[model_code].add(index)
|
|
self.type_index = {
|
|
product_type: tuple(indexes) for product_type, indexes in type_index.items()
|
|
}
|
|
self.model_index = {
|
|
model: frozenset(indexes) for model, indexes in model_index.items()
|
|
}
|
|
self.brand_item_index = {
|
|
brand: tuple(indexes) for brand, indexes in brand_item_index.items()
|
|
}
|
|
|
|
def match(self, message: str) -> MatchResult:
|
|
if not message or not message.strip():
|
|
return MatchResult(message=message, status="not_found", candidates=[])
|
|
|
|
product_match = self.alias_index.detect(message)
|
|
provisional_attributes = parse_attributes(
|
|
message,
|
|
product_match.canonical if product_match else "",
|
|
brand_index=self.brand_index,
|
|
)
|
|
|
|
if product_match is None:
|
|
model_candidates = self._model_candidates(provisional_attributes.model_codes)
|
|
if model_candidates:
|
|
product_types = {self.items[index].product_type for index in model_candidates}
|
|
if len(product_types) == 1:
|
|
product_type = next(iter(product_types))
|
|
exact_model = all(
|
|
code in self.model_index
|
|
for code in provisional_attributes.model_codes
|
|
)
|
|
product_match = ProductTypeMatch(
|
|
canonical=product_type,
|
|
score=1.0 if exact_model else 0.93,
|
|
exact=exact_model,
|
|
alias="model_code" if exact_model else "model_code_prefix",
|
|
token_start=0,
|
|
)
|
|
if product_match is None and provisional_attributes.teeth is not None:
|
|
# A tooth count is type-specific evidence in this domain: only
|
|
# saw blades carry it. This safely supports requests such as
|
|
# "210 мм 24 зуба" without requiring the word "диск".
|
|
product_match = ProductTypeMatch(
|
|
canonical="диск",
|
|
score=1.0,
|
|
exact=True,
|
|
alias="tooth_count",
|
|
token_start=0,
|
|
)
|
|
if product_match is None:
|
|
brand_result = self._match_brand_only(message, provisional_attributes)
|
|
if brand_result is not None:
|
|
return brand_result
|
|
structured_result = self._match_structured_only(
|
|
message, provisional_attributes
|
|
)
|
|
if structured_result is not None:
|
|
return structured_result
|
|
# Trigrams used to rank only inside a type selected by aliases.
|
|
# Let them infer that missing type as well, but only when the
|
|
# leading catalog rows agree and share strong word-level evidence.
|
|
# This handles "удар" -> "ударная дрель" while
|
|
# keeping numeric coincidences and arbitrary text out.
|
|
if detect_service_intent(message) is None:
|
|
lexical_inference = self.lexical_index.infer_product_type(message)
|
|
if lexical_inference is not None:
|
|
product_match = ProductTypeMatch(
|
|
canonical=lexical_inference.product_type,
|
|
score=lexical_inference.score,
|
|
exact=lexical_inference.query_word
|
|
== lexical_inference.catalog_word,
|
|
alias=lexical_inference.catalog_word,
|
|
token_start=0,
|
|
)
|
|
if product_match is None:
|
|
return MatchResult(
|
|
message=message, status="not_found", candidates=[]
|
|
)
|
|
|
|
product_type = product_match.canonical
|
|
query_attributes = parse_attributes(
|
|
message,
|
|
product_type,
|
|
brand_index=self.brand_index,
|
|
)
|
|
candidate_indices = self.type_index.get(product_type, ())
|
|
|
|
model_candidates = self._model_candidates(query_attributes.model_codes)
|
|
if model_candidates:
|
|
candidate_indices = tuple(
|
|
index for index in candidate_indices if index in model_candidates
|
|
)
|
|
|
|
compatible: dict[int, Compatibility] = {}
|
|
for index in candidate_indices:
|
|
item = self.items[index]
|
|
comparison = compare_attributes(
|
|
product_type,
|
|
query_attributes,
|
|
item.attributes,
|
|
candidate_unit=item.unit,
|
|
)
|
|
if comparison.compatible:
|
|
compatible[index] = comparison
|
|
|
|
if not compatible:
|
|
return MatchResult(message=message, status="not_found", candidates=[])
|
|
|
|
lexical_scores = self.lexical_index.rank(
|
|
message, product_type, tuple(compatible.keys())
|
|
)
|
|
ranked = self._apply_preferences(
|
|
lexical_scores, compatible, cheap=query_attributes.cheap_preference
|
|
)
|
|
if not ranked:
|
|
return MatchResult(message=message, status="not_found", candidates=[])
|
|
|
|
status = "matched" if len(ranked) == 1 else "ambiguous"
|
|
response_candidates = [
|
|
Candidate(
|
|
sku=candidate.item.sku,
|
|
confidence=self._confidence(
|
|
candidate,
|
|
product_match=product_match,
|
|
candidate_count=len(ranked),
|
|
specificity=query_attributes.specificity,
|
|
status=status,
|
|
rank=rank,
|
|
),
|
|
)
|
|
for rank, candidate in enumerate(ranked[:3])
|
|
]
|
|
return MatchResult(
|
|
message=message,
|
|
status=status,
|
|
candidates=response_candidates,
|
|
)
|
|
|
|
def _match_brand_only(
|
|
self, message: str, query_attributes: ParsedAttributes
|
|
) -> MatchResult | None:
|
|
if (
|
|
query_attributes.brand is None
|
|
or not query_attributes.brand_required
|
|
or query_attributes.model_codes
|
|
or query_attributes.specificity != 1
|
|
):
|
|
return None
|
|
|
|
candidate_indices = self.brand_item_index.get(query_attributes.brand, ())
|
|
brand_match = self.brand_index.detect(message)
|
|
if not candidate_indices or brand_match is None:
|
|
return None
|
|
|
|
compatible = {
|
|
index: Compatibility(
|
|
compatible=True,
|
|
matched_fields=1,
|
|
total_fields=1,
|
|
)
|
|
for index in candidate_indices
|
|
}
|
|
lexical_scores = self.lexical_index.rank(message, "", candidate_indices)
|
|
ranked = self._apply_preferences(
|
|
lexical_scores,
|
|
compatible,
|
|
cheap=query_attributes.cheap_preference,
|
|
)
|
|
if not ranked:
|
|
return None
|
|
if not query_attributes.cheap_preference:
|
|
# With no product type every item has the same brand evidence.
|
|
# Document length must not arbitrarily favor short catalog names.
|
|
ranked.sort(key=lambda candidate: candidate.item.sku)
|
|
|
|
status = "matched" if len(ranked) == 1 else "ambiguous"
|
|
brand_signal = ProductTypeMatch(
|
|
canonical="",
|
|
score=brand_match.score,
|
|
exact=brand_match.score == 1.0,
|
|
alias=brand_match.alias,
|
|
token_start=brand_match.token_start,
|
|
)
|
|
candidates = [
|
|
Candidate(
|
|
sku=candidate.item.sku,
|
|
confidence=self._confidence(
|
|
candidate,
|
|
product_match=brand_signal,
|
|
candidate_count=len(ranked),
|
|
specificity=query_attributes.specificity,
|
|
status=status,
|
|
rank=rank,
|
|
),
|
|
)
|
|
for rank, candidate in enumerate(ranked[:3])
|
|
]
|
|
return MatchResult(message=message, status=status, candidates=candidates)
|
|
|
|
def _match_structured_only(
|
|
self, message: str, query_attributes: ParsedAttributes
|
|
) -> MatchResult | None:
|
|
if not self._is_structured_only_query(message, query_attributes):
|
|
return None
|
|
|
|
compatible: dict[int, Compatibility] = {}
|
|
for index, item in enumerate(self.items):
|
|
comparison = compare_attributes(
|
|
item.product_type,
|
|
query_attributes,
|
|
item.attributes,
|
|
candidate_unit=item.unit,
|
|
)
|
|
if comparison.compatible and comparison.matched_fields > 0:
|
|
compatible[index] = comparison
|
|
|
|
if not compatible:
|
|
return None
|
|
|
|
lexical_scores = self.lexical_index.rank(
|
|
message, "", tuple(compatible.keys())
|
|
)
|
|
ranked = self._apply_preferences(
|
|
lexical_scores,
|
|
compatible,
|
|
cheap=query_attributes.cheap_preference,
|
|
)
|
|
if not ranked:
|
|
return None
|
|
|
|
status = "matched" if len(ranked) == 1 else "ambiguous"
|
|
selected = self._select_diverse_product_types(ranked, limit=3)
|
|
structured_signal = ProductTypeMatch(
|
|
canonical="",
|
|
score=0.90,
|
|
exact=True,
|
|
alias="structured_attributes",
|
|
token_start=0,
|
|
)
|
|
candidates = [
|
|
Candidate(
|
|
sku=candidate.item.sku,
|
|
confidence=self._confidence(
|
|
candidate,
|
|
product_match=structured_signal,
|
|
candidate_count=len(ranked),
|
|
specificity=query_attributes.specificity,
|
|
status=status,
|
|
rank=rank,
|
|
),
|
|
)
|
|
for rank, candidate in enumerate(selected)
|
|
]
|
|
return MatchResult(message=message, status=status, candidates=candidates)
|
|
|
|
def _is_structured_only_query(
|
|
self, message: str, query_attributes: ParsedAttributes
|
|
) -> bool:
|
|
has_strong_evidence = any(
|
|
(
|
|
bool(query_attributes.dimensions),
|
|
bool(query_attributes.mm_values),
|
|
bool(query_attributes.meter_values),
|
|
query_attributes.thread_size is not None,
|
|
query_attributes.profile is not None,
|
|
query_attributes.grit is not None,
|
|
query_attributes.voltage_v is not None,
|
|
query_attributes.power_w is not None,
|
|
query_attributes.teeth is not None,
|
|
query_attributes.brand is not None
|
|
and query_attributes.brand_required,
|
|
bool(query_attributes.model_codes),
|
|
query_attributes.sds_type is not None,
|
|
)
|
|
)
|
|
if not has_strong_evidence:
|
|
return False
|
|
|
|
allowed_words = set(ZERO_WEIGHT_TOKENS) | set(_STRUCTURED_QUERY_WORDS)
|
|
brand_match = self.brand_index.detect(message)
|
|
if brand_match is not None:
|
|
allowed_words.update(tokenize(brand_match.alias))
|
|
|
|
for token in tokenize(normalize_text(message), already_normalized=True):
|
|
if any(character.isdigit() for character in token):
|
|
continue
|
|
if token in allowed_words:
|
|
continue
|
|
if token.startswith(("бюджет", "дешев", "недорог")):
|
|
continue
|
|
return False
|
|
return True
|
|
|
|
@staticmethod
|
|
def _select_diverse_product_types(
|
|
ranked: list[_RankedCandidate], *, limit: int
|
|
) -> list[_RankedCandidate]:
|
|
if len({candidate.item.product_type for candidate in ranked}) == 1:
|
|
return ranked[:limit]
|
|
|
|
selected: list[_RankedCandidate] = []
|
|
product_types: set[str] = set()
|
|
|
|
for candidate in ranked:
|
|
if candidate.item.product_type in product_types:
|
|
continue
|
|
selected.append(candidate)
|
|
product_types.add(candidate.item.product_type)
|
|
if len(selected) == limit:
|
|
break
|
|
return selected
|
|
|
|
def _model_candidates(self, model_codes: frozenset[str]) -> frozenset[int]:
|
|
if not model_codes:
|
|
return frozenset()
|
|
|
|
sets: list[frozenset[int]] = []
|
|
for query_code in model_codes:
|
|
exact = self.model_index.get(query_code)
|
|
if exact:
|
|
sets.append(exact)
|
|
continue
|
|
|
|
partial_indexes: set[int] = set()
|
|
for candidate_code, indexes in self.model_index.items():
|
|
if model_code_matches(query_code, candidate_code):
|
|
partial_indexes.update(indexes)
|
|
sets.append(frozenset(partial_indexes))
|
|
|
|
if not sets or any(not indexes for indexes in sets):
|
|
return frozenset()
|
|
result = set(sets[0])
|
|
for indexes in sets[1:]:
|
|
result.intersection_update(indexes)
|
|
return frozenset(result)
|
|
|
|
def _apply_preferences(
|
|
self,
|
|
lexical_scores: list[LexicalScore],
|
|
compatible: dict[int, Compatibility],
|
|
*,
|
|
cheap: bool,
|
|
) -> list[_RankedCandidate]:
|
|
if not lexical_scores:
|
|
return []
|
|
|
|
prices = [self.items[score.item_index].price for score in lexical_scores]
|
|
minimum_price = min(prices)
|
|
maximum_price = max(prices)
|
|
price_range = maximum_price - minimum_price
|
|
|
|
ranked: list[_RankedCandidate] = []
|
|
for lexical in lexical_scores:
|
|
item = self.items[lexical.item_index]
|
|
if cheap:
|
|
cheap_score = (
|
|
1.0
|
|
if price_range == 0
|
|
else float((maximum_price - item.price) / price_range)
|
|
)
|
|
ranking_score = cheap_score
|
|
else:
|
|
ranking_score = lexical.score
|
|
ranked.append(
|
|
_RankedCandidate(
|
|
item=item,
|
|
lexical=lexical,
|
|
compatibility=compatible[lexical.item_index],
|
|
ranking_score=ranking_score,
|
|
)
|
|
)
|
|
|
|
if cheap:
|
|
ranked.sort(
|
|
key=lambda candidate: (
|
|
candidate.item.price,
|
|
-candidate.lexical.score,
|
|
candidate.item.sku,
|
|
)
|
|
)
|
|
else:
|
|
ranked.sort(
|
|
key=lambda candidate: (
|
|
-candidate.ranking_score,
|
|
candidate.item.sku,
|
|
)
|
|
)
|
|
return ranked
|
|
|
|
@staticmethod
|
|
def _confidence(
|
|
candidate: _RankedCandidate,
|
|
*,
|
|
product_match: ProductTypeMatch,
|
|
candidate_count: int,
|
|
specificity: int,
|
|
status: str,
|
|
rank: int,
|
|
) -> float:
|
|
attribute_signal = (
|
|
candidate.compatibility.score
|
|
if candidate.compatibility.total_fields > 0
|
|
else 0.55
|
|
)
|
|
uniqueness_signal = 1.0 if candidate_count == 1 else max(0.35, 0.72 - rank * 0.10)
|
|
specificity_signal = min(1.0, 0.35 + specificity * 0.16)
|
|
value = (
|
|
product_match.score * 0.30
|
|
+ candidate.lexical.score * 0.27
|
|
+ attribute_signal * 0.23
|
|
+ uniqueness_signal * 0.12
|
|
+ specificity_signal * 0.08
|
|
)
|
|
if status == "matched":
|
|
value = max(value, 0.82)
|
|
else:
|
|
value = min(value, 0.84)
|
|
return round(max(0.01, min(0.99, value)), 4)
|