Offer a spelling when a search finds nothing

The corrector landed in fa0f6d7 unwired, pending a week of zero-result logs to
say what it should be aimed at. Attaching it now instead: the guards that make
it safe here are principled rather than tuned to any particular data, so the
logs would confirm the shape of the problem without changing the answer.

ONLY ON ZERO RESULTS. A search that finds something pays for none of this - no
vocabulary build, no scoring, no extra query.

The vocabulary is harvested from columns people actually search - asset numbers
and names, vendors, models, applications, asset types - cached ten minutes and
capped per column. Long enough that someone mistyping twice does not rebuild it,
short enough that a vendor entered this morning is correctable before lunch. The
cap is there because this exists to catch a typo against the names in use, not
to mirror the database into memory to answer one query.

SUGGESTED, NEVER APPLIED. The response carries the better spelling and the
caller decides. Searching for something else on a person's behalf is how
somebody orders the wrong cartridge.

FAILS SOFT. A missing column on a lean install, or anything else, returns no
suggestion and the search answers normally. No vocabulary is worse than the
alternative; it is not fatal.

Three tests through the endpoint rather than only the unit: a misspelling gets a
suggestion, a search that succeeds gets none, and CSF18 against an existing
CSF17 returns no results AND no suggestion - the digit guard holding across the
whole stack, which is the one that would cost somebody a wrong bay.

The zero-result logging stays. It answers "what can people not find", which the
corrector does not: a typo is only one of the reasons a search comes back empty,
and the others are vocabulary nobody has entered and records that do not exist.

NOT DONE: the search page does not render the suggestion yet, so this is
invisible to a person until that Vue change lands.
This commit is contained in:
cproudlock
2026-08-21 10:41:14 -04:00
parent fa0f6d7ebc
commit 1593ee8204
2 changed files with 126 additions and 1 deletions

View File

@@ -9,13 +9,14 @@ from flask import Blueprint, request, current_app
from flask_jwt_extended import jwt_required
from sqlalchemy.orm import joinedload
from shopdb.extensions import db
from shopdb.extensions import db, cache
from shopdb.core.models import (
Application, Setting,
Asset, AssetType, Communication, Vendor, Model,
CustomField, CustomFieldValue
)
from shopdb.core.api.settings import get_cached_settings
from shopdb.core.services.spellfix import Vocabulary
from shopdb.utils.responses import success_response
logger = logging.getLogger(__name__)
@@ -1003,6 +1004,70 @@ def _check_smart_redirect(query, classification):
return None
# How long a harvested vocabulary is reused. Long enough that a burst of
# searching does not rebuild it, short enough that something entered this
# morning is correctable before lunch.
_VOCABULARY_TTL = 600
# Per-column ceiling on harvested terms. A correction is only offered when a
# search found NOTHING, so the vocabulary exists to catch a typo against the
# names people actually use - not to mirror the database. Without a bound this
# would load every asset number on a site into memory to answer one query.
_VOCABULARY_LIMIT = 5000
def _search_vocabulary():
"""Words worth correcting a search against, harvested from the data.
From the DATA and not a dictionary, because no dictionary holds Genspect,
Telesis, Keyence or wax-trace - and because a vendor added this morning is
then correctable this morning.
Cached: this runs only on a search that found nothing, but a person who
mistypes once usually mistypes twice.
"""
cached = cache.get('search_vocabulary')
if cached is not None:
return cached
vocabulary = Vocabulary()
columns = (
(Asset, (Asset.assetnumber, Asset.name)),
(Vendor, (Vendor.vendor,)),
(Model, (Model.modelnumber,)),
(Application, (Application.appname,)),
(AssetType, (AssetType.assettype,)),
)
for model, fields in columns:
for field in fields:
try:
rows = (db.session.query(field)
.filter(field.isnot(None))
.distinct()
.limit(_VOCABULARY_LIMIT)
.all())
except Exception:
# A plugin table missing on a lean install must not break
# search. No vocabulary is worse than the alternative, not
# fatal.
logger.debug('vocabulary: skipped %s', field, exc_info=True)
continue
for (value,) in rows:
vocabulary.add(value)
cache.set('search_vocabulary', vocabulary, timeout=_VOCABULARY_TTL)
return vocabulary
def _suggestion_for(query):
"""A better spelling of `query`, or None. Never raises into a search."""
try:
return _search_vocabulary().suggest_query(query)
except Exception:
logger.warning('vocabulary lookup failed', exc_info=True)
return None
@search_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def global_search():
@@ -1106,8 +1171,13 @@ def global_search():
# multi-word and cross-field matching changes may already have fixed a good
# share of what used to fail. Guessing at that would be building for a
# problem nobody has measured.
suggestion = None
if not unique_results:
logger.info('search-no-results query=%r', query)
# Offered, never applied. The response carries the better spelling and
# the caller decides: silently searching for something else is how
# somebody orders the wrong cartridge.
suggestion = _suggestion_for(query)
# Limit total results
unique_results = unique_results[:50]
@@ -1120,6 +1190,8 @@ def global_search():
'total_all': total_all,
'counts': type_counts,
}
if suggestion:
response_data['suggestion'] = suggestion
redirect = _check_smart_redirect(query, classification)
if redirect: